@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.
@@ -12,13 +12,12 @@ import fs from 'fs';
12
12
  import { readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync, mkdirSync } from 'fs';
13
13
  import { fileURLToPath } from 'url';
14
14
  import { tool } from '@opencode-ai/plugin';
15
- import { loadHistory, appendEntry, getIteration, readLastSortRoute } from '../../scripts/lib/history.js';
16
- import { parseFrontmatter, createWorkfile, setFrontmatterField, getFrontmatterField, enrichStages, parseStagesValue, parseModelsValue } from '../../scripts/lib/workfile.js';
15
+ import { loadHistory } from '../../scripts/lib/history.js';
16
+ import { parseFrontmatter, createWorkfile, enrichStages, parseModelsValue } from '../../scripts/lib/workfile.js';
17
17
  import { parseArtefactsTable, addArtefactRow, setArtefactStatus } from '../../scripts/lib/artefacts.js';
18
18
  import { addFeedbackItem, actionFeedbackItem, wontfixFeedbackItem, resolveFeedbackItem, listFeedback } from '../../scripts/lib/feedback.js';
19
19
  import { getCycleDefinition, getArtefactType, getLaws, getValidation, getAppraisers, getFlow, selectAppraisers } from '../../scripts/lib/config.js';
20
20
  import { slugify } from '../../scripts/lib/slug.js';
21
- import { runSort } from '../../scripts/sort.js';
22
21
  import { execSync, execFileSync } from 'child_process';
23
22
  import { createHash, randomUUID } from 'node:crypto';
24
23
  import { readOrCreateSecret } from '../../scripts/lib/secret.js';
@@ -103,7 +102,7 @@ new skills). It does NOT apply to running an existing, defined flow.
103
102
 
104
103
  ## Available skills
105
104
 
106
- - **Pipeline:** forge, quench, appraise, cycle, flow, sort, human-appraise
105
+ - **Pipeline:** forge, quench, appraise, orchestrate, flow, human-appraise
107
106
  - **Authoring:** add-artefact-type, add-law, add-appraiser, add-cycle, add-flow, init-foundry
108
107
  - **Maintenance:** upgrade-foundry, refresh-agents, list-agents
109
108
 
@@ -127,6 +126,12 @@ function makeIO(directory) {
127
126
  readDir: (p) => readdirSync(resolve(p)),
128
127
  mkdir: (p) => mkdirSync(resolve(p), { recursive: true }),
129
128
  unlink: (p) => { if (existsSync(resolve(p))) unlinkSync(resolve(p)); },
129
+ // exec: run a shell command in the worktree and return stdout as a UTF-8 string.
130
+ // Used by sort.js (getDirtyToolManagedFiles, getModifiedFiles) for git enforcement.
131
+ // Call sites pass full shell strings (e.g. 'git status --porcelain ...'), so we
132
+ // must use execSync rather than execFileSync. Throws on non-zero exit; callers
133
+ // already wrap in try/catch.
134
+ exec: (cmd) => execSync(cmd, { cwd: directory, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }),
130
135
  };
131
136
  }
132
137
 
@@ -165,35 +170,6 @@ export const FoundryPlugin = async ({ directory }) => {
165
170
 
166
171
  tool: {
167
172
  // ── History tools ──
168
- foundry_history_append: tool({
169
- description: 'Append an entry to the cycle history (WORK.history.yaml)',
170
- args: {
171
- cycle: tool.schema.string().describe('Cycle name'),
172
- stage: tool.schema.string().describe('Stage name'),
173
- comment: tool.schema.string().describe('Comment for this entry'),
174
- route: tool.schema.string().optional().describe('When stage=sort, the stage alias the sort routed to'),
175
- },
176
- async execute(args, context) {
177
- const io = makeIO(context.worktree);
178
- const guard = requireNoActiveStage(io);
179
- if (!guard.ok) return JSON.stringify({ error: `foundry_history_append ${guard.error}` });
180
- const historyPath = path.join(context.worktree, 'WORK.history.yaml');
181
- // Sort-route alias check: if this entry is NOT a sort, it must match
182
- // the most recent sort's `route` for this cycle.
183
- if (args.stage !== 'sort') {
184
- const expected = readLastSortRoute(historyPath, args.cycle, io);
185
- if (args.stage !== expected) {
186
- return JSON.stringify({
187
- error: `foundry_history_append: stage ${args.stage} does not match last sort route ${expected ?? 'none'}`,
188
- });
189
- }
190
- }
191
- const iteration = getIteration(historyPath, args.cycle, io);
192
- appendEntry(historyPath, { cycle: args.cycle, stage: args.stage, iteration, comment: args.comment, route: args.route }, io);
193
- return JSON.stringify({ ok: true, iteration });
194
- },
195
- }),
196
-
197
173
  foundry_history_list: tool({
198
174
  description: 'List history entries for a cycle',
199
175
  args: {
@@ -269,57 +245,6 @@ export const FoundryPlugin = async ({ directory }) => {
269
245
  },
270
246
  }),
271
247
 
272
- foundry_stage_finalize: tool({
273
- description: 'Verify stage output matches allowed file patterns; register artefacts as drafts.',
274
- args: {
275
- cycle: tool.schema.string().describe('Cycle name'),
276
- },
277
- async execute(args, context) {
278
- const io = makeIO(context.worktree);
279
- const guard = requireNoActiveStage(io);
280
- if (!guard.ok) return JSON.stringify({ error: guard.error });
281
- const last = readLastStage(io);
282
- if (!last) return JSON.stringify({ error: 'foundry_stage_finalize: no last stage recorded; call stage_end first' });
283
- if (last.cycle !== args.cycle) {
284
- return JSON.stringify({ error: `foundry_stage_finalize: cycle mismatch (last=${last.cycle}, got=${args.cycle})` });
285
- }
286
-
287
- // Load on-disk definitions and translate to finalizeStage's camelCase contract.
288
- let cycleDoc;
289
- try {
290
- cycleDoc = await getCycleDefinition('foundry', args.cycle, io);
291
- } catch (e) {
292
- return JSON.stringify({ error: `foundry_stage_finalize: ${e.message}` });
293
- }
294
- const outputType = cycleDoc.frontmatter.output;
295
- const cycleDef = { outputArtefactType: outputType };
296
- const artefactTypes = {};
297
- if (outputType) {
298
- try {
299
- const artDoc = await getArtefactType('foundry', outputType, io);
300
- artefactTypes[outputType] = { filePatterns: artDoc.frontmatter['file-patterns'] || [] };
301
- } catch {
302
- artefactTypes[outputType] = { filePatterns: [] };
303
- }
304
- }
305
-
306
- const workPath = path.join(context.worktree, 'WORK.md');
307
- const result = finalizeStage({
308
- cwd: context.worktree,
309
- baseSha: last.baseSha,
310
- stageBase: stageBaseOf(last.stage),
311
- cycleDef,
312
- artefactTypes,
313
- registerArtefact: ({ file, type, status }) => {
314
- const text = readFileSync(workPath, 'utf-8');
315
- const updated = addArtefactRow(text, { file, type, cycle: args.cycle, status });
316
- writeFileSync(workPath, updated, 'utf-8');
317
- },
318
- });
319
- return JSON.stringify(result);
320
- },
321
- }),
322
-
323
248
  // ── Workfile tools ──
324
249
  foundry_workfile_create: tool({
325
250
  description: 'Create WORK.md with frontmatter and goal',
@@ -371,137 +296,111 @@ export const FoundryPlugin = async ({ directory }) => {
371
296
  },
372
297
  }),
373
298
 
374
- foundry_workfile_set: tool({
375
- description: 'Update a single frontmatter field in WORK.md',
299
+ foundry_workfile_delete: tool({
300
+ description: 'Delete WORK.md and WORK.history.yaml (requires confirm:true)',
376
301
  args: {
377
- key: tool.schema.string().describe('Frontmatter key (cycle|stages|max-iterations|models)'),
378
- value: tool.schema.string().describe('Value to set (use JSON for arrays/objects, e.g. \'["forge:a","quench:b"]\' or \'{"forge":"openai/gpt-4o"}\')'),
302
+ confirm: tool.schema.boolean().describe('Must be true to confirm deletion'),
379
303
  },
380
304
  async execute(args, context) {
381
305
  const io = makeIO(context.worktree);
382
306
  const guard = requireNoActiveStage(io);
383
- if (!guard.ok) return JSON.stringify({ error: `foundry_workfile_set ${guard.error}` });
384
- const ALLOWED_KEYS = new Set(['cycle', 'stages', 'max-iterations', 'maxIterations', 'models', 'human-appraise', 'deadlock-appraise', 'deadlock-iterations']);
385
- if (!ALLOWED_KEYS.has(args.key)) {
386
- return JSON.stringify({ error: `foundry_workfile_set: key must be one of cycle|stages|max-iterations|models; got ${args.key}` });
307
+ if (!guard.ok) return JSON.stringify({ error: `foundry_workfile_delete ${guard.error}` });
308
+ if (args.confirm !== true) {
309
+ return JSON.stringify({ error: 'foundry_workfile_delete requires {confirm: true}' });
387
310
  }
388
311
  const workPath = path.join(context.worktree, 'WORK.md');
389
- if (!existsSync(workPath)) {
390
- return JSON.stringify({ error: 'WORK.md not found' });
391
- }
392
- const text = readFileSync(workPath, 'utf-8');
393
- // Parse JSON values for arrays/objects, keep strings as-is
394
- let value = args.value;
395
- if (args.key === 'stages') {
396
- // Always parse stages into an array (handles JSON arrays and comma-separated strings)
397
- value = parseStagesValue(args.value);
398
- } else if (args.key === 'models') {
399
- // Always parse models into an object (handles JSON objects and "key: value" strings)
400
- value = parseModelsValue(args.value);
401
- } else {
402
- try {
403
- const parsed = JSON.parse(args.value);
404
- if (typeof parsed === 'object' || Array.isArray(parsed) || typeof parsed === 'number') {
405
- value = parsed;
406
- }
407
- } catch {
408
- // Not JSON, use as plain string
409
- }
312
+ const historyPath = path.join(context.worktree, 'WORK.history.yaml');
313
+ if (existsSync(workPath)) {
314
+ unlinkSync(workPath);
410
315
  }
411
- // Auto-enrich bare stage names with cycle ID alias
412
- if (args.key === 'stages' && Array.isArray(value)) {
413
- const fm = parseFrontmatter(text);
414
- if (fm.cycle) {
415
- value = enrichStages(value, fm.cycle);
416
- }
316
+ if (existsSync(historyPath)) {
317
+ unlinkSync(historyPath);
417
318
  }
418
- const updated = setFrontmatterField(text, args.key, value);
419
- writeFileSync(workPath, updated, 'utf-8');
420
319
  return JSON.stringify({ ok: true });
421
320
  },
422
321
  }),
423
322
 
424
- foundry_workfile_configure_from_cycle: tool({
425
- description: 'Populate WORK.md frontmatter from a cycle definition in one pass. Reads the cycle def for max-iterations, human-appraise, deadlock-appraise, deadlock-iterations, and models; applies defaults for anything missing. Caller must supply the synthesized stages list (the skill owns stage synthesis).',
323
+ // ── Orchestrate tool ──
324
+ foundry_orchestrate: tool({
325
+ description: 'Run the next step of the current cycle. Call with no args on first invocation; call with lastResult={ok,error?} after a dispatch/human_appraise completes. Returns {action, ...} describing what the caller should do next.',
426
326
  args: {
427
- cycleId: tool.schema.string().describe('Cycle ID — used to locate foundry/cycles/<cycleId>.md'),
428
- stages: tool.schema.array(tool.schema.string()).describe('Ordered stage aliases (bare names will be enriched with :<cycleId>)'),
327
+ lastResult: tool.schema.object({
328
+ ok: tool.schema.boolean(),
329
+ error: tool.schema.string().optional(),
330
+ }).optional(),
331
+ cycleDef: tool.schema.string().optional().describe('Test-mode cycle definition override (path to cycle file)'),
429
332
  },
430
333
  async execute(args, context) {
334
+ const { runOrchestrate } = await import('../../scripts/orchestrate.js');
431
335
  const io = makeIO(context.worktree);
432
- const guard = requireNoActiveStage(io);
433
- if (!guard.ok) return JSON.stringify({ error: `foundry_workfile_configure_from_cycle ${guard.error}` });
336
+ const cwd = context.worktree;
434
337
 
435
- const workPath = path.join(context.worktree, 'WORK.md');
436
- if (!existsSync(workPath)) {
437
- return JSON.stringify({ error: 'WORK.md not found' });
438
- }
338
+ // Mint: same pattern as removed foundry_sort.
339
+ const mint = ({ route, cycle, exp }) => {
340
+ const nonce = randomUUID();
341
+ const payload = { route, cycle, nonce, exp };
342
+ pending.add(nonce, payload);
343
+ return signToken(payload, secret);
344
+ };
439
345
 
440
- let cycleDoc;
441
- try {
442
- cycleDoc = await getCycleDefinition('foundry', args.cycleId, io);
443
- } catch (e) {
444
- return JSON.stringify({ error: `foundry_workfile_configure_from_cycle: ${e.message}` });
445
- }
446
- const fm = cycleDoc.frontmatter || {};
447
-
448
- const stages = enrichStages(args.stages, args.cycleId);
449
-
450
- // Apply defaults for each cycle-def-backed field.
451
- const maxIterations = fm['max-iterations'] ?? 3;
452
- const humanAppraise = fm['human-appraise'] === true;
453
- const deadlockAppraise = fm['deadlock-appraise'] !== false; // default true
454
- const deadlockIterations = fm['deadlock-iterations'] ?? 5;
455
- const models = fm.models ?? null;
456
-
457
- let text = readFileSync(workPath, 'utf-8');
458
- text = setFrontmatterField(text, 'cycle', args.cycleId);
459
- text = setFrontmatterField(text, 'stages', stages);
460
- text = setFrontmatterField(text, 'max-iterations', maxIterations);
461
- text = setFrontmatterField(text, 'human-appraise', humanAppraise);
462
- text = setFrontmatterField(text, 'deadlock-appraise', deadlockAppraise);
463
- text = setFrontmatterField(text, 'deadlock-iterations', deadlockIterations);
464
- if (models && typeof models === 'object' && Object.keys(models).length > 0) {
465
- text = setFrontmatterField(text, 'models', models);
466
- }
467
- writeFileSync(workPath, text, 'utf-8');
468
-
469
- return JSON.stringify({
470
- ok: true,
471
- applied: {
472
- cycle: args.cycleId,
473
- stages,
474
- 'max-iterations': maxIterations,
475
- 'human-appraise': humanAppraise,
476
- 'deadlock-appraise': deadlockAppraise,
477
- 'deadlock-iterations': deadlockIterations,
478
- ...(models ? { models } : {}),
346
+ // Git bridge: commit staged changes with a cycle-prefixed message.
347
+ const git = {
348
+ commit: (msg) => {
349
+ execFileSync('git', ['add', '.'], { cwd, encoding: 'utf8' });
350
+ execFileSync('git', ['commit', '-m', msg], { cwd, encoding: 'utf8' });
351
+ return execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd, encoding: 'utf8' }).trim();
479
352
  },
480
- });
481
- },
482
- }),
353
+ status: () => {
354
+ const out = execFileSync('git', ['status', '--porcelain'], { cwd, encoding: 'utf8' }).trim();
355
+ return { clean: out === '', dirty: out.split('\n').filter(Boolean) };
356
+ },
357
+ };
483
358
 
484
- foundry_workfile_delete: tool({
485
- description: 'Delete WORK.md and WORK.history.yaml (requires confirm:true)',
486
- args: {
487
- confirm: tool.schema.boolean().describe('Must be true to confirm deletion'),
488
- },
489
- async execute(args, context) {
490
- const io = makeIO(context.worktree);
491
- const guard = requireNoActiveStage(io);
492
- if (!guard.ok) return JSON.stringify({ error: `foundry_workfile_delete ${guard.error}` });
493
- if (args.confirm !== true) {
494
- return JSON.stringify({ error: 'foundry_workfile_delete requires {confirm: true}' });
495
- }
496
- const workPath = path.join(context.worktree, 'WORK.md');
497
- const historyPath = path.join(context.worktree, 'WORK.history.yaml');
498
- if (existsSync(workPath)) {
499
- unlinkSync(workPath);
500
- }
501
- if (existsSync(historyPath)) {
502
- unlinkSync(historyPath);
359
+ // Finalize bridge: mimics the deleted foundry_stage_finalize body.
360
+ const finalize = async ({ cycleId, stage, baseSha }) => {
361
+ let cycleDoc;
362
+ try {
363
+ cycleDoc = await getCycleDefinition('foundry', cycleId, io);
364
+ } catch (e) {
365
+ return { ok: false, error: e.message };
366
+ }
367
+ const outputType = cycleDoc.frontmatter.output;
368
+ const cycleDef = { outputArtefactType: outputType };
369
+ const artefactTypes = {};
370
+ if (outputType) {
371
+ try {
372
+ const artDoc = await getArtefactType('foundry', outputType, io);
373
+ artefactTypes[outputType] = { filePatterns: artDoc.frontmatter['file-patterns'] || [] };
374
+ } catch {
375
+ artefactTypes[outputType] = { filePatterns: [] };
376
+ }
377
+ }
378
+ const workPath = path.join(cwd, 'WORK.md');
379
+ const result = finalizeStage({
380
+ cwd,
381
+ baseSha,
382
+ stageBase: stageBaseOf(stage),
383
+ cycleDef,
384
+ artefactTypes,
385
+ registerArtefact: ({ file, type, status }) => {
386
+ const text = readFileSync(workPath, 'utf-8');
387
+ const updated = addArtefactRow(text, { file, type, cycle: cycleId, status });
388
+ writeFileSync(workPath, updated, 'utf-8');
389
+ },
390
+ });
391
+ return result;
392
+ };
393
+
394
+ try {
395
+ const result = await runOrchestrate({
396
+ cwd, cycleDef: args.cycleDef, git, mint, finalize,
397
+ now: () => Date.now(),
398
+ lastResult: args.lastResult ?? null,
399
+ }, io);
400
+ return JSON.stringify(result);
401
+ } catch (e) {
402
+ return JSON.stringify({ action: 'violation', details: `orchestrate threw: ${e.message}`, recoverable: false, affected_files: [] });
503
403
  }
504
- return JSON.stringify({ ok: true });
505
404
  },
506
405
  }),
507
406
 
@@ -672,27 +571,6 @@ export const FoundryPlugin = async ({ directory }) => {
672
571
  },
673
572
  }),
674
573
 
675
- // ── Sort tool ──
676
- foundry_sort: tool({
677
- description: 'Determine the next stage for the current cycle and (if dispatchable) mint a single-use token.',
678
- args: {
679
- cycleDef: tool.schema.string().optional().describe('Path to cycle definition file'),
680
- },
681
- async execute(args, context) {
682
- const io = makeIO(context.worktree);
683
- const guard = requireNoActiveStage(io);
684
- if (!guard.ok) return JSON.stringify({ error: `foundry_sort ${guard.error}` });
685
- const mint = ({ route, cycle, exp }) => {
686
- const nonce = randomUUID();
687
- const payload = { route, cycle, nonce, exp };
688
- pending.add(nonce, payload);
689
- return signToken(payload, secret);
690
- };
691
- const result = runSort({ cycleDef: args.cycleDef, mint }, io);
692
- return JSON.stringify(result);
693
- },
694
- }),
695
-
696
574
  // ── Git tools ──
697
575
  foundry_git_branch: tool({
698
576
  description: 'Create and checkout a work branch for a flow',
@@ -712,25 +590,6 @@ export const FoundryPlugin = async ({ directory }) => {
712
590
  },
713
591
  }),
714
592
 
715
- foundry_git_commit: tool({
716
- description: 'Stage all changes and commit with a cycle-prefixed message',
717
- args: {
718
- cycle: tool.schema.string().describe('Cycle name'),
719
- stage: tool.schema.string().describe('Stage name'),
720
- description: tool.schema.string().describe('Commit description'),
721
- },
722
- async execute(args, context) {
723
- const io = makeIO(context.worktree);
724
- const guard = requireNoActiveStage(io);
725
- if (!guard.ok) return JSON.stringify({ error: `foundry_git_commit ${guard.error}` });
726
- execSync('git add .', { cwd: context.worktree, encoding: 'utf8' });
727
- const msg = `[${args.cycle}] ${args.stage}: ${args.description}`;
728
- execSync(`git commit -m "${msg.replace(/"/g, '\\"')}"`, { cwd: context.worktree, encoding: 'utf8' });
729
- const hash = execSync('git rev-parse --short HEAD', { cwd: context.worktree, encoding: 'utf8' }).trim();
730
- return JSON.stringify({ ok: true, hash });
731
- },
732
- }),
733
-
734
593
  foundry_git_finish: tool({
735
594
  description: 'Clean up work files, squash merge to base branch, and delete the work branch',
736
595
  args: {
@@ -746,7 +605,7 @@ export const FoundryPlugin = async ({ directory }) => {
746
605
  const opts = { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] };
747
606
 
748
607
  // Get current branch name
749
- const workBranch = execSync('git branch --show-current', opts).trim();
608
+ const workBranch = execFileSync('git', ['branch', '--show-current'], opts).trim();
750
609
  if (workBranch === base) {
751
610
  return JSON.stringify({ error: `Already on ${base} — nothing to merge` });
752
611
  }
@@ -759,23 +618,22 @@ export const FoundryPlugin = async ({ directory }) => {
759
618
 
760
619
  // Commit cleanup if there are changes
761
620
  try {
762
- execSync('git add -A', opts);
763
- const status = execSync('git status --porcelain', opts).trim();
621
+ execFileSync('git', ['add', '-A'], opts);
622
+ const status = execFileSync('git', ['status', '--porcelain'], opts).trim();
764
623
  if (status) {
765
624
  const cleanupMsg = `[${workBranch.replace('work/', '')}] cleanup: remove work files`;
766
- execSync(`git commit -m "${cleanupMsg.replace(/"/g, '\\"')}"`, opts);
625
+ execFileSync('git', ['commit', '-m', cleanupMsg], opts);
767
626
  }
768
627
  } catch { /* no changes to commit */ }
769
628
 
770
629
  // Switch to base and squash merge
771
- execSync(`git checkout ${base}`, opts);
772
- execSync(`git merge --squash ${workBranch}`, opts);
773
- const msg = args.message.replace(/"/g, '\\"');
774
- execSync(`git commit -m "${msg}"`, opts);
775
- const hash = execSync('git rev-parse --short HEAD', opts).trim();
630
+ execFileSync('git', ['checkout', base], opts);
631
+ execFileSync('git', ['merge', '--squash', workBranch], opts);
632
+ execFileSync('git', ['commit', '-m', args.message], opts);
633
+ const hash = execFileSync('git', ['rev-parse', '--short', 'HEAD'], opts).trim();
776
634
 
777
635
  // Force-delete work branch (required after squash)
778
- execSync(`git branch -D ${workBranch}`, opts);
636
+ execFileSync('git', ['branch', '-D', workBranch], opts);
779
637
 
780
638
  return JSON.stringify({ ok: true, hash, branch: base });
781
639
  },
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.3.1 — 2026-04-20
4
+
5
+ ### Changed
6
+
7
+ - `flow` skill: any cycle in a flow may now be the starting cycle (previously limited to `starting-cycles`). The list becomes a hint for ambiguous requests. A cycle whose `inputs` contract cannot be satisfied from files on disk is not eligible to start.
8
+ - `flow` skill: between-cycles logic no longer implies any carry-over ceremony. The next cycle's forge discovers the previous cycle's output via filesystem scan against its input types' `file-patterns`.
9
+ - `forge` skill: input discovery now explicitly uses filesystem scan against each input type's `file-patterns`, with the goal guiding which candidates are relevant.
10
+ - `forge` skill: the write invariant is restated accurately — forge may only write to files matching the output artefact type's `file-patterns` (plus the tool-managed files). All other files on disk are read-only. The previous "inputs are read-only" framing was a special case of this rule.
11
+
12
+ ### Notes
13
+
14
+ - No tool, schema, or enforcement changes. Existing flows continue to work. `sort.js`'s `checkModifiedFiles` already enforces the write invariant.
15
+
16
+ ## 2.3.0 — 2026-04-20
17
+
18
+ ### Breaking
19
+
20
+ - **LLM orchestration replaced with deterministic `foundry_orchestrate` tool.** The `cycle` and `sort` skills are removed; replaced by a single thin `orchestrate` skill that drives a 3-line loop.
21
+ - **Six tools deregistered** from the plugin (still exist as internal imports for tests): `foundry_sort`, `foundry_history_append`, `foundry_stage_finalize`, `foundry_git_commit`, `foundry_workfile_configure_from_cycle`, `foundry_workfile_set`.
22
+ - Upgrade requires clean main + no in-flight workfile (see `upgrade-foundry` skill).
23
+
24
+ ### Added
25
+
26
+ - `foundry_orchestrate` — single tool that owns the sort → history → dispatch → finalize → history → commit loop. Atomic stage completion.
27
+ - `scripts/orchestrate.js` — deterministic orchestration logic, composes existing internal functions.
28
+ - Orphaned-stage detection: if orchestrate is called without `lastResult` but an active stage exists, returns `violation`. Fixes the ses_256c failure mode where an LLM skipped the post-dispatch history append and wedged the cycle.
29
+
30
+ ### Fixed
31
+
32
+ - Root cause of all deferred HARDEN.md bugs (B, C, D, E, G) and the ses_256c bug: LLM misfollowing a deterministic protocol. Protocol now lives inside the plugin tool.
33
+
34
+ ### Migration
35
+
36
+ See `skills/upgrade-foundry/SKILL.md` for v2.3.0 pre-flight checks. No automated state migration — complete or discard in-flight cycles on v2.2.x before upgrading.
37
+
3
38
  ## 2.2.1 — 2026-04-20
4
39
 
5
40
  Follow-up patch addressing the five bugs deferred from v2.2.0 (see `HARDEN.md` §Deferred).
package/docs/work-spec.md CHANGED
@@ -25,8 +25,8 @@ The `stages` list is the happy path. Sort follows it but loops back to `forge` w
25
25
 
26
26
  - `flow` — set by the foundry flow skill at foundry flow start, never changes
27
27
  - `cycle` — set by the foundry flow skill when starting each foundry cycle
28
- - `stages` — set by the foundry cycle skill when starting each foundry cycle (reads artefact type to determine if quench is needed)
29
- - `max-iterations` — set by the foundry cycle skill (default 3, could be overridden in foundry cycle definition)
28
+ - `stages` — set by the orchestrate skill when starting each foundry cycle (reads artefact type to determine if quench is needed)
29
+ - `max-iterations` — set by the orchestrate skill (default 3, could be overridden in foundry cycle definition)
30
30
 
31
31
  ## Sections
32
32
 
@@ -96,9 +96,9 @@ Grouped by artefact file path. Each item is a checklist entry with a tag indicat
96
96
  | Section | Written by | Updated by |
97
97
  |---------|-----------|------------|
98
98
  | Frontmatter (`flow`) | `foundry_workfile_create` (flow skill) | nobody |
99
- | Frontmatter (`cycle`, `stages`, `max-iterations`) | `foundry_workfile_set` (cycle skill) | `foundry_workfile_set` (reset on each new cycle) |
99
+ | Frontmatter (`cycle`, `stages`, `max-iterations`) | `foundry_workfile_set` (orchestrate skill) | `foundry_workfile_set` (reset on each new cycle) |
100
100
  | Goal | `foundry_workfile_create` (flow skill) | nobody |
101
- | Artefacts | `foundry_artefacts_add` (forge skill) | `foundry_artefacts_set_status` (cycle skill) |
101
+ | Artefacts | `foundry_artefacts_add` (forge skill) | `foundry_artefacts_set_status` (orchestrate skill) |
102
102
  | Feedback | `foundry_feedback_add` (quench/appraise/hitl) | `foundry_feedback_action`/`foundry_feedback_wontfix` (forge), `foundry_feedback_resolve` (quench/appraise/hitl) |
103
103
 
104
104
  ## WORK.history.yaml
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@really-knows-ai/foundry",
3
- "version": "2.2.1",
3
+ "version": "2.3.1",
4
4
  "description": "A structured framework for AI-driven artefact creation with deterministic routing, quality gates, and iterative refinement cycles.",
5
5
  "type": "module",
6
6
  "main": ".opencode/plugins/foundry.js",