@guidobuilds/forge-ai 0.2.0 → 0.5.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 +107 -0
- package/README.md +125 -4
- package/artifacts/forge/forge.md +84 -0
- package/artifacts/forge-adversary/forge-adversary.md +122 -0
- package/{skills/forge-grill/SKILL.md → artifacts/forge-grill/forge-grill.md} +3 -1
- package/artifacts/forge-worker/forge-worker.md +257 -0
- package/artifacts/using-forge/using-forge.md +187 -0
- package/dist/src/adapters/claude-known.js +31 -0
- package/dist/src/adapters/claude.js +33 -21
- package/dist/src/adapters/codex.js +14 -14
- package/dist/src/adapters/grok-known.js +37 -0
- package/dist/src/adapters/grok.js +41 -0
- package/dist/src/adapters/opencode.js +15 -15
- package/dist/src/adapters/shared.js +6 -1
- package/dist/src/cli.js +202 -92
- package/dist/src/discovery.js +16 -18
- package/dist/src/frontmatter.js +8 -68
- package/dist/src/manifest.js +42 -9
- package/dist/src/model.js +4 -1
- package/dist/src/paths.js +4 -0
- package/dist/src/processor.js +94 -44
- package/dist/src/self-update.js +70 -0
- package/dist/src/version-check.js +75 -0
- package/dist/src/writer.js +13 -1
- package/package.json +12 -12
- package/agents/forge-worker.md +0 -60
- package/agents/forge.md +0 -61
- package/skills/forge-worker/SKILL.md +0 -132
- package/skills/using-forge/SKILL.md +0 -119
package/dist/src/manifest.js
CHANGED
|
@@ -20,27 +20,31 @@ export async function loadManifest(manifestPath) {
|
|
|
20
20
|
throw error;
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
|
-
export function buildManifest(location, files, now = new Date()) {
|
|
23
|
+
export async function buildManifest(location, files, now = new Date()) {
|
|
24
|
+
const entries = await Promise.all(files.map(async (file) => ({
|
|
25
|
+
platform: file.platform,
|
|
26
|
+
kind: file.kind,
|
|
27
|
+
name: file.name,
|
|
28
|
+
path: file.path,
|
|
29
|
+
sourcePath: file.sourcePath,
|
|
30
|
+
checksum: sha256(await readFile(file.path, 'utf8'))
|
|
31
|
+
})));
|
|
24
32
|
return {
|
|
25
33
|
schemaVersion: 1,
|
|
26
34
|
scope: location.scope,
|
|
27
35
|
projectPath: location.projectPath,
|
|
28
36
|
projectPathHash: location.projectPathHash,
|
|
29
37
|
updatedAt: now.toISOString(),
|
|
30
|
-
entries
|
|
31
|
-
platform: file.platform,
|
|
32
|
-
kind: file.kind,
|
|
33
|
-
name: file.name,
|
|
34
|
-
path: file.path,
|
|
35
|
-
sourcePath: file.sourcePath,
|
|
36
|
-
checksum: sha256(file.content)
|
|
37
|
-
}))
|
|
38
|
+
entries
|
|
38
39
|
};
|
|
39
40
|
}
|
|
40
41
|
export async function saveManifest(manifestPath, manifest) {
|
|
41
42
|
await mkdir(path.dirname(manifestPath), { recursive: true });
|
|
42
43
|
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
43
44
|
}
|
|
45
|
+
export function lookupEntryByPath(manifest, filePath) {
|
|
46
|
+
return manifest?.entries.find((entry) => entry.path === filePath);
|
|
47
|
+
}
|
|
44
48
|
export function staleEntries(oldManifest, files) {
|
|
45
49
|
if (!oldManifest)
|
|
46
50
|
return [];
|
|
@@ -71,17 +75,46 @@ export async function classifyPruneEntries(entries) {
|
|
|
71
75
|
}
|
|
72
76
|
export async function pruneEntries(entries) {
|
|
73
77
|
for (const entry of entries) {
|
|
78
|
+
if (entry.backupPath) {
|
|
79
|
+
try {
|
|
80
|
+
const content = await readFile(entry.path, 'utf8');
|
|
81
|
+
await backupFile(entry.backupPath, content);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (error.code !== 'ENOENT')
|
|
85
|
+
throw error;
|
|
86
|
+
// Source file is already gone; nothing to back up.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
74
89
|
await rm(entry.path, { force: true });
|
|
75
90
|
if (entry.kind === 'skill')
|
|
76
91
|
await removeEmptyParent(path.dirname(entry.path));
|
|
77
92
|
}
|
|
78
93
|
}
|
|
94
|
+
export function resolveBackupRoot(location, now) {
|
|
95
|
+
const scopeKey = location.scope === 'user' ? 'user' : path.join('projects', location.projectPathHash ?? 'unknown');
|
|
96
|
+
return path.join(location.stateRoot, 'backups', scopeKey, isoTimestamp(now));
|
|
97
|
+
}
|
|
98
|
+
export function resolveBackupPath(backupRoot, originalAbsolutePath, anchor) {
|
|
99
|
+
const rel = path.relative(anchor, originalAbsolutePath);
|
|
100
|
+
const safe = rel.startsWith('..') || path.isAbsolute(rel)
|
|
101
|
+
? originalAbsolutePath.replace(/^[\/\\]+/, '')
|
|
102
|
+
: rel;
|
|
103
|
+
return path.join(backupRoot, safe);
|
|
104
|
+
}
|
|
105
|
+
export async function backupFile(backupPath, content) {
|
|
106
|
+
await mkdir(path.dirname(backupPath), { recursive: true });
|
|
107
|
+
await writeFile(backupPath, content, 'utf8');
|
|
108
|
+
}
|
|
79
109
|
export function sha256(content) {
|
|
80
110
|
return createHash('sha256').update(content).digest('hex');
|
|
81
111
|
}
|
|
82
112
|
export function hashProjectPath(projectPath) {
|
|
83
113
|
return sha256(projectPath).slice(0, 32);
|
|
84
114
|
}
|
|
115
|
+
function isoTimestamp(now) {
|
|
116
|
+
return now.toISOString().replace(/[:.]/g, '-');
|
|
117
|
+
}
|
|
85
118
|
async function canonicalProjectPath(cwd) {
|
|
86
119
|
try {
|
|
87
120
|
return await realpath(cwd);
|
package/dist/src/model.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
export const platforms = ['opencode', 'claude', 'codex'];
|
|
1
|
+
export const platforms = ['opencode', 'claude', 'codex', 'grok'];
|
|
2
2
|
export function isPlatform(value) {
|
|
3
3
|
return platforms.includes(value);
|
|
4
4
|
}
|
|
5
|
+
export function hasPendingDecisions(pending) {
|
|
6
|
+
return pending.modifiedOverwrites.length > 0 || pending.foreignOverwrites.length > 0;
|
|
7
|
+
}
|
package/dist/src/paths.js
CHANGED
|
@@ -13,6 +13,8 @@ function userBase(platform, kind, home) {
|
|
|
13
13
|
return path.join(home, '.config', 'opencode', kind === 'agent' ? 'agents' : 'skills');
|
|
14
14
|
if (platform === 'claude')
|
|
15
15
|
return path.join(home, '.claude', kind === 'agent' ? 'agents' : 'skills');
|
|
16
|
+
if (platform === 'grok')
|
|
17
|
+
return path.join(home, '.grok', kind === 'agent' ? 'agents' : 'skills');
|
|
16
18
|
return kind === 'agent' ? path.join(home, '.codex', 'agents') : path.join(home, '.agents', 'skills');
|
|
17
19
|
}
|
|
18
20
|
function projectBase(platform, kind, cwd) {
|
|
@@ -20,5 +22,7 @@ function projectBase(platform, kind, cwd) {
|
|
|
20
22
|
return path.join(cwd, '.opencode', kind === 'agent' ? 'agents' : 'skills');
|
|
21
23
|
if (platform === 'claude')
|
|
22
24
|
return path.join(cwd, '.claude', kind === 'agent' ? 'agents' : 'skills');
|
|
25
|
+
if (platform === 'grok')
|
|
26
|
+
return path.join(cwd, '.grok', kind === 'agent' ? 'agents' : 'skills');
|
|
23
27
|
return kind === 'agent' ? path.join(cwd, '.codex', 'agents') : path.join(cwd, '.agents', 'skills');
|
|
24
28
|
}
|
package/dist/src/processor.js
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
|
-
import { access } from 'node:fs/promises';
|
|
1
|
+
import { access, readFile } from 'node:fs/promises';
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { renderClaudeAgent, renderClaudeSkill } from './adapters/claude.js';
|
|
5
5
|
import { renderCodexAgent, renderCodexSkill } from './adapters/codex.js';
|
|
6
|
+
import { renderGrokAgent, renderGrokSkill } from './adapters/grok.js';
|
|
6
7
|
import { renderOpenCodeAgent, renderOpenCodeSkill } from './adapters/opencode.js';
|
|
7
8
|
import { diagnostic } from './diagnostics.js';
|
|
8
9
|
import { discoverSources } from './discovery.js';
|
|
10
|
+
import { lookupEntryByPath, resolveBackupPath, sha256 } from './manifest.js';
|
|
9
11
|
import { resolveOutputPath } from './paths.js';
|
|
10
12
|
import { isPlatform, platforms } from './model.js';
|
|
11
13
|
const namePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
12
|
-
const platformKeys = new Set(['claude', 'opencode', 'codex']);
|
|
13
|
-
const allowedTopLevel = new Set(['name', 'description', 'claude', 'opencode', 'codex']);
|
|
14
|
-
const allowedProductKeys = new Set(['permissions', 'model']);
|
|
14
|
+
const platformKeys = new Set(['claude', 'opencode', 'codex', 'grok']);
|
|
15
|
+
const allowedTopLevel = new Set(['name', 'description', 'kind', 'claude', 'opencode', 'codex', 'grok']);
|
|
16
|
+
const allowedProductKeys = new Set(['permissions', 'model', 'kind']);
|
|
15
17
|
const allowedOpenCodeKeys = new Set([...allowedProductKeys, 'mode']);
|
|
16
18
|
const openCodeModes = new Set(['primary', 'subagent', 'all']);
|
|
19
|
+
const artifactKinds = new Set(['agent', 'skill']);
|
|
20
|
+
const defaultBodyBudget = 200;
|
|
21
|
+
const bodyLineBudgets = { forge: 90, 'using-forge': 220, 'forge-worker': 300, 'forge-grill': 120, 'forge-adversary': 200 };
|
|
17
22
|
export function resolvePlatforms(platform) {
|
|
18
23
|
return platform === 'all' ? platforms : [platform];
|
|
19
24
|
}
|
|
@@ -25,45 +30,43 @@ export function parseScope(value) {
|
|
|
25
30
|
}
|
|
26
31
|
export async function buildWritePlan(options) {
|
|
27
32
|
const { sources, diagnostics } = await discoverSources(options.source);
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
const seenAgents = new Set();
|
|
31
|
-
const seenSkills = new Set();
|
|
33
|
+
const artifacts = [];
|
|
34
|
+
const seen = new Set();
|
|
32
35
|
for (const source of sources) {
|
|
33
36
|
const converted = convertSource(source, options.source);
|
|
34
37
|
diagnostics.push(...converted.diagnostics);
|
|
35
38
|
if (!converted.item)
|
|
36
39
|
continue;
|
|
37
|
-
const seen = source.kind === 'agent' ? seenAgents : seenSkills;
|
|
38
40
|
if (seen.has(converted.item.name)) {
|
|
39
|
-
diagnostics.push(diagnostic('error', 'DUPLICATE_NAME', `Duplicate
|
|
41
|
+
diagnostics.push(diagnostic('error', 'DUPLICATE_NAME', `Duplicate artifact name ${converted.item.name}`, { sourcePath: source.sourcePath }));
|
|
40
42
|
continue;
|
|
41
43
|
}
|
|
42
44
|
seen.add(converted.item.name);
|
|
43
|
-
|
|
44
|
-
agents.push(converted.item);
|
|
45
|
-
else
|
|
46
|
-
skills.push(converted.item);
|
|
45
|
+
artifacts.push(converted.item);
|
|
47
46
|
}
|
|
48
47
|
const files = [];
|
|
48
|
+
const pending = { modifiedOverwrites: [], foreignOverwrites: [] };
|
|
49
49
|
if (!diagnostics.some((item) => item.severity === 'error')) {
|
|
50
50
|
for (const platform of resolvePlatforms(options.platform)) {
|
|
51
|
-
for (const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
for (const artifact of artifacts) {
|
|
52
|
+
const effectiveKind = artifact[platform]?.kind ?? artifact.kind;
|
|
53
|
+
files.push(renderFile(platform, effectiveKind, artifact, options, diagnostics));
|
|
54
|
+
}
|
|
55
55
|
}
|
|
56
56
|
files.sort((a, b) => `${a.platform}:${a.kind}:${a.name}`.localeCompare(`${b.platform}:${b.kind}:${b.name}`));
|
|
57
|
-
if (options.checkCollisions)
|
|
58
|
-
|
|
57
|
+
if (options.checkCollisions) {
|
|
58
|
+
const anchor = options.scope === 'user' ? (options.home ?? '') : (options.cwd ?? '');
|
|
59
|
+
diagnostics.push(...await classifyDestinations(files, options.manifest, options.backupRoot, anchor, pending));
|
|
60
|
+
}
|
|
59
61
|
}
|
|
60
|
-
return { files, diagnostics, sourceCount: sources.length };
|
|
62
|
+
return { files, diagnostics, pending, sourceCount: sources.length };
|
|
61
63
|
}
|
|
62
64
|
function convertSource(source, sourceRoot) {
|
|
63
65
|
const diagnostics = [];
|
|
64
66
|
const data = source.data;
|
|
65
67
|
const name = typeof data.name === 'string' ? data.name : undefined;
|
|
66
68
|
const description = typeof data.description === 'string' ? data.description : undefined;
|
|
69
|
+
const kind = data.kind;
|
|
67
70
|
for (const key of Object.keys(data)) {
|
|
68
71
|
if (!allowedTopLevel.has(key))
|
|
69
72
|
diagnostics.push(diagnostic('error', 'UNSUPPORTED_FIELD', `Unsupported canonical field ${key}`, { sourcePath: source.sourcePath }));
|
|
@@ -76,53 +79,100 @@ function convertSource(source, sourceRoot) {
|
|
|
76
79
|
diagnostics.push(diagnostic('error', 'INVALID_PLATFORM_BLOCK', `${platform} must be an object`, { sourcePath: source.sourcePath, platform: platform }));
|
|
77
80
|
continue;
|
|
78
81
|
}
|
|
79
|
-
|
|
80
|
-
|
|
82
|
+
const record = config;
|
|
83
|
+
for (const key of Object.keys(record)) {
|
|
84
|
+
const allowedKeys = platform === 'opencode' ? allowedOpenCodeKeys : allowedProductKeys;
|
|
81
85
|
if (!allowedKeys.has(key))
|
|
82
86
|
diagnostics.push(diagnostic('error', 'UNSUPPORTED_PLATFORM_FIELD', `${platform}.${key} is not supported in the MVP`, { sourcePath: source.sourcePath, platform: platform }));
|
|
83
87
|
}
|
|
84
|
-
if ('model' in
|
|
88
|
+
if ('model' in record && typeof record.model !== 'string') {
|
|
85
89
|
diagnostics.push(diagnostic('error', 'INVALID_PLATFORM_MODEL', `${platform}.model must be a string`, { sourcePath: source.sourcePath, platform: platform }));
|
|
86
90
|
}
|
|
87
|
-
if (
|
|
88
|
-
diagnostics.push(diagnostic('error', '
|
|
91
|
+
if ('kind' in record && !artifactKinds.has(record.kind)) {
|
|
92
|
+
diagnostics.push(diagnostic('error', 'INVALID_PLATFORM_KIND', `${platform}.kind must be one of agent, skill`, { sourcePath: source.sourcePath, platform: platform }));
|
|
93
|
+
}
|
|
94
|
+
if (platform === 'opencode' && 'mode' in record) {
|
|
95
|
+
if (!openCodeModes.has(record.mode)) {
|
|
96
|
+
diagnostics.push(diagnostic('error', 'INVALID_OPENCODE_MODE', 'opencode.mode must be one of primary, subagent, all', { sourcePath: source.sourcePath, platform: 'opencode' }));
|
|
97
|
+
}
|
|
98
|
+
const effectiveKind = record.kind ?? kind;
|
|
99
|
+
if (effectiveKind !== 'agent') {
|
|
100
|
+
diagnostics.push(diagnostic('error', 'OPENCODE_MODE_ON_SKILL', 'opencode.mode is only valid when the OpenCode artifact kind is agent', { sourcePath: source.sourcePath, platform: 'opencode' }));
|
|
101
|
+
}
|
|
89
102
|
}
|
|
90
103
|
}
|
|
91
104
|
if (!name)
|
|
92
|
-
diagnostics.push(diagnostic('error', 'MISSING_NAME',
|
|
105
|
+
diagnostics.push(diagnostic('error', 'MISSING_NAME', 'artifact name is required', { sourcePath: source.sourcePath }));
|
|
93
106
|
if (name && !namePattern.test(name))
|
|
94
|
-
diagnostics.push(diagnostic('error', 'INVALID_NAME',
|
|
107
|
+
diagnostics.push(diagnostic('error', 'INVALID_NAME', 'artifact name must be kebab-case', { sourcePath: source.sourcePath }));
|
|
95
108
|
if (name && name !== source.expectedName)
|
|
96
|
-
diagnostics.push(diagnostic('error', 'NAME_MISMATCH',
|
|
109
|
+
diagnostics.push(diagnostic('error', 'NAME_MISMATCH', `artifact name must match ${source.expectedName}`, { sourcePath: source.sourcePath }));
|
|
97
110
|
if (!description)
|
|
98
|
-
diagnostics.push(diagnostic('error', 'MISSING_DESCRIPTION',
|
|
111
|
+
diagnostics.push(diagnostic('error', 'MISSING_DESCRIPTION', 'artifact description is required', { sourcePath: source.sourcePath }));
|
|
112
|
+
if (kind === undefined)
|
|
113
|
+
diagnostics.push(diagnostic('error', 'MISSING_KIND', 'artifact kind is required (agent or skill)', { sourcePath: source.sourcePath }));
|
|
114
|
+
else if (!artifactKinds.has(kind))
|
|
115
|
+
diagnostics.push(diagnostic('error', 'INVALID_KIND', 'artifact kind must be one of agent, skill', { sourcePath: source.sourcePath }));
|
|
99
116
|
if (!source.body.trim())
|
|
100
|
-
diagnostics.push(diagnostic('error', 'EMPTY_BODY',
|
|
101
|
-
|
|
117
|
+
diagnostics.push(diagnostic('error', 'EMPTY_BODY', 'artifact body is required', { sourcePath: source.sourcePath }));
|
|
118
|
+
const bodyLines = source.body.split('\n').length;
|
|
119
|
+
const bodyBudget = bodyLineBudgets[name ?? source.expectedName] ?? defaultBodyBudget;
|
|
120
|
+
if (bodyLines > bodyBudget)
|
|
121
|
+
diagnostics.push(diagnostic('info', 'BODY_OVER_BUDGET', `artifact body is ${bodyLines} lines (soft budget ${bodyBudget}); keep always-loaded harness files small`, { sourcePath: source.sourcePath }));
|
|
122
|
+
if (!name || !description || !artifactKinds.has(kind) || !source.body.trim() || diagnostics.some((item) => item.severity === 'error'))
|
|
102
123
|
return { diagnostics };
|
|
103
|
-
|
|
104
|
-
|
|
124
|
+
return {
|
|
125
|
+
diagnostics,
|
|
126
|
+
item: {
|
|
127
|
+
name,
|
|
128
|
+
description,
|
|
129
|
+
kind: kind,
|
|
130
|
+
body: source.body,
|
|
131
|
+
sourcePath: path.relative(path.resolve(sourceRoot), source.sourcePath),
|
|
132
|
+
claude: productConfig(data.claude),
|
|
133
|
+
opencode: productConfig(data.opencode),
|
|
134
|
+
codex: productConfig(data.codex),
|
|
135
|
+
grok: productConfig(data.grok)
|
|
136
|
+
}
|
|
137
|
+
};
|
|
105
138
|
}
|
|
106
139
|
function productConfig(value) {
|
|
107
140
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
108
141
|
}
|
|
109
|
-
function renderFile(platform, kind,
|
|
142
|
+
function renderFile(platform, kind, artifact, options, diagnostics) {
|
|
110
143
|
const rendered = kind === 'agent'
|
|
111
|
-
? platform === 'opencode' ? renderOpenCodeAgent(
|
|
112
|
-
: platform === 'opencode' ? renderOpenCodeSkill(
|
|
144
|
+
? platform === 'opencode' ? renderOpenCodeAgent(artifact) : platform === 'claude' ? renderClaudeAgent(artifact) : platform === 'grok' ? renderGrokAgent(artifact) : renderCodexAgent(artifact)
|
|
145
|
+
: platform === 'opencode' ? renderOpenCodeSkill(artifact) : platform === 'claude' ? renderClaudeSkill(artifact) : platform === 'grok' ? renderGrokSkill(artifact) : renderCodexSkill(artifact);
|
|
113
146
|
diagnostics.push(...rendered.diagnostics);
|
|
114
|
-
return { platform, kind, scope: options.scope, name:
|
|
147
|
+
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 };
|
|
115
148
|
}
|
|
116
|
-
async function
|
|
149
|
+
async function classifyDestinations(files, manifest, backupRoot, anchor, pending) {
|
|
117
150
|
const diagnostics = [];
|
|
118
151
|
for (const file of files) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
152
|
+
const status = await classifyFile(file.path, manifest);
|
|
153
|
+
file.status = status;
|
|
154
|
+
if (status === 'managed-modified' && backupRoot) {
|
|
155
|
+
file.backupPath = resolveBackupPath(backupRoot, file.path, anchor);
|
|
156
|
+
pending.modifiedOverwrites.push(file);
|
|
157
|
+
diagnostics.push(diagnostic('warning', 'MANAGED_FILE_OVERWRITE', `Will overwrite locally edited Forge file ${file.path}; backup → ${file.backupPath}`, { platform: file.platform }));
|
|
122
158
|
}
|
|
123
|
-
|
|
124
|
-
|
|
159
|
+
else if (status === 'foreign') {
|
|
160
|
+
pending.foreignOverwrites.push(file);
|
|
161
|
+
diagnostics.push(diagnostic('warning', 'FOREIGN_FILE_OVERWRITE', `Will overwrite untracked file at ${file.path}`, { platform: file.platform }));
|
|
125
162
|
}
|
|
126
163
|
}
|
|
127
164
|
return diagnostics;
|
|
128
165
|
}
|
|
166
|
+
async function classifyFile(filePath, manifest) {
|
|
167
|
+
try {
|
|
168
|
+
await access(filePath, constants.F_OK);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return 'new';
|
|
172
|
+
}
|
|
173
|
+
const entry = lookupEntryByPath(manifest, filePath);
|
|
174
|
+
if (!entry)
|
|
175
|
+
return 'foreign';
|
|
176
|
+
const content = await readFile(filePath, 'utf8');
|
|
177
|
+
return sha256(content) === entry.checksum ? 'managed-unmodified' : 'managed-modified';
|
|
178
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
const PACKAGE = '@guidobuilds/forge-ai';
|
|
4
|
+
export function detectInstallMethod(realPath) {
|
|
5
|
+
if (/[/\\]\.npm[/\\]_npx[/\\]/.test(realPath))
|
|
6
|
+
return 'npx';
|
|
7
|
+
if (/[/\\]pnpm[/\\]/.test(realPath))
|
|
8
|
+
return 'pnpm-global';
|
|
9
|
+
if (/[/\\]homebrew[/\\]/i.test(realPath) && /[/\\]node_modules[/\\]/.test(realPath))
|
|
10
|
+
return 'npm-global-homebrew';
|
|
11
|
+
if (/[/\\]node_modules[/\\]/.test(realPath))
|
|
12
|
+
return 'npm-global';
|
|
13
|
+
return 'unknown';
|
|
14
|
+
}
|
|
15
|
+
export function buildUpdateCommand(method, version = 'latest') {
|
|
16
|
+
const target = `${PACKAGE}@${version}`;
|
|
17
|
+
switch (method) {
|
|
18
|
+
case 'pnpm-global':
|
|
19
|
+
return { command: 'pnpm', args: ['add', '-g', target, '--prefer-online'], description: 'pnpm global' };
|
|
20
|
+
case 'npm-global':
|
|
21
|
+
return { command: 'npm', args: ['install', '-g', target], description: 'npm global' };
|
|
22
|
+
case 'npm-global-homebrew':
|
|
23
|
+
return { command: '/opt/homebrew/bin/npm', args: ['install', '-g', target], description: 'npm global (Homebrew)' };
|
|
24
|
+
case 'npx':
|
|
25
|
+
return { command: '', args: [], description: 'npx (no global install)', instructions: `No global install to update. Re-run with: npx ${target} update` };
|
|
26
|
+
case 'unknown':
|
|
27
|
+
return { command: '', args: [], description: 'unknown install method', instructions: `Could not detect install method. Update manually: pnpm add -g ${target} --prefer-online` };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export async function runSelfUpdate(options) {
|
|
31
|
+
const log = options.log ?? ((message) => console.log(message));
|
|
32
|
+
const resolver = options.realPathResolver ?? ((p) => realpathSync(p));
|
|
33
|
+
const spawner = options.spawner ?? defaultSpawner;
|
|
34
|
+
let realPath;
|
|
35
|
+
try {
|
|
36
|
+
realPath = resolver(options.binaryPath);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
realPath = options.binaryPath;
|
|
40
|
+
}
|
|
41
|
+
// Try the symlink path first (catches `pnpm link --global` and similar dev setups);
|
|
42
|
+
// fall back to the resolved real path (catches standard global installs whose bin dir is generic).
|
|
43
|
+
const symlinkMethod = detectInstallMethod(options.binaryPath);
|
|
44
|
+
const method = symlinkMethod !== 'unknown' ? symlinkMethod : detectInstallMethod(realPath);
|
|
45
|
+
const cmd = buildUpdateCommand(method, options.version ?? 'latest');
|
|
46
|
+
log(`Detected install: ${cmd.description} at ${realPath}`);
|
|
47
|
+
if (cmd.instructions) {
|
|
48
|
+
log(cmd.instructions);
|
|
49
|
+
return 1;
|
|
50
|
+
}
|
|
51
|
+
log(`Running: ${cmd.command} ${cmd.args.join(' ')}`);
|
|
52
|
+
if (options.dryRun) {
|
|
53
|
+
log('(dry-run, not executing)');
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
const updateResult = spawner(cmd.command, cmd.args);
|
|
57
|
+
if (updateResult.status !== 0) {
|
|
58
|
+
log(`CLI update failed with exit code ${updateResult.status}`);
|
|
59
|
+
return updateResult.status ?? 1;
|
|
60
|
+
}
|
|
61
|
+
if (options.skipSpecUpdate)
|
|
62
|
+
return 0;
|
|
63
|
+
log('\nApplying spec kit with the updated CLI...');
|
|
64
|
+
const specResult = spawner('forge-ai', ['update']);
|
|
65
|
+
return specResult.status ?? 1;
|
|
66
|
+
}
|
|
67
|
+
function defaultSpawner(command, args) {
|
|
68
|
+
const result = spawnSync(command, args, { stdio: 'inherit' });
|
|
69
|
+
return { status: result.status };
|
|
70
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const REGISTRY_URL = 'https://registry.npmjs.org/@guidobuilds/forge-ai/latest';
|
|
4
|
+
const DEFAULT_TTL_MS = 60 * 60 * 1000;
|
|
5
|
+
const DEFAULT_FETCH_TIMEOUT_MS = 1500;
|
|
6
|
+
export async function checkLatestVersion(options) {
|
|
7
|
+
const ttl = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
8
|
+
const now = options.now ?? new Date();
|
|
9
|
+
const cached = await readCache(options.cachePath);
|
|
10
|
+
if (cached && now.getTime() - new Date(cached.checkedAt).getTime() < ttl) {
|
|
11
|
+
return makeResult(options.current, cached.latest);
|
|
12
|
+
}
|
|
13
|
+
const fetched = await fetchLatest(options.registryUrl ?? REGISTRY_URL, options.fetcher ?? fetch, options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS);
|
|
14
|
+
if (fetched === undefined)
|
|
15
|
+
return cached ? makeResult(options.current, cached.latest) : undefined;
|
|
16
|
+
await writeCache(options.cachePath, { checkedAt: now.toISOString(), latest: fetched });
|
|
17
|
+
return makeResult(options.current, fetched);
|
|
18
|
+
}
|
|
19
|
+
export function formatVersionNotice(result) {
|
|
20
|
+
if (!result.isOutdated)
|
|
21
|
+
return '';
|
|
22
|
+
return `forge-ai v${result.current} (v${result.latest} available — run \`forge-ai self-update\` to upgrade)`;
|
|
23
|
+
}
|
|
24
|
+
export function compareSemver(a, b) {
|
|
25
|
+
const pa = a.split('.').map((part) => parseInt(part, 10) || 0);
|
|
26
|
+
const pb = b.split('.').map((part) => parseInt(part, 10) || 0);
|
|
27
|
+
for (let index = 0; index < Math.max(pa.length, pb.length); index += 1) {
|
|
28
|
+
const diff = (pa[index] ?? 0) - (pb[index] ?? 0);
|
|
29
|
+
if (diff !== 0)
|
|
30
|
+
return diff;
|
|
31
|
+
}
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
function makeResult(current, latest) {
|
|
35
|
+
return { current, latest, isOutdated: compareSemver(current, latest) < 0 };
|
|
36
|
+
}
|
|
37
|
+
async function fetchLatest(url, fetcher, timeoutMs) {
|
|
38
|
+
const controller = new AbortController();
|
|
39
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
40
|
+
try {
|
|
41
|
+
const response = await fetcher(url, { signal: controller.signal });
|
|
42
|
+
if (!response.ok)
|
|
43
|
+
return undefined;
|
|
44
|
+
const json = await response.json();
|
|
45
|
+
return typeof json.version === 'string' ? json.version : undefined;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async function readCache(cachePath) {
|
|
55
|
+
try {
|
|
56
|
+
const content = await readFile(cachePath, 'utf8');
|
|
57
|
+
const parsed = JSON.parse(content);
|
|
58
|
+
if (typeof parsed.checkedAt === 'string' && typeof parsed.latest === 'string') {
|
|
59
|
+
return { checkedAt: parsed.checkedAt, latest: parsed.latest };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Cache missing or malformed; treat as no cache.
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
async function writeCache(cachePath, data) {
|
|
68
|
+
try {
|
|
69
|
+
await mkdir(path.dirname(cachePath), { recursive: true });
|
|
70
|
+
await writeFile(cachePath, `${JSON.stringify(data)}\n`, 'utf8');
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// Cache write failures are non-fatal.
|
|
74
|
+
}
|
|
75
|
+
}
|
package/dist/src/writer.js
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { backupFile } from './manifest.js';
|
|
3
4
|
export async function writeOutputs(files) {
|
|
4
5
|
for (const file of files) {
|
|
6
|
+
if (file.backupPath) {
|
|
7
|
+
try {
|
|
8
|
+
const existing = await readFile(file.path, 'utf8');
|
|
9
|
+
await backupFile(file.backupPath, existing);
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
if (error.code !== 'ENOENT')
|
|
13
|
+
throw error;
|
|
14
|
+
// Original file disappeared between classification and write; no backup needed.
|
|
15
|
+
}
|
|
16
|
+
}
|
|
5
17
|
await mkdir(path.dirname(file.path), { recursive: true });
|
|
6
18
|
await writeFile(file.path, file.content, 'utf8');
|
|
7
19
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@guidobuilds/forge-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Forge AI framework",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -10,18 +10,11 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"bin",
|
|
12
12
|
"dist/src",
|
|
13
|
-
"
|
|
14
|
-
"skills",
|
|
13
|
+
"artifacts",
|
|
15
14
|
"README.md",
|
|
15
|
+
"CHANGELOG.md",
|
|
16
16
|
"LICENSE"
|
|
17
17
|
],
|
|
18
|
-
"scripts": {
|
|
19
|
-
"build": "tsc -p tsconfig.build.json",
|
|
20
|
-
"build:test": "tsc -p tsconfig.json",
|
|
21
|
-
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
22
|
-
"test": "npm run build:test && node --test dist/tests/*.test.js",
|
|
23
|
-
"prepack": "npm run build"
|
|
24
|
-
},
|
|
25
18
|
"engines": {
|
|
26
19
|
"node": ">=20"
|
|
27
20
|
},
|
|
@@ -31,6 +24,13 @@
|
|
|
31
24
|
},
|
|
32
25
|
"dependencies": {
|
|
33
26
|
"@clack/prompts": "^1.2.0",
|
|
34
|
-
"picocolors": "^1.1.1"
|
|
27
|
+
"picocolors": "^1.1.1",
|
|
28
|
+
"yaml": "^2.8.4"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsc -p tsconfig.build.json",
|
|
32
|
+
"build:test": "tsc -p tsconfig.json",
|
|
33
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
34
|
+
"test": "npm run build:test && node --test dist/tests/*.test.js"
|
|
35
35
|
}
|
|
36
|
-
}
|
|
36
|
+
}
|
package/agents/forge-worker.md
DELETED
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: forge-worker
|
|
3
|
-
description: Forge universal worker for inspect, design, plan, build, operate, and verify work
|
|
4
|
-
claude:
|
|
5
|
-
permissions:
|
|
6
|
-
tools: [TodoWrite, Read, Write, Edit, Bash, Glob, Grep, LS, MultiEdit, WebFetch]
|
|
7
|
-
opencode:
|
|
8
|
-
mode: subagent
|
|
9
|
-
permissions:
|
|
10
|
-
todowrite: true
|
|
11
|
-
read: true
|
|
12
|
-
write: true
|
|
13
|
-
edit: true
|
|
14
|
-
bash: true
|
|
15
|
-
glob: true
|
|
16
|
-
grep: true
|
|
17
|
-
list: true
|
|
18
|
-
patch: true
|
|
19
|
-
skill: true
|
|
20
|
-
webfetch: true
|
|
21
|
-
---
|
|
22
|
-
|
|
23
|
-
You are the Forge worker.
|
|
24
|
-
|
|
25
|
-
Load and follow the `forge-worker` skill before doing work.
|
|
26
|
-
|
|
27
|
-
You are the only worker type in Forge. The orchestrator may launch multiple instances of you in parallel or sequence.
|
|
28
|
-
|
|
29
|
-
## Inputs
|
|
30
|
-
- Orchestrator prompt with the assigned subgoal, expected boundaries, and any approval context.
|
|
31
|
-
- Optional: `.forge/<feature-slug>/explore.md`
|
|
32
|
-
- Optional: `.forge/<feature-slug>/design.md`
|
|
33
|
-
- Optional: `.forge/<feature-slug>/plan.md`
|
|
34
|
-
- Optional: `.forge/<feature-slug>/build-log.md`
|
|
35
|
-
|
|
36
|
-
The skill defines routing by work type, artifact guidance, approval handling, bounded execution, escalation rules, and validation expectations.
|
|
37
|
-
|
|
38
|
-
## Contract (strict)
|
|
39
|
-
Return only:
|
|
40
|
-
|
|
41
|
-
```text
|
|
42
|
-
STATUS: success|partial|blocked
|
|
43
|
-
WORK_TYPE: inspect|design|plan|build|operate|verify|mixed
|
|
44
|
-
FEATURE_SLUG: <kebab-case>
|
|
45
|
-
ARTIFACTS:
|
|
46
|
-
- <path or None>
|
|
47
|
-
SUMMARY:
|
|
48
|
-
- <brief point>
|
|
49
|
-
NEXT_RECOMMENDED: inspect|design|plan|build|operate|verify|ask-user|none
|
|
50
|
-
RISKS:
|
|
51
|
-
- <risk or None>
|
|
52
|
-
QUESTIONS:
|
|
53
|
-
1) <question>
|
|
54
|
-
2) <question>
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
Include `QUESTIONS` only when blocked.
|
|
58
|
-
|
|
59
|
-
Do not interact directly with the user. Escalate open decisions back to the orchestrator through the contract.
|
|
60
|
-
Do not add extra format outside the defined worker contract.
|