@really-knows-ai/foundry 2.1.0 → 2.2.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.
- package/.opencode/plugins/foundry.js +329 -46
- package/CHANGELOG.md +55 -0
- package/package.json +3 -2
- package/scripts/lib/artefacts.js +6 -0
- package/scripts/lib/feedback-transitions.js +25 -0
- package/scripts/lib/feedback.js +146 -9
- package/scripts/lib/finalize.js +41 -0
- package/scripts/lib/history.js +15 -3
- package/scripts/lib/pending.js +18 -0
- package/scripts/lib/secret.js +23 -0
- package/scripts/lib/stage-guard.js +25 -0
- package/scripts/lib/state.js +31 -0
- package/scripts/lib/token.js +26 -0
- package/scripts/lib/workfile.js +12 -1
- package/scripts/sort.js +89 -14
- package/skills/add-cycle/SKILL.md +11 -6
- package/skills/appraise/SKILL.md +33 -17
- package/skills/cycle/SKILL.md +25 -19
- package/skills/flow/SKILL.md +9 -2
- package/skills/forge/SKILL.md +38 -26
- package/skills/human-appraise/SKILL.md +29 -17
- package/skills/quench/SKILL.md +31 -15
- package/skills/sort/SKILL.md +60 -28
- package/skills/upgrade-foundry/SKILL.md +33 -1
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
|
|
10
10
|
import path from 'path';
|
|
11
11
|
import fs from 'fs';
|
|
12
|
-
import { readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync } from 'fs';
|
|
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 } from '../../scripts/lib/history.js';
|
|
15
|
+
import { loadHistory, appendEntry, getIteration, readLastSortRoute } from '../../scripts/lib/history.js';
|
|
16
16
|
import { parseFrontmatter, createWorkfile, setFrontmatterField, getFrontmatterField, enrichStages, parseStagesValue, 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';
|
|
@@ -20,6 +20,16 @@ import { getCycleDefinition, getArtefactType, getLaws, getValidation, getApprais
|
|
|
20
20
|
import { slugify } from '../../scripts/lib/slug.js';
|
|
21
21
|
import { runSort } from '../../scripts/sort.js';
|
|
22
22
|
import { execSync, execFileSync } from 'child_process';
|
|
23
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
24
|
+
import { readOrCreateSecret } from '../../scripts/lib/secret.js';
|
|
25
|
+
import { createPendingStore } from '../../scripts/lib/pending.js';
|
|
26
|
+
import { signToken, verifyToken } from '../../scripts/lib/token.js';
|
|
27
|
+
import {
|
|
28
|
+
ensureFoundryDir, readActiveStage, writeActiveStage, clearActiveStage,
|
|
29
|
+
readLastStage, writeLastStage,
|
|
30
|
+
} from '../../scripts/lib/state.js';
|
|
31
|
+
import { requireNoActiveStage, requireActiveStage, stageBaseOf } from '../../scripts/lib/stage-guard.js';
|
|
32
|
+
import { finalizeStage } from '../../scripts/lib/finalize.js';
|
|
23
33
|
|
|
24
34
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
25
35
|
const packageRoot = path.resolve(__dirname, '../..');
|
|
@@ -115,11 +125,21 @@ function makeIO(directory) {
|
|
|
115
125
|
readFile: (p) => readFileSync(resolve(p), 'utf-8'),
|
|
116
126
|
writeFile: (p, content) => writeFileSync(resolve(p), content, 'utf-8'),
|
|
117
127
|
readDir: (p) => readdirSync(resolve(p)),
|
|
128
|
+
mkdir: (p) => mkdirSync(resolve(p), { recursive: true }),
|
|
129
|
+
unlink: (p) => { if (existsSync(resolve(p))) unlinkSync(resolve(p)); },
|
|
118
130
|
};
|
|
119
131
|
}
|
|
120
132
|
|
|
121
133
|
export const FoundryPlugin = async ({ directory }) => {
|
|
122
|
-
|
|
134
|
+
// Bootstrap per-worktree HMAC secret (created on first boot, persisted to .foundry/secret).
|
|
135
|
+
// Note: `directory` is the worktree root at plugin-boot time. Per-invocation `context.worktree`
|
|
136
|
+
// may differ in multi-worktree setups — we still use `context.worktree` inside tool `execute`
|
|
137
|
+
// bodies to locate `.foundry/` on disk, and use the plugin-boot `secret` only for
|
|
138
|
+
// signing/verifying. A worktree change mid-session would mismatch; deferred out of v2.2.0 scope.
|
|
139
|
+
const secret = readOrCreateSecret(directory);
|
|
140
|
+
const pending = createPendingStore();
|
|
141
|
+
|
|
142
|
+
const plugin = {
|
|
123
143
|
config: async (config) => {
|
|
124
144
|
config.skills = config.skills || {};
|
|
125
145
|
config.skills.paths = config.skills.paths || [];
|
|
@@ -151,12 +171,25 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
151
171
|
cycle: tool.schema.string().describe('Cycle name'),
|
|
152
172
|
stage: tool.schema.string().describe('Stage name'),
|
|
153
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'),
|
|
154
175
|
},
|
|
155
176
|
async execute(args, context) {
|
|
156
177
|
const io = makeIO(context.worktree);
|
|
178
|
+
const guard = requireNoActiveStage(io);
|
|
179
|
+
if (!guard.ok) return JSON.stringify({ error: `foundry_history_append ${guard.error}` });
|
|
157
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
|
+
}
|
|
158
191
|
const iteration = getIteration(historyPath, args.cycle, io);
|
|
159
|
-
appendEntry(historyPath, { cycle: args.cycle, stage: args.stage, iteration, comment: args.comment }, io);
|
|
192
|
+
appendEntry(historyPath, { cycle: args.cycle, stage: args.stage, iteration, comment: args.comment, route: args.route }, io);
|
|
160
193
|
return JSON.stringify({ ok: true, iteration });
|
|
161
194
|
},
|
|
162
195
|
}),
|
|
@@ -174,6 +207,119 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
174
207
|
},
|
|
175
208
|
}),
|
|
176
209
|
|
|
210
|
+
// ── Stage lifecycle tools ──
|
|
211
|
+
foundry_stage_begin: tool({
|
|
212
|
+
description: 'Open a subagent work stage; consumes a dispatch token from foundry_sort.',
|
|
213
|
+
args: {
|
|
214
|
+
stage: tool.schema.string().describe('Stage alias, e.g. "forge:create-haiku"'),
|
|
215
|
+
cycle: tool.schema.string().describe('Cycle name'),
|
|
216
|
+
token: tool.schema.string().describe('Token received from foundry_sort via the dispatch prompt'),
|
|
217
|
+
},
|
|
218
|
+
async execute(args, context) {
|
|
219
|
+
const io = makeIO(context.worktree);
|
|
220
|
+
// Precondition: no active stage.
|
|
221
|
+
const current = readActiveStage(io);
|
|
222
|
+
if (current) {
|
|
223
|
+
return JSON.stringify({ error: `foundry_stage_begin requires no active stage; current: ${current.stage}` });
|
|
224
|
+
}
|
|
225
|
+
// Verify token signature + expiry.
|
|
226
|
+
const v = verifyToken(args.token, secret);
|
|
227
|
+
if (!v.ok) return JSON.stringify({ error: `foundry_stage_begin: token ${v.reason}` });
|
|
228
|
+
// Payload must match args.
|
|
229
|
+
if (v.payload.route !== args.stage || v.payload.cycle !== args.cycle) {
|
|
230
|
+
return JSON.stringify({ error: `foundry_stage_begin: token payload mismatch (route=${v.payload.route}, cycle=${v.payload.cycle})` });
|
|
231
|
+
}
|
|
232
|
+
// Single-use nonce check.
|
|
233
|
+
const meta = pending.consume(v.payload.nonce);
|
|
234
|
+
if (!meta) return JSON.stringify({ error: `foundry_stage_begin: nonce not pending or already consumed` });
|
|
235
|
+
|
|
236
|
+
// Resolve base SHA from git.
|
|
237
|
+
let baseSha;
|
|
238
|
+
try {
|
|
239
|
+
baseSha = execSync('git rev-parse HEAD', { cwd: context.worktree }).toString().trim();
|
|
240
|
+
} catch {
|
|
241
|
+
return JSON.stringify({ error: `foundry_stage_begin: git rev-parse HEAD failed (no commits?)` });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const tokenHash = createHash('sha256').update(args.token).digest('hex');
|
|
245
|
+
const active = {
|
|
246
|
+
cycle: args.cycle,
|
|
247
|
+
stage: args.stage,
|
|
248
|
+
tokenHash,
|
|
249
|
+
baseSha,
|
|
250
|
+
startedAt: new Date().toISOString(),
|
|
251
|
+
};
|
|
252
|
+
writeActiveStage(io, active);
|
|
253
|
+
return JSON.stringify({ ok: true, active });
|
|
254
|
+
},
|
|
255
|
+
}),
|
|
256
|
+
|
|
257
|
+
foundry_stage_end: tool({
|
|
258
|
+
description: 'Close the active subagent work stage; preserves baseSha for finalize.',
|
|
259
|
+
args: {
|
|
260
|
+
summary: tool.schema.string().describe('Short summary of the work done'),
|
|
261
|
+
},
|
|
262
|
+
async execute(args, context) {
|
|
263
|
+
const io = makeIO(context.worktree);
|
|
264
|
+
const active = readActiveStage(io);
|
|
265
|
+
if (!active) return JSON.stringify({ error: 'foundry_stage_end requires active stage; current: none' });
|
|
266
|
+
writeLastStage(io, { cycle: active.cycle, stage: active.stage, baseSha: active.baseSha, summary: args.summary });
|
|
267
|
+
clearActiveStage(io);
|
|
268
|
+
return JSON.stringify({ ok: true, summary: args.summary });
|
|
269
|
+
},
|
|
270
|
+
}),
|
|
271
|
+
|
|
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
|
+
|
|
177
323
|
// ── Workfile tools ──
|
|
178
324
|
foundry_workfile_create: tool({
|
|
179
325
|
description: 'Create WORK.md with frontmatter and goal',
|
|
@@ -186,16 +332,19 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
186
332
|
models: tool.schema.string().optional().describe('Per-stage model overrides as JSON object, e.g. \'{"forge":"openai/gpt-4o"}\''),
|
|
187
333
|
},
|
|
188
334
|
async execute(args, context) {
|
|
335
|
+
const io = makeIO(context.worktree);
|
|
336
|
+
const guard = requireNoActiveStage(io);
|
|
337
|
+
if (!guard.ok) return JSON.stringify({ error: `foundry_workfile_create ${guard.error}` });
|
|
189
338
|
const workPath = path.join(context.worktree, 'WORK.md');
|
|
190
339
|
if (existsSync(workPath)) {
|
|
191
|
-
return JSON.stringify({ error: 'WORK.md
|
|
340
|
+
return JSON.stringify({ error: 'foundry_workfile_create requires no WORK.md; current: exists' });
|
|
192
341
|
}
|
|
193
342
|
const fm = { flow: args.flow, cycle: args.cycle };
|
|
194
343
|
if (args.stages) {
|
|
195
344
|
fm.stages = enrichStages(args.stages, args.cycle);
|
|
196
345
|
}
|
|
197
346
|
if (args.maxIterations !== undefined) {
|
|
198
|
-
fm
|
|
347
|
+
fm['max-iterations'] = args.maxIterations;
|
|
199
348
|
}
|
|
200
349
|
if (args.models) {
|
|
201
350
|
fm.models = parseModelsValue(args.models);
|
|
@@ -225,10 +374,17 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
225
374
|
foundry_workfile_set: tool({
|
|
226
375
|
description: 'Update a single frontmatter field in WORK.md',
|
|
227
376
|
args: {
|
|
228
|
-
key: tool.schema.string().describe('Frontmatter key'),
|
|
377
|
+
key: tool.schema.string().describe('Frontmatter key (cycle|stages|max-iterations|models)'),
|
|
229
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"}\')'),
|
|
230
379
|
},
|
|
231
380
|
async execute(args, context) {
|
|
381
|
+
const io = makeIO(context.worktree);
|
|
382
|
+
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}` });
|
|
387
|
+
}
|
|
232
388
|
const workPath = path.join(context.worktree, 'WORK.md');
|
|
233
389
|
if (!existsSync(workPath)) {
|
|
234
390
|
return JSON.stringify({ error: 'WORK.md not found' });
|
|
@@ -265,10 +421,78 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
265
421
|
},
|
|
266
422
|
}),
|
|
267
423
|
|
|
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).',
|
|
426
|
+
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>)'),
|
|
429
|
+
},
|
|
430
|
+
async execute(args, context) {
|
|
431
|
+
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}` });
|
|
434
|
+
|
|
435
|
+
const workPath = path.join(context.worktree, 'WORK.md');
|
|
436
|
+
if (!existsSync(workPath)) {
|
|
437
|
+
return JSON.stringify({ error: 'WORK.md not found' });
|
|
438
|
+
}
|
|
439
|
+
|
|
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 } : {}),
|
|
479
|
+
},
|
|
480
|
+
});
|
|
481
|
+
},
|
|
482
|
+
}),
|
|
483
|
+
|
|
268
484
|
foundry_workfile_delete: tool({
|
|
269
|
-
description: 'Delete WORK.md and WORK.history.yaml',
|
|
270
|
-
args: {
|
|
271
|
-
|
|
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
|
+
}
|
|
272
496
|
const workPath = path.join(context.worktree, 'WORK.md');
|
|
273
497
|
const historyPath = path.join(context.worktree, 'WORK.history.yaml');
|
|
274
498
|
if (existsSync(workPath)) {
|
|
@@ -282,48 +506,45 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
282
506
|
}),
|
|
283
507
|
|
|
284
508
|
// ── Artefacts tools ──
|
|
285
|
-
|
|
286
|
-
|
|
509
|
+
// NOTE: `foundry_artefacts_add` was removed in v2.2.0. Artefacts are now
|
|
510
|
+
// registered automatically by `foundry_stage_finalize` as drafts, then
|
|
511
|
+
// promoted to done|blocked via `foundry_artefacts_set_status`.
|
|
512
|
+
foundry_artefacts_set_status: tool({
|
|
513
|
+
description: 'Update the status of an artefact in WORK.md (done|blocked only)',
|
|
287
514
|
args: {
|
|
288
515
|
file: tool.schema.string().describe('Artefact file path'),
|
|
289
|
-
|
|
290
|
-
cycle: tool.schema.string().describe('Cycle name'),
|
|
291
|
-
status: tool.schema.string().optional().describe('Status (default: draft)'),
|
|
516
|
+
status: tool.schema.string().describe('New status (done|blocked)'),
|
|
292
517
|
},
|
|
293
518
|
async execute(args, context) {
|
|
519
|
+
const io = makeIO(context.worktree);
|
|
520
|
+
const guard = requireNoActiveStage(io);
|
|
521
|
+
if (!guard.ok) return JSON.stringify({ error: `foundry_artefacts_set_status ${guard.error}` });
|
|
294
522
|
const workPath = path.join(context.worktree, 'WORK.md');
|
|
295
523
|
const text = readFileSync(workPath, 'utf-8');
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
524
|
+
try {
|
|
525
|
+
const updated = setArtefactStatus(text, args.file, args.status);
|
|
526
|
+
writeFileSync(workPath, updated, 'utf-8');
|
|
527
|
+
return JSON.stringify({ ok: true });
|
|
528
|
+
} catch (e) {
|
|
529
|
+
return JSON.stringify({ error: e.message });
|
|
530
|
+
}
|
|
299
531
|
},
|
|
300
532
|
}),
|
|
301
533
|
|
|
302
|
-
|
|
303
|
-
description: '
|
|
534
|
+
foundry_artefacts_list: tool({
|
|
535
|
+
description: 'List artefacts from the WORK.md table. Optionally filter by cycle — callers should always pass the current cycle to avoid picking up stale rows from prior sessions.',
|
|
304
536
|
args: {
|
|
305
|
-
|
|
306
|
-
status: tool.schema.string().describe('New status'),
|
|
537
|
+
cycle: tool.schema.string().optional().describe('Only return rows whose Cycle column matches this value'),
|
|
307
538
|
},
|
|
308
539
|
async execute(args, context) {
|
|
309
|
-
const workPath = path.join(context.worktree, 'WORK.md');
|
|
310
|
-
const text = readFileSync(workPath, 'utf-8');
|
|
311
|
-
const updated = setArtefactStatus(text, args.file, args.status);
|
|
312
|
-
writeFileSync(workPath, updated, 'utf-8');
|
|
313
|
-
return JSON.stringify({ ok: true });
|
|
314
|
-
},
|
|
315
|
-
}),
|
|
316
|
-
|
|
317
|
-
foundry_artefacts_list: tool({
|
|
318
|
-
description: 'List all artefacts from the WORK.md table',
|
|
319
|
-
args: {},
|
|
320
|
-
async execute(_args, context) {
|
|
321
540
|
const workPath = path.join(context.worktree, 'WORK.md');
|
|
322
541
|
if (!existsSync(workPath)) {
|
|
323
542
|
return JSON.stringify({ error: 'WORK.md not found' });
|
|
324
543
|
}
|
|
325
544
|
const text = readFileSync(workPath, 'utf-8');
|
|
326
|
-
|
|
545
|
+
const rows = parseArtefactsTable(text);
|
|
546
|
+
const filtered = args.cycle ? rows.filter(r => r.cycle === args.cycle) : rows;
|
|
547
|
+
return JSON.stringify(filtered);
|
|
327
548
|
},
|
|
328
549
|
}),
|
|
329
550
|
|
|
@@ -336,11 +557,28 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
336
557
|
tag: tool.schema.string().describe('Tag for the feedback item'),
|
|
337
558
|
},
|
|
338
559
|
async execute(args, context) {
|
|
560
|
+
const io = makeIO(context.worktree);
|
|
561
|
+
const guard = requireActiveStage(io);
|
|
562
|
+
if (!guard.ok) return JSON.stringify({ error: `foundry_feedback_add requires active stage; ${guard.error}` });
|
|
563
|
+
const stageBase = stageBaseOf(guard.active.stage);
|
|
564
|
+
// Per-stage tag allow-list.
|
|
565
|
+
if (stageBase === 'forge') {
|
|
566
|
+
return JSON.stringify({ error: 'foundry_feedback_add: forge stages do not add feedback' });
|
|
567
|
+
}
|
|
568
|
+
if (stageBase === 'quench' && args.tag !== 'validation') {
|
|
569
|
+
return JSON.stringify({ error: `foundry_feedback_add: quench may only add tag "validation"; got "${args.tag}"` });
|
|
570
|
+
}
|
|
571
|
+
if (stageBase === 'appraise' && !args.tag.startsWith('law:')) {
|
|
572
|
+
return JSON.stringify({ error: `foundry_feedback_add: appraise tag must start with "law:"; got "${args.tag}"` });
|
|
573
|
+
}
|
|
574
|
+
if (stageBase === 'human-appraise' && args.tag !== 'human') {
|
|
575
|
+
return JSON.stringify({ error: `foundry_feedback_add: human-appraise may only add tag "human"; got "${args.tag}"` });
|
|
576
|
+
}
|
|
339
577
|
const workPath = path.join(context.worktree, 'WORK.md');
|
|
340
578
|
const content = readFileSync(workPath, 'utf-8');
|
|
341
|
-
const
|
|
342
|
-
writeFileSync(workPath,
|
|
343
|
-
return JSON.stringify({ ok: true });
|
|
579
|
+
const r = addFeedbackItem(content, args.file, args.text, args.tag);
|
|
580
|
+
if (!r.deduped) writeFileSync(workPath, r.text, 'utf-8');
|
|
581
|
+
return JSON.stringify({ ok: true, deduped: r.deduped });
|
|
344
582
|
},
|
|
345
583
|
}),
|
|
346
584
|
|
|
@@ -351,10 +589,18 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
351
589
|
index: tool.schema.number().describe('Zero-based index of the feedback item'),
|
|
352
590
|
},
|
|
353
591
|
async execute(args, context) {
|
|
592
|
+
const io = makeIO(context.worktree);
|
|
593
|
+
const guard = requireActiveStage(io);
|
|
594
|
+
if (!guard.ok) return JSON.stringify({ error: `foundry_feedback_action requires active stage; ${guard.error}` });
|
|
595
|
+
const stageBase = stageBaseOf(guard.active.stage);
|
|
596
|
+
if (stageBase !== 'forge') {
|
|
597
|
+
return JSON.stringify({ error: `foundry_feedback_action requires active forge stage; current: ${guard.active.stage}` });
|
|
598
|
+
}
|
|
354
599
|
const workPath = path.join(context.worktree, 'WORK.md');
|
|
355
600
|
const content = readFileSync(workPath, 'utf-8');
|
|
356
|
-
const
|
|
357
|
-
|
|
601
|
+
const r = actionFeedbackItem(content, args.file, args.index, stageBase);
|
|
602
|
+
if (!r.ok) return JSON.stringify({ error: r.error });
|
|
603
|
+
writeFileSync(workPath, r.text, 'utf-8');
|
|
358
604
|
return JSON.stringify({ ok: true });
|
|
359
605
|
},
|
|
360
606
|
}),
|
|
@@ -367,10 +613,18 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
367
613
|
reason: tool.schema.string().describe('Reason for wont-fix'),
|
|
368
614
|
},
|
|
369
615
|
async execute(args, context) {
|
|
616
|
+
const io = makeIO(context.worktree);
|
|
617
|
+
const guard = requireActiveStage(io);
|
|
618
|
+
if (!guard.ok) return JSON.stringify({ error: `foundry_feedback_wontfix requires active stage; ${guard.error}` });
|
|
619
|
+
const stageBase = stageBaseOf(guard.active.stage);
|
|
620
|
+
if (stageBase !== 'forge') {
|
|
621
|
+
return JSON.stringify({ error: `foundry_feedback_wontfix requires active forge stage; current: ${guard.active.stage}` });
|
|
622
|
+
}
|
|
370
623
|
const workPath = path.join(context.worktree, 'WORK.md');
|
|
371
624
|
const content = readFileSync(workPath, 'utf-8');
|
|
372
|
-
const
|
|
373
|
-
|
|
625
|
+
const r = wontfixFeedbackItem(content, args.file, args.index, args.reason, stageBase);
|
|
626
|
+
if (!r.ok) return JSON.stringify({ error: r.error });
|
|
627
|
+
writeFileSync(workPath, r.text, 'utf-8');
|
|
374
628
|
return JSON.stringify({ ok: true });
|
|
375
629
|
},
|
|
376
630
|
}),
|
|
@@ -384,10 +638,18 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
384
638
|
reason: tool.schema.string().optional().describe('Reason (required if rejected)'),
|
|
385
639
|
},
|
|
386
640
|
async execute(args, context) {
|
|
641
|
+
const io = makeIO(context.worktree);
|
|
642
|
+
const guard = requireActiveStage(io);
|
|
643
|
+
if (!guard.ok) return JSON.stringify({ error: `foundry_feedback_resolve requires active stage; ${guard.error}` });
|
|
644
|
+
const stageBase = stageBaseOf(guard.active.stage);
|
|
645
|
+
if (!['quench', 'appraise', 'human-appraise'].includes(stageBase)) {
|
|
646
|
+
return JSON.stringify({ error: `foundry_feedback_resolve requires active quench|appraise|human-appraise stage; current: ${guard.active.stage}` });
|
|
647
|
+
}
|
|
387
648
|
const workPath = path.join(context.worktree, 'WORK.md');
|
|
388
649
|
const content = readFileSync(workPath, 'utf-8');
|
|
389
|
-
const
|
|
390
|
-
|
|
650
|
+
const r = resolveFeedbackItem(content, args.file, args.index, args.resolution, args.reason, stageBase);
|
|
651
|
+
if (!r.ok) return JSON.stringify({ error: r.error });
|
|
652
|
+
writeFileSync(workPath, r.text, 'utf-8');
|
|
391
653
|
return JSON.stringify({ ok: true });
|
|
392
654
|
},
|
|
393
655
|
}),
|
|
@@ -412,13 +674,21 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
412
674
|
|
|
413
675
|
// ── Sort tool ──
|
|
414
676
|
foundry_sort: tool({
|
|
415
|
-
description: '
|
|
677
|
+
description: 'Determine the next stage for the current cycle and (if dispatchable) mint a single-use token.',
|
|
416
678
|
args: {
|
|
417
679
|
cycleDef: tool.schema.string().optional().describe('Path to cycle definition file'),
|
|
418
680
|
},
|
|
419
681
|
async execute(args, context) {
|
|
420
682
|
const io = makeIO(context.worktree);
|
|
421
|
-
const
|
|
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);
|
|
422
692
|
return JSON.stringify(result);
|
|
423
693
|
},
|
|
424
694
|
}),
|
|
@@ -431,6 +701,9 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
431
701
|
description: tool.schema.string().describe('Branch description suffix'),
|
|
432
702
|
},
|
|
433
703
|
async execute(args, context) {
|
|
704
|
+
const io = makeIO(context.worktree);
|
|
705
|
+
const guard = requireNoActiveStage(io);
|
|
706
|
+
if (!guard.ok) return JSON.stringify({ error: `foundry_git_branch ${guard.error}` });
|
|
434
707
|
const flowSlug = slugify(args.flowId);
|
|
435
708
|
const descSlug = slugify(args.description);
|
|
436
709
|
const branch = `work/${flowSlug}-${descSlug}`;
|
|
@@ -447,6 +720,9 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
447
720
|
description: tool.schema.string().describe('Commit description'),
|
|
448
721
|
},
|
|
449
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}` });
|
|
450
726
|
execSync('git add .', { cwd: context.worktree, encoding: 'utf8' });
|
|
451
727
|
const msg = `[${args.cycle}] ${args.stage}: ${args.description}`;
|
|
452
728
|
execSync(`git commit -m "${msg.replace(/"/g, '\\"')}"`, { cwd: context.worktree, encoding: 'utf8' });
|
|
@@ -462,6 +738,9 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
462
738
|
baseBranch: tool.schema.string().optional().describe('Target branch (default: main)'),
|
|
463
739
|
},
|
|
464
740
|
async execute(args, context) {
|
|
741
|
+
const io = makeIO(context.worktree);
|
|
742
|
+
const guard = requireNoActiveStage(io);
|
|
743
|
+
if (!guard.ok) return JSON.stringify({ error: `foundry_git_finish ${guard.error}` });
|
|
465
744
|
const base = args.baseBranch || 'main';
|
|
466
745
|
const cwd = context.worktree;
|
|
467
746
|
const opts = { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] };
|
|
@@ -617,4 +896,8 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
617
896
|
}),
|
|
618
897
|
},
|
|
619
898
|
};
|
|
899
|
+
|
|
900
|
+
Object.defineProperty(plugin, Symbol.for('foundry.test.pending'), { value: pending });
|
|
901
|
+
Object.defineProperty(plugin, Symbol.for('foundry.test.secret'), { value: secret });
|
|
902
|
+
return plugin;
|
|
620
903
|
};
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 2.2.1 — 2026-04-20
|
|
4
|
+
|
|
5
|
+
Follow-up patch addressing the five bugs deferred from v2.2.0 (see `HARDEN.md` §Deferred).
|
|
6
|
+
|
|
7
|
+
### Breaking changes
|
|
8
|
+
|
|
9
|
+
- **Cycle-definition deadlock config flattened.** The nested `human-appraise: {enabled, deadlock-threshold}` block is replaced by three flat keys:
|
|
10
|
+
- `human-appraise: <bool>` (default `false`) — include `human-appraise` in the stage loop every iteration
|
|
11
|
+
- `deadlock-appraise: <bool>` (default `true`) — route to `human-appraise` when LLM appraisers deadlock
|
|
12
|
+
- `deadlock-iterations: <number>` (default `5`) — deadlock threshold
|
|
13
|
+
Run the `upgrade-foundry` skill to migrate existing cycle defs — the old nested form is no longer read.
|
|
14
|
+
|
|
15
|
+
### New
|
|
16
|
+
|
|
17
|
+
- **`foundry_workfile_configure_from_cycle({cycleId, stages})`** — populates WORK.md frontmatter from a cycle definition in one call. Replaces the prior 6–7 sequential `foundry_workfile_set` calls at cycle start. Defaults for `max-iterations`, `human-appraise`, `deadlock-appraise`, `deadlock-iterations`, and `models` now live in plugin code rather than skill prose.
|
|
18
|
+
- **`foundry_artefacts_list({cycle})`** — optional cycle filter. Callers should always pass the current cycle to avoid picking up stale rows from prior aborted sessions.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **Bug B — deadlock routing.** Sort now reads the flat deadlock keys from WORK.md frontmatter and routes to `human-appraise` on deadlock (either an existing `human-appraise:<cycle>` stage in `stages`, or a synthesized one). When `deadlock-appraise: false`, deadlock marks the cycle `blocked`.
|
|
23
|
+
- **Bug C — stale artefact validation.** `quench`, `appraise`, and `human-appraise` skills now pass the current cycle to `foundry_artefacts_list`, scoping validation to artefacts produced by the current cycle instead of every row that has ever landed in WORK.md.
|
|
24
|
+
- **Bug D — overwriting WORK.md.** The `flow` skill now calls `foundry_workfile_get` before `foundry_workfile_create` and prompts the user to resume, discard, or abort when an existing workfile is detected. Silent overwrite is not offered; resume requires matching `flow` and `cycle`.
|
|
25
|
+
- **Bug E — missing micro-commits.** `foundry_sort` now returns `{route: 'violation'}` when `WORK.md`, `WORK.history.yaml`, or anything under `.foundry/` has uncommitted changes at the start of a sort call and history is non-empty. Structurally enforces the one-commit-per-stage contract that previously lived only in skill prose. First sort of a cycle is exempt (empty history).
|
|
26
|
+
- **Bug G — workfile setup boilerplate.** See `foundry_workfile_configure_from_cycle` above.
|
|
27
|
+
|
|
28
|
+
### Migration
|
|
29
|
+
|
|
30
|
+
Run the `upgrade-foundry` skill to migrate cycle definitions to the flat deadlock keys (Bug B). No other migration required — WORK.md, `.foundry/`, and feedback state are forward-compatible.
|
|
31
|
+
|
|
32
|
+
## 2.2.0 — 2026-04-19
|
|
33
|
+
|
|
34
|
+
### Breaking changes
|
|
35
|
+
|
|
36
|
+
- **`foundry_artefacts_add` removed.** Artefact registration now happens exclusively via `foundry_stage_finalize` after a forge stage closes.
|
|
37
|
+
- **`foundry_artefacts_set_status` no longer accepts `draft`.** Only `done` and `blocked` are valid. New artefacts are registered as `draft` automatically by `stage_finalize`.
|
|
38
|
+
- **Feedback / artefact / workfile mutation tools now enforce stage-lock preconditions.** Tools callable by subagents require an active stage matching their role; tools callable by the orchestrator require no active stage. Out-of-band calls return a structured error instead of mutating state.
|
|
39
|
+
- **Feedback state machine strictly enforced.** `approved` is terminal. `quench` cannot approve/reject `wont-fix` items. See `HARDEN.md` §4 for the full matrix.
|
|
40
|
+
- **`foundry_sort` dispatchable routes now return a `token` field.** Subagents must redeem the token via `foundry_stage_begin`; forged or replayed tokens are rejected.
|
|
41
|
+
|
|
42
|
+
### New
|
|
43
|
+
|
|
44
|
+
- **`foundry_stage_begin(stage, cycle, token)`** — subagents open a work stage by consuming a single-use HMAC-signed token.
|
|
45
|
+
- **`foundry_stage_end(summary)`** — subagents close a stage; preserves `baseSha` for finalize.
|
|
46
|
+
- **`foundry_stage_finalize(cycle)`** — orchestrator verifies stage output against allowed file patterns, registers matching files as draft artefacts, rejects stray writes with `{error: "unexpected_files", files: [...]}`.
|
|
47
|
+
- **`.foundry/` state directory** (gitignored) — holds `.secret` (per-worktree HMAC key, mode 0600), `active-stage.json` (present only during an active stage), `last-stage.json` (for finalize lookup).
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
- Normalized `maxIterations` → `max-iterations` across workfile read/write paths (previously inconsistent between flow and cycle skills, causing latent deadlock-detection issues).
|
|
52
|
+
|
|
53
|
+
### Migration
|
|
54
|
+
|
|
55
|
+
Upgrade with the `upgrade-foundry` skill. `.foundry/` is created automatically on first plugin boot; `.secret` is generated idempotently. No data migration required — existing `WORK.md` and `foundry/*` configs are compatible.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@really-knows-ai/foundry",
|
|
3
|
-
"version": "2.1
|
|
3
|
+
"version": "2.2.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",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"docs/concepts.md",
|
|
41
41
|
"docs/getting-started.md",
|
|
42
42
|
"README.md",
|
|
43
|
-
"LICENSE"
|
|
43
|
+
"LICENSE",
|
|
44
|
+
"CHANGELOG.md"
|
|
44
45
|
]
|
|
45
46
|
}
|
package/scripts/lib/artefacts.js
CHANGED
|
@@ -86,6 +86,12 @@ export function addArtefactRow(text, { file, type, cycle, status }) {
|
|
|
86
86
|
* @returns {string} Updated text
|
|
87
87
|
*/
|
|
88
88
|
export function setArtefactStatus(text, file, newStatus) {
|
|
89
|
+
if (newStatus === 'draft') {
|
|
90
|
+
throw new Error('status draft not permitted; use stage_finalize for registration');
|
|
91
|
+
}
|
|
92
|
+
if (!['done', 'blocked'].includes(newStatus)) {
|
|
93
|
+
throw new Error(`invalid status: ${newStatus}`);
|
|
94
|
+
}
|
|
89
95
|
const lines = text.split('\n');
|
|
90
96
|
let inTable = false;
|
|
91
97
|
let found = false;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
// Matrix: [current][target] => set of allowed stageBases
|
|
4
|
+
const MATRIX = {
|
|
5
|
+
open: { actioned: ['forge'], 'wont-fix': ['forge'] },
|
|
6
|
+
actioned: { approved: ['quench', 'appraise', 'human-appraise'], rejected: ['quench', 'appraise', 'human-appraise'] },
|
|
7
|
+
'wont-fix': { approved: ['appraise', 'human-appraise'], rejected: ['appraise', 'human-appraise'] },
|
|
8
|
+
rejected: { actioned: ['forge'], 'wont-fix': ['forge'] },
|
|
9
|
+
approved: {}, // terminal
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function validateTransition(current, target, stageBase) {
|
|
13
|
+
const row = MATRIX[current];
|
|
14
|
+
if (!row) return { ok: false, reason: `unknown state: ${current}` };
|
|
15
|
+
const allowedStages = row[target];
|
|
16
|
+
if (!allowedStages) return { ok: false, reason: `invalid transition ${current} → ${target}` };
|
|
17
|
+
if (!allowedStages.includes(stageBase)) {
|
|
18
|
+
return { ok: false, reason: `stage ${stageBase} cannot transition ${current} → ${target}` };
|
|
19
|
+
}
|
|
20
|
+
return { ok: true };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function hashText(text) {
|
|
24
|
+
return createHash('sha256').update(text).digest('hex').slice(0, 16);
|
|
25
|
+
}
|