@guidobuilds/forge-ai 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,18 @@
1
1
  import { access, readFile } from 'node:fs/promises';
2
2
  import { constants } from 'node:fs';
3
+ import { homedir } from 'node:os';
3
4
  import path from 'node:path';
4
5
  import { renderClaudeAgent, renderClaudeSkill } from './adapters/claude.js';
5
6
  import { renderCodexAgent, renderCodexSkill } from './adapters/codex.js';
6
7
  import { renderGrokAgent, renderGrokSkill } from './adapters/grok.js';
7
8
  import { renderOpenCodeAgent, renderOpenCodeSkill } from './adapters/opencode.js';
9
+ import { composeBody } from './compose.js';
8
10
  import { diagnostic } from './diagnostics.js';
9
11
  import { discoverSources } from './discovery.js';
10
12
  import { lookupEntryByPath, resolveBackupPath, sha256 } from './manifest.js';
11
- import { resolveOutputPath } from './paths.js';
13
+ import { getModelPreference } from './model-preferences.js';
14
+ import { openCodeUserRoots, resolveOpenCodeUserV2Path, resolveOutputPath } from './paths.js';
15
+ import { supportsModel } from './platform-capabilities.js';
12
16
  import { isPlatform, platforms } from './model.js';
13
17
  const namePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
14
18
  const platformKeys = new Set(['claude', 'opencode', 'codex', 'grok']);
@@ -55,7 +59,13 @@ export async function buildWritePlan(options) {
55
59
  for (const platform of resolvePlatforms(options.platform)) {
56
60
  for (const artifact of artifacts) {
57
61
  const effectiveKind = artifact[platform]?.kind ?? artifact.kind;
58
- files.push(renderFile(platform, effectiveKind, artifact, options, diagnostics));
62
+ const rendered = renderFile(platform, effectiveKind, artifact, options, diagnostics);
63
+ if (platform === 'opencode' && options.scope === 'user') {
64
+ files.push(...await expandOpenCodeUserTargets(rendered, options.home ?? homedir(), diagnostics));
65
+ }
66
+ else {
67
+ files.push(rendered);
68
+ }
59
69
  }
60
70
  }
61
71
  files.sort((a, b) => `${a.platform}:${a.kind}:${a.name}`.localeCompare(`${b.platform}:${b.kind}:${b.name}`));
@@ -126,10 +136,6 @@ function convertSource(source, sourceRoot) {
126
136
  diagnostics.push(diagnostic('error', 'INVALID_KIND', 'artifact kind must be one of agent, skill', { sourcePath: source.sourcePath }));
127
137
  if (!source.body.trim())
128
138
  diagnostics.push(diagnostic('error', 'EMPTY_BODY', 'artifact body is required', { sourcePath: source.sourcePath }));
129
- const bodyLines = source.body.split('\n').length;
130
- const bodyBudget = bodyLineBudgets[name ?? source.expectedName] ?? defaultBodyBudget;
131
- if (bodyLines > bodyBudget)
132
- diagnostics.push(diagnostic('info', 'BODY_OVER_BUDGET', `artifact body is ${bodyLines} lines (soft budget ${bodyBudget}); keep always-loaded harness files small`, { sourcePath: source.sourcePath }));
133
139
  if (!name || !description || !artifactKinds.has(kind) || !source.body.trim() || diagnostics.some((item) => item.severity === 'error'))
134
140
  return { diagnostics };
135
141
  return {
@@ -153,21 +159,64 @@ function productConfig(value) {
153
159
  : undefined;
154
160
  }
155
161
  function renderFile(platform, kind, artifact, options, diagnostics) {
162
+ const composedBody = composeBody(artifact.body, platform);
163
+ const bodyLines = composedBody.split('\n').length;
164
+ const bodyBudget = bodyLineBudgets[artifact.name] ?? defaultBodyBudget;
165
+ if (bodyLines > bodyBudget)
166
+ diagnostics.push(diagnostic('info', 'BODY_OVER_BUDGET', `${platform} ${artifact.name} body is ${bodyLines} lines (soft budget ${bodyBudget}); keep always-loaded harness files small`, { sourcePath: artifact.sourcePath, platform }));
167
+ let composed = { ...artifact, body: composedBody };
168
+ const modelOverride = options.modelPreferences && supportsModel(platform, kind) ? getModelPreference(options.modelPreferences, platform, artifact.name) : undefined;
169
+ if (modelOverride)
170
+ composed = { ...composed, [platform]: { ...composed[platform], model: modelOverride } };
156
171
  const rendered = kind === 'agent'
157
- ? platform === 'opencode' ? renderOpenCodeAgent(artifact) : platform === 'claude' ? renderClaudeAgent(artifact) : platform === 'grok' ? renderGrokAgent(artifact) : renderCodexAgent(artifact)
158
- : platform === 'opencode' ? renderOpenCodeSkill(artifact) : platform === 'claude' ? renderClaudeSkill(artifact) : platform === 'grok' ? renderGrokSkill(artifact) : renderCodexSkill(artifact);
172
+ ? platform === 'opencode' ? renderOpenCodeAgent(composed) : platform === 'claude' ? renderClaudeAgent(composed) : platform === 'grok' ? renderGrokAgent(composed) : renderCodexAgent(composed)
173
+ : platform === 'opencode' ? renderOpenCodeSkill(composed) : platform === 'claude' ? renderClaudeSkill(composed) : platform === 'grok' ? renderGrokSkill(composed) : renderCodexSkill(composed);
159
174
  diagnostics.push(...rendered.diagnostics);
160
175
  return { platform, kind, scope: options.scope, name: artifact.name, sourcePath: artifact.sourcePath, path: resolveOutputPath(platform, kind, options.scope, artifact.name, options.cwd, options.home), content: rendered.content };
161
176
  }
177
+ // `renderFile` above always computes OpenCode's v1 path (`~/.config/opencode/...`) for user scope
178
+ // — that stays the default target. v1 and the v2 preview (`opencode2`) read user-scope
179
+ // agents/skills from different directories (see src/paths.ts), and a machine can have either,
180
+ // both, or — before OpenCode has ever run — neither. Writing v2's directory unconditionally would
181
+ // silently create config for a generation that isn't actually installed; skipping v1 whenever v2
182
+ // is present would regress the long-supported default. So: write to each generation's directory
183
+ // only if it already exists on disk, and if neither does, keep today's behavior (write v1) rather
184
+ // than silently installing nothing.
185
+ async function expandOpenCodeUserTargets(base, home, diagnostics) {
186
+ const roots = openCodeUserRoots(home);
187
+ const [v1Exists, v2Exists] = await Promise.all([pathExists(roots.v1), pathExists(roots.v2)]);
188
+ if (!v1Exists && !v2Exists) {
189
+ diagnostics.push(diagnostic('info', 'OPENCODE_USER_ROOT_NOT_FOUND', `Neither ${roots.v1} (v1) nor ${roots.v2} (v2) exists yet; installing to the v1 default. Run OpenCode at least once, then re-run \`forge-ai install\`/\`update\`, to also cover whichever generation you actually use.`, { platform: 'opencode', sourcePath: base.sourcePath }));
190
+ return [base];
191
+ }
192
+ const files = [];
193
+ if (v1Exists)
194
+ files.push(base);
195
+ if (v2Exists)
196
+ files.push({ ...base, path: resolveOpenCodeUserV2Path(base.kind, base.name, home) });
197
+ return files;
198
+ }
199
+ async function pathExists(target) {
200
+ try {
201
+ await access(target, constants.F_OK);
202
+ return true;
203
+ }
204
+ catch {
205
+ return false;
206
+ }
207
+ }
162
208
  async function classifyDestinations(files, manifest, backupRoot, anchor, pending) {
163
209
  const diagnostics = [];
164
210
  for (const file of files) {
165
- const status = await classifyFile(file.path, manifest);
211
+ const { status, checksum } = await classifyFile(file.path, manifest);
166
212
  file.status = status;
167
- if (status === 'managed-modified' && backupRoot) {
168
- file.backupPath = resolveBackupPath(backupRoot, file.path, anchor);
169
- pending.modifiedOverwrites.push(file);
170
- diagnostics.push(diagnostic('warning', 'MANAGED_FILE_OVERWRITE', `Will overwrite locally edited Forge file ${file.path}; backup → ${file.backupPath}`, { platform: file.platform }));
213
+ if (status === 'managed-modified') {
214
+ file.expectedChecksum = checksum;
215
+ if (backupRoot) {
216
+ file.backupPath = resolveBackupPath(backupRoot, file.path, anchor);
217
+ pending.modifiedOverwrites.push(file);
218
+ diagnostics.push(diagnostic('warning', 'MANAGED_FILE_OVERWRITE', `Will overwrite locally edited Forge file ${file.path}; backup → ${file.backupPath}`, { platform: file.platform }));
219
+ }
171
220
  }
172
221
  else if (status === 'foreign') {
173
222
  pending.foreignOverwrites.push(file);
@@ -181,11 +230,12 @@ async function classifyFile(filePath, manifest) {
181
230
  await access(filePath, constants.F_OK);
182
231
  }
183
232
  catch {
184
- return 'new';
233
+ return { status: 'new' };
185
234
  }
186
235
  const entry = lookupEntryByPath(manifest, filePath);
187
236
  if (!entry)
188
- return 'foreign';
237
+ return { status: 'foreign' };
189
238
  const content = await readFile(filePath, 'utf8');
190
- return sha256(content) === entry.checksum ? 'managed-unmodified' : 'managed-modified';
239
+ const checksum = sha256(content);
240
+ return checksum === entry.checksum ? { status: 'managed-unmodified' } : { status: 'managed-modified', checksum };
191
241
  }
@@ -1,6 +1,31 @@
1
1
  import { realpathSync } from 'node:fs';
2
2
  import { spawnSync } from 'node:child_process';
3
+ import path from 'node:path';
4
+ import { resolveExecutable } from './executable-resolution.js';
3
5
  const PACKAGE = '@guidobuilds/forge-ai';
6
+ const TRUSTED_COMMANDS = new Set(['pnpm', 'npm', 'forge-ai']);
7
+ // --to allowlist: the literal 'latest' or a semver X.Y.Z (optional -prerelease / +build), optionally
8
+ // 'v'-prefixed. Rejects ranges (^1.0.0, ~1.0.0), git URLs, and any other dist-tag, so no arbitrary
9
+ // spec ever reaches `npm/pnpm install -g`.
10
+ export function isValidVersionSpec(value) {
11
+ return value === 'latest' || /^v?\d+\.\d+\.\d+([-+].*)?$/.test(value);
12
+ }
13
+ // 'v0.4.0' -> '0.4.0'; leaves 'latest' and bare semvers untouched.
14
+ export function normalizeVersionSpec(value) {
15
+ return value.replace(/^v(?=\d)/, '');
16
+ }
17
+ function resolveAndValidate(command, resolver, log) {
18
+ const resolved = resolver(command);
19
+ if (!resolved) {
20
+ log(`Could not resolve a trusted ${command} binary; refusing to run.`);
21
+ return { ok: false };
22
+ }
23
+ if (!TRUSTED_COMMANDS.has(path.basename(resolved))) {
24
+ log(`Refusing to run ${resolved}: unexpected binary name (expected ${[...TRUSTED_COMMANDS].join(', ')}).`);
25
+ return { ok: false };
26
+ }
27
+ return { ok: true, resolved };
28
+ }
4
29
  export function detectInstallMethod(realPath) {
5
30
  if (/[/\\]\.npm[/\\]_npx[/\\]/.test(realPath))
6
31
  return 'npx';
@@ -31,6 +56,7 @@ export async function runSelfUpdate(options) {
31
56
  const log = options.log ?? ((message) => console.log(message));
32
57
  const resolver = options.realPathResolver ?? ((p) => realpathSync(p));
33
58
  const spawner = options.spawner ?? defaultSpawner;
59
+ const resolveCommand = options.resolveCommand ?? ((command) => resolveExecutable(command, { cwd: process.cwd() }));
34
60
  let realPath;
35
61
  try {
36
62
  realPath = resolver(options.binaryPath);
@@ -42,7 +68,12 @@ export async function runSelfUpdate(options) {
42
68
  // fall back to the resolved real path (catches standard global installs whose bin dir is generic).
43
69
  const symlinkMethod = detectInstallMethod(options.binaryPath);
44
70
  const method = symlinkMethod !== 'unknown' ? symlinkMethod : detectInstallMethod(realPath);
45
- const cmd = buildUpdateCommand(method, options.version ?? 'latest');
71
+ const version = normalizeVersionSpec(options.version ?? 'latest');
72
+ if (!isValidVersionSpec(version)) {
73
+ log(`Invalid --to ${options.version}; expected a semver or "latest".`);
74
+ return 1;
75
+ }
76
+ const cmd = buildUpdateCommand(method, version);
46
77
  log(`Detected install: ${cmd.description} at ${realPath}`);
47
78
  if (cmd.instructions) {
48
79
  log(cmd.instructions);
@@ -53,7 +84,16 @@ export async function runSelfUpdate(options) {
53
84
  log('(dry-run, not executing)');
54
85
  return 0;
55
86
  }
56
- const updateResult = spawner(cmd.command, cmd.args);
87
+ const spawn = (command, args) => {
88
+ if (options.resolveCommand) {
89
+ const checked = resolveAndValidate(command, resolveCommand, log);
90
+ if (!checked.ok)
91
+ return { status: 1 };
92
+ return spawner(checked.resolved, args);
93
+ }
94
+ return spawner(command, args);
95
+ };
96
+ const updateResult = spawn(cmd.command, cmd.args);
57
97
  if (updateResult.status !== 0) {
58
98
  log(`CLI update failed with exit code ${updateResult.status}`);
59
99
  return updateResult.status ?? 1;
@@ -61,10 +101,16 @@ export async function runSelfUpdate(options) {
61
101
  if (options.skipSpecUpdate)
62
102
  return 0;
63
103
  log('\nApplying spec kit with the updated CLI...');
64
- const specResult = spawner('forge-ai', ['update']);
104
+ const specResult = spawn('forge-ai', ['update']);
65
105
  return specResult.status ?? 1;
66
106
  }
107
+ // Trust boundary: package managers are developer-facing binaries resolved to an absolute path via
108
+ // resolveExecutable (which rejects candidates inside the current working tree) and checked against
109
+ // the { pnpm, npm, forge-ai } basename allowlist — never a bare name that PATH/cwd could shadow.
67
110
  function defaultSpawner(command, args) {
68
- const result = spawnSync(command, args, { stdio: 'inherit' });
111
+ const checked = resolveAndValidate(command, (c) => resolveExecutable(c, { cwd: process.cwd() }), (m) => console.error(m));
112
+ if (!checked.ok)
113
+ return { status: 1 };
114
+ const result = spawnSync(checked.resolved, args, { stdio: 'inherit' });
69
115
  return { status: result.status };
70
116
  }
@@ -1,27 +1,25 @@
1
1
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { backupFile } from './manifest.js';
3
+ import { backupFile, sha256 } from './manifest.js';
4
4
  export async function writeOutputs(files) {
5
5
  for (const file of files) {
6
- if (file.backupPath) {
6
+ let existing;
7
+ if (file.expectedChecksum || file.backupPath) {
7
8
  try {
8
- const existing = await readFile(file.path, 'utf8');
9
- await backupFile(file.backupPath, existing);
9
+ existing = await readFile(file.path, 'utf8');
10
10
  }
11
11
  catch (error) {
12
12
  if (error.code !== 'ENOENT')
13
13
  throw error;
14
- // Original file disappeared between classification and write; no backup needed.
15
14
  }
16
15
  }
16
+ if (file.expectedChecksum && existing !== undefined && sha256(existing) !== file.expectedChecksum) {
17
+ throw new Error(`File ${file.path} changed after classification; aborting to avoid overwriting recent edits.`);
18
+ }
19
+ if (file.backupPath && existing !== undefined) {
20
+ await backupFile(file.backupPath, existing);
21
+ }
17
22
  await mkdir(path.dirname(file.path), { recursive: true });
18
23
  await writeFile(file.path, file.content, 'utf8');
19
24
  }
20
25
  }
21
- export async function writePluginFiles(outDir, files) {
22
- for (const file of files) {
23
- const target = path.join(outDir, file.relativePath);
24
- await mkdir(path.dirname(target), { recursive: true });
25
- await writeFile(target, file.content, 'utf8');
26
- }
27
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guidobuilds/forge-ai",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Forge AI framework",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,6 +32,6 @@
32
32
  "build:test": "tsc -p tsconfig.json",
33
33
  "typecheck": "tsc -p tsconfig.json --noEmit",
34
34
  "test": "npm run build:test && node --test dist/tests/*.test.js",
35
- "pretest": "git rev-parse --git-dir >/dev/null 2>&1 && git config core.hooksPath .githooks || true"
35
+ "generate-fixtures": "npm run build && node scripts/generate-fixtures.mjs"
36
36
  }
37
37
  }
@@ -1,43 +0,0 @@
1
- import { discoverArtifacts } from '../processor.js';
2
- import { renderClaudeAgent, renderClaudeSkill } from './claude.js';
3
- // Namespaces both agent-dispatch (subagent_type) and skill-load (Skill tool) cross-references to
4
- // their plugin-qualified form. Agent dispatch is NAMESPACE-REQUIRED and skill load is BARE-WORKS
5
- // only in the absence of a same-named collision from another installed source (see f0's spike and
6
- // the f1 adversarial verify finding in verification.md: a pre-existing CLI-push install of
7
- // using-forge/forge-grill under the same bare names silently wins a same-named Skill-tool
8
- // resolution on the affected machine). Namespacing both categories the same way keeps one uniform
9
- // mechanism instead of a second special case.
10
- //
11
- // Longest-alternative-first ordering (forge-worker-leaf before forge-worker) is required so the
12
- // regex engine matches the full literal `forge-worker-leaf` token before it could fall through to
13
- // the shorter `forge-worker` alternative — this avoids double-tagging embedded occurrences. The
14
- // two skill-load tokens (using-forge, forge-grill) do not share a prefix with any other token in
15
- // the list (nor with each other), so their position in the alternation does not affect matching;
16
- // they are listed after the agent-dispatch tokens for readability only.
17
- const PLUGIN_CROSS_REFERENCE_TOKENS = /(?<!forge:)\b(forge-worker-leaf|forge-worker|forge-adversary|using-forge|forge-grill)\b/g;
18
- export function rewritePluginCrossReferences(body) {
19
- return body.replace(PLUGIN_CROSS_REFERENCE_TOKENS, (match) => `forge:${match}`);
20
- }
21
- export async function buildClaudePluginPackage(source, version, license) {
22
- const { artifacts, diagnostics } = await discoverArtifacts(source);
23
- const files = [];
24
- for (const artifact of artifacts) {
25
- const effectiveKind = artifact.claude?.kind ?? artifact.kind;
26
- const rewritten = { ...artifact, body: rewritePluginCrossReferences(artifact.body) };
27
- const rendered = effectiveKind === 'agent' ? renderClaudeAgent(rewritten) : renderClaudeSkill(rewritten);
28
- diagnostics.push(...rendered.diagnostics);
29
- const relativePath = effectiveKind === 'agent' ? `agents/${artifact.name}.md` : `skills/${artifact.name}/SKILL.md`;
30
- files.push({ relativePath, content: rendered.content });
31
- }
32
- files.push({
33
- relativePath: '.claude-plugin/plugin.json',
34
- content: `${JSON.stringify({
35
- name: 'forge',
36
- version,
37
- description: 'Forge: a thin orchestrator with delegated workers, plans, and adversarial verification.',
38
- author: { name: 'Guido Caffa' },
39
- license
40
- }, null, 2)}\n`
41
- });
42
- return { files, diagnostics };
43
- }
@@ -1,98 +0,0 @@
1
- import { stringifyYaml } from '../frontmatter.js';
2
- import { discoverArtifacts } from '../processor.js';
3
- const referenceNames = new Map([
4
- ['using-forge', 'using-forge'],
5
- ['forge-worker', 'worker'],
6
- ['forge-worker-leaf', 'worker-leaf'],
7
- ['forge-adversary', 'adversary'],
8
- ['forge-grill', 'grill']
9
- ]);
10
- function requiredArtifact(artifacts, name, diagnostics) {
11
- const artifact = artifacts.find((candidate) => candidate.name === name);
12
- if (!artifact) {
13
- diagnostics.push({ severity: 'error', code: 'CODEX_PLUGIN_ARTIFACT_MISSING', message: `Codex plugin requires canonical artifact ${name}`, platform: 'codex' });
14
- }
15
- return artifact;
16
- }
17
- export function renderCodexPluginReference(artifact) {
18
- let body = artifact.body.trim();
19
- if (artifact.name === 'forge-worker') {
20
- body = body
21
- .replace(/spawn `forge-worker-leaf` sub-agents/g, 'request root fan-out of worker-leaf sub-agents')
22
- .replace(/Spawn `forge-worker-leaf`/g, 'Request root fan-out of a worker-leaf')
23
- .replace(/Spawn forge-worker-leaf/g, 'Request root fan-out of worker-leaf')
24
- .replace(/spawn forge-worker-leaf/g, 'request root fan-out of worker-leaf')
25
- .replace('On harnesses without spawn tools (Codex), return `DELEGATION_REQUESTS` for the orchestrator to fan out `forge-worker-leaf` dispatches. Omit `DELEGATION_REQUESTS` when you self-spawn.', 'On Codex, always return `DELEGATION_REQUESTS` for the root orchestrator to fan out worker-leaf dispatches. Never self-spawn from this coordinator contract.');
26
- body = `## Codex standalone subdelegation override
27
-
28
- This contract is injected into a standard/default Codex child. Never spawn a named Forge agent and never set \`agent_type\` or \`subagent_type\`. When any coordinator subdelegation trigger fires, return structured \`DELEGATION_REQUESTS\` to the root orchestrator. The root alone fans those requests out as standard/default agents after injecting the complete worker-leaf contract.
29
-
30
- ${body}`;
31
- }
32
- if (artifact.name === 'using-forge') {
33
- body = body
34
- .replace(/- Never do worker work inline\./g, '- When sub-agent spawning is available, never do worker work inline; otherwise execute the route sequentially inline under the applicable role contract.')
35
- .replace(/- Delegate all development and operational execution to `forge-worker`\./g, '- When sub-agent spawning is available, delegate development and operational execution to standard/default agents with the complete worker contract injected.')
36
- .replace(/`forge-worker` \*\*must\*\* spawn leaves \(or return `DELEGATION_REQUESTS` on Codex\)/g, '`references/worker.md` must return `DELEGATION_REQUESTS` to the root on Codex')
37
- .replace(/Codex: parse `DELEGATION_REQUESTS` and fan out `forge-worker-leaf` yourself/g, 'Codex: parse `DELEGATION_REQUESTS` and fan out standard/default agents with the complete worker-leaf contract yourself');
38
- }
39
- return `# ${artifact.name}\n\n${body}\n`;
40
- }
41
- export function renderCodexForgeSkill(artifact) {
42
- const body = artifact.body
43
- .replace('Load and follow the `using-forge` skill before routing work.', 'Read and follow `references/using-forge.md` before routing work.')
44
- .replace(/`forge-worker-leaf`/g, '`references/worker-leaf.md`')
45
- .replace(/`forge-worker`/g, '`references/worker.md`')
46
- .replace(/`forge-adversary`/g, '`references/adversary.md`')
47
- .replace(/`forge-grill`/g, '`references/grill.md`')
48
- .replace(/see `using-forge`/g, 'see `references/using-forge.md`')
49
- .replace('- Never do worker work inline.', '- When sub-agent spawning is available, never do worker work inline; when unavailable, execute sequentially inline using the applicable role contract.')
50
- .replace('- Never do non-development execution work inline.', '- When sub-agent spawning is available, never do non-development execution work inline; when unavailable, execute it sequentially inline using the applicable role contract.')
51
- .replace('- Delegate all technical and operational work to Forge workers.', '- When sub-agent spawning is available, delegate all technical and operational work to standard/default agents with injected Forge role contracts.');
52
- const codexRuntime = `## Codex plugin runtime
53
-
54
- This plugin is skills-only. The files under \`references/\` are private role contracts, not discoverable custom agent types.
55
-
56
- - When delegating, call Codex's available sub-agent spawning tool with a standard/default agent. Do not set \`agent_type\`, \`subagent_type\`, or depend on a named Forge agent being installed.
57
- - Before spawning, read the complete applicable role contract and include it verbatim in the task: \`references/worker.md\`, \`references/worker-leaf.md\`, or \`references/adversary.md\`.
58
- - Child agents must not rely on plugin skill discovery. Their prompt must contain every instruction and reference needed for the bounded task.
59
- - If a coordinator returns \`DELEGATION_REQUESTS\`, the root agent fans those requests out as standard/default agents using the complete \`references/worker-leaf.md\` contract.
60
- - If no sub-agent spawning tool is available, execute the same route sequentially in the main agent, preserve approval and independent-verification boundaries as far as the runtime permits, and explicitly report that Forge is running in inline fallback mode.
61
-
62
- `;
63
- return `${stringifyYaml({ name: 'forge', description: artifact.description })}${codexRuntime}${body.trim()}\n`;
64
- }
65
- export async function buildCodexPluginPackage(source, version, license) {
66
- const { artifacts, diagnostics } = await discoverArtifacts(source);
67
- const forge = requiredArtifact(artifacts, 'forge', diagnostics);
68
- const files = [];
69
- if (forge)
70
- files.push({ relativePath: 'skills/forge/SKILL.md', content: renderCodexForgeSkill(forge) });
71
- for (const [canonicalName, outputName] of referenceNames) {
72
- const artifact = requiredArtifact(artifacts, canonicalName, diagnostics);
73
- if (artifact)
74
- files.push({ relativePath: `skills/forge/references/${outputName}.md`, content: renderCodexPluginReference(artifact) });
75
- }
76
- files.push({
77
- relativePath: '.codex-plugin/plugin.json',
78
- content: `${JSON.stringify({
79
- name: 'forge',
80
- version,
81
- description: 'Forge: a thin orchestrator with delegated workers, plans, and adversarial verification.',
82
- author: { name: 'Guido Caffa' },
83
- license,
84
- repository: 'https://github.com/guidobuilds/forge',
85
- skills: './skills/',
86
- interface: {
87
- displayName: 'Forge',
88
- shortDescription: 'Orchestrate development work with delegated verification.',
89
- longDescription: 'Forge routes development work through scoped workers, approval gates, durable plans, and independent adversarial verification.',
90
- developerName: 'Guido Caffa',
91
- category: 'Productivity',
92
- capabilities: ['Orchestration', 'Delegation', 'Verification'],
93
- defaultPrompt: ['Use Forge to implement this task with the lightest safe workflow.']
94
- }
95
- }, null, 2)}\n`
96
- });
97
- return { files, diagnostics };
98
- }
@@ -1,69 +0,0 @@
1
- import { access, readFile, readdir } from 'node:fs/promises';
2
- import { constants } from 'node:fs';
3
- import path from 'node:path';
4
- import { fileURLToPath } from 'node:url';
5
- import { buildClaudePluginPackage } from './adapters/claude-plugin.js';
6
- import { buildCodexPluginPackage } from './adapters/codex-plugin.js';
7
- import { formatDiagnostic, hasErrors } from './diagnostics.js';
8
- import { writePluginFiles } from './writer.js';
9
- export async function runBuildPlugin(options) {
10
- const log = options.log ?? ((message) => console.log(message));
11
- const logError = options.logError ?? ((message) => console.error(message));
12
- const identityPath = options.target === 'claude' ? '.claude-plugin/plugin.json' : '.codex-plugin/plugin.json';
13
- if (!options.force && (await isUnsafeOutDir(options.outDir, identityPath))) {
14
- logError(`Refusing to write into non-empty directory ${options.outDir} that does not look like a prior Forge ${options.target} plugin build (${identityPath} must identify plugin "forge"); re-run with --force to overwrite.`);
15
- return 1;
16
- }
17
- const { version, license } = await readPackageMeta();
18
- const { files, diagnostics } = options.target === 'claude'
19
- ? await buildClaudePluginPackage(options.source, version, license)
20
- : await buildCodexPluginPackage(options.source, version, license);
21
- if (options.dryRun) {
22
- log(`build-plugin: ${files.length} file(s) would be written to ${options.outDir}`);
23
- for (const file of files)
24
- log(`- ${file.relativePath}`);
25
- for (const item of diagnostics)
26
- log(formatDiagnostic(item));
27
- return 0;
28
- }
29
- await writePluginFiles(options.outDir, files);
30
- log(`Wrote ${files.length} file(s) to ${options.outDir}.`);
31
- for (const item of diagnostics)
32
- log(formatDiagnostic(item));
33
- return hasErrors(diagnostics) ? 1 : 0;
34
- }
35
- async function isUnsafeOutDir(outDir, identityPath) {
36
- let entries;
37
- try {
38
- entries = await readdir(outDir);
39
- }
40
- catch {
41
- return false; // missing directory is safe: writePluginFiles will create it.
42
- }
43
- if (entries.length === 0)
44
- return false; // empty directory is safe.
45
- try {
46
- const manifestPath = path.join(outDir, identityPath);
47
- await access(manifestPath, constants.F_OK);
48
- const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
49
- return manifest.name !== 'forge';
50
- }
51
- catch {
52
- return true;
53
- }
54
- }
55
- async function readPackageMeta() {
56
- try {
57
- const packageJson = JSON.parse(await readFile(path.join(packageRoot(), 'package.json'), 'utf8'));
58
- return {
59
- version: typeof packageJson.version === 'string' ? packageJson.version : '0.0.0',
60
- license: typeof packageJson.license === 'string' ? packageJson.license : 'UNLICENSED'
61
- };
62
- }
63
- catch {
64
- return { version: '0.0.0', license: 'UNLICENSED' };
65
- }
66
- }
67
- function packageRoot() {
68
- return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
69
- }