@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.
@@ -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: files.map((file) => ({
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
  }
@@ -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 agents = [];
29
- const skills = [];
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 ${source.kind} name ${converted.item.name}`, { sourcePath: source.sourcePath }));
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
- if (source.kind === 'agent')
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 agent of agents)
52
- files.push(renderFile(platform, 'agent', agent, options, diagnostics));
53
- for (const skill of skills)
54
- files.push(renderFile(platform, 'skill', skill, options, diagnostics));
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
- diagnostics.push(...await collisionDiagnostics(files, Boolean(options.force)));
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
- for (const key of Object.keys(config)) {
80
- const allowedKeys = platform === 'opencode' && source.kind === 'agent' ? allowedOpenCodeKeys : allowedProductKeys;
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 config && typeof config.model !== 'string') {
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 (platform === 'opencode' && source.kind === 'agent' && 'mode' in config && !openCodeModes.has(config.mode)) {
88
- diagnostics.push(diagnostic('error', 'INVALID_OPENCODE_MODE', 'opencode.mode must be one of primary, subagent, all', { sourcePath: source.sourcePath, platform: 'opencode' }));
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', `${source.kind} name is required`, { sourcePath: source.sourcePath }));
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', `${source.kind} name must be kebab-case`, { sourcePath: source.sourcePath }));
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', `${source.kind} name must match ${source.expectedName}`, { sourcePath: source.sourcePath }));
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', `${source.kind} description is required`, { sourcePath: source.sourcePath }));
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', `${source.kind} body is required`, { sourcePath: source.sourcePath }));
101
- if (!name || !description || !source.body.trim() || diagnostics.some((item) => item.severity === 'error'))
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
- const base = { name, description, sourcePath: path.relative(path.resolve(sourceRoot), source.sourcePath), claude: productConfig(data.claude), opencode: productConfig(data.opencode), codex: productConfig(data.codex) };
104
- return { diagnostics, item: source.kind === 'agent' ? { ...base, definition: source.body } : { ...base, instructions: source.body } };
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, item, options, diagnostics) {
142
+ function renderFile(platform, kind, artifact, options, diagnostics) {
110
143
  const rendered = kind === 'agent'
111
- ? platform === 'opencode' ? renderOpenCodeAgent(item) : platform === 'claude' ? renderClaudeAgent(item) : renderCodexAgent(item)
112
- : platform === 'opencode' ? renderOpenCodeSkill(item) : platform === 'claude' ? renderClaudeSkill(item) : renderCodexSkill(item);
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: item.name, sourcePath: item.sourcePath ?? '', path: resolveOutputPath(platform, kind, options.scope, item.name, options.cwd, options.home), content: rendered.content };
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 collisionDiagnostics(files, force) {
149
+ async function classifyDestinations(files, manifest, backupRoot, anchor, pending) {
117
150
  const diagnostics = [];
118
151
  for (const file of files) {
119
- try {
120
- await access(file.path, constants.F_OK);
121
- diagnostics.push(diagnostic(force ? 'warning' : 'error', force ? 'OVERWRITE_FORCED' : 'DESTINATION_EXISTS', force ? `--force will overwrite ${file.path}` : `Destination exists; use --force to overwrite ${file.path}`, { platform: file.platform }));
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
- catch {
124
- // Missing destination is safe.
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
+ }
@@ -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.2.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
- "agents",
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
+ }
@@ -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.