@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.
- package/CHANGELOG.md +50 -0
- package/README.md +44 -57
- package/artifacts/forge/forge.md +7 -4
- package/artifacts/forge-adversary/forge-adversary.md +3 -0
- package/artifacts/forge-worker/forge-worker.md +4 -5
- package/artifacts/forge-worker-leaf/forge-worker-leaf.md +3 -0
- package/artifacts/using-forge/using-forge.md +2 -2
- package/bin/forge-ai.mjs +1 -1
- package/dist/src/adapters/claude-known.js +6 -2
- package/dist/src/adapters/codex-known.js +9 -0
- package/dist/src/adapters/codex.js +6 -1
- package/dist/src/adapters/grok.js +3 -0
- package/dist/src/adapters/opencode-known.js +15 -0
- package/dist/src/adapters/opencode.js +20 -4
- package/dist/src/adapters/shared.js +15 -1
- package/dist/src/cli.js +500 -49
- package/dist/src/compose.js +12 -0
- package/dist/src/dispatch-snippets.js +56 -0
- package/dist/src/executable-resolution.js +49 -0
- package/dist/src/index.js +0 -1
- package/dist/src/manifest.js +172 -14
- package/dist/src/model-preferences.js +41 -0
- package/dist/src/opencode-discovery.js +32 -0
- package/dist/src/paths.js +55 -3
- package/dist/src/platform-capabilities.js +17 -0
- package/dist/src/processor.js +66 -16
- package/dist/src/self-update.js +50 -4
- package/dist/src/writer.js +10 -12
- package/package.json +2 -2
- package/dist/src/adapters/claude-plugin.js +0 -43
- package/dist/src/adapters/codex-plugin.js +0 -98
- package/dist/src/build-plugin.js +0 -69
package/dist/src/processor.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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(
|
|
158
|
-
: platform === 'opencode' ? renderOpenCodeSkill(
|
|
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'
|
|
168
|
-
file.
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
239
|
+
const checksum = sha256(content);
|
|
240
|
+
return checksum === entry.checksum ? { status: 'managed-unmodified' } : { status: 'managed-modified', checksum };
|
|
191
241
|
}
|
package/dist/src/self-update.js
CHANGED
|
@@ -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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
}
|
package/dist/src/writer.js
CHANGED
|
@@ -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
|
-
|
|
6
|
+
let existing;
|
|
7
|
+
if (file.expectedChecksum || file.backupPath) {
|
|
7
8
|
try {
|
|
8
|
-
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
}
|
package/dist/src/build-plugin.js
DELETED
|
@@ -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
|
-
}
|