@really-knows-ai/foundry 2.2.1 → 2.3.1

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.
@@ -0,0 +1,418 @@
1
+ // Foundry v2.3.0 orchestrate: deterministic cycle orchestration.
2
+ // Composes internal functions (sort, finalize, history, commit, configure)
3
+ // into a single entry point the LLM drives via a 3-line loop.
4
+
5
+ import { runSort } from './sort.js';
6
+ import {
7
+ getCycleDefinition,
8
+ getArtefactType,
9
+ getValidation,
10
+ } from './lib/config.js';
11
+ import { parseFrontmatter, writeFrontmatter } from './lib/workfile.js';
12
+ import { parseArtefactsTable, addArtefactRow, setArtefactStatus } from './lib/artefacts.js';
13
+ import { readActiveStage, readLastStage, clearActiveStage } from './lib/state.js';
14
+ import { appendEntry, getIteration } from './lib/history.js';
15
+ import { listFeedback } from './lib/feedback.js';
16
+
17
+ export function renderDispatchPrompt({ stage, cycle, token, cwd, filePatterns }) {
18
+ const lines = [
19
+ `You are a Foundry stage agent. Invoke the ${stage.split(':')[0]} skill and follow its instructions exactly.`,
20
+ ``,
21
+ `Stage: ${stage}`,
22
+ `Cycle: ${cycle}`,
23
+ `Token: ${token}`,
24
+ `Working directory: ${cwd}`,
25
+ ];
26
+ if (filePatterns && filePatterns.length) {
27
+ lines.push(`File patterns (forge only): ${JSON.stringify(filePatterns)}`);
28
+ }
29
+ lines.push(
30
+ ``,
31
+ `Your FIRST tool call MUST be foundry_stage_begin({stage, cycle, token}) using the values above.`,
32
+ `Your LAST tool call MUST be foundry_stage_end({summary}).`,
33
+ ``,
34
+ `When done, report back a brief summary. Do NOT call foundry_history_append, foundry_git_commit, or foundry_artefacts_add — the orchestrator handles all of those.`
35
+ );
36
+ return lines.join('\n');
37
+ }
38
+
39
+ export function synthesizeStages({ cycleId, hasValidation, humanAppraise }) {
40
+ const stages = [`forge:${cycleId}`];
41
+ if (hasValidation) stages.push(`quench:${cycleId}`);
42
+ stages.push(`appraise:${cycleId}`);
43
+ if (humanAppraise) stages.push(`human-appraise:${cycleId}`);
44
+ return stages;
45
+ }
46
+
47
+ export function needsSetup(workMdContent) {
48
+ const match = workMdContent.match(/^---\n([\s\S]*?)\n---/);
49
+ if (!match) return true;
50
+ const fm = match[1];
51
+ return !/^stages:/m.test(fm);
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Task-6 stub helpers (wired in later). readForgeFilePatterns is real now
56
+ // because the first-call dispatch prompt needs it.
57
+ // ---------------------------------------------------------------------------
58
+
59
+ export function findCycleOutputArtefact(cycleId, io) {
60
+ if (!io.exists('WORK.md')) return null;
61
+ const content = io.readFile('WORK.md');
62
+ const rows = parseArtefactsTable(content);
63
+ const match = rows.find(r => r.cycle === cycleId);
64
+ return match ? { file: match.file, type: match.type, status: match.status } : null;
65
+ }
66
+
67
+ export async function readCycleTargets(cycleId, io) {
68
+ try {
69
+ const cd = await getCycleDefinition('foundry', cycleId, io);
70
+ return cd.frontmatter?.targets ?? [];
71
+ } catch {
72
+ return [];
73
+ }
74
+ }
75
+
76
+ export async function readForgeFilePatterns(cycleId, io) {
77
+ try {
78
+ const cd = await getCycleDefinition('foundry', cycleId, io);
79
+ const output = cd.frontmatter?.output;
80
+ if (!output) return null;
81
+ const at = await getArtefactType('foundry', output, io);
82
+ return at.frontmatter?.['file-patterns'] ?? null;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ function readRecentFeedback(cycleId, io, limit = 5) {
89
+ // Best-effort: surface recent deadlocked items (rejected or wont-fix) for
90
+ // the human-appraise checkpoint. Returns last `limit` matching entries.
91
+ // On any parse error, return [] rather than crashing the cycle.
92
+ try {
93
+ if (!io.exists('WORK.md')) return [];
94
+ const content = io.readFile('WORK.md');
95
+ const rows = parseArtefactsTable(content);
96
+ const items = listFeedback(content, cycleId, rows);
97
+ const deadlocked = items.filter(
98
+ it => it.state === 'wont-fix' || it.state === 'rejected'
99
+ );
100
+ return deadlocked.slice(-limit);
101
+ } catch {
102
+ return [];
103
+ }
104
+ }
105
+
106
+ function violation(details, affectedFiles = []) {
107
+ return {
108
+ action: 'violation',
109
+ details,
110
+ recoverable: false,
111
+ affected_files: affectedFiles,
112
+ };
113
+ }
114
+
115
+ function markArtefactBlocked(cycleId, io) {
116
+ if (!io.exists('WORK.md')) return { ok: true };
117
+ const content = io.readFile('WORK.md');
118
+ const rows = parseArtefactsTable(content);
119
+ const row = rows.find(r => r.cycle === cycleId);
120
+ if (!row) return { ok: true };
121
+ try {
122
+ io.writeFile('WORK.md', setArtefactStatus(content, row.file, 'blocked'));
123
+ return { ok: true };
124
+ } catch (e) {
125
+ // Surface to caller: setArtefactStatus is strict (e.g. row already
126
+ // blocked/done, invalid status). Don't crash; let caller annotate
127
+ // the violation.
128
+ return { ok: false, error: e?.message || String(e) };
129
+ }
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // Sort result -> action shape
134
+ // ---------------------------------------------------------------------------
135
+
136
+ async function handleSortResult(sortResult, { cycleId, cwd, io }) {
137
+ const { route, model, token, details } = sortResult;
138
+ const base = typeof route === 'string' ? route.split(':')[0] : '';
139
+
140
+ if (route === 'done') {
141
+ const art = findCycleOutputArtefact(cycleId, io);
142
+ return {
143
+ action: 'done',
144
+ cycle: cycleId,
145
+ artefact_file: art?.file ?? null,
146
+ next_cycles: await readCycleTargets(cycleId, io),
147
+ };
148
+ }
149
+
150
+ if (route === 'blocked') {
151
+ const art = findCycleOutputArtefact(cycleId, io);
152
+ return {
153
+ action: 'blocked',
154
+ cycle: cycleId,
155
+ artefact_file: art?.file ?? null,
156
+ reason: details ?? 'iteration limit reached with unresolved feedback',
157
+ };
158
+ }
159
+
160
+ if (route === 'violation') {
161
+ return violation(details ?? 'sort returned violation');
162
+ }
163
+
164
+ if (base === 'human-appraise') {
165
+ const art = findCycleOutputArtefact(cycleId, io);
166
+ return {
167
+ action: 'human_appraise',
168
+ stage: route,
169
+ token,
170
+ context: {
171
+ cycle: cycleId,
172
+ artefact_file: art?.file ?? null,
173
+ recent_feedback: readRecentFeedback(cycleId, io),
174
+ },
175
+ };
176
+ }
177
+
178
+ // forge | quench | appraise
179
+ if (!model) {
180
+ const art = findCycleOutputArtefact(cycleId, io);
181
+ return violation(
182
+ `cycle ${cycleId} stage ${route} has no model declared in cycle definition (\`models:\` field) and no default available`,
183
+ [art?.file].filter(Boolean)
184
+ );
185
+ }
186
+
187
+ const filePatterns = base === 'forge'
188
+ ? await readForgeFilePatterns(cycleId, io)
189
+ : null;
190
+
191
+ return {
192
+ action: 'dispatch',
193
+ stage: route,
194
+ subagent_type: model,
195
+ prompt: renderDispatchPrompt({
196
+ stage: route,
197
+ cycle: cycleId,
198
+ token,
199
+ cwd,
200
+ filePatterns,
201
+ }),
202
+ };
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Main entry point
207
+ // ---------------------------------------------------------------------------
208
+
209
+ export async function runOrchestrate(args = {}, io) {
210
+ const {
211
+ cwd = process.cwd(),
212
+ cycleDef: cycleDefOverride = null,
213
+ git,
214
+ mint,
215
+ now = Date.now,
216
+ lastResult = null,
217
+ finalize = null,
218
+ } = args;
219
+
220
+ if (!io.exists('WORK.md')) {
221
+ return violation('no WORK.md; flow skill must create it first');
222
+ }
223
+
224
+ let workContent = io.readFile('WORK.md');
225
+ const fm = parseFrontmatter(workContent);
226
+ const cycleId = fm.cycle;
227
+ if (!cycleId) {
228
+ return violation('WORK.md frontmatter missing cycle field', ['WORK.md']);
229
+ }
230
+
231
+ if (needsSetup(workContent)) {
232
+ if (lastResult) {
233
+ return violation(
234
+ 'inconsistent state: lastResult provided but WORK.md still needs setup',
235
+ ['WORK.md']
236
+ );
237
+ }
238
+
239
+ const foundryDir = 'foundry';
240
+ let cycleDefDoc;
241
+ try {
242
+ cycleDefDoc = await getCycleDefinition(foundryDir, cycleId, io);
243
+ } catch {
244
+ return violation(`cycle definition not found for id: ${cycleId}`, ['WORK.md']);
245
+ }
246
+ const cfm = cycleDefDoc.frontmatter || {};
247
+
248
+ const outputType = cfm.output;
249
+ if (!outputType) {
250
+ return violation(`cycle ${cycleId} missing output field`, ['WORK.md']);
251
+ }
252
+
253
+ try {
254
+ await getArtefactType(foundryDir, outputType, io);
255
+ } catch {
256
+ return violation(`artefact type not found: ${outputType}`, ['WORK.md']);
257
+ }
258
+
259
+ const validation = await getValidation(foundryDir, outputType, io);
260
+
261
+ let stages;
262
+ if (Array.isArray(cfm.stages)) {
263
+ if (cfm.stages.length === 0) {
264
+ const art = findCycleOutputArtefact(cycleId, io);
265
+ return violation(
266
+ `cycle ${cycleId} has no stages declared in cycle definition`,
267
+ [art?.file, 'WORK.md'].filter(Boolean)
268
+ );
269
+ }
270
+ stages = cfm.stages.map(s =>
271
+ typeof s === 'string' && s.includes(':') ? s : `${s}:${cycleId}`
272
+ );
273
+ } else {
274
+ stages = synthesizeStages({
275
+ cycleId,
276
+ hasValidation: !!validation && validation.length > 0,
277
+ humanAppraise: cfm['human-appraise'] === true,
278
+ });
279
+ }
280
+
281
+ const newFm = { ...fm };
282
+ newFm.stages = stages;
283
+ newFm['max-iterations'] = cfm['max-iterations'] ?? 3;
284
+ newFm['human-appraise'] = cfm['human-appraise'] === true;
285
+ newFm['deadlock-appraise'] = cfm['deadlock-appraise'] !== false;
286
+ newFm['deadlock-iterations'] = cfm['deadlock-iterations'] ?? 5;
287
+ if (cfm.models) newFm.models = cfm.models;
288
+
289
+ const body = workContent.replace(/^---\n[\s\S]+?\n---\n?/, '');
290
+ const fmBlock = writeFrontmatter(newFm);
291
+ const newWork = body ? `${fmBlock}\n${body}` : fmBlock;
292
+ io.writeFile('WORK.md', newWork);
293
+
294
+ if (git && typeof git.commit === 'function') {
295
+ git.commit(`[${cycleId}] setup: configure stages and limits`);
296
+ }
297
+
298
+ workContent = io.readFile('WORK.md');
299
+ }
300
+
301
+ const activeStage = readActiveStage(io);
302
+ const lastStage = readLastStage(io);
303
+
304
+ if (activeStage && !lastResult) {
305
+ return violation(
306
+ `prior stage ${activeStage.stage} orphaned — no lastResult provided but active stage exists. ` +
307
+ `Likely cause: previous orchestrate call returned dispatch but caller did not follow up.`,
308
+ []
309
+ );
310
+ }
311
+
312
+ if (lastResult) {
313
+ // Subagent crash path: stage_end may NOT have been called, so activeStage
314
+ // can still exist and lastStage may be stale or absent. Prefer activeStage
315
+ // (current dispatch) over lastStage (could be from a prior cycle).
316
+ if (lastResult.ok === false) {
317
+ const failedStage = activeStage || lastStage;
318
+ if (!failedStage) {
319
+ return violation('lastResult.ok=false but no stage recorded — orphaned state');
320
+ }
321
+ const blockResult = markArtefactBlocked(cycleId, io);
322
+ if (activeStage) clearActiveStage(io);
323
+ const art = findCycleOutputArtefact(cycleId, io);
324
+ const blockNote = blockResult.ok ? '' : ` (also: failed to mark artefact blocked: ${blockResult.error})`;
325
+ return violation(
326
+ `subagent dispatch failed: ${lastResult.error || 'unknown error'}${blockNote}`,
327
+ [art?.file].filter(Boolean)
328
+ );
329
+ }
330
+
331
+ // Happy path: foundry_stage_end has run, which writes lastStage and clears
332
+ // activeStage. lastStage is the canonical source of stage identity & baseSha.
333
+ if (!lastStage) {
334
+ return violation('lastResult provided but no last stage recorded — orphaned state');
335
+ }
336
+
337
+ let finalizeResult;
338
+ if (typeof finalize !== 'function') {
339
+ return violation(
340
+ 'orchestrate caller must inject a `finalize` function when providing lastResult; ' +
341
+ 'the plugin wires lib/finalize.finalizeStage; tests must pass a stub.',
342
+ []
343
+ );
344
+ }
345
+ finalizeResult = await finalize({
346
+ cycleId,
347
+ stage: lastStage.stage,
348
+ baseSha: lastStage.baseSha,
349
+ io,
350
+ });
351
+
352
+ if (!finalizeResult.ok) {
353
+ const blockResult = markArtefactBlocked(cycleId, io);
354
+ if (activeStage) clearActiveStage(io);
355
+ const blockNote = blockResult.ok ? '' : ` (also: failed to mark artefact blocked: ${blockResult.error})`;
356
+ if (finalizeResult.error === 'unexpected_files') {
357
+ return violation(
358
+ `unexpected files written by subagent: ${(finalizeResult.files || []).join(', ')}${blockNote}`,
359
+ finalizeResult.files || []
360
+ );
361
+ }
362
+ return violation(`stage_finalize error: ${finalizeResult.error}${blockNote}`, []);
363
+ }
364
+
365
+ for (const a of finalizeResult.artefacts ?? []) {
366
+ let wm = io.readFile('WORK.md');
367
+ const rows = parseArtefactsTable(wm);
368
+ if (!rows.some(r => r.file === a.file)) {
369
+ wm = addArtefactRow(wm, {
370
+ file: a.file,
371
+ type: a.type,
372
+ cycle: cycleId,
373
+ status: a.status ?? 'draft',
374
+ });
375
+ io.writeFile('WORK.md', wm);
376
+ }
377
+ }
378
+
379
+ const summary = lastStage.summary || '(no summary)';
380
+ const historyPath = 'WORK.history.yaml';
381
+ const iteration = getIteration(historyPath, cycleId, io);
382
+
383
+ appendEntry(historyPath, {
384
+ cycle: cycleId,
385
+ stage: 'sort',
386
+ iteration,
387
+ route: lastStage.stage,
388
+ comment: `route ${lastStage.stage}`,
389
+ }, io);
390
+ appendEntry(historyPath, {
391
+ cycle: cycleId,
392
+ stage: lastStage.stage,
393
+ iteration,
394
+ comment: summary,
395
+ }, io);
396
+
397
+ if (git && typeof git.commit === 'function') {
398
+ git.commit(`[${cycleId}] ${lastStage.stage}: ${summary}`);
399
+ }
400
+ // Defensive: stage_end clears activeStage already; this is a no-op in the
401
+ // normal lifecycle but cleans up if the subagent skipped stage_end.
402
+ if (activeStage) clearActiveStage(io);
403
+ }
404
+
405
+ const sortResult = runSort(
406
+ {
407
+ cycleDef: cycleDefOverride,
408
+ mint,
409
+ now: typeof now === 'function' ? now() : now,
410
+ },
411
+ io
412
+ );
413
+
414
+ return handleSortResult(sortResult, { cycleId, cwd, io });
415
+ }
416
+
417
+ // Test-only export; keep underscored to discourage runtime use.
418
+ export { handleSortResult as __handleSortResultForTest };
@@ -2,7 +2,7 @@
2
2
  name: flow
3
3
  type: composite
4
4
  description: Runs a defined foundry flow to produce artefacts. Use this whenever the user references a flow by id, name, or paraphrase (e.g. "use the creative flow", "run creative-flow"). Do not brainstorm — the flow's cycles already define the work. The user's request is the goal to pass in.
5
- composes: [cycle]
5
+ composes: [orchestrate]
6
6
  ---
7
7
 
8
8
  # Flow
@@ -20,9 +20,10 @@ Before running this skill, verify that the `foundry/` directory exists in the pr
20
20
  1. Call `foundry_config_flow` with the flow ID — get the flow definition
21
21
  2. Call `foundry_git_branch` with the flow ID and a short description — create the work branch
22
22
  3. Determine the starting cycle:
23
- - If only one starting cycle, use it
24
- - If multiple starting cycles, check whether the user's request makes the choice obvious (e.g., "write a haiku" clearly maps to `create-haiku`)
25
- - If ambiguous, prompt the user to choose
23
+ - Any cycle listed in the flow can be the starting cycle. The flow's `starting-cycles` list is a hint for when the user's request is ambiguous.
24
+ - Map the user's goal to a cycle by matching the requested output (e.g. "write a short story from the tennis haiku" `create-short-story`; "write a haiku" → `create-haiku`).
25
+ - If the goal is ambiguous, prompt the user to choose from the flow's cycles, defaulting the recommendation to entries in `starting-cycles`.
26
+ - A cycle whose `inputs` contract cannot be satisfied from files already on disk should not be chosen as the starting cycle. If no other cycle matches, inform the user which input types are missing and offer to run a cycle that produces them first.
26
27
  4. Pre-check for an existing workfile (prevents silent data loss from an aborted prior session):
27
28
  a. Call `foundry_workfile_get`.
28
29
  b. If it returns `{error: ...}` (no WORK.md), proceed to step 5.
@@ -30,8 +31,8 @@ Before running this skill, verify that the `foundry/` directory exists in the pr
30
31
  - **Resume** — keep the existing workfile and skip to step 6. **Only offer resume if the existing `flow` AND `cycle` match what the user just asked for.** If either differs, do not offer resume — running the wrong cycle against stale state corrupts the workflow.
31
32
  - **Discard** — call `foundry_workfile_delete`, then proceed to step 5.
32
33
  - **Abort** — stop the skill without modifying anything.
33
- 5. Call `foundry_workfile_create` with **only** the flow ID, chosen cycle ID, and goal — do **not** pass `stages` or `maxIterations`. The `cycle` skill will read the cycle definition and populate those via `foundry_workfile_set` in the next step.
34
- 6. Execute the cycle by invoking the cycle skill
34
+ 5. Call `foundry_workfile_create` with **only** the flow ID, chosen cycle ID, and goal — do **not** pass `stages` or `maxIterations`. The `orchestrate` skill will read the cycle definition and handle setup on its first call.
35
+ 6. Execute the cycle by invoking the orchestrate skill
35
36
 
36
37
  ## Between cycles
37
38
 
@@ -49,9 +50,10 @@ When a cycle completes (sort returns `done`):
49
50
  - Check input contracts for each
50
51
  - The user chooses which target to pursue (or which to pursue first)
51
52
  5. Set up the next cycle:
52
- - Call `foundry_workfile_set` with `key: "cycle"`, `value: <next-cycle-id>`
53
- - Reset stages and iteration count for the new cycle
54
- - Execute the cycle by invoking the cycle skill
53
+ - Call `foundry_workfile_delete` to clear the completed cycle's WORK.md.
54
+ - Call `foundry_workfile_create` with **only** the flow ID, the next cycle ID, and the goal — do **not** pass `stages` or `maxIterations`. The orchestrate skill will detect `needsSetup` on its first call and bootstrap the rest of the frontmatter from the cycle definition.
55
+ - Do **not** register the completed cycle's output as an input to the next cycle. The output file is on disk and the next cycle's forge discovers it through the input type's `file-patterns` — see the forge skill's input-discovery protocol.
56
+ - Execute the cycle by invoking the orchestrate skill.
55
57
 
56
58
  ## Completing a flow
57
59
 
@@ -30,7 +30,11 @@ Forge runs inside an enforced stage. Your **first** and **last** tool calls are
30
30
  3. `foundry_config_cycle` — understand what to produce and what inputs are available.
31
31
  4. `foundry_config_artefact_type` with the output type ID — get the artefact type definition, especially its `file-patterns`.
32
32
  5. `foundry_config_laws` — get all applicable laws (global + type-specific).
33
- 6. If the cycle has inputs, read the input artefacts (read-only context).
33
+ 6. If the cycle declares `inputs`, discover input files by filesystem scan:
34
+ - For each type listed in `inputs`, call `foundry_config_artefact_type` to get its `file-patterns`.
35
+ - Glob the working tree against those patterns to enumerate candidate input files.
36
+ - Read the goal (from `foundry_workfile_get`) and select the files that are relevant to this run. If the goal names specific files or slugs, use those; if it describes a category ("all the auth tests"), select the matching subset; if it's open-ended, you may consume all candidates or ask the user when the set is clearly ambiguous.
37
+ - Read the selected files for context.
34
38
  7. Produce the artefact, respecting all applicable laws from the start.
35
39
  8. Write the artefact file to a location that matches the artefact type's `file-patterns`.
36
40
  9. `foundry_stage_end({summary})`.
@@ -40,16 +44,22 @@ Forge runs inside an enforced stage. Your **first** and **last** tool calls are
40
44
  1. `foundry_stage_begin(...)`.
41
45
  2. `foundry_feedback_list` — find unresolved feedback for the artefact.
42
46
  3. Read the artefact file.
43
- 4. If the cycle has inputs, read the input artefacts (read-only context).
47
+ 4. If the cycle declares `inputs`, discover them via filesystem scan against each input type's `file-patterns` (same protocol as first-generation step 6). Re-read the relevant files — they may have changed on disk since the previous iteration (nothing in this cycle wrote to them, but the user may have modified them between iterations).
44
48
  5. For each unresolved feedback item, either:
45
49
  - Address it and call `foundry_feedback_action` (marks item `actioned`), or
46
50
  - Call `foundry_feedback_wontfix` with a justification — available only for `law:` / `human` tags (validation feedback must be actioned).
47
51
  6. Update the artefact file.
48
52
  7. `foundry_stage_end({summary})`.
49
53
 
50
- ## File-pattern hygiene
54
+ ## Write invariant
51
55
 
52
- Writes during forge must match the output artefact type's `file-patterns`. Writing to any other path causes `foundry_stage_finalize` to return `{error: 'unexpected_files'}` and the orchestrator will mark the cycle's target artefact `blocked`. You will not get a retry. Plus `WORK.md` and `WORK.history.yaml` (managed by tools). Nothing else.
56
+ Forge may only write to:
57
+ - Files matching the output artefact type's `file-patterns`.
58
+ - `WORK.md` and `WORK.history.yaml` (tool-managed).
59
+
60
+ Everything else on disk — including files of the cycle's input types, files of unrelated artefact types, and files outside any artefact type — is read-only for this stage. This is not an honor-system rule: `foundry_stage_finalize` returns `{error: 'unexpected_files'}` and `sort`'s `checkModifiedFiles` routes a violation on the next call. Either outcome marks the cycle's target artefact `blocked` and you do not get a retry.
61
+
62
+ When a cycle's output type overlaps with one of its input types (e.g. a `refine-haiku` cycle with input `haiku` and output `haiku`), the overlap is intentional: the cycle's job is to modify existing files of that type. The write invariant still holds — you may only touch files matching the output type's patterns, which in this case includes the files you read as inputs.
53
63
 
54
64
  ## Unresolved feedback
55
65
 
@@ -75,4 +85,4 @@ Feedback tagged `human` (from the human-appraise stage) takes absolute priority:
75
85
  - You do not evaluate or score the artefact.
76
86
  - You do not mark feedback as actioned unless you actually changed the artefact to address it.
77
87
  - You do not wont-fix validation feedback.
78
- - You do not modify input artefacts they are read-only.
88
+ - You do not write to any file outside the output artefact type's `file-patterns` (plus `WORK.md` / `WORK.history.yaml`). Input files are read-only unless the output type's patterns happen to cover them.
@@ -23,6 +23,18 @@ Human-appraise runs inside an enforced stage. Your **first** and **last** tool c
23
23
 
24
24
  Human-appraise makes **no disk writes**. All output flows through `foundry_feedback_add` / `foundry_feedback_resolve` / `foundry_artefacts_set_status`. `foundry_stage_finalize` flags unexpected writes as a violation.
25
25
 
26
+ ## Input
27
+
28
+ When invoked from orchestrate, you receive `{cycle, token, context}`:
29
+ - `cycle` — the current cycle id
30
+ - `token` — single-use token for `foundry_stage_begin`
31
+ - `context.artefact_file` — the target artefact
32
+ - `context.recent_feedback` — recent deadlocked feedback items to present to the user
33
+
34
+ Your FIRST tool call must be `foundry_stage_begin({stage: 'human-appraise:<cycle>', cycle, token})`.
35
+
36
+ Your LAST tool call must be `foundry_stage_end({summary: '<one-sentence description of the user verdict>'})` — orchestrate reads this summary for the commit message.
37
+
26
38
  ## Protocol
27
39
 
28
40
  1. `foundry_stage_begin(...)`.
@@ -0,0 +1,69 @@
1
+ ---
2
+ name: orchestrate
3
+ description: Runs a foundry cycle by calling foundry_orchestrate in a loop and acting on the returned action.
4
+ ---
5
+
6
+ # Orchestrate
7
+
8
+ You drive a foundry cycle by calling `foundry_orchestrate` repeatedly and acting on each returned `action`. The tool owns all step-ordering, history, committing, and routing. Your job is to dispatch subagents, run human-appraise when asked, and report terminal states.
9
+
10
+ ## Prerequisites
11
+
12
+ Before running this skill, verify that `foundry/` exists in the project root and `WORK.md` has been created by the flow skill (with `flow`, `cycle`, and `goal` fields). If not, stop and tell the user to run the flow skill first.
13
+
14
+ ## Protocol
15
+
16
+ Loop until `foundry_orchestrate` returns a terminal action (`done`, `blocked`, or `violation`):
17
+
18
+ 1. Call `foundry_orchestrate({lastResult})`. Omit `lastResult` on the first iteration. On subsequent iterations, pass `{kind, ok}` reflecting the previous action's outcome.
19
+
20
+ 2. Switch on the returned `action`:
21
+
22
+ ### `dispatch`
23
+
24
+ Payload: `{stage, subagent_type, prompt}`.
25
+
26
+ Call the `task` tool:
27
+ ```
28
+ task tool:
29
+ subagent_type: <subagent_type-from-payload>
30
+ description: "Run <stage> for <cycle>"
31
+ prompt: <prompt-from-payload — pass verbatim>
32
+ ```
33
+
34
+ When the task returns, call `foundry_orchestrate({lastResult: {kind: 'dispatch', ok: true}})`. If the task tool itself errored or reported a subagent crash, pass `{kind: 'dispatch', ok: false, error: '<message>'}`.
35
+
36
+ ### `human_appraise`
37
+
38
+ Payload: `{stage, token, context}`.
39
+
40
+ Invoke the `human-appraise` skill inline, passing `{cycle, token, context}`. The skill will prompt the user, collect feedback, and call `foundry_stage_end({summary})`.
41
+
42
+ When it returns, call `foundry_orchestrate({lastResult: {kind: 'human_appraise', ok: true}})`.
43
+
44
+ ### `done`
45
+
46
+ Payload: `{cycle, artefact_file, next_cycles}`.
47
+
48
+ 1. Call `foundry_artefacts_set_status({file: artefact_file, status: 'done'})`.
49
+ 2. Report to the user: "Cycle `<cycle>` complete. Output: `<artefact_file>`. Next cycles available: `<next_cycles>`."
50
+ 3. Return control to the flow skill.
51
+
52
+ ### `blocked`
53
+
54
+ Payload: `{cycle, artefact_file, reason}`.
55
+
56
+ Report to the user: "Cycle `<cycle>` blocked on `<artefact_file>`: `<reason>`." Return control to the flow skill. The artefact has already been marked blocked.
57
+
58
+ ### `violation`
59
+
60
+ Payload: `{details, affected_files}`.
61
+
62
+ Report to the user: "Cycle halted (violation): `<details>`. Affected files: `<affected_files>`." Return control to the flow skill. Affected artefacts have already been marked blocked.
63
+
64
+ ## What you do NOT do
65
+
66
+ - You do NOT inline forge / quench / appraise work. Always dispatch via `task`.
67
+ - You do NOT mint, modify, or cache tokens. The `prompt` from orchestrate already contains the token verbatim.
68
+ - You do NOT call `foundry_history_append`, `foundry_git_commit`, `foundry_stage_finalize`, or `foundry_sort`. These are not registered tools in v2.3+; orchestrate handles them internally.
69
+ - You do NOT reorder the protocol. `foundry_orchestrate` returns, you act, you call back. Nothing else between.
@@ -130,6 +130,37 @@ For each `foundry/cycles/*.md` whose frontmatter has the old nested form, migrat
130
130
 
131
131
  The old nested form is no longer read. After migration, verify by asking: "cycle `<id>`: human-appraise every iteration? deadlock-appraise on? deadlock-iterations = N?".
132
132
 
133
+ ### 4c. v2.2.x → v2.3.0
134
+
135
+ v2.3.0 replaces the LLM-driven sort orchestrator with the `foundry_orchestrate` plugin tool. The `cycle` and `sort` skills are removed. Six tools are deregistered: `foundry_sort`, `foundry_history_append`, `foundry_stage_finalize`, `foundry_git_commit`, `foundry_workfile_configure_from_cycle`, `foundry_workfile_set`.
136
+
137
+ #### Pre-flight checks
138
+
139
+ Before upgrading, verify a clean base state. Abort the upgrade if any of these fail:
140
+
141
+ 1. **Branch**: must be on `main` (or the user's configured default base branch).
142
+ - Check: `git rev-parse --abbrev-ref HEAD` — must match expected default.
143
+ - If on `work/*`: abort with "You're on a work branch. Switch to main and complete or discard any in-flight flow before upgrading."
144
+
145
+ 2. **Working tree**: must be clean.
146
+ - Check: `git status --porcelain` — must be empty.
147
+ - If dirty: abort with "Uncommitted changes. Commit or stash before upgrading."
148
+
149
+ 3. **In-flight workfile**: `WORK.md` must not exist.
150
+ - Check: is `WORK.md` present in the repo root?
151
+ - If yes: abort with "In-flight workfile detected. Delete it (`foundry_workfile_delete`) or complete the cycle before upgrading."
152
+
153
+ Only when all three pass, proceed with the plugin swap.
154
+
155
+ #### Upgrade steps
156
+
157
+ 1. Install the new plugin package version: `npm install @really-knows-ai/foundry@2.3.0 --save-dev`.
158
+ 2. Swap `.opencode/plugins/foundry.js` with the new version from `node_modules/@really-knows-ai/foundry/.opencode/plugins/foundry.js`.
159
+ 3. Remove `skills/cycle/` and `skills/sort/` directories from the project if they exist locally (they shouldn't — skills live in the package).
160
+ 4. Commit the upgrade: `chore: upgrade foundry to 2.3.0`.
161
+
162
+ No state migration is performed. In-flight cycles from v2.2.x must be completed or discarded before upgrading.
163
+
133
164
  ### 5. Migrate flows
134
165
 
135
166
  For each flow needing migration: