@guidobuilds/forge-ai 0.2.0 → 0.3.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 ADDED
@@ -0,0 +1,60 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ Versions prior to 0.3.0 are not reconstructed here; see git history for earlier changes.
9
+
10
+ ## [Unreleased]
11
+
12
+ ## [0.3.0] - 2026-05-15
13
+
14
+ ### Added
15
+
16
+ - Unified canonical **artifact** model: all sources live under `artifacts/<name>/<name>.md` and declare `kind: agent | skill` in frontmatter. Each per-platform block can override `kind`, so one artifact renders as an agent on one platform and a skill on another.
17
+ - `forge` orchestrator installs as a Claude Code **skill** (`/forge`) on Claude Code, while remaining a primary agent on OpenCode and Codex. This fixes the previous setup where `forge` was installed as a Claude subagent and could not delegate to `forge-worker` (Claude subagents cannot call `Task`).
18
+ - Installer **classifies each destination** as one of `new`, `managed-unmodified`, `managed-modified`, or `foreign`, and prints the status next to each file (`[refresh]`, `[overwrite, backup -> …]`, `[foreign overwrite]`, `[new]`).
19
+ - **Automatic backups** of user-edited Forge files before overwrite or prune, stored under `~/.forge-ai/backups/<scope>/<ISO-timestamp>/<relative-path>`. A single timestamp directory groups all backups from one run.
20
+ - **Combined confirmation prompt** in interactive mode listing every file that needs the user's decision (edited overwrites, edited deletions, foreign overwrites) with the destination backup directory.
21
+ - Non-interactive installs refuse with exit code 1 when there are edited or foreign files and neither `--yes` nor `--force` is set, instead of silently overwriting.
22
+ - New diagnostic codes: `MANAGED_FILE_OVERWRITE`, `FOREIGN_FILE_OVERWRITE`, `MISSING_KIND`, `INVALID_KIND`, `INVALID_PLATFORM_KIND`, `OPENCODE_MODE_ON_SKILL`, `SUPPORT_FILES_NOT_COPIED`.
23
+ - Claude tool and model validation (`src/adapters/claude-known.ts`): unknown tools/models in agent frontmatter emit warnings instead of being silently accepted.
24
+ - Skill source directories now allow sibling files (groundwork for future support-file bundling); detected files emit an `info` diagnostic noting that copying is not yet implemented.
25
+
26
+ ### Changed
27
+
28
+ - Source layout: `agents/` and `skills/` directories are gone; everything moved to `artifacts/<name>/<name>.md`.
29
+ - `forge-worker` agent and `forge-worker` skill (previously two files with the same name in separate namespaces) merged into a **single** `forge-worker` artifact rendered as a subagent on every platform. The artificial "thin agent loads a skill" indirection is removed.
30
+ - `package.json` `files` now ships `artifacts/` instead of `agents/` + `skills/`.
31
+ - Refreshing a managed-unmodified file is **silent** — no warning emitted. Previously every existing destination produced an indiscriminate `OVERWRITE_FORCED`.
32
+ - Stale-managed files the user edited are now **backed up and deleted** during `update` (when `--yes`/`--force`); previously they were left on disk with a `CHECKSUM_MISMATCH` warning. Interactive runs prompt before backing up + deleting.
33
+ - OpenCode `permissions` keys switched from boolean (`true`/`false`) to explicit strings (`allow`/`deny`); orchestrator now explicitly denies file/code operations.
34
+ - Adapters consume a unified `CanonicalArtifact` type (with `body` field) instead of separate `CanonicalAgent`/`CanonicalSkill` types.
35
+ - Frontmatter parsing now uses the `yaml` library (replaces the hand-rolled parser), with stricter spec compliance and better edge-case handling (colons in descriptions, horizontal rules in bodies, BOM, CRLF).
36
+ - Claude agent `tools` and skill `allowed-tools` are emitted as comma-separated strings (matches Claude Code's native format).
37
+ - Development tooling migrated from npm to **pnpm**: `packageManager: "pnpm@11.1.1"` pinned via Corepack (no global install needed); `pnpm.onlyBuiltDependencies: []` allowlist is explicit so postinstall script blocking is documented behavior. The published package is unaffected — consumers still install via `npx`.
38
+
39
+ ### Removed
40
+
41
+ - `CanonicalAgent` and `CanonicalSkill` types (replaced by `CanonicalArtifact`).
42
+ - `agents/` and `skills/` source directories.
43
+ - `DESTINATION_EXISTS` error and `OVERWRITE_FORCED` warning (replaced by status-aware `MANAGED_FILE_OVERWRITE` and `FOREIGN_FILE_OVERWRITE`).
44
+ - `CHECKSUM_MISMATCH` warning during prune (the file is now backed up and deleted on consent).
45
+ - `package-lock.json` (replaced by `pnpm-lock.yaml`).
46
+
47
+ ### Fixed
48
+
49
+ - `forge` orchestrator deployment to Claude Code now actually works: it is installed as a `/forge` skill in the main thread, which retains the `Task` tool and can delegate to the `forge-worker` subagent. The previous subagent install was inert because Claude subagents cannot call `Task`.
50
+ - Installer no longer raises spurious overwrite warnings for files Forge installed itself and that have not been edited.
51
+
52
+ ### Security
53
+
54
+ - Migrated the development workflow to pnpm. pnpm 10+ **blocks postinstall scripts by default** (`onlyBuiltDependencies` allowlist), enforces strict `node_modules` (no phantom dependencies), and uses an auditable text lockfile (`pnpm-lock.yaml`). The npm registry is the same, but install-time defaults are hardened.
55
+ - User edits to Forge-managed files are **always backed up** before being overwritten or deleted, eliminating silent data loss when running `update` against a customized install.
56
+
57
+ ### Migration from 0.2.0
58
+
59
+ - Run `npx @guidobuilds/forge-ai update` (not `install`) after upgrading. `update` prunes the now-orphaned `.claude/agents/forge.md` and the old standalone `forge-worker` skill from the previous layout. Local edits to any of those files are backed up automatically to `~/.forge-ai/backups/`.
60
+ - `install` (without `update`) will leave the orphaned files on disk. They are harmless but unmanaged.
package/README.md CHANGED
@@ -108,11 +108,13 @@ npx @guidobuilds/forge-ai validate --source .
108
108
 
109
109
  ## Local Development
110
110
 
111
+ Forge uses [pnpm](https://pnpm.io) for development (pinned via `packageManager` in `package.json`, so `corepack enable` is enough — no global install needed). The published package is still consumed by end users via `npx`/npm, unchanged.
112
+
111
113
  From a local checkout:
112
114
 
113
115
  ```sh
114
- npm install
115
- npm run build
116
+ pnpm install
117
+ pnpm run build
116
118
  node bin/forge-ai.mjs install --source . --platform all --scope user
117
119
  ```
118
120
 
@@ -134,6 +136,8 @@ Forge replaces its managed agent and skill definitions in your supported agent c
134
136
 
135
137
  Forge records installed files in manifests under `~/.forge-ai/` so updates can safely remove files that are no longer bundled. `update` prunes stale managed files by default only when the current file still matches the recorded checksum; use `--no-prune` to keep stale managed files. `--dry-run` previews writes and deletes without changing files or manifests.
136
138
 
139
+ Forge now routes a single canonical artifact to the right artifact kind per agent: the orchestrator installs as a Claude Code skill (`/forge`) but as an agent on OpenCode and Codex. If you installed an earlier version, run `update` (not `install`) so Forge prunes the now-stale `forge` agent and standalone `forge-worker` skill left by the previous layout.
140
+
137
141
  ## Uninstalling
138
142
 
139
143
  Remove Forge from the agent configuration directories for OpenCode, Codex, or Claude Code by deleting the installed Forge agent and skill entries.
@@ -1,15 +1,25 @@
1
1
  ---
2
2
  name: forge
3
3
  description: Forge orchestrator with dynamic runtime routing and a single worker type
4
+ kind: agent
4
5
  claude:
5
- permissions:
6
- tools: [Task, AskUserQuestion, TodoWrite]
6
+ kind: skill
7
7
  opencode:
8
8
  mode: primary
9
9
  permissions:
10
- task: true
11
- question: true
12
- todowrite: true
10
+ task: allow
11
+ question: allow
12
+ todowrite: allow
13
+ read: deny
14
+ write: deny
15
+ edit: deny
16
+ bash: deny
17
+ glob: deny
18
+ grep: deny
19
+ list: deny
20
+ patch: deny
21
+ skill: allow
22
+ webfetch: deny
13
23
  ---
14
24
 
15
25
  # Role
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: forge-grill
3
3
  description: Stress-test a plan or design through Forge orchestration, batching user questions while delegating codebase-answerable work to forge-worker.
4
+ kind: skill
4
5
  ---
5
6
 
6
7
  # Forge Grill Skill
@@ -1,15 +1,35 @@
1
1
  ---
2
2
  name: forge-worker
3
- description: Execute bounded Forge work across inspect, design, plan, build, operate, and verify modes.
3
+ description: Forge universal worker for inspect, design, plan, build, operate, and verify work
4
+ kind: agent
5
+ claude:
6
+ permissions:
7
+ tools: [TodoWrite, Read, Write, Edit, Bash, Glob, Grep, LS, MultiEdit, WebFetch]
8
+ opencode:
9
+ mode: subagent
10
+ permissions:
11
+ todowrite: allow
12
+ read: allow
13
+ write: allow
14
+ edit: allow
15
+ bash: allow
16
+ glob: allow
17
+ grep: allow
18
+ list: allow
19
+ patch: allow
20
+ skill: allow
21
+ webfetch: allow
4
22
  ---
5
23
 
6
- # Forge Worker Skill
24
+ # Forge Worker
7
25
 
8
26
  ## Role
9
27
  Execute only the subgoal assigned by the Forge orchestrator.
10
28
 
11
29
  You are a universal worker derived from Forge's existing explore, design, plan, build, and helper behaviors. Treat those as internal modes, not mandatory phases.
12
30
 
31
+ You are the only worker type in Forge. The orchestrator may launch multiple instances of you in parallel or sequence.
32
+
13
33
  ## Inputs
14
34
 
15
35
  - Orchestrator prompt with the assigned subgoal, constraints, approval context, and expected validation
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: using-forge
3
3
  description: Route work through the lightest safe Forge workflow using dynamic runtime routing.
4
+ kind: skill
4
5
  ---
5
6
 
6
7
  # Using Forge Skill
@@ -0,0 +1,31 @@
1
+ export const knownClaudeTools = new Set([
2
+ 'Read',
3
+ 'Write',
4
+ 'Edit',
5
+ 'MultiEdit',
6
+ 'NotebookEdit',
7
+ 'Bash',
8
+ 'Glob',
9
+ 'Grep',
10
+ 'LS',
11
+ 'Task',
12
+ 'TodoWrite',
13
+ 'WebFetch',
14
+ 'WebSearch',
15
+ 'AskUserQuestion',
16
+ 'ExitPlanMode'
17
+ ]);
18
+ const mcpToolPattern = /^mcp__[A-Za-z0-9_-]+__[A-Za-z0-9_*-]+$/;
19
+ export function isKnownClaudeTool(name) {
20
+ return knownClaudeTools.has(name) || mcpToolPattern.test(name);
21
+ }
22
+ export const knownClaudeModels = new Set([
23
+ 'sonnet',
24
+ 'opus',
25
+ 'haiku',
26
+ 'inherit'
27
+ ]);
28
+ const versionedModelPattern = /^claude-(?:sonnet|opus|haiku)-[A-Za-z0-9.-]+$/;
29
+ export function isKnownClaudeModel(value) {
30
+ return knownClaudeModels.has(value) || versionedModelPattern.test(value);
31
+ }
@@ -1,32 +1,44 @@
1
1
  import { stringifyYaml } from '../frontmatter.js';
2
2
  import { diagnostic } from '../diagnostics.js';
3
- import { isRecord, stringList } from './shared.js';
4
- export function renderClaudeAgent(agent) {
3
+ import { isKnownClaudeModel, isKnownClaudeTool } from './claude-known.js';
4
+ import { isRecord, patternList, stringList } from './shared.js';
5
+ export function renderClaudeAgent(artifact) {
5
6
  const diagnostics = [];
6
- const fm = { name: agent.name, description: agent.description };
7
- if (agent.claude?.model)
8
- fm.model = agent.claude.model;
9
- const permissions = agent.claude?.permissions;
7
+ const fm = { name: artifact.name, description: artifact.description };
8
+ if (artifact.claude?.model) {
9
+ fm.model = artifact.claude.model;
10
+ if (!isKnownClaudeModel(artifact.claude.model)) {
11
+ diagnostics.push(diagnostic('warning', 'CLAUDE_UNKNOWN_MODEL', `Unknown Claude model "${artifact.claude.model}" for ${artifact.name}`, { platform: 'claude' }));
12
+ }
13
+ }
14
+ const permissions = artifact.claude?.permissions;
10
15
  const tools = isRecord(permissions) ? stringList(permissions.tools) : stringList(permissions);
11
- if (tools)
12
- fm.tools = tools;
13
- else if (permissions !== undefined)
14
- diagnostics.push(diagnostic('info', 'CLAUDE_AGENT_TOOLS_IGNORED', `Claude agent permissions must be a tools string list for ${agent.name}`, { platform: 'claude' }));
15
- return { content: `${stringifyYaml(fm)}${agent.definition}\n`, diagnostics };
16
+ if (tools) {
17
+ fm.tools = tools.join(', ');
18
+ for (const tool of tools) {
19
+ if (!isKnownClaudeTool(tool)) {
20
+ diagnostics.push(diagnostic('warning', 'CLAUDE_UNKNOWN_TOOL', `Unknown Claude tool "${tool}" for ${artifact.name}`, { platform: 'claude' }));
21
+ }
22
+ }
23
+ }
24
+ else if (permissions !== undefined) {
25
+ diagnostics.push(diagnostic('info', 'CLAUDE_AGENT_TOOLS_IGNORED', `Claude agent permissions must be a tools string list for ${artifact.name}`, { platform: 'claude' }));
26
+ }
27
+ return { content: `${stringifyYaml(fm)}${artifact.body}\n`, diagnostics };
16
28
  }
17
- export function renderClaudeSkill(skill) {
29
+ export function renderClaudeSkill(artifact) {
18
30
  const diagnostics = [];
19
- const fm = { name: skill.name, description: skill.description };
20
- const permissions = skill.claude?.permissions;
21
- const allowedTools = isRecord(permissions) ? stringList(permissions['allowed-tools']) : undefined;
31
+ const fm = { name: artifact.name, description: artifact.description };
32
+ const permissions = artifact.claude?.permissions;
33
+ const allowedTools = isRecord(permissions) ? patternList(permissions['allowed-tools']) : undefined;
22
34
  if (allowedTools) {
23
- fm['allowed-tools'] = allowedTools;
24
- diagnostics.push(diagnostic('warning', 'CLAUDE_SKILL_ALLOWED_TOOLS', `Claude skill allowed-tools preapproves tools but does not universally restrict them for ${skill.name}`, { platform: 'claude' }));
35
+ fm['allowed-tools'] = allowedTools.join(', ');
36
+ diagnostics.push(diagnostic('warning', 'CLAUDE_SKILL_ALLOWED_TOOLS', `Claude skill allowed-tools preapproves tools but does not universally restrict them for ${artifact.name}`, { platform: 'claude' }));
25
37
  }
26
38
  else if (permissions !== undefined) {
27
- diagnostics.push(diagnostic('info', 'CLAUDE_SKILL_PERMISSIONS_IGNORED', `Claude skill permissions are not emitted for ${skill.name}`, { platform: 'claude' }));
39
+ diagnostics.push(diagnostic('info', 'CLAUDE_SKILL_PERMISSIONS_IGNORED', `Claude skill permissions are not emitted for ${artifact.name}`, { platform: 'claude' }));
28
40
  }
29
- if (skill.claude?.model)
30
- diagnostics.push(diagnostic('info', 'CLAUDE_SKILL_MODEL_IGNORED', `Claude skill model is not emitted for ${skill.name}`, { platform: 'claude' }));
31
- return { content: `${stringifyYaml(fm)}${skill.instructions}\n`, diagnostics };
41
+ if (artifact.claude?.model)
42
+ diagnostics.push(diagnostic('info', 'CLAUDE_SKILL_MODEL_IGNORED', `Claude skill model is not emitted for ${artifact.name}`, { platform: 'claude' }));
43
+ return { content: `${stringifyYaml(fm)}${artifact.body}\n`, diagnostics };
32
44
  }
@@ -2,28 +2,28 @@ import { stringifyYaml } from '../frontmatter.js';
2
2
  import { diagnostic } from '../diagnostics.js';
3
3
  import { isRecord, tomlString } from './shared.js';
4
4
  const safeSandboxModes = new Set(['read-only', 'workspace-write']);
5
- export function renderCodexAgent(agent) {
6
- const diagnostics = [diagnostic('info', 'CODEX_PARTIAL_AGENT_SUPPORT', `Codex agent output is partial and does not generate AGENTS.md or profiles for ${agent.name}`, { platform: 'codex' })];
7
- const lines = [`name = ${tomlString(agent.name)}`, `description = ${tomlString(agent.description)}`, `developer_instructions = ${tomlString(agent.definition)}`];
8
- if (agent.codex?.model)
9
- lines.push(`model = ${tomlString(agent.codex.model)}`);
10
- const permissions = agent.codex?.permissions;
5
+ export function renderCodexAgent(artifact) {
6
+ const diagnostics = [diagnostic('info', 'CODEX_PARTIAL_AGENT_SUPPORT', `Codex agent output is partial and does not generate AGENTS.md or profiles for ${artifact.name}`, { platform: 'codex' })];
7
+ const lines = [`name = ${tomlString(artifact.name)}`, `description = ${tomlString(artifact.description)}`, `developer_instructions = ${tomlString(artifact.body)}`];
8
+ if (artifact.codex?.model)
9
+ lines.push(`model = ${tomlString(artifact.codex.model)}`);
10
+ const permissions = artifact.codex?.permissions;
11
11
  if (isRecord(permissions) && typeof permissions.sandbox_mode === 'string') {
12
12
  if (safeSandboxModes.has(permissions.sandbox_mode))
13
13
  lines.push(`sandbox_mode = ${tomlString(permissions.sandbox_mode)}`);
14
14
  else
15
- diagnostics.push(diagnostic('warning', 'CODEX_UNSAFE_SANDBOX_IGNORED', `Unsafe Codex sandbox_mode ignored for ${agent.name}`, { platform: 'codex' }));
15
+ diagnostics.push(diagnostic('warning', 'CODEX_UNSAFE_SANDBOX_IGNORED', `Unsafe Codex sandbox_mode ignored for ${artifact.name}`, { platform: 'codex' }));
16
16
  }
17
17
  else if (permissions !== undefined) {
18
- diagnostics.push(diagnostic('info', 'CODEX_AGENT_PERMISSIONS_IGNORED', `Codex agent permissions are not emitted for ${agent.name}`, { platform: 'codex' }));
18
+ diagnostics.push(diagnostic('info', 'CODEX_AGENT_PERMISSIONS_IGNORED', `Codex agent permissions are not emitted for ${artifact.name}`, { platform: 'codex' }));
19
19
  }
20
20
  return { content: `${lines.join('\n')}\n`, diagnostics };
21
21
  }
22
- export function renderCodexSkill(skill) {
22
+ export function renderCodexSkill(artifact) {
23
23
  const diagnostics = [];
24
- if (skill.codex?.permissions)
25
- diagnostics.push(diagnostic('info', 'CODEX_SKILL_PERMISSIONS_IGNORED', `Codex skill permissions are not emitted for ${skill.name}`, { platform: 'codex' }));
26
- if (skill.codex?.model)
27
- diagnostics.push(diagnostic('info', 'CODEX_SKILL_MODEL_IGNORED', `Codex skill model is not emitted for ${skill.name}`, { platform: 'codex' }));
28
- return { content: `${stringifyYaml({ name: skill.name, description: skill.description })}${skill.instructions}\n`, diagnostics };
24
+ if (artifact.codex?.permissions)
25
+ diagnostics.push(diagnostic('info', 'CODEX_SKILL_PERMISSIONS_IGNORED', `Codex skill permissions are not emitted for ${artifact.name}`, { platform: 'codex' }));
26
+ if (artifact.codex?.model)
27
+ diagnostics.push(diagnostic('info', 'CODEX_SKILL_MODEL_IGNORED', `Codex skill model is not emitted for ${artifact.name}`, { platform: 'codex' }));
28
+ return { content: `${stringifyYaml({ name: artifact.name, description: artifact.description })}${artifact.body}\n`, diagnostics };
29
29
  }
@@ -1,20 +1,20 @@
1
1
  import { stringifyYaml } from '../frontmatter.js';
2
2
  import { diagnostic } from '../diagnostics.js';
3
- export function renderOpenCodeAgent(agent) {
4
- const fm = { description: agent.description };
5
- if (agent.opencode?.mode)
6
- fm.mode = agent.opencode.mode;
7
- if (agent.opencode?.model)
8
- fm.model = agent.opencode.model;
9
- if (agent.opencode?.permissions)
10
- fm.permission = agent.opencode.permissions;
11
- return { content: `${stringifyYaml(fm)}${agent.definition}\n`, diagnostics: [] };
3
+ export function renderOpenCodeAgent(artifact) {
4
+ const fm = { description: artifact.description };
5
+ if (artifact.opencode?.mode)
6
+ fm.mode = artifact.opencode.mode;
7
+ if (artifact.opencode?.model)
8
+ fm.model = artifact.opencode.model;
9
+ if (artifact.opencode?.permissions)
10
+ fm.permission = artifact.opencode.permissions;
11
+ return { content: `${stringifyYaml(fm)}${artifact.body}\n`, diagnostics: [] };
12
12
  }
13
- export function renderOpenCodeSkill(skill) {
13
+ export function renderOpenCodeSkill(artifact) {
14
14
  const diagnostics = [];
15
- if (skill.opencode?.permissions)
16
- diagnostics.push(diagnostic('info', 'OPENCODE_SKILL_PERMISSIONS_IGNORED', `OpenCode skill permissions are not emitted for ${skill.name}`, { platform: 'opencode' }));
17
- if (skill.opencode?.model)
18
- diagnostics.push(diagnostic('info', 'OPENCODE_SKILL_MODEL_IGNORED', `OpenCode skill model is not emitted for ${skill.name}`, { platform: 'opencode' }));
19
- return { content: `${stringifyYaml({ name: skill.name, description: skill.description })}${skill.instructions}\n`, diagnostics };
15
+ if (artifact.opencode?.permissions)
16
+ diagnostics.push(diagnostic('info', 'OPENCODE_SKILL_PERMISSIONS_IGNORED', `OpenCode skill permissions are not emitted for ${artifact.name}`, { platform: 'opencode' }));
17
+ if (artifact.opencode?.model)
18
+ diagnostics.push(diagnostic('info', 'OPENCODE_SKILL_MODEL_IGNORED', `OpenCode skill model is not emitted for ${artifact.name}`, { platform: 'opencode' }));
19
+ return { content: `${stringifyYaml({ name: artifact.name, description: artifact.description })}${artifact.body}\n`, diagnostics };
20
20
  }
@@ -1,8 +1,13 @@
1
1
  export function isRecord(value) {
2
2
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
3
3
  }
4
+ const toolNamePattern = /^[A-Za-z0-9_*-]+$/;
5
+ const patternBodyPattern = /^[^,\n]+$/;
4
6
  export function stringList(value) {
5
- return Array.isArray(value) && value.every((item) => typeof item === 'string' && /^[A-Za-z0-9_*.,:-]+$/.test(item)) ? value : undefined;
7
+ return Array.isArray(value) && value.every((item) => typeof item === 'string' && toolNamePattern.test(item)) ? value : undefined;
8
+ }
9
+ export function patternList(value) {
10
+ return Array.isArray(value) && value.every((item) => typeof item === 'string' && item.length > 0 && patternBodyPattern.test(item)) ? value : undefined;
6
11
  }
7
12
  export function tomlString(value) {
8
13
  return JSON.stringify(value);
package/dist/src/cli.js CHANGED
@@ -6,9 +6,11 @@ import os from 'node:os';
6
6
  import path from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { formatDiagnostic, hasErrors } from './diagnostics.js';
9
- import { buildManifest, classifyPruneEntries, loadManifest, pruneEntries, resolveManifestLocation, saveManifest, staleEntries } from './manifest.js';
9
+ import { buildManifest, classifyPruneEntries, loadManifest, pruneEntries, resolveBackupPath, resolveBackupRoot, resolveManifestLocation, saveManifest, staleEntries } from './manifest.js';
10
10
  import { buildWritePlan, parsePlatform, parseScope } from './processor.js';
11
11
  import { writeOutputs } from './writer.js';
12
+ import { hasPendingDecisions } from './model.js';
13
+ const emptyPrunePlan = { deletable: [], modifiedWithConsent: [], skippedMissing: [] };
12
14
  export async function main(argv = process.argv.slice(2), promptIO = {}) {
13
15
  if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
14
16
  showUsage();
@@ -48,27 +50,46 @@ export async function main(argv = process.argv.slice(2), promptIO = {}) {
48
50
  }
49
51
  const cwd = process.cwd();
50
52
  const home = resolveHome(promptIO);
51
- let plan = await buildWritePlan({ source: options.source, platform: options.platform, scope: options.scope, cwd, home, checkCollisions: install && !options.dryRun, force: options.force });
52
- if (install && !options.dryRun && !options.force && canOfferUpdate(plan.diagnostics)) {
53
- const accepted = await promptForUpdate(plan, promptIO);
53
+ const now = new Date();
54
+ let manifestLocation;
55
+ let oldManifest;
56
+ let backupRoot;
57
+ if (install) {
58
+ manifestLocation = await resolveManifestLocation(options.scope, cwd, home);
59
+ oldManifest = await loadManifest(manifestLocation.manifestPath);
60
+ backupRoot = resolveBackupRoot(manifestLocation, now);
61
+ }
62
+ const plan = await buildWritePlan({
63
+ source: options.source,
64
+ platform: options.platform,
65
+ scope: options.scope,
66
+ cwd,
67
+ home,
68
+ manifest: oldManifest,
69
+ backupRoot,
70
+ checkCollisions: install,
71
+ });
72
+ let prunePlan = emptyPrunePlan;
73
+ if (install && command === 'update' && options.prune) {
74
+ prunePlan = await classifyPrune(oldManifest, plan.files, backupRoot, options.scope, cwd, home);
75
+ }
76
+ const needsConfirm = install && !options.force && (hasPendingDecisions(plan.pending) || prunePlan.modifiedWithConsent.length > 0);
77
+ if (install && !options.dryRun && needsConfirm) {
78
+ if (!interactive) {
79
+ printPlan(command, plan.sourceCount, plan.files, plan.diagnostics, prunePlan);
80
+ console.error('Forge needs your decision on edited or untracked files; re-run with --yes or --force to accept overwrites + backups.');
81
+ return 1;
82
+ }
83
+ const accepted = await promptForUpdate(plan, prunePlan, backupRoot, promptIO);
54
84
  if (accepted === undefined) {
55
- if (interactive)
56
- p.cancel('Cancelled', clackIO(promptIO));
85
+ p.cancel('Cancelled', clackIO(promptIO));
57
86
  return 1;
58
87
  }
59
- if (accepted) {
60
- options.force = true;
61
- plan = await buildWritePlan({ source: options.source, platform: options.platform, scope: options.scope, cwd, home, checkCollisions: true, force: true });
88
+ if (!accepted) {
89
+ p.outro(pc.yellow('Forge was not installed.'), clackIO(promptIO));
90
+ return 1;
62
91
  }
63
92
  }
64
- let prunePlan = { deletable: [], skipped: [] };
65
- let manifestLocation;
66
- if (install) {
67
- manifestLocation = await resolveManifestLocation(options.scope, cwd, home);
68
- const oldManifest = await loadManifest(manifestLocation.manifestPath);
69
- if (command === 'update' && options.prune)
70
- prunePlan = await classifyPruneEntries(staleEntries(oldManifest, plan.files));
71
- }
72
93
  printPlan(command, plan.sourceCount, plan.files, plan.diagnostics, prunePlan);
73
94
  if (hasErrors(plan.diagnostics)) {
74
95
  if (interactive)
@@ -82,8 +103,8 @@ export async function main(argv = process.argv.slice(2), promptIO = {}) {
82
103
  try {
83
104
  await writeOutputs(plan.files);
84
105
  if (command === 'update' && options.prune)
85
- await pruneEntries(prunePlan.deletable);
86
- await saveManifest(manifestLocation.manifestPath, buildManifest(manifestLocation, plan.files));
106
+ await pruneEntries([...prunePlan.deletable, ...prunePlan.modifiedWithConsent]);
107
+ await saveManifest(manifestLocation.manifestPath, await buildManifest(manifestLocation, plan.files));
87
108
  spinner.stop(`Wrote ${plan.files.length} file(s).`);
88
109
  }
89
110
  catch (error) {
@@ -94,11 +115,12 @@ export async function main(argv = process.argv.slice(2), promptIO = {}) {
94
115
  else {
95
116
  await writeOutputs(plan.files);
96
117
  if (command === 'update' && options.prune)
97
- await pruneEntries(prunePlan.deletable);
98
- await saveManifest(manifestLocation.manifestPath, buildManifest(manifestLocation, plan.files));
118
+ await pruneEntries([...prunePlan.deletable, ...prunePlan.modifiedWithConsent]);
119
+ await saveManifest(manifestLocation.manifestPath, await buildManifest(manifestLocation, plan.files));
99
120
  console.log(`Wrote ${plan.files.length} file(s).`);
100
- if (command === 'update' && options.prune && prunePlan.deletable.length > 0)
101
- console.log(`Deleted ${prunePlan.deletable.length} stale file(s).`);
121
+ const totalDeleted = prunePlan.deletable.length + prunePlan.modifiedWithConsent.length;
122
+ if (command === 'update' && options.prune && totalDeleted > 0)
123
+ console.log(`Deleted ${totalDeleted} stale file(s).`);
102
124
  console.log(`Updated manifest ${manifestLocation.manifestPath}.`);
103
125
  }
104
126
  }
@@ -156,6 +178,20 @@ function parseArgs(argv) {
156
178
  return { error: 'validate only accepts --platform and --source' };
157
179
  return { options };
158
180
  }
181
+ async function classifyPrune(oldManifest, files, backupRoot, scope, cwd, home) {
182
+ const stale = staleEntries(oldManifest, files);
183
+ const classified = await classifyPruneEntries(stale);
184
+ const anchor = scope === 'user' ? home : cwd;
185
+ const modifiedWithConsent = [];
186
+ const skippedMissing = [];
187
+ for (const item of classified.skipped) {
188
+ if (item.reason === 'checksum-mismatch')
189
+ modifiedWithConsent.push({ ...item, backupPath: resolveBackupPath(backupRoot, item.path, anchor) });
190
+ else
191
+ skippedMissing.push(item);
192
+ }
193
+ return { deletable: classified.deletable, modifiedWithConsent, skippedMissing };
194
+ }
159
195
  async function promptForMissingInstallOptions(options, promptIO) {
160
196
  if (options.yes)
161
197
  return true;
@@ -196,18 +232,27 @@ async function promptForMissingInstallOptions(options, promptIO) {
196
232
  }
197
233
  return true;
198
234
  }
199
- async function promptForUpdate(plan, promptIO) {
235
+ async function promptForUpdate(plan, prunePlan, backupRoot, promptIO) {
200
236
  if (!isInteractivePrompt(promptIO))
201
237
  return false;
202
- const existing = plan.diagnostics.filter((item) => item.code === 'DESTINATION_EXISTS');
203
- const count = existing.length;
204
- p.log.warn(`${count} Forge output${count === 1 ? '' : 's'} already exist.`, clackIO(promptIO));
238
+ const io = clackIO(promptIO);
239
+ const sections = [];
240
+ if (plan.pending.modifiedOverwrites.length > 0) {
241
+ sections.push(`${pc.yellow('Edited by you, will be overwritten (backup):')}\n${plan.pending.modifiedOverwrites.map((file) => ` - ${file.path}`).join('\n')}`);
242
+ }
243
+ if (prunePlan.modifiedWithConsent.length > 0) {
244
+ sections.push(`${pc.yellow('Edited by you, will be deleted (backup):')}\n${prunePlan.modifiedWithConsent.map((entry) => ` - ${entry.path}`).join('\n')}`);
245
+ }
246
+ if (plan.pending.foreignOverwrites.length > 0) {
247
+ sections.push(`${pc.yellow('Untracked files in Forge install paths, will be overwritten:')}\n${plan.pending.foreignOverwrites.map((file) => ` - ${file.path}`).join('\n')}`);
248
+ }
249
+ p.log.warn(`The following actions need your confirmation:\n\n${sections.join('\n\n')}\n\nBackups → ${backupRoot ?? '(none)'}`, io);
205
250
  const accepted = await p.confirm({
206
- message: 'Update the existing Forge files?',
207
- active: 'Update',
251
+ message: 'Continue with overwrites + backups?',
252
+ active: 'Continue',
208
253
  inactive: 'Cancel',
209
254
  initialValue: false,
210
- ...clackIO(promptIO)
255
+ ...io
211
256
  });
212
257
  if (p.isCancel(accepted))
213
258
  return undefined;
@@ -222,10 +267,6 @@ function normalizeCommand(command) {
222
267
  return 'validate';
223
268
  return undefined;
224
269
  }
225
- function canOfferUpdate(diagnostics) {
226
- const errors = diagnostics.filter((item) => item.severity === 'error');
227
- return errors.length > 0 && errors.every((item) => item.code === 'DESTINATION_EXISTS');
228
- }
229
270
  function isInteractivePrompt(promptIO) {
230
271
  const env = promptIO.env ?? process.env;
231
272
  const interactive = promptIO.isInteractive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
@@ -254,19 +295,32 @@ function showUsage() {
254
295
  console.log(' forge-ai update [--platform opencode|claude|codex|all] [--scope user|project] [--source <dir>] [--dry-run] [--no-prune] [--yes]');
255
296
  console.log(' forge-ai validate [--platform opencode|claude|codex|all] [--source <dir>]');
256
297
  }
257
- function printPlan(command, sourceCount, files, diagnostics, prunePlan = { deletable: [], skipped: [] }) {
298
+ function printPlan(command, sourceCount, files, diagnostics, prunePlan) {
258
299
  console.log(`${command}: ${sourceCount} source(s), ${files.length} output(s)`);
259
300
  for (const file of files)
260
- console.log(`- ${file.platform} ${file.kind} ${file.name} -> ${file.path}`);
261
- for (const file of prunePlan.deletable)
262
- console.log(`- delete stale ${file.platform} ${file.kind} ${file.name} -> ${file.path}`);
263
- for (const file of prunePlan.skipped) {
264
- if (file.reason === 'checksum-mismatch')
265
- console.log(`warning CHECKSUM_MISMATCH: Skipping stale managed file with local changes ${file.path}`);
266
- }
301
+ console.log(`- ${file.platform} ${file.kind} ${file.name} -> ${file.path}${statusSuffix(file)}`);
302
+ for (const item of prunePlan.deletable)
303
+ console.log(`- delete stale ${item.platform} ${item.kind} ${item.name} -> ${item.path}`);
304
+ for (const item of prunePlan.modifiedWithConsent)
305
+ console.log(`- delete stale ${item.platform} ${item.kind} ${item.name} -> ${item.path} [backup -> ${item.backupPath}]`);
306
+ for (const item of prunePlan.skippedMissing)
307
+ console.log(`- skip missing ${item.platform} ${item.kind} ${item.name} -> ${item.path}`);
267
308
  for (const item of diagnostics)
268
309
  console.log(formatDiagnostic(item));
269
310
  }
311
+ function statusSuffix(file) {
312
+ if (file.status === 'managed-modified' && file.backupPath)
313
+ return ` [overwrite, backup -> ${file.backupPath}]`;
314
+ if (file.status === 'managed-modified')
315
+ return ' [overwrite]';
316
+ if (file.status === 'foreign')
317
+ return ' [foreign overwrite]';
318
+ if (file.status === 'managed-unmodified')
319
+ return ' [refresh]';
320
+ if (file.status === 'new')
321
+ return ' [new]';
322
+ return '';
323
+ }
270
324
  if (import.meta.url === `file://${process.argv[1]}`) {
271
325
  main().then((code) => { process.exitCode = code; }, (error) => { console.error(error); process.exitCode = 1; });
272
326
  }
@@ -6,35 +6,33 @@ export async function discoverSources(source) {
6
6
  const root = path.resolve(source);
7
7
  const sources = [];
8
8
  const diagnostics = [];
9
- await discoverAgents(root, sources, diagnostics);
10
- await discoverSkills(root, sources, diagnostics);
9
+ await discoverArtifacts(root, sources, diagnostics);
11
10
  if (sources.length === 0)
12
- diagnostics.push(diagnostic('error', 'NO_SOURCES', 'No canonical agents or skills found', { sourcePath: root }));
11
+ diagnostics.push(diagnostic('error', 'NO_SOURCES', 'No canonical artifacts found', { sourcePath: root }));
13
12
  sources.sort((a, b) => a.sourcePath.localeCompare(b.sourcePath));
14
13
  return { sources, diagnostics };
15
14
  }
16
- async function discoverAgents(root, sources, diagnostics) {
17
- const dir = path.join(root, 'agents');
18
- for (const entry of await safeReaddir(dir)) {
19
- if (!entry.isFile() || !entry.name.endsWith('.md'))
20
- continue;
21
- const sourcePath = path.join(dir, entry.name);
22
- await readSource('agent', sourcePath, path.basename(entry.name, '.md'), sources, diagnostics);
23
- }
24
- }
25
- async function discoverSkills(root, sources, diagnostics) {
26
- const dir = path.join(root, 'skills');
15
+ async function discoverArtifacts(root, sources, diagnostics) {
16
+ const dir = path.join(root, 'artifacts');
27
17
  for (const entry of await safeReaddir(dir)) {
28
18
  if (!entry.isDirectory())
29
19
  continue;
30
- const sourcePath = path.join(dir, entry.name, 'SKILL.md');
31
- await readSource('skill', sourcePath, entry.name, sources, diagnostics);
20
+ const name = entry.name;
21
+ const artifactDir = path.join(dir, name);
22
+ const mainFile = `${name}.md`;
23
+ const supportFiles = (await safeReaddir(artifactDir))
24
+ .filter((file) => file.isFile() && file.name !== mainFile)
25
+ .map((file) => path.join(artifactDir, file.name));
26
+ await readSource(path.join(artifactDir, mainFile), name, supportFiles, sources, diagnostics);
32
27
  }
33
28
  }
34
- async function readSource(kind, sourcePath, expectedName, sources, diagnostics) {
29
+ async function readSource(sourcePath, expectedName, supportFiles, sources, diagnostics) {
35
30
  try {
36
31
  const parsed = parseFrontmatter(await readFile(sourcePath, 'utf8'));
37
- sources.push({ kind, sourcePath, expectedName, data: parsed.data, body: parsed.body });
32
+ sources.push({ sourcePath, expectedName, data: parsed.data, body: parsed.body, supportFiles: supportFiles.length > 0 ? supportFiles : undefined });
33
+ if (supportFiles.length > 0) {
34
+ diagnostics.push(diagnostic('info', 'SUPPORT_FILES_NOT_COPIED', `Support files alongside ${expectedName} are not copied yet`, { sourcePath }));
35
+ }
38
36
  }
39
37
  catch (error) {
40
38
  diagnostics.push(diagnostic('error', 'PARSE_ERROR', error instanceof Error ? error.message : String(error), { sourcePath }));
@@ -1,5 +1,6 @@
1
+ import { parse, stringify } from 'yaml';
1
2
  export function parseFrontmatter(content) {
2
- const normalized = content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
3
+ const normalized = content.replace(/^/, '').replace(/\r\n/g, '\n');
3
4
  if (!normalized.startsWith('---\n')) {
4
5
  return { data: {}, body: normalized.trim() };
5
6
  }
@@ -8,74 +9,13 @@ export function parseFrontmatter(content) {
8
9
  throw new Error('Missing closing frontmatter delimiter');
9
10
  const rawYaml = normalized.slice(4, end);
10
11
  const body = normalized.slice(normalized.indexOf('\n', end + 1) + 1).trim();
11
- return { data: parseSimpleYaml(rawYaml), body };
12
- }
13
- export function parseSimpleYaml(input) {
14
- const root = {};
15
- const stack = [{ indent: -1, object: root }];
16
- const lines = input.split('\n');
17
- for (let index = 0; index < lines.length; index += 1) {
18
- const raw = lines[index];
19
- if (!raw.trim() || raw.trimStart().startsWith('#'))
20
- continue;
21
- const indent = raw.match(/^ */)?.[0].length ?? 0;
22
- const trimmed = raw.trim();
23
- const match = trimmed.match(/^([A-Za-z0-9_-]+):(?:\s*(.*))?$/);
24
- if (!match)
25
- throw new Error(`Invalid YAML at line ${index + 1}`);
26
- while (stack.length > 1 && indent <= stack[stack.length - 1].indent)
27
- stack.pop();
28
- const parent = stack[stack.length - 1].object;
29
- const key = match[1];
30
- const value = match[2] ?? '';
31
- if (value === '') {
32
- const child = {};
33
- parent[key] = child;
34
- stack.push({ indent, object: child });
35
- }
36
- else {
37
- parent[key] = parseScalar(value);
38
- }
39
- }
40
- return root;
41
- }
42
- function parseScalar(value) {
43
- const trimmed = value.trim();
44
- if (trimmed === 'true')
45
- return true;
46
- if (trimmed === 'false')
47
- return false;
48
- if (trimmed === 'null')
49
- return null;
50
- if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
51
- const inner = trimmed.slice(1, -1).trim();
52
- if (!inner)
53
- return [];
54
- return inner.split(',').map((item) => String(parseScalar(item.trim())));
55
- }
56
- if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
57
- return trimmed.slice(1, -1);
12
+ const parsed = rawYaml.trim() === '' ? {} : parse(rawYaml);
13
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
14
+ throw new Error('Frontmatter must be a YAML mapping');
58
15
  }
59
- return trimmed;
16
+ return { data: parsed, body };
60
17
  }
61
18
  export function stringifyYaml(data) {
62
- const lines = Object.entries(data).flatMap(([key, value]) => stringifyYamlValue(key, value, 0));
63
- return `---\n${lines.join('\n')}\n---\n\n`;
64
- }
65
- function stringifyYamlValue(key, value, indent) {
66
- const prefix = ' '.repeat(indent);
67
- if (value && typeof value === 'object' && !Array.isArray(value)) {
68
- return [`${prefix}${key}:`, ...Object.entries(value).flatMap(([childKey, childValue]) => stringifyYamlValue(childKey, childValue, indent + 2))];
69
- }
70
- if (Array.isArray(value))
71
- return [`${prefix}${key}: [${value.map(formatScalar).join(', ')}]`];
72
- return [`${prefix}${key}: ${formatScalar(value)}`];
73
- }
74
- function formatScalar(value) {
75
- if (typeof value === 'boolean')
76
- return value ? 'true' : 'false';
77
- if (typeof value === 'number')
78
- return String(value);
79
- const text = String(value ?? '');
80
- return /^[A-Za-z0-9_./,@* -]+$/.test(text) && text !== '' ? text : JSON.stringify(text);
19
+ const yaml = stringify(data, { lineWidth: 0, defaultKeyType: 'PLAIN' });
20
+ return `---\n${yaml}---\n\n`;
81
21
  }
@@ -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
@@ -2,3 +2,6 @@ export const platforms = ['opencode', 'claude', 'codex'];
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
+ }
@@ -1,4 +1,4 @@
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';
@@ -6,14 +6,16 @@ import { renderCodexAgent, renderCodexSkill } from './adapters/codex.js';
6
6
  import { renderOpenCodeAgent, renderOpenCodeSkill } from './adapters/opencode.js';
7
7
  import { diagnostic } from './diagnostics.js';
8
8
  import { discoverSources } from './discovery.js';
9
+ import { lookupEntryByPath, resolveBackupPath, sha256 } from './manifest.js';
9
10
  import { resolveOutputPath } from './paths.js';
10
11
  import { isPlatform, platforms } from './model.js';
11
12
  const namePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
12
13
  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 allowedTopLevel = new Set(['name', 'description', 'kind', 'claude', 'opencode', 'codex']);
15
+ const allowedProductKeys = new Set(['permissions', 'model', 'kind']);
15
16
  const allowedOpenCodeKeys = new Set([...allowedProductKeys, 'mode']);
16
17
  const openCodeModes = new Set(['primary', 'subagent', 'all']);
18
+ const artifactKinds = new Set(['agent', 'skill']);
17
19
  export function resolvePlatforms(platform) {
18
20
  return platform === 'all' ? platforms : [platform];
19
21
  }
@@ -25,45 +27,43 @@ export function parseScope(value) {
25
27
  }
26
28
  export async function buildWritePlan(options) {
27
29
  const { sources, diagnostics } = await discoverSources(options.source);
28
- const agents = [];
29
- const skills = [];
30
- const seenAgents = new Set();
31
- const seenSkills = new Set();
30
+ const artifacts = [];
31
+ const seen = new Set();
32
32
  for (const source of sources) {
33
33
  const converted = convertSource(source, options.source);
34
34
  diagnostics.push(...converted.diagnostics);
35
35
  if (!converted.item)
36
36
  continue;
37
- const seen = source.kind === 'agent' ? seenAgents : seenSkills;
38
37
  if (seen.has(converted.item.name)) {
39
- diagnostics.push(diagnostic('error', 'DUPLICATE_NAME', `Duplicate ${source.kind} name ${converted.item.name}`, { sourcePath: source.sourcePath }));
38
+ diagnostics.push(diagnostic('error', 'DUPLICATE_NAME', `Duplicate artifact name ${converted.item.name}`, { sourcePath: source.sourcePath }));
40
39
  continue;
41
40
  }
42
41
  seen.add(converted.item.name);
43
- if (source.kind === 'agent')
44
- agents.push(converted.item);
45
- else
46
- skills.push(converted.item);
42
+ artifacts.push(converted.item);
47
43
  }
48
44
  const files = [];
45
+ const pending = { modifiedOverwrites: [], foreignOverwrites: [] };
49
46
  if (!diagnostics.some((item) => item.severity === 'error')) {
50
47
  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));
48
+ for (const artifact of artifacts) {
49
+ const effectiveKind = artifact[platform]?.kind ?? artifact.kind;
50
+ files.push(renderFile(platform, effectiveKind, artifact, options, diagnostics));
51
+ }
55
52
  }
56
53
  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)));
54
+ if (options.checkCollisions) {
55
+ const anchor = options.scope === 'user' ? (options.home ?? '') : (options.cwd ?? '');
56
+ diagnostics.push(...await classifyDestinations(files, options.manifest, options.backupRoot, anchor, pending));
57
+ }
59
58
  }
60
- return { files, diagnostics, sourceCount: sources.length };
59
+ return { files, diagnostics, pending, sourceCount: sources.length };
61
60
  }
62
61
  function convertSource(source, sourceRoot) {
63
62
  const diagnostics = [];
64
63
  const data = source.data;
65
64
  const name = typeof data.name === 'string' ? data.name : undefined;
66
65
  const description = typeof data.description === 'string' ? data.description : undefined;
66
+ const kind = data.kind;
67
67
  for (const key of Object.keys(data)) {
68
68
  if (!allowedTopLevel.has(key))
69
69
  diagnostics.push(diagnostic('error', 'UNSUPPORTED_FIELD', `Unsupported canonical field ${key}`, { sourcePath: source.sourcePath }));
@@ -76,53 +76,95 @@ function convertSource(source, sourceRoot) {
76
76
  diagnostics.push(diagnostic('error', 'INVALID_PLATFORM_BLOCK', `${platform} must be an object`, { sourcePath: source.sourcePath, platform: platform }));
77
77
  continue;
78
78
  }
79
- for (const key of Object.keys(config)) {
80
- const allowedKeys = platform === 'opencode' && source.kind === 'agent' ? allowedOpenCodeKeys : allowedProductKeys;
79
+ const record = config;
80
+ for (const key of Object.keys(record)) {
81
+ const allowedKeys = platform === 'opencode' ? allowedOpenCodeKeys : allowedProductKeys;
81
82
  if (!allowedKeys.has(key))
82
83
  diagnostics.push(diagnostic('error', 'UNSUPPORTED_PLATFORM_FIELD', `${platform}.${key} is not supported in the MVP`, { sourcePath: source.sourcePath, platform: platform }));
83
84
  }
84
- if ('model' in config && typeof config.model !== 'string') {
85
+ if ('model' in record && typeof record.model !== 'string') {
85
86
  diagnostics.push(diagnostic('error', 'INVALID_PLATFORM_MODEL', `${platform}.model must be a string`, { sourcePath: source.sourcePath, platform: platform }));
86
87
  }
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' }));
88
+ if ('kind' in record && !artifactKinds.has(record.kind)) {
89
+ diagnostics.push(diagnostic('error', 'INVALID_PLATFORM_KIND', `${platform}.kind must be one of agent, skill`, { sourcePath: source.sourcePath, platform: platform }));
90
+ }
91
+ if (platform === 'opencode' && 'mode' in record) {
92
+ if (!openCodeModes.has(record.mode)) {
93
+ diagnostics.push(diagnostic('error', 'INVALID_OPENCODE_MODE', 'opencode.mode must be one of primary, subagent, all', { sourcePath: source.sourcePath, platform: 'opencode' }));
94
+ }
95
+ const effectiveKind = record.kind ?? kind;
96
+ if (effectiveKind !== 'agent') {
97
+ 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' }));
98
+ }
89
99
  }
90
100
  }
91
101
  if (!name)
92
- diagnostics.push(diagnostic('error', 'MISSING_NAME', `${source.kind} name is required`, { sourcePath: source.sourcePath }));
102
+ diagnostics.push(diagnostic('error', 'MISSING_NAME', 'artifact name is required', { sourcePath: source.sourcePath }));
93
103
  if (name && !namePattern.test(name))
94
- diagnostics.push(diagnostic('error', 'INVALID_NAME', `${source.kind} name must be kebab-case`, { sourcePath: source.sourcePath }));
104
+ diagnostics.push(diagnostic('error', 'INVALID_NAME', 'artifact name must be kebab-case', { sourcePath: source.sourcePath }));
95
105
  if (name && name !== source.expectedName)
96
- diagnostics.push(diagnostic('error', 'NAME_MISMATCH', `${source.kind} name must match ${source.expectedName}`, { sourcePath: source.sourcePath }));
106
+ diagnostics.push(diagnostic('error', 'NAME_MISMATCH', `artifact name must match ${source.expectedName}`, { sourcePath: source.sourcePath }));
97
107
  if (!description)
98
- diagnostics.push(diagnostic('error', 'MISSING_DESCRIPTION', `${source.kind} description is required`, { sourcePath: source.sourcePath }));
108
+ diagnostics.push(diagnostic('error', 'MISSING_DESCRIPTION', 'artifact description is required', { sourcePath: source.sourcePath }));
109
+ if (kind === undefined)
110
+ diagnostics.push(diagnostic('error', 'MISSING_KIND', 'artifact kind is required (agent or skill)', { sourcePath: source.sourcePath }));
111
+ else if (!artifactKinds.has(kind))
112
+ diagnostics.push(diagnostic('error', 'INVALID_KIND', 'artifact kind must be one of agent, skill', { sourcePath: source.sourcePath }));
99
113
  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'))
114
+ diagnostics.push(diagnostic('error', 'EMPTY_BODY', 'artifact body is required', { sourcePath: source.sourcePath }));
115
+ if (!name || !description || !artifactKinds.has(kind) || !source.body.trim() || diagnostics.some((item) => item.severity === 'error'))
102
116
  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 } };
117
+ return {
118
+ diagnostics,
119
+ item: {
120
+ name,
121
+ description,
122
+ kind: kind,
123
+ body: source.body,
124
+ sourcePath: path.relative(path.resolve(sourceRoot), source.sourcePath),
125
+ claude: productConfig(data.claude),
126
+ opencode: productConfig(data.opencode),
127
+ codex: productConfig(data.codex)
128
+ }
129
+ };
105
130
  }
106
131
  function productConfig(value) {
107
132
  return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
108
133
  }
109
- function renderFile(platform, kind, item, options, diagnostics) {
134
+ function renderFile(platform, kind, artifact, options, diagnostics) {
110
135
  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);
136
+ ? platform === 'opencode' ? renderOpenCodeAgent(artifact) : platform === 'claude' ? renderClaudeAgent(artifact) : renderCodexAgent(artifact)
137
+ : platform === 'opencode' ? renderOpenCodeSkill(artifact) : platform === 'claude' ? renderClaudeSkill(artifact) : renderCodexSkill(artifact);
113
138
  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 };
139
+ 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
140
  }
116
- async function collisionDiagnostics(files, force) {
141
+ async function classifyDestinations(files, manifest, backupRoot, anchor, pending) {
117
142
  const diagnostics = [];
118
143
  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 }));
144
+ const status = await classifyFile(file.path, manifest);
145
+ file.status = status;
146
+ if (status === 'managed-modified' && backupRoot) {
147
+ file.backupPath = resolveBackupPath(backupRoot, file.path, anchor);
148
+ pending.modifiedOverwrites.push(file);
149
+ diagnostics.push(diagnostic('warning', 'MANAGED_FILE_OVERWRITE', `Will overwrite locally edited Forge file ${file.path}; backup → ${file.backupPath}`, { platform: file.platform }));
122
150
  }
123
- catch {
124
- // Missing destination is safe.
151
+ else if (status === 'foreign') {
152
+ pending.foreignOverwrites.push(file);
153
+ diagnostics.push(diagnostic('warning', 'FOREIGN_FILE_OVERWRITE', `Will overwrite untracked file at ${file.path}`, { platform: file.platform }));
125
154
  }
126
155
  }
127
156
  return diagnostics;
128
157
  }
158
+ async function classifyFile(filePath, manifest) {
159
+ try {
160
+ await access(filePath, constants.F_OK);
161
+ }
162
+ catch {
163
+ return 'new';
164
+ }
165
+ const entry = lookupEntryByPath(manifest, filePath);
166
+ if (!entry)
167
+ return 'foreign';
168
+ const content = await readFile(filePath, 'utf8');
169
+ return sha256(content) === entry.checksum ? 'managed-unmodified' : 'managed-modified';
170
+ }
@@ -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.3.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.