@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 +60 -0
- package/README.md +6 -2
- package/{agents → artifacts/forge}/forge.md +15 -5
- package/{skills/forge-grill/SKILL.md → artifacts/forge-grill/forge-grill.md} +1 -0
- package/{skills/forge-worker/SKILL.md → artifacts/forge-worker/forge-worker.md} +22 -2
- package/{skills/using-forge/SKILL.md → artifacts/using-forge/using-forge.md} +1 -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/opencode.js +15 -15
- package/dist/src/adapters/shared.js +6 -1
- package/dist/src/cli.js +96 -42
- 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 +3 -0
- package/dist/src/processor.js +85 -43
- package/dist/src/writer.js +13 -1
- package/package.json +12 -12
- package/agents/forge-worker.md +0 -60
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
6
|
-
tools: [Task, AskUserQuestion, TodoWrite]
|
|
6
|
+
kind: skill
|
|
7
7
|
opencode:
|
|
8
8
|
mode: primary
|
|
9
9
|
permissions:
|
|
10
|
-
task:
|
|
11
|
-
question:
|
|
12
|
-
todowrite:
|
|
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,15 +1,35 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: forge-worker
|
|
3
|
-
description:
|
|
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
|
|
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
|
|
@@ -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 {
|
|
4
|
-
|
|
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:
|
|
7
|
-
if (
|
|
8
|
-
fm.model =
|
|
9
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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(
|
|
29
|
+
export function renderClaudeSkill(artifact) {
|
|
18
30
|
const diagnostics = [];
|
|
19
|
-
const fm = { name:
|
|
20
|
-
const permissions =
|
|
21
|
-
const allowedTools = isRecord(permissions) ?
|
|
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 ${
|
|
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 ${
|
|
39
|
+
diagnostics.push(diagnostic('info', 'CLAUDE_SKILL_PERMISSIONS_IGNORED', `Claude skill permissions are not emitted for ${artifact.name}`, { platform: 'claude' }));
|
|
28
40
|
}
|
|
29
|
-
if (
|
|
30
|
-
diagnostics.push(diagnostic('info', 'CLAUDE_SKILL_MODEL_IGNORED', `Claude skill model is not emitted for ${
|
|
31
|
-
return { content: `${stringifyYaml(fm)}${
|
|
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(
|
|
6
|
-
const diagnostics = [diagnostic('info', 'CODEX_PARTIAL_AGENT_SUPPORT', `Codex agent output is partial and does not generate AGENTS.md or profiles for ${
|
|
7
|
-
const lines = [`name = ${tomlString(
|
|
8
|
-
if (
|
|
9
|
-
lines.push(`model = ${tomlString(
|
|
10
|
-
const 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 ${
|
|
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 ${
|
|
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(
|
|
22
|
+
export function renderCodexSkill(artifact) {
|
|
23
23
|
const diagnostics = [];
|
|
24
|
-
if (
|
|
25
|
-
diagnostics.push(diagnostic('info', 'CODEX_SKILL_PERMISSIONS_IGNORED', `Codex skill permissions are not emitted for ${
|
|
26
|
-
if (
|
|
27
|
-
diagnostics.push(diagnostic('info', 'CODEX_SKILL_MODEL_IGNORED', `Codex skill model is not emitted for ${
|
|
28
|
-
return { content: `${stringifyYaml({ name:
|
|
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(
|
|
4
|
-
const fm = { description:
|
|
5
|
-
if (
|
|
6
|
-
fm.mode =
|
|
7
|
-
if (
|
|
8
|
-
fm.model =
|
|
9
|
-
if (
|
|
10
|
-
fm.permission =
|
|
11
|
-
return { content: `${stringifyYaml(fm)}${
|
|
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(
|
|
13
|
+
export function renderOpenCodeSkill(artifact) {
|
|
14
14
|
const diagnostics = [];
|
|
15
|
-
if (
|
|
16
|
-
diagnostics.push(diagnostic('info', 'OPENCODE_SKILL_PERMISSIONS_IGNORED', `OpenCode skill permissions are not emitted for ${
|
|
17
|
-
if (
|
|
18
|
-
diagnostics.push(diagnostic('info', 'OPENCODE_SKILL_MODEL_IGNORED', `OpenCode skill model is not emitted for ${
|
|
19
|
-
return { content: `${stringifyYaml({ name:
|
|
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' &&
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
p.cancel('Cancelled', clackIO(promptIO));
|
|
85
|
+
p.cancel('Cancelled', clackIO(promptIO));
|
|
57
86
|
return 1;
|
|
58
87
|
}
|
|
59
|
-
if (accepted) {
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
|
203
|
-
const
|
|
204
|
-
|
|
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: '
|
|
207
|
-
active: '
|
|
251
|
+
message: 'Continue with overwrites + backups?',
|
|
252
|
+
active: 'Continue',
|
|
208
253
|
inactive: 'Cancel',
|
|
209
254
|
initialValue: false,
|
|
210
|
-
...
|
|
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
|
|
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
|
|
262
|
-
console.log(`- delete stale ${
|
|
263
|
-
for (const
|
|
264
|
-
|
|
265
|
-
|
|
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
|
}
|
package/dist/src/discovery.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
17
|
-
const dir = path.join(root, '
|
|
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
|
|
31
|
-
|
|
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(
|
|
29
|
+
async function readSource(sourcePath, expectedName, supportFiles, sources, diagnostics) {
|
|
35
30
|
try {
|
|
36
31
|
const parsed = parseFrontmatter(await readFile(sourcePath, 'utf8'));
|
|
37
|
-
sources.push({
|
|
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 }));
|
package/dist/src/frontmatter.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { parse, stringify } from 'yaml';
|
|
1
2
|
export function parseFrontmatter(content) {
|
|
2
|
-
const normalized = content.replace(
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
16
|
+
return { data: parsed, body };
|
|
60
17
|
}
|
|
61
18
|
export function stringifyYaml(data) {
|
|
62
|
-
const
|
|
63
|
-
return `---\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
|
}
|
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
|
@@ -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
|
+
}
|
package/dist/src/processor.js
CHANGED
|
@@ -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
|
|
29
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
|
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 (
|
|
88
|
-
diagnostics.push(diagnostic('error', '
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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
|
-
|
|
104
|
-
|
|
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,
|
|
134
|
+
function renderFile(platform, kind, artifact, options, diagnostics) {
|
|
110
135
|
const rendered = kind === 'agent'
|
|
111
|
-
? platform === 'opencode' ? renderOpenCodeAgent(
|
|
112
|
-
: platform === 'opencode' ? renderOpenCodeSkill(
|
|
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:
|
|
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
|
|
141
|
+
async function classifyDestinations(files, manifest, backupRoot, anchor, pending) {
|
|
117
142
|
const diagnostics = [];
|
|
118
143
|
for (const file of files) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
+
}
|
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.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
|
-
"
|
|
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.
|