Skip to content
Open
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@
- **A brain verifies the checkout before it mines** (#344): graft checks the
working copy against the repo a brain expects, so rules are never mined from
the wrong tree.
- **Droid and Pi wiring** via `graft init`: Droid (Factory CLI) gets an
`AGENTS.md` section plus a repo-level `.factory/mcp.json` MCP registration
(its documented project level); Pi gets a graft-owned skill at
`.pi/skills/graft/SKILL.md` and deliberately no MCP registration (pi's own
no-MCP stance — the CLI on PATH is the integration surface). Both detect
from their config dirs (`~/.factory`, `~/.pi`, or repo-local equivalents).
- **A `project-agents` init row** for the vendor-neutral conventions: one
selection writes the `AGENTS.md` section plus graft's skill at
`.agents/skills/graft/SKILL.md` — the Agent-Skills-standard location droid,
pi, and other standard-reading tools discover natively — with strictly
project-scoped writes (no machine-wide config), and no new registry row
needed for the next standard-reading agent.

### Fixed

Expand Down
8 changes: 7 additions & 1 deletion src/hosts/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { installCodexHooks } from './codex-hooks.js';
import { installCursorHooks } from './cursor-hooks.js';
import type { ConfigWrite } from './config-write.js';
import { installAntigravitySkill } from './antigravity.js';
import { installProjectAgentSkill } from './project-agents.js';

export interface HostsInitResult {
written: { id: string; path: string; action: string }[];
Expand Down Expand Up @@ -94,5 +95,10 @@ export function runHostsInit(
opts.global === false || !selected.some((h) => h.id === 'antigravity')
? []
: installAntigravitySkill(home);
return { written, skipped, unknown, mcp, hooks: [...hooks, ...cursorHooks, ...antigravitySkill] };
// The project-agents skill is repo-local (.agents/skills/) — --no-global does
// NOT suppress it; there is no out-of-repo write in the row at all.
const projectAgentSkill = !selected.some((h) => h.id === 'project-agents')
? []
: installProjectAgentSkill(repo);
return { written, skipped, unknown, mcp, hooks: [...hooks, ...cursorHooks, ...antigravitySkill, ...projectAgentSkill] };
}
9 changes: 8 additions & 1 deletion src/hosts/mcp-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,8 +212,15 @@ export function mcpTargets(
out.push(jsonTarget(id, 'opencode', join(repo, 'opencode.json'), 'mcp', opencodeEntry()));
}
break;
case 'droid':
// Droid reads MCP from `.factory/mcp.json` at the project root — the
// committed, team-shared level of its user/folder/project trio
// (docs.factory.ai/harness/mcp.md). Standard `{command,args}` under
// `mcpServers`, repo scope like cursor/gemini/kiro.
out.push(jsonTarget(id, 'droid', join(repo, '.factory', 'mcp.json'), 'mcpServers', entry));
break;
default:
break; // copilot / windsurf / adal: no MCP target in this phase
break; // copilot / windsurf / adal / pi / project-agents: no MCP target (pi's own no-MCP stance)
}
}
return out;
Expand Down
2 changes: 2 additions & 0 deletions src/hosts/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { mcpTargets } from './mcp-config.js';
import { hookTargets } from './codex-hooks.js';
import { cursorHookTargets } from './cursor-hooks.js';
import { antigravitySkillTargets } from './antigravity.js';
import { projectAgentSkillTargets } from './project-agents.js';
import { claudeTargets } from '../claude/init.js';
import { claudeGlobalTargets } from './claude-global.js';

Expand Down Expand Up @@ -85,6 +86,7 @@ export function planInit(repo: string, opts: { home?: string; ids?: string[] } =
...(host.id === 'agents' ? hookTargets(home) : []),
...(host.id === 'cursor' ? cursorHookTargets(repo) : []),
...(host.id === 'antigravity' ? antigravitySkillTargets(home) : []),
...(host.id === 'project-agents' ? projectAgentSkillTargets(repo) : []),
],
})),
];
Expand Down
48 changes: 48 additions & 0 deletions src/hosts/project-agents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* The vendor-neutral, project-scoped Agent-Skills-standard wiring.
*
* `.agents/skills/<name>/SKILL.md` is the one location multiple agents read
* natively (pi documents it; droid's skills doc lists it as its compatibility
* scope) — so one write covers every standard-reading tool in the repo, and
* any future one, without a registry row per vendor. The always-on half
* (AGENTS.md) comes from the `project-agents` host's own registry entry; this
* module is the skill half.
*
* `projectAgentSkillTargets()` is the pure "which files would this touch" half
* (for `graft init --dry-run` / the picker); `installProjectAgentSkill()` does
* the write.
*/
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { skillTemplate } from '../claude/skill-template.js';
import type { PlannedWrite } from './plan.js';
import type { ConfigWrite } from './config-write.js';

/** The skill file path, under the repo's shared Agent-Skills dir. */
function skillPath(repo: string): string {
return join(repo, '.agents', 'skills', 'graft', 'SKILL.md');
}

/** The files installing the project-standard skill would touch — pure, no writes. */
export function projectAgentSkillTargets(repo: string): PlannedWrite[] {
return [
{
hostId: 'project-agents', id: 'project-agents-skill',
path: skillPath(repo),
scope: 'repo', kind: 'skill', what: 'graft skill (.agents/skills standard)',
},
];
}

/** Write graft's skill into `<repo>/.agents/skills/graft/SKILL.md`, idempotently. */
export function installProjectAgentSkill(repo: string): ConfigWrite[] {
const path = skillPath(repo);
const content = skillTemplate();
const existed = existsSync(path);
if (existed && readFileSync(path, 'utf8') === content) {
return [{ id: 'project-agents-skill', path, action: 'unchanged' }];
}
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, content);
return [{ id: 'project-agents-skill', path, action: existed ? 'updated' : 'created' }];
}
45 changes: 45 additions & 0 deletions src/hosts/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,51 @@ export const HOSTS: HostTarget[] = [
content: windsurfRule,
detect: (p) => p.dirExists(join(p.home, '.codeium', 'windsurf')) || p.dirExists(join(p.repo, '.windsurf')),
},
{
// Droid reads AGENTS.md (docs.factory.ai/harness/agents-md.md) — the same
// file as the 'agents' row, so selecting both runs two identical upserts
// and the second reports 'unchanged'. Its own row exists for detection
// (the ~/.factory install probe) and the MCP target (.factory/mcp.json).
id: 'droid',
name: 'Droid (Factory CLI)',
kind: 'section',
relPath: 'AGENTS.md',
content: instructionBody,
detect: (p) => p.dirExists(join(p.home, '.factory')) || p.dirExists(join(p.repo, '.factory')),
},
{
// Pi has no MCP support by design (its README: "No MCP" — CLI tools and
// skills are the extension surface), so wiring is the skill file alone:
// pi discovers `.pi/skills/` from the cwd upward, alongside AGENTS.md.
id: 'pi',
name: 'Pi Coding Agent',
kind: 'owned',
relPath: join('.pi', 'skills', 'graft', 'SKILL.md'),
content: skillTemplate,
detect: (p) => p.dirExists(join(p.home, '.pi')) || p.dirExists(join(p.repo, '.pi')),
},
{
// The vendor-neutral convention row: every agent that reads project-level
// STANDARD files — AGENTS.md plus the Agent-Skills `.agents/skills/`
// location (droid's documented compatibility scope, pi's documented skill
// dir, Codex-style CLIs' instruction file) — wires from this one row, and
// a future standard-reading tool needs no registry entry. Strictly
// project-scoped: machine-wide and vendor-specific config stays with each
// vendor's own row (the agents row's ~/.codex writes, droid's MCP).
// Detection: any installed agent in the standard-reading family, or a repo
// that already carries the `.agents/` convention.
id: 'project-agents',
name: 'Project-standard agents (AGENTS.md + .agents/skills)',
kind: 'section',
relPath: 'AGENTS.md',
content: instructionBody,
detect: (p) =>
p.dirExists(join(p.home, '.codex')) ||
p.dirExists(join(p.home, '.config', 'opencode')) ||
p.dirExists(join(p.home, '.factory')) ||
p.dirExists(join(p.home, '.pi')) ||
p.dirExists(join(p.repo, '.agents')),
},
];

export function hostIds(): string[] {
Expand Down
12 changes: 12 additions & 0 deletions src/hosts/retract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { ALL_MARKERS, type Markers } from './sections.js';
import { mcpTargets, stripTomlSection } from './mcp-config.js';
import { hookTargets } from './codex-hooks.js';
import { antigravitySkillTargets } from './antigravity.js';
import { projectAgentSkillTargets } from './project-agents.js';
import { claudeGlobalTargets } from './claude-global.js';
import { claudeTargets } from '../claude/init.js';
import { isGraftAllowEntry, isGraftFooterRegex } from '../claude/settings-merge.js';
Expand Down Expand Up @@ -420,6 +421,17 @@ function targets(repo: string, opts: RetractOpts): Target[] {
});
}

// 2b. The project-agents skill — a repo-local write outside the host's own
// relPath (AGENTS.md), so it needs its own target here. Removing it is
// safe while AGENTS.md stays: the standard-reading agents lose the
// on-demand playbook but keep the always-on section, matching what a
// repo wired by an older graft had.
if (!exclude.has('project-agents')) {
for (const t of projectAgentSkillTargets(repo)) {
add({ hostId: t.hostId, path: t.path, what: t.what, scope: t.scope, run: (a) => removeFile(t.path, a) });
}
}

// 3. Claude Code: settings fragments, both shims, the skill, and the .mcp.json key.
if (!exclude.has('claude')) {
const [settings, statusline, hooks, skill, mcp] = claudeTargets(repo).map((t) => t.path);
Expand Down
31 changes: 21 additions & 10 deletions test/claude-shim-resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,35 +45,46 @@ function runShim(root: string, bakedDir: string, projectDir: string): string | n
return existsSync(marker) ? readFileSync(marker, 'utf8') : null;
}

// The fakes' versions sit far above any real release ON PURPOSE: the shim's
// candidate list includes the node install the TEST RUNNER itself lives under
// (`process.execPath`/../lib), so on a machine with a real `npm i -g` graft
// (0.18.0 as of this comment) that real install joins the race and outranks
// 0.11/0.9 fakes — the shim correctly loads it, the fake marker never lands,
// and the test reports a resolution the fixture never contained. The fakes
// only model relative order against each other, so they claim versions no
// real install will carry and stay hermetic either way.
const WINNER = '99.0.0';
const LOSER = '0.9.1';

test('an upgraded global install wins over the stale baked path', () => {
const root = tmpRepo('shim-upgrade');
const stale = fakeInstall(root, 'old-node-install', '0.9.1');
fakeInstall(join(root, 'project', 'node_modules', '@nanonets'), 'graft', '0.11.0');
const stale = fakeInstall(root, 'old-node-install', LOSER);
fakeInstall(join(root, 'project', 'node_modules', '@nanonets'), 'graft', WINNER);
// BAKED points at the install that `graft init` ran from — still on disk (an
// nvm switch leaves it there), still first in the candidate list, now stale.
assert.equal(runShim(root, stale, join(root, 'project')), '0.11.0');
assert.equal(runShim(root, stale, join(root, 'project')), WINNER);
});

test('the baked path still wins when it is the newest', () => {
const root = tmpRepo('shim-baked-newest');
const baked = fakeInstall(root, 'current', '0.11.0');
fakeInstall(join(root, 'project', 'node_modules', '@nanonets'), 'graft', '0.9.1');
assert.equal(runShim(root, baked, join(root, 'project')), '0.11.0');
const baked = fakeInstall(root, 'current', WINNER);
fakeInstall(join(root, 'project', 'node_modules', '@nanonets'), 'graft', LOSER);
assert.equal(runShim(root, baked, join(root, 'project')), WINNER);
});

test('a single candidate is used whatever its version', () => {
const root = tmpRepo('shim-single');
const only = fakeInstall(root, 'only', '0.9.1');
const only = fakeInstall(root, 'only', WINNER);
mkdirSync(join(root, 'project'), { recursive: true });
assert.equal(runShim(root, only, join(root, 'project')), '0.9.1');
assert.equal(runShim(root, only, join(root, 'project')), WINNER);
});

test('an install with an unreadable version loses to a known one', () => {
const root = tmpRepo('shim-noversion');
const broken = fakeInstall(root, 'broken', '0.0.0');
writeFileSync(join(root, 'broken', 'package.json'), 'not json');
fakeInstall(join(root, 'project', 'node_modules', '@nanonets'), 'graft', '0.9.1');
assert.equal(runShim(root, broken, join(root, 'project')), '0.9.1');
fakeInstall(join(root, 'project', 'node_modules', '@nanonets'), 'graft', WINNER);
assert.equal(runShim(root, broken, join(root, 'project')), WINNER);
});

test('no candidate at all exits quietly — a hook must never fail the session', () => {
Expand Down
59 changes: 57 additions & 2 deletions test/hosts-init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,69 @@ test('explicit agents list overrides detection and flags unknown ids', () => {
test('all writes every host and re-run converges (idempotent)', () => {
const home = fresh(); const repo = fresh();
const first = runHostsInit(repo, { home, all: true });
assert.equal(first.written.length, 10);
assert.equal(first.written.length, 13);
const second = runHostsInit(repo, { home, all: true });
assert.ok(second.written.every((w) => w.action === 'unchanged'));
// `agents` and `antigravity` share AGENTS.md, but the fenced section is written once
assert.ok(second.hooks.every((w) => w.action === 'unchanged'), 'skill/hook writes converge too');
// `agents`, `droid`, `project-agents`, and `antigravity` share AGENTS.md, but the
// fenced section is written once
const agents = readFileSync(join(repo, 'AGENTS.md'), 'utf8');
assert.equal(agents.match(/graft:start/g)!.length, 1);
});

test('project-agents: AGENTS.md section plus the .agents/skills standard skill, nothing machine-wide', () => {
const home = fresh(); const repo = fresh();
mkdirSync(join(home, '.factory'));
const r = runHostsInit(repo, { home, agents: ['project-agents'] });
assert.deepEqual(r.written.map((w) => w.id), ['project-agents']);
assert.ok(readFileSync(join(repo, 'AGENTS.md'), 'utf8').includes('graft ask'));
const skill = readFileSync(join(repo, '.agents', 'skills', 'graft', 'SKILL.md'), 'utf8');
assert.match(skill, /^---\nname: graft\n/);
assert.ok(skill.includes('graft callers'), 'the skill carries the full playbook, not just a pointer');
assert.deepEqual(r.mcp, [], 'the row is instructions + skill only');
// re-run converges, and the vendor rows on top never duplicate the shared section
const second = runHostsInit(repo, { home, agents: ['project-agents'] });
assert.ok(second.written.every((w) => w.action === 'unchanged'));
const all = runHostsInit(repo, { home, agents: ['project-agents', 'agents', 'droid', 'pi'] });
assert.equal(readFileSync(join(repo, 'AGENTS.md'), 'utf8').match(/graft:start/g)!.length, 1);
});

test('droid: detected via ~/.factory or repo .factory, wired as an AGENTS.md section', () => {
const home = fresh(); const repo = fresh();
mkdirSync(join(home, '.factory'));
const r = runHostsInit(repo, { home, agents: ['droid'] });
assert.deepEqual(r.written.map((w) => w.id), ['droid']);
const agents = readFileSync(join(repo, 'AGENTS.md'), 'utf8');
assert.ok(agents.includes('graft ask'));
assert.equal(agents.match(/graft:start/g)!.length, 1, 'one fenced section despite the shared file');
// selecting the generic agents row on top is an identical upsert, not a duplicate block
const both = runHostsInit(repo, { home, agents: ['agents', 'droid'] });
assert.deepEqual(both.written.filter((w) => w.id === 'agents' || w.id === 'droid').map((w) => w.action),
['unchanged', 'unchanged']);
assert.equal(readFileSync(join(repo, 'AGENTS.md'), 'utf8').match(/graft:start/g)!.length, 1);
});

test('droid: also detected from a repo-local .factory dir', () => {
const home = fresh(); const repo = fresh();
mkdirSync(join(repo, '.factory'));
const r = runHostsInit(repo, { home });
assert.ok(r.written.some((w) => w.id === 'droid'));
});

test('pi: detected via ~/.pi or repo .pi, wired as a graft-owned skill file', () => {
const home = fresh(); const repo = fresh();
mkdirSync(join(home, '.pi'));
const r = runHostsInit(repo, { home, agents: ['pi'] });
assert.deepEqual(r.written.map((w) => w.id), ['pi']);
const skill = readFileSync(join(repo, '.pi', 'skills', 'graft', 'SKILL.md'), 'utf8');
assert.match(skill, /^---\nname: graft\n/);
assert.ok(skill.includes('graft ask'));
assert.deepEqual(r.mcp, [], 'pi has no MCP target (its own no-MCP stance)');
const repoDotPi = fresh();
mkdirSync(join(repoDotPi, '.pi'));
assert.ok(runHostsInit(repoDotPi, { home: fresh() }).written.some((w) => w.id === 'pi'));
});

test('preserves user content around the fenced section', () => {
const home = fresh(); const repo = fresh();
const target = join(repo, '.github', 'copilot-instructions.md');
Expand Down
10 changes: 10 additions & 0 deletions test/hosts-mcp-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,16 @@ test('grok gets a repo-local TOML MCP section', () => {
assert.deepEqual(again.map((x) => x.action), ['unchanged']);
});

test('droid gets a repo-local .factory/mcp.json; pi and project-agents get nothing', () => {
const repo = fresh(); const home = fresh();
const w = registerMcpConfigs(repo, ['droid', 'pi', 'project-agents'], { home });
assert.deepEqual(w.map((x) => x.id).sort(), ['droid'], 'pi registers no MCP config at all');
const droid = JSON.parse(readFileSync(join(repo, '.factory', 'mcp.json'), 'utf8'));
assert.deepEqual(droid.mcpServers.graft, { command: 'npx', args: ['-y', '@nanonets/graft', 'mcp'] });
const again = registerMcpConfigs(repo, ['droid'], { home });
assert.deepEqual(again.map((x) => x.action), ['unchanged']);
});

test('JSON with non-object mcpServers value is skipped', () => {
const repo = fresh(); const home = fresh();
mkdirSync(join(repo, '.cursor'), { recursive: true });
Expand Down
19 changes: 17 additions & 2 deletions test/hosts-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ function probeFor(home: string, repo: string): DetectProbe {
function fresh(): string { return mkdtempSync(join(tmpdir(), 'graft-registry-')); }

test('registry exposes the known hosts', () => {
assert.deepEqual(hostIds().sort(), ['adal', 'agents', 'antigravity', 'copilot', 'cursor', 'gemini', 'grok', 'hermes', 'kiro', 'windsurf']);
assert.deepEqual(hostIds().sort(), ['adal', 'agents', 'antigravity', 'copilot', 'cursor', 'droid', 'gemini', 'grok', 'hermes', 'kiro', 'pi', 'project-agents', 'windsurf']);
for (const h of HOSTS) {
assert.ok(h.relPath.length > 0);
assert.ok(h.content().length > 0);
Expand All @@ -32,7 +32,22 @@ test('home config dirs light up their hosts', () => {
mkdirSync(join(home, '.gemini'));
mkdirSync(join(home, '.codex'));
const ids = detectHosts(probeFor(home, repo)).map((h) => h.id).sort();
assert.deepEqual(ids, ['agents', 'cursor', 'gemini']);
assert.deepEqual(ids, ['agents', 'cursor', 'gemini', 'project-agents']);
});

test('project-agents lights up from any standard-reader home dir or a repo .agents dir', () => {
const repo = fresh();
for (const dir of [['.codex'], ['.config', 'opencode'], ['.factory'], ['.pi']] as const) {
const home = fresh();
mkdirSync(join(home, ...dir), { recursive: true });
assert.ok(
detectHosts(probeFor(home, repo)).some((h) => h.id === 'project-agents'),
`~/${join(...dir)} should detect project-agents`,
);
}
const home = fresh();
mkdirSync(join(repo, '.agents'));
assert.ok(detectHosts(probeFor(home, repo)).some((h) => h.id === 'project-agents'), 'repo .agents/ detects');
});

test('repo-local markers also light up hosts', () => {
Expand Down
Loading