@really-knows-ai/foundry 3.5.1 → 3.5.3

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.
@@ -160,7 +160,7 @@ function cycleArgs(s) { return {
160
160
  inputs: s.object({
161
161
  type: s.enum(['any-of', 'all-of']).describe('Contract type: any-of (at least one) or all-of (all must exist)'),
162
162
  artefacts: s.array(s.string()).describe('Artefact type IDs this cycle reads'),
163
- }).optional().describe('Input contract for this cycle'),
163
+ }).optional().describe('Input contract for this cycle. Omit for source cycles that start from the user goal; empty artefacts arrays are invalid.'),
164
164
  targets: s.array(s.string()).optional().describe('Downstream cycle IDs this cycle can route to'),
165
165
  humanAppraise: s.boolean().optional().describe('Include human-appraise in every iteration'),
166
166
  deadlockAppraise: s.boolean().optional().describe('Route to human-appraise on LLM appraiser deadlock'),
@@ -173,7 +173,7 @@ function cycleArgs(s) { return {
173
173
  read: s.array(s.string()).describe('Memory store keys this cycle can read'),
174
174
  write: s.array(s.string()).describe('Memory store keys this cycle can write'),
175
175
  }).optional().describe('Flow memory permissions'),
176
- models: s.object({}).optional().describe('Per-stage model overrides (e.g. { forge: "openai/gpt-4o" })'),
176
+ models: s.object({}).optional().describe('Per-stage model overrides (e.g. { forge: "opencode-go/deepseek-v4-flash", appraise: "opencode-go/qwen3.6-plus" }). Preserve user-selected stage models.'),
177
177
  description: s.string().optional().describe('Prose description placed after frontmatter'),
178
178
  }; }
179
179
 
@@ -1,13 +1,16 @@
1
1
  import { getCycleDefinition, getArtefactType, getLaws, getAppraisers, getFlow } from '../../../scripts/lib/config.js';
2
2
  import { makeIO } from './helpers.js';
3
+ import { writeCall } from '../../../scripts/lib/stage-calls.js';
3
4
 
4
- function makeConfigTool(tool, description, argSchema, invoke) {
5
+ function makeConfigTool(tool, description, argSchema, invoke, logName) {
5
6
  return tool({
6
7
  description,
7
8
  args: argSchema,
8
9
  async execute(args, context) {
9
10
  const io = makeIO(context.worktree);
10
- return JSON.stringify(await invoke(args, io));
11
+ const result = await invoke(args, io);
12
+ if (logName) writeCall(io, logName);
13
+ return JSON.stringify(result);
11
14
  },
12
15
  });
13
16
  }
@@ -18,16 +21,19 @@ export function createConfigTools({ tool }) {
18
21
  tool, 'Get a cycle definition from foundry config',
19
22
  { cycleId: tool.schema.string().describe('Cycle ID') },
20
23
  (args, io) => getCycleDefinition('foundry', args.cycleId, io),
24
+ 'foundry_config_cycle',
21
25
  ),
22
26
  foundry_config_artefact_type: makeConfigTool(
23
27
  tool, 'Get an artefact type definition',
24
28
  { typeId: tool.schema.string().describe('Artefact type ID') },
25
29
  (args, io) => getArtefactType('foundry', args.typeId, io),
30
+ 'foundry_config_artefact_type',
26
31
  ),
27
32
  foundry_config_laws: makeConfigTool(
28
33
  tool, 'Get laws, optionally filtered by artefact type',
29
34
  { typeId: tool.schema.string().optional().describe('Artefact type ID') },
30
35
  (args, io) => getLaws('foundry', io, { typeId: args.typeId }),
36
+ 'foundry_config_laws',
31
37
  ),
32
38
  foundry_config_appraisers: makeConfigTool(
33
39
  tool, 'List all appraisers',
@@ -3,6 +3,7 @@ import { parseFrontmatter } from '../../../scripts/lib/workfile.js';
3
3
  import { requireActiveStage, stageBaseOf } from '../../../scripts/lib/stage-guard.js';
4
4
  import { guarded, notFailedGuard } from '../../../scripts/lib/guards.js';
5
5
  import { makeIO, branchIoFactory, asyncIoFactory, flowBranchGuard } from './helpers.js';
6
+ import { writeCall } from '../../../scripts/lib/stage-calls.js';
6
7
 
7
8
  const gateNotFailed = notFailedGuard(makeIO);
8
9
 
@@ -170,6 +171,7 @@ async function executeFeedbackList(args, context) {
170
171
  }
171
172
  try {
172
173
  const store = openFeedbackStore('WORK.feedback.yaml', io);
174
+ writeCall(io, 'foundry_feedback_list');
173
175
  const items = store.list()
174
176
  .filter(it => !args.file || it.file === args.file)
175
177
  .map(it => {
@@ -8,10 +8,31 @@ import { syncStore } from '../../../scripts/lib/memory/store.js';
8
8
  import { makeIO, makeMemoryIO, branchIoFactory, asyncIoFactory, flowBranchGuard } from './helpers.js';
9
9
  import { markWorkfileFailed, readFailedStatus, clearWorkfileFailed } from '../../../scripts/lib/failed-flow.js';
10
10
  import { guarded, notFailedGuard } from '../../../scripts/lib/guards.js';
11
+ import { initForgeCallLog, verifyAndClearForgeCallLog } from '../../../scripts/lib/stage-calls.js';
12
+ import { openFeedbackStore } from '../../../scripts/lib/feedback-store.js';
13
+
14
+ const FORGE_REQUIRED_TOOLS = [
15
+ 'foundry_config_cycle',
16
+ 'foundry_workfile_get',
17
+ 'foundry_config_artefact_type',
18
+ 'foundry_config_laws',
19
+ 'foundry_feedback_list',
20
+ ];
21
+
22
+ function stageBase(stage) { return stage.split(':')[0]; }
11
23
 
12
24
  const gateNotFailed = notFailedGuard(makeIO);
13
25
 
14
- // -- Helpers for foundry_stage_begin --
26
+ // -- Helpers for forge tool call verification --
27
+
28
+ function verifyAndManageForgeTools(io, active) {
29
+ const verified = verifyAndClearForgeCallLog(io, FORGE_REQUIRED_TOOLS);
30
+ if (!verified.ok) {
31
+ postMissingToolsFeedback(io, active, verified.missing);
32
+ return;
33
+ }
34
+ resolveSystemFeedback(io, active);
35
+ }
15
36
 
16
37
  function resolveBaseSha(worktree) {
17
38
  try {
@@ -59,9 +80,14 @@ async function executeStageBegin(args, context, pending) {
59
80
  startedAt: new Date().toISOString(),
60
81
  };
61
82
  writeActiveStage(io, active);
83
+ initForgeIfApplicable(io, active.stage);
62
84
  return JSON.stringify({ ok: true, active });
63
85
  }
64
86
 
87
+ function initForgeIfApplicable(io, stage) {
88
+ if (stageBase(stage) === 'forge') initForgeCallLog(io);
89
+ }
90
+
65
91
  // -- Helpers for foundry_stage_end --
66
92
 
67
93
  function markWorkfileFailedSilently(io, msg) {
@@ -82,6 +108,11 @@ async function executeStageEnd(args, context) {
82
108
  if (!active) {
83
109
  return JSON.stringify({ error: 'foundry_stage_end requires active stage; current: none' });
84
110
  }
111
+
112
+ if (stageBase(active.stage) === 'forge') {
113
+ verifyAndManageForgeTools(io, active);
114
+ }
115
+
85
116
  writeLastStage(io, {
86
117
  cycle: active.cycle,
87
118
  stage: active.stage,
@@ -101,6 +132,26 @@ async function executeStageEnd(args, context) {
101
132
  return JSON.stringify({ ok: true, summary: args.summary });
102
133
  }
103
134
 
135
+ function postMissingToolsFeedback(io, active, missing) {
136
+ try {
137
+ const store = openFeedbackStore('WORK.feedback.yaml', io);
138
+ store.add({
139
+ file: '(forge)',
140
+ tag: 'system:missing-tool-calls',
141
+ text: `Missing required forge tools: ${missing.join(', ')}`,
142
+ source: active.stage,
143
+ cycle: active.cycle,
144
+ });
145
+ } catch { /* feedback file not initialised yet; non-critical */ }
146
+ }
147
+
148
+ function resolveSystemFeedback(io, active) {
149
+ try {
150
+ const store = openFeedbackStore('WORK.feedback.yaml', io);
151
+ store.resolveSystemItems(active.stage, active.cycle);
152
+ } catch { /* non-critical */ }
153
+ }
154
+
104
155
  // -- Helpers for foundry_stage_retry --
105
156
 
106
157
  function checkGitWorkingTreeClean(worktree) {
@@ -4,6 +4,7 @@ import { requireNoActiveStage } from '../../../scripts/lib/stage-guard.js';
4
4
  import { guarded, notFailedGuard } from '../../../scripts/lib/guards.js';
5
5
  import { parseFrontmatter, createWorkfile, enrichStages, parseModelsValue } from '../../../scripts/lib/workfile.js';
6
6
  import { makeIO, branchIoFactory, asyncIoFactory, flowBranchGuard } from './helpers.js';
7
+ import { writeCall } from '../../../scripts/lib/stage-calls.js';
7
8
 
8
9
  const gateNotFailed = notFailedGuard(makeIO);
9
10
 
@@ -84,6 +85,7 @@ export function createWorkfileTools({ tool }) {
84
85
  const fm = parseFrontmatter(text);
85
86
  const goalMatch = text.match(/# Goal\n\n([\s\S]*?)(?=\n\||\n##|$)/);
86
87
  const goal = goalMatch ? goalMatch[1].trim() : '';
88
+ writeCall(makeIO(context.worktree), 'foundry_workfile_get');
87
89
  return JSON.stringify({ ...fm, goal });
88
90
  },
89
91
  }),
package/dist/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.5.3] - 2026-05-23
4
+
5
+ ### Added
6
+
7
+ - Forge tool-call verification: `stage_end` checks that required tools were called and posts system feedback if not, with automatic retry via the existing feedback loop.
8
+
9
+ ### Fixed
10
+
11
+ - Forge dispatch prompts now include explicit required-tool instructions with the actual output type filled in.
12
+
13
+ ### Changed
14
+
15
+ - `readForgeFilePatterns` returns `{ patterns, outputType }` instead of a bare array.
16
+ - Build quality gate now writes a seal; `prepublishOnly` verifies the seal instead of re-running lint and tests.
17
+
18
+ ## [3.5.2] - 2026-05-23
19
+
20
+ ### Fixed
21
+
22
+ - Guide agents to omit empty `inputs` for source cycles and preserve stage-specific model overrides when creating cycles.
23
+
3
24
  ## [3.5.1] - 2026-05-22
4
25
 
5
26
  ### Fixed
@@ -60,14 +60,22 @@ function cloneItem(it) {
60
60
  return { ...it, history: it.history.map(h => ({ ...h })) };
61
61
  }
62
62
 
63
+ function resolveSystemItemsImpl({ items, stage, cycle, timestamp, persist }) {
64
+ const snapshot = { state: 'resolved', stage, cycle, timestamp: timestamp() };
65
+ const next = items.map(it =>
66
+ it.tag === 'system:missing-tool-calls' && it.history[0].state !== 'resolved'
67
+ ? { ...it, history: [snapshot, ...it.history] }
68
+ : it
69
+ );
70
+ persist(next);
71
+ }
72
+
63
73
  export function openFeedbackStore(path, io) {
64
74
  let items = loadItems(path, io);
65
-
66
75
  function persist(nextItems) {
67
76
  saveItems(path, nextItems, io);
68
77
  items = nextItems;
69
78
  }
70
-
71
79
  return {
72
80
  list() { return items.map(cloneItem); },
73
81
  get(id) {
@@ -89,14 +97,13 @@ export function openFeedbackStore(path, io) {
89
97
  });
90
98
  },
91
99
  writeDeadlockedSnapshotForTest(params) {
92
- return storeWriteDeadlockedSnapshot(params, items, {
93
- timestamp: nowIso, persist,
94
- });
100
+ return storeWriteDeadlockedSnapshot(params, items, { timestamp: nowIso, persist });
95
101
  },
96
102
  writeDeadlockedSnapshots(ids, reason, stage, cycle) {
97
- return storeWriteDeadlockedSnapshots({ ids, reason, stage, cycle }, items, {
98
- timestamp: nowIso, persist,
99
- });
103
+ return storeWriteDeadlockedSnapshots({ ids, reason, stage, cycle }, items, { timestamp: nowIso, persist });
104
+ },
105
+ resolveSystemItems(stage, cycle) {
106
+ resolveSystemItemsImpl({ items, stage, cycle, timestamp: nowIso, persist });
100
107
  },
101
108
  };
102
109
  }
@@ -0,0 +1,56 @@
1
+ const LOG_PATH = '.foundry/.forge-tool-calls.jsonl';
2
+ const RETRIES_PATH = '.foundry/.forge-tool-retries';
3
+
4
+ export function initForgeCallLog(io) {
5
+ io.writeFile(LOG_PATH, '');
6
+ }
7
+
8
+ export function writeCall(io, toolName) {
9
+ if (!io.exists(LOG_PATH)) return;
10
+ const entry = JSON.stringify({ tool: toolName, ts: Date.now() }) + '\n';
11
+ const existing = io.readFile(LOG_PATH);
12
+ io.writeFile(LOG_PATH, existing + entry);
13
+ }
14
+
15
+ function addCallFromLine(line, called) {
16
+ try {
17
+ const rec = JSON.parse(line);
18
+ if (rec.tool) called.add(rec.tool);
19
+ } catch { /* skip malformed lines */ }
20
+ }
21
+
22
+ function readCallSet(io) {
23
+ const called = new Set();
24
+ if (!io.exists(LOG_PATH)) return called;
25
+ const content = io.readFile(LOG_PATH);
26
+ for (const line of content.split('\n')) {
27
+ if (line) addCallFromLine(line, called);
28
+ }
29
+ return called;
30
+ }
31
+
32
+ export function verifyAndClearForgeCallLog(io, expected) {
33
+ const called = readCallSet(io);
34
+ const missing = expected.filter(t => !called.has(t));
35
+ io.unlink(LOG_PATH);
36
+ return missing.length ? { ok: false, missing } : { ok: true, missing: [] };
37
+ }
38
+
39
+ export function readForgeRetryCount(io) {
40
+ if (!io.exists(RETRIES_PATH)) return 0;
41
+ try {
42
+ return parseInt(io.readFile(RETRIES_PATH).trim(), 10) || 0;
43
+ } catch {
44
+ return 0;
45
+ }
46
+ }
47
+
48
+ export function incrementForgeRetryCount(io) {
49
+ const count = readForgeRetryCount(io) + 1;
50
+ io.writeFile(RETRIES_PATH, String(count));
51
+ return count;
52
+ }
53
+
54
+ export function resetForgeRetryCount(io) {
55
+ io.unlink(RETRIES_PATH);
56
+ }
@@ -39,7 +39,8 @@ export async function readForgeFilePatterns(cycleId, io) {
39
39
  const output = extractOutputType(cd);
40
40
  if (!output) return null;
41
41
  try {
42
- return await fetchFilePatterns(output, io);
42
+ const patterns = await fetchFilePatterns(output, io);
43
+ return patterns ? { patterns, outputType: output } : null;
43
44
  } catch {
44
45
  return null;
45
46
  }
@@ -228,9 +229,28 @@ export function buildDispatchMultiResponse(tasks, stage, cycle) {
228
229
  // Dispatch prompt rendering (pure utility, used by handleSortResult and exported publicly).
229
230
  // ---------------------------------------------------------------------------
230
231
 
231
- export function renderDispatchPrompt({ stage, cycle, token, cwd, filePatterns }) {
232
+ function buildForgePromptLines({ cycle, outputType }) {
233
+ return [
234
+ ``,
235
+ `Before producing output you MUST call these tools to understand the context:`,
236
+ outputType
237
+ ? ` - foundry_config_cycle({ cycleId: "${cycle}" }) — to learn the cycle definition, including its output type "${outputType}"`
238
+ : ` - foundry_config_cycle({ cycleId: "${cycle}" }) — to learn the cycle definition`,
239
+ outputType
240
+ ? ` - foundry_config_artefact_type({ typeId: "${outputType}" }) — to learn the artefact type definition and file patterns`
241
+ : ` - foundry_config_artefact_type({ typeId: "<output type>" }) — to learn the artefact type definition and file patterns`,
242
+ outputType
243
+ ? ` - foundry_config_laws({ typeId: "${outputType}" }) — to learn all applicable quality laws`
244
+ : ` - foundry_config_laws({ typeId: "<output type>" }) — to learn all applicable quality laws`,
245
+ ` - foundry_workfile_get({}) — to learn the goal`,
246
+ ` - foundry_feedback_list({}) — to check for existing feedback from prior iterations`,
247
+ ];
248
+ }
249
+
250
+ export function renderDispatchPrompt({ stage, cycle, token, cwd, filePatterns, outputType }) {
251
+ const base = stage.split(':')[0];
232
252
  const lines = [
233
- `You are a Foundry stage agent. Invoke the ${stage.split(':')[0]} skill and follow its instructions exactly.`,
253
+ `You are a Foundry stage agent. Invoke the ${base} skill and follow its instructions exactly.`,
234
254
  ``,
235
255
  `Stage: ${stage}`,
236
256
  `Cycle: ${cycle}`,
@@ -240,6 +260,9 @@ export function renderDispatchPrompt({ stage, cycle, token, cwd, filePatterns })
240
260
  if (filePatterns && filePatterns.length) {
241
261
  lines.push(`File patterns (forge only): ${JSON.stringify(filePatterns)}`);
242
262
  }
263
+ if (base === 'forge') {
264
+ lines.push(...buildForgePromptLines({ cycle, outputType }));
265
+ }
243
266
  lines.push(
244
267
  ``,
245
268
  `Your FIRST tool call MUST be foundry_stage_begin({stage, cycle, token}) using the values above.`,
@@ -2,8 +2,8 @@
2
2
  // Private phase functions used by runOrchestrate.
3
3
 
4
4
  import {
5
- getCycleDefinition,
6
5
  getArtefactType,
6
+ getCycleDefinition,
7
7
  getLawsForQuench,
8
8
  } from './lib/config.js';
9
9
  import { parseFrontmatter, writeFrontmatter } from './lib/workfile.js';
@@ -13,73 +13,40 @@ import { stageBaseOf } from './lib/stage-guard.js';
13
13
  import { allowedPatternsForStage } from './lib/git-policy.js';
14
14
  import { loadExtractor } from './lib/assay/loader.js';
15
15
  import { checkExtractorAgainstCycle } from './lib/assay/permissions.js';
16
- import { getArtefactFiles } from './lib/artefacts.js';
17
16
  import {
18
- readCycleTargets,
19
17
  readForgeFilePatterns,
20
- readRecentFeedback,
21
18
  computeOpenFeedback,
22
19
  violation,
23
20
  tryCommit,
24
21
  synthesizeStages,
25
22
  renderDispatchPrompt,
26
23
  } from './orchestrate-cycle.js';
24
+ import {
25
+ doneAction,
26
+ blockedAction,
27
+ humanAppraiseAction,
28
+ missingModelViolation,
29
+ } from './orchestrate-terminals.js';
27
30
 
28
- async function findOutputArtefacts(cfm, io, foundryDir, baseBranch) {
29
- const outputType = cfm ? cfm['output-type'] : undefined;
30
- if (!outputType) return null;
31
- const artefacts = await getArtefactFiles(foundryDir, outputType, io, { baseBranch });
32
- return artefacts.find(a => a.state !== 'deleted') || null;
33
- }
34
-
35
- async function doneAction(cycleId, io, foundryDir, baseBranch) {
36
- const fd = foundryDir || 'foundry';
37
- const base = baseBranch || 'main';
38
- const cfm = (await getCycleDefinition(fd, cycleId, io)).frontmatter;
39
- const artefact = await findOutputArtefacts(cfm, io, fd, base);
40
- const artefactFile = artefact ? artefact.file : null;
41
- return { action: 'done', cycle: cycleId, artefact_file: artefactFile, next_cycles: await readCycleTargets(cycleId, io) };
42
- }
43
-
44
- async function blockedAction(cycleId, io, details, foundryDir, baseBranch) {
45
- const fd = foundryDir || 'foundry';
46
- const base = baseBranch || 'main';
47
- const cfm = (await getCycleDefinition(fd, cycleId, io)).frontmatter;
48
- const artefact = await findOutputArtefacts(cfm, io, fd, base);
49
- const artefactFile = artefact ? artefact.file : null;
50
- const reason = details || 'iteration limit reached with unresolved feedback';
51
- return { action: 'blocked', cycle: cycleId, artefact_file: artefactFile, reason };
52
- }
53
-
54
- async function humanAppraiseAction(route, token, ctx) {
55
- const { cycleId, io, baseBranch } = ctx;
56
- const fd = ctx.foundryDir || 'foundry';
57
- const base = baseBranch || 'main';
58
- const cfm = (await getCycleDefinition(fd, cycleId, io)).frontmatter;
59
- const artefact = await findOutputArtefacts(cfm, io, fd, base);
60
- const artefactFile = artefact ? artefact.file : null;
61
- return { action: 'human_appraise', stage: route, token, context: { cycle: cycleId, artefact_file: artefactFile, recent_feedback: readRecentFeedback(io) } };
62
- }
63
-
64
- async function missingModelViolation(cycleId, route, io, foundryDir, baseBranch) {
65
- const fd = foundryDir || 'foundry';
66
- const base = baseBranch || 'main';
67
- const cfm = (await getCycleDefinition(fd, cycleId, io)).frontmatter;
68
- const outputType = cfm ? cfm['output-type'] : undefined;
69
- const artefacts = outputType ? await getArtefactFiles(fd, outputType, io, { baseBranch: base }) : [];
70
- const affectedFiles = artefacts.filter(a => a.state !== 'deleted').map(a => a.file);
71
- return violation(`cycle ${cycleId} stage ${route} has no model declared in cycle definition`, affectedFiles);
72
- }
73
-
74
- function makeDispatchPayload(route, cycleId, token, cwd, filePatterns) {
75
- return { stage: route, cycle: cycleId, token, cwd, filePatterns };
31
+ function makeDispatchPayload({ route, cycleId, token, cwd, filePatterns, outputType }) {
32
+ return { stage: route, cycle: cycleId, token, cwd, filePatterns, outputType };
76
33
  }
77
34
 
78
35
  async function buildDispatchAction(route, model, token, ctx) {
79
36
  if (!model) return missingModelViolation(ctx.cycleId, route, ctx.io, ctx.foundryDir, ctx.baseBranch ?? 'main');
80
37
  const base = route.split(':')[0];
81
- const filePatterns = base === 'forge' ? await readForgeFilePatterns(ctx.cycleId, ctx.io) : null;
82
- return { action: 'dispatch', stage: route, subagent_type: model, prompt: renderDispatchPrompt(makeDispatchPayload(route, ctx.cycleId, token, ctx.cwd, filePatterns)) };
38
+ let filePatterns = null;
39
+ let outputType = null;
40
+ if (base === 'forge') {
41
+ const result = await readForgeFilePatterns(ctx.cycleId, ctx.io);
42
+ if (result) {
43
+ filePatterns = result.patterns;
44
+ outputType = result.outputType;
45
+ }
46
+ }
47
+ const payload = { route, cycleId: ctx.cycleId, token, cwd: ctx.cwd, filePatterns, outputType };
48
+ return { action: 'dispatch', stage: route, subagent_type: model,
49
+ prompt: renderDispatchPrompt(makeDispatchPayload(payload)) };
83
50
  }
84
51
 
85
52
  export function routeDispatch(route) {
@@ -282,7 +249,11 @@ function writeHistoryEntries(ctx) {
282
249
 
283
250
  async function computeAllowedPatterns(lastStage, cycleId, io) {
284
251
  const stageBase = stageBaseOf(lastStage.stage);
285
- const forgeFilePatterns = stageBase === 'forge' ? (await readForgeFilePatterns(cycleId, io)) ?? [] : [];
252
+ let forgeFilePatterns = [];
253
+ if (stageBase === 'forge') {
254
+ const result = await readForgeFilePatterns(cycleId, io);
255
+ forgeFilePatterns = result ? result.patterns : [];
256
+ }
286
257
  return allowedPatternsForStage({ stageBase, forgeFilePatterns });
287
258
  }
288
259
 
@@ -0,0 +1,49 @@
1
+ import { getCycleDefinition } from './lib/config.js';
2
+ import { getArtefactFiles } from './lib/artefacts.js';
3
+ import { readCycleTargets, readRecentFeedback, violation } from './orchestrate-cycle.js';
4
+
5
+ async function findOutputArtefacts(cfm, io, foundryDir, baseBranch) {
6
+ const outputType = cfm ? cfm['output-type'] : undefined;
7
+ if (!outputType) return null;
8
+ const artefacts = await getArtefactFiles(foundryDir, outputType, io, { baseBranch });
9
+ return artefacts.find(a => a.state !== 'deleted') || null;
10
+ }
11
+
12
+ export async function doneAction(cycleId, io, foundryDir, baseBranch) {
13
+ const fd = foundryDir || 'foundry';
14
+ const base = baseBranch || 'main';
15
+ const cfm = (await getCycleDefinition(fd, cycleId, io)).frontmatter;
16
+ const artefact = await findOutputArtefacts(cfm, io, fd, base);
17
+ const artefactFile = artefact ? artefact.file : null;
18
+ return { action: 'done', cycle: cycleId, artefact_file: artefactFile, next_cycles: await readCycleTargets(cycleId, io) };
19
+ }
20
+
21
+ export async function blockedAction(cycleId, io, details, foundryDir, baseBranch) {
22
+ const fd = foundryDir || 'foundry';
23
+ const base = baseBranch || 'main';
24
+ const cfm = (await getCycleDefinition(fd, cycleId, io)).frontmatter;
25
+ const artefact = await findOutputArtefacts(cfm, io, fd, base);
26
+ const artefactFile = artefact ? artefact.file : null;
27
+ const reason = details || 'iteration limit reached with unresolved feedback';
28
+ return { action: 'blocked', cycle: cycleId, artefact_file: artefactFile, reason };
29
+ }
30
+
31
+ export async function humanAppraiseAction(route, token, ctx) {
32
+ const { cycleId, io, baseBranch } = ctx;
33
+ const fd = ctx.foundryDir || 'foundry';
34
+ const base = baseBranch || 'main';
35
+ const cfm = (await getCycleDefinition(fd, cycleId, io)).frontmatter;
36
+ const artefact = await findOutputArtefacts(cfm, io, fd, base);
37
+ const artefactFile = artefact ? artefact.file : null;
38
+ return { action: 'human_appraise', stage: route, token, context: { cycle: cycleId, artefact_file: artefactFile, recent_feedback: readRecentFeedback(io) } };
39
+ }
40
+
41
+ export async function missingModelViolation(cycleId, route, io, foundryDir, baseBranch) {
42
+ const fd = foundryDir || 'foundry';
43
+ const base = baseBranch || 'main';
44
+ const cfm = (await getCycleDefinition(fd, cycleId, io)).frontmatter;
45
+ const outputType = cfm ? cfm['output-type'] : undefined;
46
+ const artefacts = outputType ? await getArtefactFiles(fd, outputType, io, { baseBranch: base }) : [];
47
+ const affectedFiles = artefacts.filter(a => a.state !== 'deleted').map(a => a.file);
48
+ return violation(`cycle ${cycleId} stage ${route} has no model declared in cycle definition`, affectedFiles);
49
+ }
@@ -40,6 +40,10 @@ When invoked with pre-filled fields matching the `foundry_config_create_cycle` t
40
40
 
41
41
  Context fields: `{id, name, outputType, description, inputs?, targets?, humanAppraise?, deadlockAppraise?, deadlockIterations?, maxIterations?, assay?, memory?, models?}`
42
42
 
43
+ `inputs` is optional. A source cycle that starts from the user's run goal and has no upstream artefact dependency omits `inputs` entirely. Empty input contracts are invalid: do not pass `inputs: {type: "any-of", artefacts: []}`.
44
+
45
+ `models` is a map of stage names to model IDs. Preserve user-selected model overrides exactly, for example `{forge: "opencode-go/deepseek-v4-flash", appraise: "opencode-go/qwen3.6-plus"}`.
46
+
43
47
  When invoked with a context:
44
48
  - If all required fields are present, skip the Understand phase and proceed to Plan → Confirm → Build.
45
49
  - If only some fields are present, ask only for the missing ones.
@@ -80,7 +84,7 @@ If the parent flow or required artefact type is missing and the user's goal clea
80
84
 
81
85
  **Optional clusters** — After each cluster, ask whether the user wants to configure it; if not, skip:
82
86
 
83
- - **Routing**: `inputs` (input contract: `{type: "any-of"|"all-of", artefacts: string[]}`), `targets` (cycle IDs to route to after completion), `maxIterations` (maximum iterations before forced progression)
87
+ - **Routing**: `inputs` (input contract: `{type: "any-of"|"all-of", artefacts: string[]}`; omit for source cycles with no upstream artefact dependency), `targets` (cycle IDs to route to after completion), `maxIterations` (maximum iterations before forced progression)
84
88
  - **Human-appraise**: `humanAppraise` (boolean, default false) — human reviews every iteration; `deadlockAppraise` (boolean, default true) — human is pulled in when LLM appraisers deadlock; `deadlockIterations` (number, default 5) — deadlock threshold. Only applies when either appraise is enabled.
85
89
  - **Memory and models**: `assay` (assay configuration), `memory` (memory configuration), `models` (stage-specific model overrides, e.g. `{forge: "openai/gpt-4o", appraise: "openai/gpt-4o"}`). For models, offer each stage (forge, quench, appraise) individually. If the user has no preference, omit the `models` map and use the session defaults.
86
90
 
@@ -98,7 +102,7 @@ Ask: "Proceed with this plan?" — wait for user answer before building. If the
98
102
 
99
103
  1. **Validate**: Call `foundry_config_validate_cycle({ name: "<id>", body: "<assembled markdown>" })`. Assemble the body from the fields using the frontmatter 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 or flows that do not exist yet.
100
104
 
101
- 2. **Create**: Call `foundry_config_create_cycle({ id: "<id>", name: "<name>", outputType: "<type>", description: "<description>", inputs: ..., targets: ..., humanAppraise: ..., deadlockAppraise: ..., deadlockIterations: ..., maxIterations: ..., assay: ..., memory: ..., models: ... })`. The tool:
105
+ 2. **Create**: Call `foundry_config_create_cycle({ id: "<id>", name: "<name>", outputType: "<type>", description: "<description>", targets: ..., humanAppraise: ..., deadlockAppraise: ..., deadlockIterations: ..., maxIterations: ..., assay: ..., memory: ..., models: ... })`. Include `inputs` only when the cycle reads upstream artefacts, and include `models` whenever the user selected stage-specific model overrides. The tool:
102
106
  - re-validates the body (TOCTOU);
103
107
  - writes `foundry/cycles/<id>.md`;
104
108
  - produces one git commit on the current `config/*` branch.
@@ -69,7 +69,7 @@ Create missing dependencies in validation order:
69
69
 
70
70
  3. **Appraisers** (may reference models): For each new appraiser, gather `id`, `name`, `description`, and optional `model` preference. Context object: `{id, name, description, model?}`.
71
71
 
72
- 4. **Cycles** (reference artefact types, laws, appraisers): For each new cycle, gather `id`, `name`, `outputType`, `description`, and any optional settings (inputs, targets, appraise, assay, memory, models). Context object: `{id, name, outputType, description, inputs?, targets?, humanAppraise?, deadlockAppraise?, deadlockIterations?, maxIterations?, assay?, memory?, models?}`.
72
+ 4. **Cycles** (reference artefact types, laws, appraisers): For each new cycle, gather `id`, `name`, `outputType`, `description`, and any optional settings (inputs, targets, appraise, assay, memory, models). Context object: `{id, name, outputType, description, inputs?, targets?, humanAppraise?, deadlockAppraise?, deadlockIterations?, maxIterations?, assay?, memory?, models?}`. For a source cycle that starts from the user's run goal and has no upstream artefact dependency, omit `inputs` entirely; never pass `inputs` with an empty `artefacts` array.
73
73
 
74
74
  For the haiku example, default to a `haiku` artefact type, `haikus/*.md` file pattern, laws for form, imagery, and mood, a deterministic syllable validator where project dependencies allow it, two or three distinct appraisers, one cycle, and one flow.
75
75
 
@@ -92,6 +92,7 @@ Flow: <id> — <name>
92
92
  · <id> — <description>
93
93
  Cycles:
94
94
  · <id> → <outputType> — <description>
95
+ inputs/models: <omitted or explicit settings>
95
96
  ```
96
97
 
97
98
  Ask "Proceed with this plan?" — do not build anything until the user confirms.
@@ -121,9 +122,11 @@ Build order (dependency order):
121
122
 
122
123
  4. **Cycles**: For each new cycle, invoke the `add-cycle` protocol with the captured context.
123
124
 
124
- > Invoke the add-cycle protocol with context: `{id: "haiku-cycle", name: "Haiku Cycle", outputType: "haiku", description: "Generates haiku poems"}`.
125
+ > Invoke the add-cycle protocol with context: `{id: "haiku-cycle", name: "Haiku Cycle", outputType: "haiku", description: "Generates haiku poems", models: {forge: "opencode-go/deepseek-v4-flash", appraise: "opencode-go/qwen3.6-plus"}}`.
125
126
  > If all required fields are present, proceed directly to Build. Otherwise ask for missing required fields only.
126
127
 
128
+ Preserve every user-selected stage model in the cycle context. If the cycle has no upstream artefact input, leave `inputs` absent from the context.
129
+
127
130
  **Build-only mode**: When all required fields for a sub-skill are present in the context, the sub-skill skips Understand, Plan, and Confirm — proceeding directly to validate → create → commit. When only some required fields are present, the sub-skill enters its Understand phase to ask only for those missing required fields, then proceeds to Build (still skipping Plan and Confirm since the parent's combined plan already handled confirmation). Optional fields that are missing are silently skipped.
128
131
 
129
132
  **Error handling during build**: If a sub-skill's Build phase fails (validation error or tool error), surface the error to the user:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@really-knows-ai/foundry",
3
- "version": "3.5.1",
3
+ "version": "3.5.3",
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",
@@ -58,7 +58,7 @@
58
58
  "test:all": "node --test --experimental-test-module-mocks --test-reporter=dot",
59
59
  "test:coverage": "node --test --experimental-test-coverage --test-reporter=dot",
60
60
  "lint": "eslint src/ tests/ scripts/",
61
- "build:full": "pnpm run lint --fix && pnpm run test:all && pnpm run build",
62
- "build:all": "pnpm run lint && pnpm run test:all && pnpm run build"
61
+ "build:full": "pnpm run lint --fix && pnpm run test:all && pnpm run build && node scripts/seal.js",
62
+ "build:all": "pnpm run lint && pnpm run test:all && pnpm run build && node scripts/seal.js"
63
63
  }
64
64
  }