@really-knows-ai/foundry 3.7.1 → 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) {
@@ -318,7 +318,7 @@ function makeReadLawTool(tool) {
318
318
  function makeAddLawTool(tool) {
319
319
  return tool({
320
320
  description: 'Add a new law (config-tier; requires a config/* branch). ' +
321
- 'Fields: id, name, description, passing, failing, target ({kind, file|typeId}), validators ([{id, command, failureMeans?}]).',
321
+ 'Args: id, name, description, passing, failing, target ({kind, file|typeId}), validators ([{id, command, failureMeans?}]). Every validator needs a companion test (TDD) before the law is created.',
322
322
  args: {
323
323
  id: tool.schema.string().describe('Law identifier. Becomes the ## <id> heading.'),
324
324
  name: tool.schema.string().describe('Human-readable name stored as prose after heading.'),
@@ -332,9 +332,9 @@ function makeAddLawTool(tool) {
332
332
  }).describe('Where to write the law'),
333
333
  validators: tool.schema.array(tool.schema.object({
334
334
  id: tool.schema.string().describe('Validator identifier'),
335
- command: tool.schema.string().describe('CLI command with optional {pattern} / {files} placeholders. Prefer JavaScript (.mjs) scripts as separate files (e.g. "node foundry/artefacts/<type>/check.mjs {files}"). Stdout must be NDJSON: one JSON object per line with required fields "file" (relative path) and "text" (message). Optional: "location" (line:col), "severity" (error|warning). Exit code is ignored.'),
335
+ command: tool.schema.string().describe('CLI command with optional {pattern} / {files} placeholders. Prefer .mjs scripts (e.g. "node foundry/artefacts/<type>/check.mjs {files}") with a companion .test.js file (TDD). Stdout must be NDJSON: one JSON object per line with required fields "file" (relative path) and "text" (message). Optional: "location" (line:col), "severity" (error|warning). Exit code is ignored.'),
336
336
  failureMeans: tool.schema.string().optional().describe('Description of what failure means'),
337
- })).optional().describe('Optional deterministic validators'),
337
+ })).optional().describe('Optional deterministic validators. Each requires a companion test file.'),
338
338
  },
339
339
  execute: guarded('foundry_config_add_law', CREATE_GUARDS, executeAddLaw, { branchIo: branchIoFactory, io: asyncIoFactory }),
340
340
  });
@@ -4,8 +4,7 @@ import { existsSync, unlinkSync, writeFileSync, readFileSync } from 'fs';
4
4
  import { slugify } from '../../../scripts/lib/slug.js';
5
5
  import { CONFIG_RE, DRY_RUN_RE } from '../../../scripts/lib/branch-guard.js';
6
6
  import { finishWorkBranchWithArchive } from '../../../scripts/lib/git-finish/work-finish.js';
7
- import { finishDryRun } from '../../../scripts/lib/snapshot/finish.js';
8
- import { asyncIoFactory } from './helpers.js';
7
+ import { checkConfigBranchFiles } from '../../../scripts/lib/git-policy.js';
9
8
 
10
9
  const WORK_FILES = ['WORK.md', 'WORK.history.yaml', 'WORK.feedback.yaml'];
11
10
 
@@ -233,15 +232,35 @@ export function finishBranchCommon({ branchName, branchType, base, cwd, args })
233
232
  const opts = { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] };
234
233
  const planned = computeFinishPlan({ branchName, branchType, base, args, cwd });
235
234
  if (args.confirm !== true) return makeConfirmRefusal(planned);
236
- const dirty = dirtyTrackedFiles(cwd);
237
- if (dirty.length) return makeDirtyRefusal(dirty);
238
- if (branchType === 'work') deleteWorkFilesAndCommit(planned.filesToDelete, cwd, branchName);
235
+ const guardErr = runPreMergeGuards({ branchName, branchType, base, cwd, opts, planned });
236
+ if (guardErr) return guardErr;
239
237
  const mergeErr = squashMergeIntoBase(base, branchName, branchType, opts);
240
238
  if (mergeErr) return mergeErr;
241
239
  const { hash } = commitAndDeleteBranch(args.message, branchName, opts);
242
240
  return JSON.stringify({ ok: true, hash, branch: base });
243
241
  }
244
242
 
243
+ function runPreMergeGuards({ branchName, branchType, base, cwd, opts, planned }) {
244
+ const dirty = dirtyTrackedFiles(cwd);
245
+ if (dirty.length) return makeDirtyRefusal(dirty);
246
+ if (branchType === 'work') {
247
+ deleteWorkFilesAndCommit(planned.filesToDelete, cwd, branchName);
248
+ return null;
249
+ }
250
+ if (branchType !== 'config') return null;
251
+ const diff = execFileSync('git', ['diff', '--name-only', `${base}..${branchName}`],
252
+ { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
253
+ const result = checkConfigBranchFiles(diff);
254
+ if (result) {
255
+ return JSON.stringify({
256
+ ok: false,
257
+ error: 'Config branches may only change files inside foundry/. Outside files detected:',
258
+ outside: result.files,
259
+ });
260
+ }
261
+ return null;
262
+ }
263
+
245
264
  // -- finishWorkBranch helpers --
246
265
 
247
266
  function makeExecGit(cwd, opts) {
@@ -321,34 +340,3 @@ export function finishConfigBranch({ configBranch, base, cwd, args }) {
321
340
  });
322
341
  }
323
342
 
324
- // -- finishDryRunBranch --
325
-
326
- export async function finishDryRunBranch({ branch, args, cwd }) {
327
- const io = asyncIoFactory({ worktree: cwd });
328
- const exec = (argv) => execFileSync('git', argv,
329
- { cwd, encoding: 'utf8', stdio: 'pipe' });
330
-
331
- if (args.confirm !== true) {
332
- return JSON.stringify({
333
- ok: false,
334
- error: 'foundry_git_finish requires {confirm: true} to perform destructive operations. Re-invoke with confirm:true to apply the plan.',
335
- planned: {
336
- branch,
337
- action: 'snapshot + discard (dry-run finish)',
338
- snapshotPath: '.snapshots/<runId> (computed at apply time)',
339
- },
340
- });
341
- }
342
-
343
- try {
344
- const out = await finishDryRun({
345
- message: args.message, branch, io, execFile: exec,
346
- });
347
- return JSON.stringify(out);
348
- } catch (err) {
349
- return JSON.stringify({
350
- ok: false,
351
- error: `foundry_git_finish: dry-run finish failed: ${err.message ?? String(err)}`,
352
- });
353
- }
354
- }
@@ -6,7 +6,6 @@ import {
6
6
  classifyBranch,
7
7
  finishWorkBranch,
8
8
  finishConfigBranch,
9
- finishDryRunBranch,
10
9
  KIND_DRY_RUN,
11
10
  KINDS,
12
11
  } from './git-helpers.js';
@@ -14,6 +13,7 @@ import { makeIO, makeExec, asyncIoFactory } from './helpers.js';
14
13
  import { requireNoActiveStage } from '../../../scripts/lib/stage-guard.js';
15
14
  import { currentBranch } from '../../../scripts/lib/branch-guard.js';
16
15
  import { truncateTrace } from '../../../scripts/lib/tracing.js';
16
+ import { finishDryRun } from '../../../scripts/lib/snapshot/finish.js';
17
17
 
18
18
  function refuse(error) { return JSON.stringify({ error }); }
19
19
 
@@ -107,6 +107,36 @@ function refuseUnknownFinishBranch(branch) {
107
107
  `(expected work/<x>, config/<x>, or dry-run/<x>/<y>).`);
108
108
  }
109
109
 
110
+ async function finishDryRunBranch({ branch, args, cwd }) {
111
+ const io = asyncIoFactory({ worktree: cwd });
112
+ const exec = (argv) => execFileSync('git', argv,
113
+ { cwd, encoding: 'utf8', stdio: 'pipe' });
114
+
115
+ if (args.confirm !== true) {
116
+ return JSON.stringify({
117
+ ok: false,
118
+ error: 'foundry_git_finish requires {confirm: true} to perform destructive operations. Re-invoke with confirm:true to apply the plan.',
119
+ planned: {
120
+ branch,
121
+ action: 'snapshot + discard (dry-run finish)',
122
+ snapshotPath: '.snapshots/<runId> (computed at apply time)',
123
+ },
124
+ });
125
+ }
126
+
127
+ try {
128
+ const out = await finishDryRun({
129
+ message: args.message, branch, io, execFile: exec,
130
+ });
131
+ return JSON.stringify(out);
132
+ } catch (err) {
133
+ return JSON.stringify({
134
+ ok: false,
135
+ error: `foundry_git_finish: dry-run finish failed: ${err.message ?? String(err)}`,
136
+ });
137
+ }
138
+ }
139
+
110
140
  function routeDryRunFinish(branch, args, cwd) {
111
141
  if (args.baseBranch !== undefined)
112
142
  return refuseBaseBranchForDryRun();
@@ -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,35 @@
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
+
17
+ ## [3.7.3] - 2026-05-27
18
+
19
+ ### Added
20
+
21
+ - Config branch file enforcement: `foundry_git_finish` on a `config/*` branch now validates that every changed file lives inside `foundry/` or is tool-managed. Files outside `foundry/` are rejected with a clear list of offending paths. This prevents test fixtures, artefact output, or other non-config files from accidentally landing on config branches.
22
+
23
+ ### Changed
24
+
25
+ - The `add-law` skill clarifies that all flow artefacts — validator scripts, tests, and test fixtures — must live inside `foundry/`. Test fixtures colocate under `foundry/artefacts/<type>/test/fixtures/`. The worked example and "what you do NOT do" list updated accordingly.
26
+
27
+ ## [3.7.2] - 2026-05-27
28
+
29
+ ### Changed
30
+
31
+ - Every validator now requires a companion test file written with TDD. The `add-law` skill walks through TDD (test first, confirm failure, implement, verify pass), produces a `.test.js` file alongside each validator, and refuses to create validators without passing tests. The `foundry_config_add_law` tool description surfaces the requirement.
32
+
3
33
  ## [3.7.1] - 2026-05-27
4
34
 
5
35
  ### Fixed
@@ -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
 
@@ -99,3 +99,21 @@ export function allowedPatternsForStage({ stageBase, forgeFilePatterns = [] } =
99
99
  if (stageBase === 'assay') return ['foundry-memory/**'];
100
100
  return [];
101
101
  }
102
+
103
+ /**
104
+ * Check that every file changed on a config branch lives inside foundry/ or
105
+ * is tool-managed. Returns null when clean, or { files: [...] } when outside
106
+ * files are detected.
107
+ *
108
+ * @param {string} diffOut - Raw `git diff --name-only base..branch` output
109
+ * @returns {null|{files: string[]}}
110
+ */
111
+ export function checkConfigBranchFiles(diffOut) {
112
+ if (!diffOut) return null;
113
+ const toolManaged = new Set(TOOL_MANAGED);
114
+ const outside = diffOut.split('\n')
115
+ .map(f => f.trim())
116
+ .filter(f => f.length > 0 && !toolManaged.has(f) && !f.startsWith('foundry/'))
117
+ .filter(f => !TOOL_MANAGED_PREFIX.some(p => f.startsWith(p)));
118
+ return outside.length ? { files: outside } : null;
119
+ }
@@ -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;
@@ -68,6 +68,10 @@ Walk the user through which elements of the law can be validated deterministical
68
68
 
69
69
  For each script-checkable element, write a standalone `.mjs` script next to the artefacts it validates (e.g. `foundry/artefacts/<type>/check-line-count.mjs`) and reference it in the command (e.g. `node foundry/artefacts/<type>/check-line-count.mjs {files}`). Place validators alongside the artefacts so they colocate with what they validate. Use existing project dependencies and Node.js built‑ins. Hand‑rolled heuristics (custom syllable counters, regex parsers, manual character walks) are a last resort — they produce false positives, waste tokens on debugging, and break on edge cases. Install a library instead. Only write validation logic from scratch when no npm package exists for the task and the heuristic is trivially correct.
70
70
 
71
+ All flow artefacts — validator scripts, tests, test fixtures — live inside `foundry/`. Never place artefacts outside `foundry/`. Test fixtures colocate with the validator's test file under `foundry/artefacts/<type>/test/fixtures/`. When test fixtures match an artefact type's `file-patterns:`, they trigger false-positive quench feedback during flow runs. Keeping them inside `foundry/` prevents this.
72
+
73
+ Every validator carries a companion test file alongside it (e.g. `check-line-count.test.js`). The test uses Node's built‑in test runner — `node --test check-line-count.test.js`. Follow TDD: write the test, confirm it fails against a current artefact, implement the validator, verify the test passes. The test feeds sample inputs to the validator script and asserts the correct JSONL output on stdout — it validates the JSONL contract, not just that the script runs.
74
+
71
75
  **Validators**: Ask about `validators` (optional) — offer to create one or skip.
72
76
 
73
77
  **Conflict check**: Read all existing laws that would apply to the same artefact types. Check for contradiction, duplication, or overlap. If any conflict is found, present it to the user:
@@ -85,7 +89,7 @@ For each script-checkable element, write a standalone `.mjs` script next to the
85
89
 
86
90
  ### 2. Plan
87
91
 
88
- Present a structured summary: law id, name, description, passing/failing criteria, target (global or type-specific with typeId), and validators (which elements are checked deterministically). Ask: "Does this capture what you want, or should we adjust the wording?" Iterate until the user is satisfied.
92
+ Present a structured summary: law id, name, description, passing/failing criteria, target (global or type-specific with typeId), validators (which elements are checked deterministically), and the companion test file for each validator. Ask: "Does this capture what you want, or should we adjust the wording?" Iterate until the user is satisfied.
89
93
 
90
94
  ### 3. Confirm
91
95
 
@@ -93,9 +97,15 @@ Ask: "Proceed with this plan?" — wait for user answer before building. If the
93
97
 
94
98
  ### 4. Build
95
99
 
96
- 1. **Validate**: Call `foundry_config_validate_law({ name: "<id>", body: "<assembled markdown>" })`. Assemble the body from the fields using the `## <id>` heading format the tool produces internally. If the result is `{ ok: false, errors: [...] }`, address each error and re-run until `{ ok: true }`. Common issues: missing required frontmatter keys, references to artefact types that do not exist yet.
100
+ 1. **Write validators with TDD**: For each validator declared in the plan:
101
+
102
+ a. **Write the test first** — create a companion test file alongside the validator (e.g. `foundry/artefacts/<type>/check-line-count.test.js`). The test imports or spawns the validator script with sample inputs and asserts the correct JSONL output on stdout. Run `node --test` to confirm it fails.
97
103
 
98
- 2. **Create**: Translate the scope into the `target` argument:
104
+ b. **Implement the validator** write the `.mjs` script. Run the test again to confirm it passes. Do not commit the validator without its passing test.
105
+
106
+ 2. **Validate**: Call `foundry_config_validate_law({ name: "<id>", body: "<assembled markdown>" })`. Assemble the body from the fields using the `## <id>` heading format the tool produces internally. If the result is `{ ok: false, errors: [...] }`, address each error and re-run until `{ ok: true }`. Common issues: missing required frontmatter keys, references to artefact types that do not exist yet.
107
+
108
+ 3. **Create**: Translate the scope into the `target` argument:
99
109
  - Global → `target: { kind: "global", file: "<file-name>.md" }`
100
110
  - Type-specific → `target: { kind: "type-specific", typeId: "<artefact-type>" }`
101
111
 
@@ -117,7 +127,7 @@ Ask: "Proceed with this plan?" — wait for user answer before building. If the
117
127
 
118
128
  The tool appends to an existing `laws.md` automatically when the new law id is not already present. It only errors when a law with the same id is already in the file — in that case use `foundry_config_edit_law({ id: "<law-id>", description: "<updated>", passing: "<updated>", failing: "<updated>" })` to modify the existing law in place.
119
129
 
120
- 3. **Verify uniqueness**: After the file is created, confirm the law id is unique across all law files. If a collision exists, read the colliding law, present the conflict to the user, propose a rename or merge, ask one focused question about the user's preference, then write and commit the resolution.
130
+ 4. **Verify uniqueness**: After the file is created, confirm the law id is unique across all law files. If a collision exists, read the colliding law, present the conflict to the user, propose a rename or merge, ask one focused question about the user's preference, then write and commit the resolution.
121
131
 
122
132
  ### 5. Editing existing laws (prose or validators)
123
133
 
@@ -145,7 +155,7 @@ Then proceed with the update.
145
155
 
146
156
  > 🔍 **Drift check:** Verify that the changed validator still aligns with the law's prose. If the validator has narrowed or broadened, the prose may need a corresponding update.
147
157
 
148
- Then proceed with the update.
158
+ After the validator implementation changes, update the companion test file. Run the tests to confirm they pass against the updated validator before committing.
149
159
 
150
160
  #### 5e. Apply the update
151
161
 
@@ -258,8 +268,47 @@ validators:
258
268
  failure-means: The artefact file does not contain exactly three non-empty lines.
259
269
  ~~~
260
270
 
271
+ #### Companion test
272
+
273
+ `foundry/artefacts/haiku/check-line-count.test.js`:
274
+
275
+ ~~~js
276
+ import { describe, it } from 'node:test';
277
+ import { execSync } from 'node:child_process';
278
+ import assert from 'node:assert/strict';
279
+
280
+ describe('check-line-count', () => {
281
+ it('passes for exactly three non-empty lines', () => {
282
+ const result = execSync(
283
+ `node foundry/artefacts/haiku/check-line-count.mjs foundry/artefacts/haiku/test/fixtures/haiku-valid.md`,
284
+ { encoding: 'utf8' },
285
+ );
286
+ assert.strictEqual(result.trim(), '');
287
+ });
288
+
289
+ it('reports an error for fewer than three lines', () => {
290
+ const result = execSync(
291
+ `node foundry/artefacts/haiku/check-line-count.mjs foundry/artefacts/haiku/test/fixtures/haiku-short.md`,
292
+ { encoding: 'utf8' },
293
+ );
294
+ assert.match(result, /Expected 3 non-empty lines/);
295
+ });
296
+
297
+ it('reports an error for more than three lines', () => {
298
+ const result = execSync(
299
+ `node foundry/artefacts/haiku/check-line-count.mjs foundry/artefacts/haiku/test/fixtures/haiku-long.md`,
300
+ { encoding: 'utf8' },
301
+ );
302
+ assert.match(result, /Expected 3 non-empty lines/);
303
+ });
304
+ });
305
+ ~~~
306
+
261
307
  ## What you do NOT do
262
308
 
263
309
  - You do not skip the conflict check
264
310
  - You do not silently overwrite existing laws
265
311
  - You do not create artefact types unless the user's stated goal clearly requires it; ask one focused question when multiple designs are plausible
312
+ - You do not write validators without companion tests
313
+ - You do not place flow artefacts or test fixtures outside `foundry/`
314
+ - You do not accept test failures — fix the validator and retry until every test passes
@@ -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.1",
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",