@really-knows-ai/foundry 3.7.3 → 3.8.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,12 +46,15 @@ function deleteStaleAgents(agentsDir) {
46
46
  existing = [];
47
47
  }
48
48
  for (const entry of existing) {
49
- if (entry.startsWith('foundry-') && entry.endsWith('.md')) {
50
- unlinkSync(path.join(agentsDir, entry));
51
- }
49
+ if (isModelledAgent(entry)) unlinkSync(path.join(agentsDir, entry));
52
50
  }
53
51
  }
54
52
 
53
+ function isModelledAgent(entry) {
54
+ return entry.startsWith('foundry-') && entry.endsWith('.md')
55
+ && entry !== 'foundry-forge.md' && entry !== 'foundry-appraise.md';
56
+ }
57
+
55
58
  function writeAgentFiles(agentsDir, models) {
56
59
  for (const modelId of models) {
57
60
  const slug = makeSlug(modelId);
@@ -60,6 +63,23 @@ function writeAgentFiles(agentsDir, models) {
60
63
  }
61
64
  }
62
65
 
66
+ const DEFAULT_AGENT = `---
67
+ description: "Default Foundry STAGE stage agent"
68
+ mode: subagent
69
+ hidden: true
70
+ ---
71
+ You are a Foundry stage agent. Follow the skill instructions provided in your task prompt exactly.
72
+ `;
73
+
74
+ function writeDefaultAgents(agentsDir) {
75
+ for (const stage of ['forge', 'appraise']) {
76
+ const filePath = path.join(agentsDir, `foundry-${stage}.md`);
77
+ if (!existsSync(filePath)) {
78
+ writeFileSync(filePath, DEFAULT_AGENT.replace('STAGE', stage), 'utf8');
79
+ }
80
+ }
81
+ }
82
+
63
83
  /**
64
84
  * Snapshot the current foundry-*.md agent files in the agents directory.
65
85
  * Returns a plain object mapping filename → sha256 hex digest.
@@ -110,6 +130,7 @@ export function refreshAgents(worktree) {
110
130
  mkdirSync(agentsDir, { recursive: true });
111
131
  deleteStaleAgents(agentsDir);
112
132
  writeAgentFiles(agentsDir, models);
133
+ writeDefaultAgents(agentsDir);
113
134
 
114
135
  return { ok: true, count: models.length };
115
136
  } catch (err) {
@@ -155,18 +176,23 @@ function resolveGuideSource(packageRoot) {
155
176
  export function writeFoundryGuideAgent(worktree, packageRoot) {
156
177
  const targetDir = path.join(worktree, '.opencode', 'agents');
157
178
  const targetPath = path.join(targetDir, 'foundry.md');
179
+ let written = false;
180
+
181
+ if (!existsSync(targetPath)) {
182
+ const sourcePath = resolveGuideSource(packageRoot);
183
+ try {
184
+ const content = readFileSync(sourcePath, 'utf8');
185
+ mkdirSync(targetDir, { recursive: true });
186
+ writeFileSync(targetPath, content, 'utf8');
187
+ written = true;
188
+ } catch (err) {
189
+ return { ok: false, error: `Failed to write guide agent: ${err.message ?? String(err)}` };
190
+ }
191
+ }
158
192
 
159
- if (existsSync(targetPath)) return { ok: true, written: false };
193
+ writeDefaultAgents(targetDir);
160
194
 
161
- const sourcePath = resolveGuideSource(packageRoot);
162
- try {
163
- const content = readFileSync(sourcePath, 'utf8');
164
- mkdirSync(targetDir, { recursive: true });
165
- writeFileSync(targetPath, content, 'utf8');
166
- return { ok: true, written: true };
167
- } catch (err) {
168
- return { ok: false, error: `Failed to write guide agent: ${err.message ?? String(err)}` };
169
- }
195
+ return { ok: true, written };
170
196
  }
171
197
 
172
198
  function resolveSkillsSource(packageRoot) {
@@ -11,9 +11,9 @@ import { requireNotFailed } from '../../../scripts/lib/failed-flow.js';
11
11
  import { requireOnFlowBranch } from '../../../scripts/lib/branch-guard.js';
12
12
 
13
13
  function createMint(secret, pending) {
14
- return ({ route, cycle, exp }) => {
14
+ return ({ route, cycle, exp, model }) => {
15
15
  const nonce = randomUUID();
16
- const payload = { route, cycle, nonce, exp };
16
+ const payload = model ? { route, cycle, nonce, exp, model } : { route, cycle, nonce, exp };
17
17
  pending.add(nonce, payload);
18
18
  return signToken(payload, secret);
19
19
  };
@@ -41,13 +41,18 @@ function resolveBaseSha(worktree) {
41
41
  }
42
42
  }
43
43
 
44
- function verifyStageToken(token, secret, stage, cycle) {
44
+ function verifyStageToken(token, secret, stage, cycle, agent) {
45
45
  const v = verifyToken(token, secret);
46
46
  if (!v.ok) return { error: `foundry_stage_begin: token ${v.reason}` };
47
47
  if (v.payload.route !== stage || v.payload.cycle !== cycle) {
48
48
  return { error: `foundry_stage_begin: token payload mismatch (route=${v.payload.route}, cycle=${v.payload.cycle})` };
49
49
  }
50
- return { payload: v.payload };
50
+ return checkTokenAgentBinding(v.payload, agent);
51
+ }
52
+
53
+ function checkTokenAgentBinding(payload, agent) {
54
+ if (!payload.model || !agent || payload.model === agent) return { payload };
55
+ return { error: `foundry_stage_begin: token is scoped to subagent '${payload.model}', not '${agent}'. Dispatch forge via task(), not inline.` };
51
56
  }
52
57
 
53
58
  async function executeStageBegin(args, context, pending) {
@@ -59,7 +64,7 @@ async function executeStageBegin(args, context, pending) {
59
64
  return JSON.stringify({ error: `foundry_stage_begin requires no active stage; current: ${current.stage}` });
60
65
  }
61
66
 
62
- const tokenResult = verifyStageToken(args.token, secret, args.stage, args.cycle);
67
+ const tokenResult = verifyStageToken(args.token, secret, args.stage, args.cycle, context.agent);
63
68
  if (tokenResult.error) return JSON.stringify({ error: tokenResult.error });
64
69
 
65
70
  const baseSha = resolveBaseSha(context.worktree);
package/dist/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.8.0] - 2026-05-27
4
+
5
+ ### Added
6
+
7
+ - Default `foundry-forge` and `foundry-appraise` subagents: hidden, model-less stage agents created during bootstrap and preserved across model refreshes. When a cycle has no model overrides, these agents handle forge and appraise dispatch, ensuring tokens are always scoped to a known subagent type.
8
+
9
+ - Stage token agent binding: `foundry_stage_begin` now verifies the calling agent matches the token's scoped subagent. The main Foundry agent cannot use tokens issued for `foundry-forge` or `foundry-opencode-*` — it must dispatch via `task()`. This enforces the subagent dispatch model at the protocol level.
10
+
11
+ ### Fixed
12
+
13
+ - Cycle-level `models.appraise` now correctly flows into appraise dispatch as the default model when individual appraisers lack an explicit `model` field. Previously the value was read for agent-file validation but discarded before dispatch, causing all appraisers to run on the session default.
14
+
15
+ - Forge and orchestrate skill guidance clarify that stage skills are subagent-only and must not be run inline. The `forge` SKILL.md opens with an explicit "This skill is subagent-only" warning.
16
+
3
17
  ## [3.7.3] - 2026-05-27
4
18
 
5
19
  ### Added
@@ -127,9 +127,8 @@ function addTasksForArtefact(tasks, artefact, entry, ctx) {
127
127
  * Map an appraiser's model to a subagent type string.
128
128
  */
129
129
  function resolveSubagentType(appraiser, ctx) {
130
- const name = appraiser.model || ctx.defaultModel || 'general';
131
- if (name === 'general') return 'general';
132
-
130
+ const name = appraiser.model || ctx.defaultModel || 'appraise';
131
+ if (name === 'appraise') return 'foundry-appraise';
133
132
  return `foundry-${name.replace(/[/.]/g, '-')}`;
134
133
  }
135
134
 
@@ -10,6 +10,7 @@ import { ulid as defaultUlid } from './lib/ulid.js';
10
10
  import { computeArtefactVersion } from './lib/artefacts.js';
11
11
  import { enforceForgeContract } from './lib/forge-contract.js';
12
12
  import { loadHistory } from './lib/history.js';
13
+ import { getCycleDefinition } from './lib/config.js';
13
14
  import {
14
15
  readCycleTargets,
15
16
  readForgeFilePatterns,
@@ -109,15 +110,23 @@ function buildQuenchContext(cycleId, args, io) {
109
110
  feedback: buildFeedback(cycleId, stageId, io) };
110
111
  }
111
112
 
112
- function buildAppraiseCtx(cycleId, args, io) {
113
+ async function buildAppraiseCtx(cycleId, args, io) {
113
114
  const stageId = `appraise:${cycleId}`;
115
+ const defaultModel = args.defaultModel ?? await readAppraiseModel(cycleId, io);
114
116
  return { cycleId, io, git: args.git, finalize: buildFinalizeWrapper(cycleId, args, io),
115
- foundryDir: 'foundry', defaultModel: args.defaultModel,
117
+ foundryDir: 'foundry', defaultModel,
116
118
  baseBranch: args.baseBranch ?? 'main', cwd: args.cwd ?? process.cwd(),
117
119
  activeStage: readActiveStage(io), lastStage: readLastStage(io),
118
120
  feedback: buildFeedback(cycleId, stageId, io) };
119
121
  }
120
122
 
123
+ async function readAppraiseModel(cycleId, io) {
124
+ try {
125
+ const cd = await getCycleDefinition('foundry', cycleId, io);
126
+ return cd.frontmatter?.models?.appraise;
127
+ } catch { return undefined; }
128
+ }
129
+
121
130
  function resolveBaseSha(io) {
122
131
  try {
123
132
  const sha = io.exec(['git', 'rev-parse', 'HEAD']);
@@ -222,13 +231,13 @@ async function dispatchAppraiseOrConsolidate(sortResult, preCheck, args, io, res
222
231
 
223
232
  async function handleAppraiseGatherRoute(sortResult, preCheck, args, io) {
224
233
  writeStageRecord(io, preCheck.cycleId, sortResult.route);
225
- const result = await gatherAppraiseContext(buildAppraiseCtx(preCheck.cycleId, args, io));
234
+ const result = await gatherAppraiseContext(await buildAppraiseCtx(preCheck.cycleId, args, io));
226
235
  if (result.action === 'violation') { clearActiveStage(io); return result; }
227
236
  return dispatchAppraiseOrConsolidate(sortResult, preCheck, args, io, result);
228
237
  }
229
238
 
230
239
  async function handleAppraiseConsolidateRoute(sortResult, preCheck, args, io) {
231
- const ctx = buildAppraiseCtx(preCheck.cycleId, args, io);
240
+ const ctx = await buildAppraiseCtx(preCheck.cycleId, args, io);
232
241
  const result = await consolidateAppraise(ctx, args.lastResults);
233
242
  if (result.action === 'violation') {
234
243
  clearActiveStage(io);
@@ -165,9 +165,15 @@ function resolveModelId(routeBase, models, defaultModel) {
165
165
  }
166
166
 
167
167
  function pickModelId(route, frontmatter, defaultModel) {
168
- const models = frontmatter.models;
169
- if (!models) return defaultModel || null;
170
- return resolveModelId(baseStage(route), models, defaultModel) || null;
168
+ const routeBase = baseStage(route);
169
+ const resolved = frontmatter.models ? resolveModelId(routeBase, frontmatter.models, defaultModel) : null;
170
+ return resolved || defaultModel || defaultForStage(routeBase);
171
+ }
172
+
173
+ function defaultForStage(routeBase) {
174
+ if (routeBase === 'forge') return 'forge';
175
+ if (routeBase === 'appraise') return 'appraise';
176
+ return null;
171
177
  }
172
178
 
173
179
  function resolveModel(route, frontmatter, agentsDir, io, defaultModel) {
@@ -177,6 +183,7 @@ function resolveModel(route, frontmatter, agentsDir, io, defaultModel) {
177
183
  const model = `foundry-${modelId.replace(/[/.]/g, '-')}`;
178
184
  const agentPath = `${agentsDir}/${model}.md`;
179
185
  if (!io.exists(agentPath)) {
186
+ if (modelId === 'forge' || modelId === 'appraise') return model;
180
187
  return {
181
188
  error: `Missing required subagent: ${model}.md is not present in ${agentsDir}/. `
182
189
  + `Call foundry_refresh_agents() to regenerate agent files, then restart.`,
@@ -194,7 +201,7 @@ function checkModel(route, frontmatter, agentsDir, io, defaultModel) {
194
201
  function mintToken({ route, model, mint, cycle, now, ulid, reason }) {
195
202
  const result = { route, ...(model ? { model } : {}), reason };
196
203
  if (mint && isDispatchableRoute(route)) {
197
- const token = mint({ route, cycle, exp: now + 10 * 60 * 1000, nonce: ulid(now) });
204
+ const token = mint({ route, cycle, exp: now + 10 * 60 * 1000, nonce: ulid(now), model });
198
205
  if (token) result.token = token;
199
206
  }
200
207
  return result;
@@ -6,6 +6,8 @@ description: Produces or revises an artefact, guided by WORK.md and the foundry
6
6
 
7
7
  # Forge
8
8
 
9
+ **This skill is subagent-only.** It describes the protocol a forge subagent follows when dispatched via `task()` from the orchestrate loop. Do NOT load this skill and run forge inline — the orchestrate skill returns a `dispatch` action with a pre-built prompt; call `task()` with it.
10
+
9
11
  You produce or revise artefacts. You read the work file to understand the goal and follow the feedback item in the dispatch prompt, and read the foundry cycle definition to understand what you're producing and what inputs you can read.
10
12
 
11
13
  ## Prerequisites
@@ -37,7 +37,13 @@ Loop until `foundry_orchestrate` returns a terminal action (`done`, `blocked`, o
37
37
 
38
38
  Payload: `{stage, subagent_type, prompt}`.
39
39
 
40
- Call the `task` tool:
40
+ Call the `task` tool. Do NOT load the forge, quench, or appraise skills yourself — the subagent will use them internally:
41
+
42
+ ```
43
+ task tool:
44
+ subagent_type: <subagent_type-from-payload>
45
+ description: "Run <stage> for <cycle>"
46
+ prompt: <prompt-from-payload — pass verbatim>
41
47
  ```
42
48
  task tool:
43
49
  subagent_type: <subagent_type-from-payload>
@@ -51,7 +57,7 @@ When the task returns, call `foundry_orchestrate({lastResult: {ok: true}})`. If
51
57
 
52
58
  Payload: `{stage, cycle, tasks}`.
53
59
 
54
- Fire all tasks in parallel by making multiple `task` tool calls in a single response:
60
+ Fire all tasks in parallel by making multiple `task` tool calls in a single response. Do NOT load stage skills yourself:
55
61
 
56
62
  ```
57
63
  task tool:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@really-knows-ai/foundry",
3
- "version": "3.7.3",
3
+ "version": "3.8.0",
4
4
  "description": "A skill-driven framework for governed artefact generation with AI coding tools. Define your own artefact types, laws, and flows — Foundry handles the forge → quench → appraise pipeline with deterministic routing, quality gates, and iterative refinement.",
5
5
  "type": "module",
6
6
  "main": "dist/.opencode/plugins/foundry.js",