@guidobuilds/forge-ai 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -46,6 +46,10 @@ Use the lightest safe routing for the current request. Common worker work types
46
46
 
47
47
  Use artifacts in `.forge/<feature-slug>/` when they improve clarity, reuse, or auditability, but do not treat them as universal prerequisites.
48
48
 
49
+ ## Route announcement
50
+
51
+ Before the first dispatch, state the chosen route to the user: work types joined by arrows (e.g. `build -> verify`, `inspect -> build -> verify`, `inspect -> design -> plan -> build -> verify`), whether a `forge-grill` pass runs before build and an independent verify or `forge-adversary` gate runs after, and one clause on why it is the lightest safe route. Re-announce only when the route changes materially mid-flight.
52
+
49
53
  ## Dispatch strategies
50
54
 
51
55
  Choose between three dispatch strategies at runtime:
@@ -71,10 +75,17 @@ Avoid parallel dispatch when:
71
75
 
72
76
  - Never do worker work inline.
73
77
  - Translate the request into goal, constraints, and safest routing before delegating.
78
+ - Announce the chosen route to the user before the first dispatch (see Route announcement).
79
+ - Run `forge-grill` proactively: stress-test any plan or design before build when work is non-trivial, risk-bearing, multi-step, or carries unresolved assumptions. Skip for trivial, surgical, or read-only work.
74
80
  - Delegate all development and operational execution to `forge-worker`.
75
81
  - Prefer one bounded worker run when it is sufficient; add more runs only when they reduce ambiguity, risk, or elapsed time.
76
82
  - Do not let workers silently infer missing build-shaping goals.
77
83
  - If a worker returns `blocked`, decide whether to ask the user, refine the subgoal, or launch another worker run for more inspection.
84
+ - Size the state model to the work (see State model): skip it for trivial changes; add it for non-trivial or multi-session work.
85
+ - For non-trivial work, do not accept a builder's self-certified `passing`; dispatch a separate verify run to confirm it (`forge-adversary` for risk-bearing work, else a `forge-worker` verify run).
86
+ - Delegate by size: handle inline only a 1-3 file read, a mechanical known write, or a git status check; delegate to `forge-worker` when the work needs 4+ files read, multi-file analysis or writes, or running tests/builds/installs. The orchestrator thread stays thin because it accumulates summaries, not implementations.
87
+ - Assign an effort level per dispatch (see Effort routing).
88
+ - When `.forge/repo-facts.md` or `.forge/lessons.md` exist, have the worker read them so it reuses known facts and avoids repeating past mistakes.
78
89
 
79
90
  ## Approval heuristics
80
91
 
@@ -87,16 +98,72 @@ Approvals depend on the action being authorized and the risk of that action, not
87
98
  - If the requested action is already explicit and low-risk, do not create artificial gates.
88
99
  - If a materially important decision is unresolved, use the worker contract to escalate it and keep the user thread in the orchestrator.
89
100
 
101
+ ## Effort routing
102
+
103
+ Match model effort to the work, not the reverse — higher effort spends more reasoning and tool calls, not more speed, so over-spending wastes tokens and time for the same result. State an effort level in each dispatch:
104
+
105
+ - **low**: `inspect`, `verify`, `operate`, and routine/mechanical `build`.
106
+ - **medium**: most `build` and `plan`.
107
+ - **high**: `design`, hard trade-offs, synthesis across many worker results, or genuinely novel build.
108
+
109
+ When unsure, start low and escalate only if the result is insufficient. The host harness owns actual model selection; this is the routing intent the orchestrator states and the worker honors.
110
+
90
111
  ## Artifact toolkit
91
112
 
92
- Preferred durable artifacts remain:
113
+ Preferred process artifacts (write when they help future runs or clarify approval state):
93
114
 
94
115
  - `.forge/<feature-slug>/explore.md`
95
116
  - `.forge/<feature-slug>/design.md`
96
117
  - `.forge/<feature-slug>/plan.md`
97
118
  - `.forge/<feature-slug>/build-log.md`
98
119
 
99
- Use them when they help future runs or clarify approval state. Skip them when they would add ceremony without reducing risk.
120
+ State-model artifacts (the source of truth for non-trivial or multi-session work, see State model):
121
+
122
+ - `.forge/<feature-slug>/feature-list.json` — unit-of-work ledger: `behavior` + `verification` + `state`
123
+ - `.forge/<feature-slug>/verification.md` — recorded verification evidence (the Definition of Done store)
124
+ - `.forge/<feature-slug>/progress.md` — session continuity log
125
+ - `.forge/<feature-slug>/session-handoff.md` — cross-session / blocked handoff
126
+
127
+ Project-scoped files (persist across features, not under a slug):
128
+
129
+ - `.forge/repo-facts.md` — durable stack/commands/conventions/constraints (the standing "where to go" spec)
130
+ - `.forge/lessons.md` — topic-keyed accumulated lessons (the Feedback ratchet)
131
+ - `.forge/index.md` — one-line-per-slug cross-task ledger
132
+
133
+ Skip any artifact when it would add ceremony without reducing risk. `forge-worker` owns the exact schema and templates.
134
+
135
+ ## State model (adaptive)
136
+
137
+ Size the state model to the work so the lightest safe workflow stays the default.
138
+
139
+ - **Trivial / surgical** (single-file, low-risk, obvious, no cross-session memory): no state artifacts. Route `build -> verify` (or `inspect -> build -> verify`). The builder may self-verify.
140
+ - **Single non-trivial feature**: create `feature-list.json` and `verification.md`. A separate verify dispatch must record evidence before any feature reaches `passing`.
141
+ - **Multi-feature / multi-session / blocked / handoff-likely**: also create `progress.md` and `session-handoff.md`; carry multiple `feature-list.json` entries with `dependencies`.
142
+
143
+ Triggers:
144
+ - Create `feature-list.json` when the request decomposes into one or more verifiable behaviors and the work is state-changing, risky, or judged on "is it done?".
145
+ - Create `progress.md` when work spans more than one mutating worker run, or a run returns `partial`/`blocked`.
146
+ - Create `session-handoff.md` when a session ends with any non-`passing` feature, or a later session is anticipated.
147
+ - Bootstrap `.forge/repo-facts.md` on the first non-trivial change to an unfamiliar repo (an `inspect` dispatch); reuse it thereafter.
148
+ - Record lessons in `.forge/lessons.md` after any run that made a decision, fixed a non-obvious bug, set a convention, or hit a failure.
149
+ - Never create state artifacts for read-only inspection or one-shot obvious edits.
150
+
151
+ Feature `state` is one of `not_started | active | blocked | passing`. The orchestrator owns transitions: a builder may move `not_started -> active`, but only a verify dispatch (`forge-adversary` or a `forge-worker` verify run) moves a feature to `passing` (or `-> blocked` on failure).
152
+
153
+ Closure: when a feature reaches `passing`, the verify dispatch flushes durable lessons to `.forge/lessons.md` and appends a line to `.forge/index.md`. When all features in a slug are `passing` and their `archiveWhen` conditions hold, the slug may be archived under `.forge/_archive/<slug>/`.
154
+
155
+ ## Definition of Done
156
+
157
+ A feature's `state` may become `passing` only when ALL hold:
158
+
159
+ 1. its `verification` command was actually run,
160
+ 2. the result is recorded in `.forge/<feature-slug>/verification.md` (command + output excerpt + pass verdict + timestamp),
161
+ 3. `evidence` in `feature-list.json` points to that entry,
162
+ 4. every `id` in its `dependencies` is already `passing`.
163
+
164
+ No feature moves to `passing` on assertion alone, and never by weakening, deleting, skipping, or stubbing the check; the recorded output must actually exercise the named `behavior`. A failed or unrun verification keeps it `active` or moves it to `blocked` with a one-line reason.
165
+
166
+ Independent, adversarial verification: for non-trivial work the builder may NOT self-certify. Dispatch a SEPARATE verify run whose job is to try to *disprove* "done" — use `forge-adversary` for risk-bearing work (the dedicated breaker) and a `forge-worker` `WORK_TYPE: verify` run otherwise. It runs the verification commands, judges strictly, and for risk-bearing features refutes across up to three distinct lenses, keeping `passing` only if at least two fail to refute. It writes `verification.md` (logging refuted/uncertain candidates too) and flips states. Build and verify are different instances. Trivial-tier work is exempt.
100
167
 
101
168
  ## Contract enforcement
102
169
 
@@ -0,0 +1,37 @@
1
+ // Canonical tool names used in Grok Build agent-profile frontmatter (tools: / disallowedTools: fields).
2
+ // The CLI --tools/--disallowed-tools flags use shorter aliases (e.g. "grep" for grep_search,
3
+ // "bash" for run_terminal_cmd). Both forms are accepted here to avoid spurious warnings.
4
+ export const knownGrokTools = new Set([
5
+ 'read_file',
6
+ 'search_replace',
7
+ 'grep_search',
8
+ 'grep', // CLI alias for grep_search
9
+ 'list_dir',
10
+ 'run_terminal_cmd',
11
+ 'bash', // display alias for run_terminal_cmd
12
+ 'web_search',
13
+ 'web_fetch',
14
+ 'todo_write',
15
+ 'task',
16
+ 'kill_task',
17
+ 'get_task_output',
18
+ 'memory_search',
19
+ 'memory_get',
20
+ 'search_tool',
21
+ 'use_tool',
22
+ 'lsp'
23
+ ]);
24
+ export function isKnownGrokTool(name) {
25
+ return knownGrokTools.has(name);
26
+ }
27
+ export const knownGrokModels = new Set([
28
+ 'inherit',
29
+ 'grok-build',
30
+ 'grok-build-plan',
31
+ 'grok-composer-2.5-fast'
32
+ ]);
33
+ const grokVersionedModelPattern = /^grok-[a-z0-9.-]+$/;
34
+ const gatewayModelPattern = /^[a-z0-9-]+\/[a-z0-9.-]+$/;
35
+ export function isKnownGrokModel(value) {
36
+ return knownGrokModels.has(value) || grokVersionedModelPattern.test(value) || gatewayModelPattern.test(value);
37
+ }
@@ -0,0 +1,41 @@
1
+ import { stringifyYaml } from '../frontmatter.js';
2
+ import { diagnostic } from '../diagnostics.js';
3
+ import { isKnownGrokModel, isKnownGrokTool } from './grok-known.js';
4
+ import { isRecord, patternList, stringList } from './shared.js';
5
+ export function renderGrokAgent(artifact) {
6
+ const diagnostics = [];
7
+ const fm = { name: artifact.name, description: artifact.description };
8
+ if (artifact.grok?.model) {
9
+ fm.model = artifact.grok.model;
10
+ if (!isKnownGrokModel(artifact.grok.model)) {
11
+ diagnostics.push(diagnostic('warning', 'GROK_UNKNOWN_MODEL', `Unknown Grok model "${artifact.grok.model}" for ${artifact.name}`, { platform: 'grok' }));
12
+ }
13
+ }
14
+ const permissions = artifact.grok?.permissions;
15
+ const tools = isRecord(permissions) ? stringList(permissions.tools) : stringList(permissions);
16
+ if (tools) {
17
+ fm.tools = tools; // YAML sequence — Grok expects tools as a list, not comma-joined
18
+ for (const tool of tools) {
19
+ if (!isKnownGrokTool(tool)) {
20
+ diagnostics.push(diagnostic('warning', 'GROK_UNKNOWN_TOOL', `Unknown Grok tool "${tool}" for ${artifact.name}`, { platform: 'grok' }));
21
+ }
22
+ }
23
+ }
24
+ else if (permissions !== undefined) {
25
+ diagnostics.push(diagnostic('info', 'GROK_AGENT_TOOLS_IGNORED', `Grok agent permissions must be a tools string list for ${artifact.name}`, { platform: 'grok' }));
26
+ }
27
+ const disallowedTools = isRecord(permissions) ? patternList(permissions['disallowedTools']) : undefined;
28
+ if (disallowedTools)
29
+ fm.disallowedTools = disallowedTools;
30
+ return { content: `${stringifyYaml(fm)}${artifact.body}\n`, diagnostics };
31
+ }
32
+ export function renderGrokSkill(artifact) {
33
+ const diagnostics = [];
34
+ if (artifact.grok?.permissions !== undefined) {
35
+ diagnostics.push(diagnostic('info', 'GROK_SKILL_PERMISSIONS_IGNORED', `Grok skill permissions are not emitted for ${artifact.name}`, { platform: 'grok' }));
36
+ }
37
+ if (artifact.grok?.model) {
38
+ diagnostics.push(diagnostic('info', 'GROK_SKILL_MODEL_IGNORED', `Grok skill model is not emitted for ${artifact.name}`, { platform: 'grok' }));
39
+ }
40
+ return { content: `${stringifyYaml({ name: artifact.name, description: artifact.description })}${artifact.body}\n`, diagnostics };
41
+ }
package/dist/src/cli.js CHANGED
@@ -8,6 +8,8 @@ import { fileURLToPath } from 'node:url';
8
8
  import { formatDiagnostic, hasErrors } from './diagnostics.js';
9
9
  import { buildManifest, classifyPruneEntries, loadManifest, pruneEntries, resolveBackupPath, resolveBackupRoot, resolveManifestLocation, saveManifest, staleEntries } from './manifest.js';
10
10
  import { buildWritePlan, parsePlatform, parseScope } from './processor.js';
11
+ import { runSelfUpdate } from './self-update.js';
12
+ import { checkLatestVersion, formatVersionNotice } from './version-check.js';
11
13
  import { writeOutputs } from './writer.js';
12
14
  import { hasPendingDecisions } from './model.js';
13
15
  const emptyPrunePlan = { deletable: [], modifiedWithConsent: [], skippedMissing: [] };
@@ -32,108 +34,129 @@ export async function main(argv = process.argv.slice(2), promptIO = {}) {
32
34
  showUsage();
33
35
  return 1;
34
36
  }
35
- const install = command === 'install' || command === 'update';
36
- if (install && !options.sourceExplicit)
37
- options.source = bundledSourceRoot();
38
- if (command === 'update' || options.yes)
39
- options.force = true;
40
- const interactive = install && isInteractivePrompt(promptIO);
41
- if (interactive)
42
- p.intro(`${pc.bold('Forge AI')} ${pc.dim(command === 'update' ? 'updater' : 'installer')}`, clackIO(promptIO));
43
- if (install) {
44
- const prompted = await promptForMissingInstallOptions(options, promptIO);
45
- if (!prompted) {
46
- if (interactive)
47
- p.cancel('Cancelled', clackIO(promptIO));
48
- return 1;
49
- }
50
- }
51
- const cwd = process.cwd();
52
- const home = resolveHome(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);
37
+ if (command === 'self-update') {
38
+ return runSelfUpdate({
39
+ binaryPath: process.argv[1] ?? bundledSourceRoot(),
40
+ version: options.targetVersion,
41
+ dryRun: options.dryRun,
42
+ skipSpecUpdate: options.skipSpecUpdate,
43
+ });
75
44
  }
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;
45
+ const versionCheckPromise = shouldCheckForUpdates(options, promptIO)
46
+ ? checkLatestVersion({ current: readPackageVersion(), cachePath: path.join(resolveHome(promptIO), '.forge-ai', 'version-check.json') }).catch(() => undefined)
47
+ : Promise.resolve(undefined);
48
+ try {
49
+ const install = command === 'install' || command === 'update';
50
+ if (install && !options.sourceExplicit)
51
+ options.source = bundledSourceRoot();
52
+ if (command === 'update' || options.yes)
53
+ options.force = true;
54
+ const interactive = install && isInteractivePrompt(promptIO);
55
+ if (interactive)
56
+ p.intro(`${pc.bold('Forge AI')} ${pc.dim(command === 'update' ? 'updater' : 'installer')}`, clackIO(promptIO));
57
+ if (install) {
58
+ const prompted = await promptForMissingInstallOptions(options, promptIO);
59
+ if (!prompted) {
60
+ if (interactive)
61
+ p.cancel('Cancelled', clackIO(promptIO));
62
+ return 1;
63
+ }
82
64
  }
83
- const accepted = await promptForUpdate(plan, prunePlan, backupRoot, promptIO);
84
- if (accepted === undefined) {
85
- p.cancel('Cancelled', clackIO(promptIO));
86
- return 1;
65
+ const cwd = process.cwd();
66
+ const home = resolveHome(promptIO);
67
+ const now = new Date();
68
+ let manifestLocation;
69
+ let oldManifest;
70
+ let backupRoot;
71
+ if (install) {
72
+ manifestLocation = await resolveManifestLocation(options.scope, cwd, home);
73
+ oldManifest = await loadManifest(manifestLocation.manifestPath);
74
+ backupRoot = resolveBackupRoot(manifestLocation, now);
87
75
  }
88
- if (!accepted) {
89
- p.outro(pc.yellow('Forge was not installed.'), clackIO(promptIO));
76
+ const plan = await buildWritePlan({
77
+ source: options.source,
78
+ platform: options.platform,
79
+ scope: options.scope,
80
+ cwd,
81
+ home,
82
+ manifest: oldManifest,
83
+ backupRoot,
84
+ checkCollisions: install,
85
+ });
86
+ let prunePlan = emptyPrunePlan;
87
+ if (install && command === 'update' && options.prune) {
88
+ prunePlan = await classifyPrune(oldManifest, plan.files, backupRoot, options.scope, cwd, home);
89
+ }
90
+ const needsConfirm = install && !options.force && (hasPendingDecisions(plan.pending) || prunePlan.modifiedWithConsent.length > 0);
91
+ if (install && !options.dryRun && needsConfirm) {
92
+ if (!interactive) {
93
+ printPlan(command, plan.sourceCount, plan.files, plan.diagnostics, prunePlan);
94
+ console.error('Forge needs your decision on edited or untracked files; re-run with --yes or --force to accept overwrites + backups.');
95
+ return 1;
96
+ }
97
+ const accepted = await promptForUpdate(plan, prunePlan, backupRoot, promptIO);
98
+ if (accepted === undefined) {
99
+ p.cancel('Cancelled', clackIO(promptIO));
100
+ return 1;
101
+ }
102
+ if (!accepted) {
103
+ p.outro(pc.yellow('Forge was not installed.'), clackIO(promptIO));
104
+ return 1;
105
+ }
106
+ }
107
+ printPlan(command, plan.sourceCount, plan.files, plan.diagnostics, prunePlan);
108
+ if (hasErrors(plan.diagnostics)) {
109
+ if (interactive)
110
+ p.outro(pc.red('Forge was not installed.'), clackIO(promptIO));
90
111
  return 1;
91
112
  }
92
- }
93
- printPlan(command, plan.sourceCount, plan.files, plan.diagnostics, prunePlan);
94
- if (hasErrors(plan.diagnostics)) {
95
- if (interactive)
96
- p.outro(pc.red('Forge was not installed.'), clackIO(promptIO));
97
- return 1;
98
- }
99
- if (install && !options.dryRun) {
100
- if (interactive) {
101
- const spinner = p.spinner(clackIO(promptIO));
102
- spinner.start(options.force ? 'Updating Forge files' : 'Installing Forge files');
103
- try {
113
+ if (install && !options.dryRun) {
114
+ if (interactive) {
115
+ const spinner = p.spinner(clackIO(promptIO));
116
+ spinner.start(options.force ? 'Updating Forge files' : 'Installing Forge files');
117
+ try {
118
+ await writeOutputs(plan.files);
119
+ if (command === 'update' && options.prune)
120
+ await pruneEntries([...prunePlan.deletable, ...prunePlan.modifiedWithConsent]);
121
+ await saveManifest(manifestLocation.manifestPath, await buildManifest(manifestLocation, plan.files));
122
+ spinner.stop(`Wrote ${plan.files.length} file(s).`);
123
+ }
124
+ catch (error) {
125
+ spinner.error('Failed to write Forge files');
126
+ throw error;
127
+ }
128
+ }
129
+ else {
104
130
  await writeOutputs(plan.files);
105
131
  if (command === 'update' && options.prune)
106
132
  await pruneEntries([...prunePlan.deletable, ...prunePlan.modifiedWithConsent]);
107
133
  await saveManifest(manifestLocation.manifestPath, await buildManifest(manifestLocation, plan.files));
108
- spinner.stop(`Wrote ${plan.files.length} file(s).`);
109
- }
110
- catch (error) {
111
- spinner.error('Failed to write Forge files');
112
- throw error;
134
+ console.log(`Wrote ${plan.files.length} file(s).`);
135
+ const totalDeleted = prunePlan.deletable.length + prunePlan.modifiedWithConsent.length;
136
+ if (command === 'update' && options.prune && totalDeleted > 0)
137
+ console.log(`Deleted ${totalDeleted} stale file(s).`);
138
+ console.log(`Updated manifest ${manifestLocation.manifestPath}.`);
113
139
  }
114
140
  }
115
- else {
116
- await writeOutputs(plan.files);
117
- if (command === 'update' && options.prune)
118
- await pruneEntries([...prunePlan.deletable, ...prunePlan.modifiedWithConsent]);
119
- await saveManifest(manifestLocation.manifestPath, await buildManifest(manifestLocation, plan.files));
120
- console.log(`Wrote ${plan.files.length} 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).`);
124
- console.log(`Updated manifest ${manifestLocation.manifestPath}.`);
141
+ else if (install && interactive) {
142
+ p.log.info(`Dry run only. ${plan.files.length} file(s) would be written.`, clackIO(promptIO));
125
143
  }
144
+ if (install && interactive) {
145
+ p.outro(options.dryRun ? pc.cyan('Dry run complete.') : pc.green('Forge is ready.'), clackIO(promptIO));
146
+ }
147
+ return 0;
126
148
  }
127
- else if (install && interactive) {
128
- p.log.info(`Dry run only. ${plan.files.length} file(s) would be written.`, clackIO(promptIO));
129
- }
130
- if (install && interactive) {
131
- p.outro(options.dryRun ? pc.cyan('Dry run complete.') : pc.green('Forge is ready.'), clackIO(promptIO));
149
+ finally {
150
+ const result = await versionCheckPromise;
151
+ if (result) {
152
+ const notice = formatVersionNotice(result);
153
+ if (notice)
154
+ console.log(notice);
155
+ }
132
156
  }
133
- return 0;
134
157
  }
135
158
  function parseArgs(argv) {
136
- const options = { command: argv[0], platform: 'all', scope: 'user', source: '.', dryRun: false, force: false, prune: true, yes: false, platformExplicit: false, scopeExplicit: false, sourceExplicit: false };
159
+ const options = { command: argv[0], platform: 'all', scope: 'user', source: '.', dryRun: false, force: false, prune: true, yes: false, noUpdateCheck: false, skipSpecUpdate: false, platformExplicit: false, scopeExplicit: false, sourceExplicit: false };
137
160
  for (let index = 1; index < argv.length; index += 1) {
138
161
  const arg = argv[index];
139
162
  if (arg === '--dry-run')
@@ -144,6 +167,16 @@ function parseArgs(argv) {
144
167
  options.prune = false;
145
168
  else if (arg === '--yes' || arg === '-y')
146
169
  options.yes = true;
170
+ else if (arg === '--no-update-check')
171
+ options.noUpdateCheck = true;
172
+ else if (arg === '--skip-spec-update')
173
+ options.skipSpecUpdate = true;
174
+ else if (arg === '--to') {
175
+ const value = argv[++index];
176
+ if (!value)
177
+ return { error: 'Missing --to value' };
178
+ options.targetVersion = value;
179
+ }
147
180
  else if (arg === '--platform') {
148
181
  const value = argv[++index];
149
182
  const platform = value ? parsePlatform(value) : undefined;
@@ -176,6 +209,10 @@ function parseArgs(argv) {
176
209
  return { error: '--no-prune is only accepted for update' };
177
210
  if (command === 'validate' && (options.dryRun || options.force || options.yes || options.scopeExplicit))
178
211
  return { error: 'validate only accepts --platform and --source' };
212
+ if (command !== 'self-update' && (options.targetVersion !== undefined || options.skipSpecUpdate))
213
+ return { error: '--to and --skip-spec-update are only accepted for self-update' };
214
+ if (command === 'self-update' && (options.platformExplicit || options.scopeExplicit || options.sourceExplicit || options.force || options.yes))
215
+ return { error: 'self-update only accepts --to, --dry-run, --skip-spec-update' };
179
216
  return { options };
180
217
  }
181
218
  async function classifyPrune(oldManifest, files, backupRoot, scope, cwd, home) {
@@ -205,10 +242,11 @@ async function promptForMissingInstallOptions(options, promptIO) {
205
242
  message: 'Install Forge for which coding agent?',
206
243
  initialValue: 'all',
207
244
  options: [
208
- { value: 'all', label: 'All supported agents', hint: 'OpenCode, Codex, and Claude Code' },
245
+ { value: 'all', label: 'All supported agents', hint: 'OpenCode, Codex, Claude Code, and Grok Build' },
209
246
  { value: 'opencode', label: 'OpenCode' },
210
247
  { value: 'codex', label: 'Codex' },
211
- { value: 'claude', label: 'Claude Code' }
248
+ { value: 'claude', label: 'Claude Code' },
249
+ { value: 'grok', label: 'Grok Build' }
212
250
  ],
213
251
  ...io
214
252
  });
@@ -265,8 +303,23 @@ function normalizeCommand(command) {
265
303
  return 'update';
266
304
  if (command === 'validate')
267
305
  return 'validate';
306
+ if (command === 'self-update')
307
+ return 'self-update';
268
308
  return undefined;
269
309
  }
310
+ function shouldCheckForUpdates(options, promptIO) {
311
+ if (options.noUpdateCheck)
312
+ return false;
313
+ const env = promptIO.env ?? process.env;
314
+ if (env.CI === 'true')
315
+ return false;
316
+ if (env.FORGE_NO_UPDATE_CHECK === '1' || env.FORGE_NO_UPDATE_CHECK === 'true')
317
+ return false;
318
+ // Skip in non-interactive runs (pipes, CI, scripts): the notice is a UX nudge for terminal users.
319
+ if (!isInteractivePrompt(promptIO))
320
+ return false;
321
+ return true;
322
+ }
270
323
  function isInteractivePrompt(promptIO) {
271
324
  const env = promptIO.env ?? process.env;
272
325
  const interactive = promptIO.isInteractive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
@@ -291,9 +344,12 @@ function readPackageVersion() {
291
344
  }
292
345
  }
293
346
  function showUsage() {
294
- console.log('Usage: forge-ai install [--platform opencode|claude|codex|all] [--scope user|project] [--source <dir>] [--dry-run] [--force] [--yes]');
295
- console.log(' forge-ai update [--platform opencode|claude|codex|all] [--scope user|project] [--source <dir>] [--dry-run] [--no-prune] [--yes]');
296
- console.log(' forge-ai validate [--platform opencode|claude|codex|all] [--source <dir>]');
347
+ console.log('Usage: forge-ai install [--platform opencode|claude|codex|grok|all] [--scope user|project] [--source <dir>] [--dry-run] [--force] [--yes]');
348
+ console.log(' forge-ai update [--platform opencode|claude|codex|grok|all] [--scope user|project] [--source <dir>] [--dry-run] [--no-prune] [--yes]');
349
+ console.log(' forge-ai validate [--platform opencode|claude|codex|grok|all] [--source <dir>]');
350
+ console.log(' forge-ai self-update [--to <version>] [--dry-run] [--skip-spec-update]');
351
+ console.log('');
352
+ console.log('Global flags: --no-update-check (also FORGE_NO_UPDATE_CHECK=1 or CI=true)');
297
353
  }
298
354
  function printPlan(command, sourceCount, files, diagnostics, prunePlan) {
299
355
  console.log(`${command}: ${sourceCount} source(s), ${files.length} output(s)`);
package/dist/src/model.js CHANGED
@@ -1,4 +1,4 @@
1
- export const platforms = ['opencode', 'claude', 'codex'];
1
+ export const platforms = ['opencode', 'claude', 'codex', 'grok'];
2
2
  export function isPlatform(value) {
3
3
  return platforms.includes(value);
4
4
  }
package/dist/src/paths.js CHANGED
@@ -13,6 +13,8 @@ function userBase(platform, kind, home) {
13
13
  return path.join(home, '.config', 'opencode', kind === 'agent' ? 'agents' : 'skills');
14
14
  if (platform === 'claude')
15
15
  return path.join(home, '.claude', kind === 'agent' ? 'agents' : 'skills');
16
+ if (platform === 'grok')
17
+ return path.join(home, '.grok', kind === 'agent' ? 'agents' : 'skills');
16
18
  return kind === 'agent' ? path.join(home, '.codex', 'agents') : path.join(home, '.agents', 'skills');
17
19
  }
18
20
  function projectBase(platform, kind, cwd) {
@@ -20,5 +22,7 @@ function projectBase(platform, kind, cwd) {
20
22
  return path.join(cwd, '.opencode', kind === 'agent' ? 'agents' : 'skills');
21
23
  if (platform === 'claude')
22
24
  return path.join(cwd, '.claude', kind === 'agent' ? 'agents' : 'skills');
25
+ if (platform === 'grok')
26
+ return path.join(cwd, '.grok', kind === 'agent' ? 'agents' : 'skills');
23
27
  return kind === 'agent' ? path.join(cwd, '.codex', 'agents') : path.join(cwd, '.agents', 'skills');
24
28
  }
@@ -3,6 +3,7 @@ import { constants } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { renderClaudeAgent, renderClaudeSkill } from './adapters/claude.js';
5
5
  import { renderCodexAgent, renderCodexSkill } from './adapters/codex.js';
6
+ import { renderGrokAgent, renderGrokSkill } from './adapters/grok.js';
6
7
  import { renderOpenCodeAgent, renderOpenCodeSkill } from './adapters/opencode.js';
7
8
  import { diagnostic } from './diagnostics.js';
8
9
  import { discoverSources } from './discovery.js';
@@ -10,12 +11,14 @@ import { lookupEntryByPath, resolveBackupPath, sha256 } from './manifest.js';
10
11
  import { resolveOutputPath } from './paths.js';
11
12
  import { isPlatform, platforms } from './model.js';
12
13
  const namePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
13
- const platformKeys = new Set(['claude', 'opencode', 'codex']);
14
- const allowedTopLevel = new Set(['name', 'description', 'kind', 'claude', 'opencode', 'codex']);
14
+ const platformKeys = new Set(['claude', 'opencode', 'codex', 'grok']);
15
+ const allowedTopLevel = new Set(['name', 'description', 'kind', 'claude', 'opencode', 'codex', 'grok']);
15
16
  const allowedProductKeys = new Set(['permissions', 'model', 'kind']);
16
17
  const allowedOpenCodeKeys = new Set([...allowedProductKeys, 'mode']);
17
18
  const openCodeModes = new Set(['primary', 'subagent', 'all']);
18
19
  const artifactKinds = new Set(['agent', 'skill']);
20
+ const defaultBodyBudget = 200;
21
+ const bodyLineBudgets = { forge: 90, 'using-forge': 220, 'forge-worker': 300, 'forge-grill': 120, 'forge-adversary': 200 };
19
22
  export function resolvePlatforms(platform) {
20
23
  return platform === 'all' ? platforms : [platform];
21
24
  }
@@ -112,6 +115,10 @@ function convertSource(source, sourceRoot) {
112
115
  diagnostics.push(diagnostic('error', 'INVALID_KIND', 'artifact kind must be one of agent, skill', { sourcePath: source.sourcePath }));
113
116
  if (!source.body.trim())
114
117
  diagnostics.push(diagnostic('error', 'EMPTY_BODY', 'artifact body is required', { sourcePath: source.sourcePath }));
118
+ const bodyLines = source.body.split('\n').length;
119
+ const bodyBudget = bodyLineBudgets[name ?? source.expectedName] ?? defaultBodyBudget;
120
+ if (bodyLines > bodyBudget)
121
+ diagnostics.push(diagnostic('info', 'BODY_OVER_BUDGET', `artifact body is ${bodyLines} lines (soft budget ${bodyBudget}); keep always-loaded harness files small`, { sourcePath: source.sourcePath }));
115
122
  if (!name || !description || !artifactKinds.has(kind) || !source.body.trim() || diagnostics.some((item) => item.severity === 'error'))
116
123
  return { diagnostics };
117
124
  return {
@@ -124,7 +131,8 @@ function convertSource(source, sourceRoot) {
124
131
  sourcePath: path.relative(path.resolve(sourceRoot), source.sourcePath),
125
132
  claude: productConfig(data.claude),
126
133
  opencode: productConfig(data.opencode),
127
- codex: productConfig(data.codex)
134
+ codex: productConfig(data.codex),
135
+ grok: productConfig(data.grok)
128
136
  }
129
137
  };
130
138
  }
@@ -133,8 +141,8 @@ function productConfig(value) {
133
141
  }
134
142
  function renderFile(platform, kind, artifact, options, diagnostics) {
135
143
  const rendered = kind === 'agent'
136
- ? platform === 'opencode' ? renderOpenCodeAgent(artifact) : platform === 'claude' ? renderClaudeAgent(artifact) : renderCodexAgent(artifact)
137
- : platform === 'opencode' ? renderOpenCodeSkill(artifact) : platform === 'claude' ? renderClaudeSkill(artifact) : renderCodexSkill(artifact);
144
+ ? platform === 'opencode' ? renderOpenCodeAgent(artifact) : platform === 'claude' ? renderClaudeAgent(artifact) : platform === 'grok' ? renderGrokAgent(artifact) : renderCodexAgent(artifact)
145
+ : platform === 'opencode' ? renderOpenCodeSkill(artifact) : platform === 'claude' ? renderClaudeSkill(artifact) : platform === 'grok' ? renderGrokSkill(artifact) : renderCodexSkill(artifact);
138
146
  diagnostics.push(...rendered.diagnostics);
139
147
  return { platform, kind, scope: options.scope, name: artifact.name, sourcePath: artifact.sourcePath, path: resolveOutputPath(platform, kind, options.scope, artifact.name, options.cwd, options.home), content: rendered.content };
140
148
  }
@@ -0,0 +1,70 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
3
+ const PACKAGE = '@guidobuilds/forge-ai';
4
+ export function detectInstallMethod(realPath) {
5
+ if (/[/\\]\.npm[/\\]_npx[/\\]/.test(realPath))
6
+ return 'npx';
7
+ if (/[/\\]pnpm[/\\]/.test(realPath))
8
+ return 'pnpm-global';
9
+ if (/[/\\]homebrew[/\\]/i.test(realPath) && /[/\\]node_modules[/\\]/.test(realPath))
10
+ return 'npm-global-homebrew';
11
+ if (/[/\\]node_modules[/\\]/.test(realPath))
12
+ return 'npm-global';
13
+ return 'unknown';
14
+ }
15
+ export function buildUpdateCommand(method, version = 'latest') {
16
+ const target = `${PACKAGE}@${version}`;
17
+ switch (method) {
18
+ case 'pnpm-global':
19
+ return { command: 'pnpm', args: ['add', '-g', target, '--prefer-online'], description: 'pnpm global' };
20
+ case 'npm-global':
21
+ return { command: 'npm', args: ['install', '-g', target], description: 'npm global' };
22
+ case 'npm-global-homebrew':
23
+ return { command: '/opt/homebrew/bin/npm', args: ['install', '-g', target], description: 'npm global (Homebrew)' };
24
+ case 'npx':
25
+ return { command: '', args: [], description: 'npx (no global install)', instructions: `No global install to update. Re-run with: npx ${target} update` };
26
+ case 'unknown':
27
+ return { command: '', args: [], description: 'unknown install method', instructions: `Could not detect install method. Update manually: pnpm add -g ${target} --prefer-online` };
28
+ }
29
+ }
30
+ export async function runSelfUpdate(options) {
31
+ const log = options.log ?? ((message) => console.log(message));
32
+ const resolver = options.realPathResolver ?? ((p) => realpathSync(p));
33
+ const spawner = options.spawner ?? defaultSpawner;
34
+ let realPath;
35
+ try {
36
+ realPath = resolver(options.binaryPath);
37
+ }
38
+ catch {
39
+ realPath = options.binaryPath;
40
+ }
41
+ // Try the symlink path first (catches `pnpm link --global` and similar dev setups);
42
+ // fall back to the resolved real path (catches standard global installs whose bin dir is generic).
43
+ const symlinkMethod = detectInstallMethod(options.binaryPath);
44
+ const method = symlinkMethod !== 'unknown' ? symlinkMethod : detectInstallMethod(realPath);
45
+ const cmd = buildUpdateCommand(method, options.version ?? 'latest');
46
+ log(`Detected install: ${cmd.description} at ${realPath}`);
47
+ if (cmd.instructions) {
48
+ log(cmd.instructions);
49
+ return 1;
50
+ }
51
+ log(`Running: ${cmd.command} ${cmd.args.join(' ')}`);
52
+ if (options.dryRun) {
53
+ log('(dry-run, not executing)');
54
+ return 0;
55
+ }
56
+ const updateResult = spawner(cmd.command, cmd.args);
57
+ if (updateResult.status !== 0) {
58
+ log(`CLI update failed with exit code ${updateResult.status}`);
59
+ return updateResult.status ?? 1;
60
+ }
61
+ if (options.skipSpecUpdate)
62
+ return 0;
63
+ log('\nApplying spec kit with the updated CLI...');
64
+ const specResult = spawner('forge-ai', ['update']);
65
+ return specResult.status ?? 1;
66
+ }
67
+ function defaultSpawner(command, args) {
68
+ const result = spawnSync(command, args, { stdio: 'inherit' });
69
+ return { status: result.status };
70
+ }