@really-knows-ai/foundry 2.0.1 → 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 +343 -51
- package/CHANGELOG.md +55 -0
- package/package.json +4 -3
- 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/slug.js +33 -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 +99 -15
- 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/refresh-agents/SKILL.md +6 -3
- package/skills/sort/SKILL.md +86 -16
- package/skills/upgrade-foundry/SKILL.md +52 -6
|
@@ -9,16 +9,27 @@
|
|
|
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';
|
|
19
19
|
import { getCycleDefinition, getArtefactType, getLaws, getValidation, getAppraisers, getFlow, selectAppraisers } from '../../scripts/lib/config.js';
|
|
20
|
+
import { slugify } from '../../scripts/lib/slug.js';
|
|
20
21
|
import { runSort } from '../../scripts/sort.js';
|
|
21
|
-
import { execSync } from 'child_process';
|
|
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';
|
|
22
33
|
|
|
23
34
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
35
|
const packageRoot = path.resolve(__dirname, '../..');
|
|
@@ -114,11 +125,21 @@ function makeIO(directory) {
|
|
|
114
125
|
readFile: (p) => readFileSync(resolve(p), 'utf-8'),
|
|
115
126
|
writeFile: (p, content) => writeFileSync(resolve(p), content, 'utf-8'),
|
|
116
127
|
readDir: (p) => readdirSync(resolve(p)),
|
|
128
|
+
mkdir: (p) => mkdirSync(resolve(p), { recursive: true }),
|
|
129
|
+
unlink: (p) => { if (existsSync(resolve(p))) unlinkSync(resolve(p)); },
|
|
117
130
|
};
|
|
118
131
|
}
|
|
119
132
|
|
|
120
133
|
export const FoundryPlugin = async ({ directory }) => {
|
|
121
|
-
|
|
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 = {
|
|
122
143
|
config: async (config) => {
|
|
123
144
|
config.skills = config.skills || {};
|
|
124
145
|
config.skills.paths = config.skills.paths || [];
|
|
@@ -150,12 +171,25 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
150
171
|
cycle: tool.schema.string().describe('Cycle name'),
|
|
151
172
|
stage: tool.schema.string().describe('Stage name'),
|
|
152
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'),
|
|
153
175
|
},
|
|
154
176
|
async execute(args, context) {
|
|
155
177
|
const io = makeIO(context.worktree);
|
|
178
|
+
const guard = requireNoActiveStage(io);
|
|
179
|
+
if (!guard.ok) return JSON.stringify({ error: `foundry_history_append ${guard.error}` });
|
|
156
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
|
+
}
|
|
157
191
|
const iteration = getIteration(historyPath, args.cycle, io);
|
|
158
|
-
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);
|
|
159
193
|
return JSON.stringify({ ok: true, iteration });
|
|
160
194
|
},
|
|
161
195
|
}),
|
|
@@ -173,23 +207,145 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
173
207
|
},
|
|
174
208
|
}),
|
|
175
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
|
+
|
|
176
323
|
// ── Workfile tools ──
|
|
177
324
|
foundry_workfile_create: tool({
|
|
178
325
|
description: 'Create WORK.md with frontmatter and goal',
|
|
179
326
|
args: {
|
|
180
327
|
flow: tool.schema.string().describe('Flow name'),
|
|
181
328
|
cycle: tool.schema.string().describe('Cycle name'),
|
|
182
|
-
stages: tool.schema.array(tool.schema.string()).describe('Ordered stage names'),
|
|
183
|
-
maxIterations: tool.schema.number().describe('Maximum iterations'),
|
|
329
|
+
stages: tool.schema.array(tool.schema.string()).optional().describe('Ordered stage names'),
|
|
330
|
+
maxIterations: tool.schema.number().optional().describe('Maximum iterations'),
|
|
184
331
|
goal: tool.schema.string().describe('Goal text'),
|
|
185
332
|
models: tool.schema.string().optional().describe('Per-stage model overrides as JSON object, e.g. \'{"forge":"openai/gpt-4o"}\''),
|
|
186
333
|
},
|
|
187
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}` });
|
|
188
338
|
const workPath = path.join(context.worktree, 'WORK.md');
|
|
189
339
|
if (existsSync(workPath)) {
|
|
190
|
-
return JSON.stringify({ error: 'WORK.md
|
|
340
|
+
return JSON.stringify({ error: 'foundry_workfile_create requires no WORK.md; current: exists' });
|
|
341
|
+
}
|
|
342
|
+
const fm = { flow: args.flow, cycle: args.cycle };
|
|
343
|
+
if (args.stages) {
|
|
344
|
+
fm.stages = enrichStages(args.stages, args.cycle);
|
|
345
|
+
}
|
|
346
|
+
if (args.maxIterations !== undefined) {
|
|
347
|
+
fm['max-iterations'] = args.maxIterations;
|
|
191
348
|
}
|
|
192
|
-
const fm = { flow: args.flow, cycle: args.cycle, stages: enrichStages(args.stages, args.cycle), maxIterations: args.maxIterations };
|
|
193
349
|
if (args.models) {
|
|
194
350
|
fm.models = parseModelsValue(args.models);
|
|
195
351
|
}
|
|
@@ -218,10 +374,17 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
218
374
|
foundry_workfile_set: tool({
|
|
219
375
|
description: 'Update a single frontmatter field in WORK.md',
|
|
220
376
|
args: {
|
|
221
|
-
key: tool.schema.string().describe('Frontmatter key'),
|
|
377
|
+
key: tool.schema.string().describe('Frontmatter key (cycle|stages|max-iterations|models)'),
|
|
222
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"}\')'),
|
|
223
379
|
},
|
|
224
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
|
+
}
|
|
225
388
|
const workPath = path.join(context.worktree, 'WORK.md');
|
|
226
389
|
if (!existsSync(workPath)) {
|
|
227
390
|
return JSON.stringify({ error: 'WORK.md not found' });
|
|
@@ -258,10 +421,78 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
258
421
|
},
|
|
259
422
|
}),
|
|
260
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
|
+
|
|
261
484
|
foundry_workfile_delete: tool({
|
|
262
|
-
description: 'Delete WORK.md and WORK.history.yaml',
|
|
263
|
-
args: {
|
|
264
|
-
|
|
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
|
+
}
|
|
265
496
|
const workPath = path.join(context.worktree, 'WORK.md');
|
|
266
497
|
const historyPath = path.join(context.worktree, 'WORK.history.yaml');
|
|
267
498
|
if (existsSync(workPath)) {
|
|
@@ -275,48 +506,45 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
275
506
|
}),
|
|
276
507
|
|
|
277
508
|
// ── Artefacts tools ──
|
|
278
|
-
|
|
279
|
-
|
|
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)',
|
|
280
514
|
args: {
|
|
281
515
|
file: tool.schema.string().describe('Artefact file path'),
|
|
282
|
-
|
|
283
|
-
cycle: tool.schema.string().describe('Cycle name'),
|
|
284
|
-
status: tool.schema.string().optional().describe('Status (default: draft)'),
|
|
516
|
+
status: tool.schema.string().describe('New status (done|blocked)'),
|
|
285
517
|
},
|
|
286
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}` });
|
|
287
522
|
const workPath = path.join(context.worktree, 'WORK.md');
|
|
288
523
|
const text = readFileSync(workPath, 'utf-8');
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
+
}
|
|
292
531
|
},
|
|
293
532
|
}),
|
|
294
533
|
|
|
295
|
-
|
|
296
|
-
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.',
|
|
297
536
|
args: {
|
|
298
|
-
|
|
299
|
-
status: tool.schema.string().describe('New status'),
|
|
537
|
+
cycle: tool.schema.string().optional().describe('Only return rows whose Cycle column matches this value'),
|
|
300
538
|
},
|
|
301
539
|
async execute(args, context) {
|
|
302
|
-
const workPath = path.join(context.worktree, 'WORK.md');
|
|
303
|
-
const text = readFileSync(workPath, 'utf-8');
|
|
304
|
-
const updated = setArtefactStatus(text, args.file, args.status);
|
|
305
|
-
writeFileSync(workPath, updated, 'utf-8');
|
|
306
|
-
return JSON.stringify({ ok: true });
|
|
307
|
-
},
|
|
308
|
-
}),
|
|
309
|
-
|
|
310
|
-
foundry_artefacts_list: tool({
|
|
311
|
-
description: 'List all artefacts from the WORK.md table',
|
|
312
|
-
args: {},
|
|
313
|
-
async execute(_args, context) {
|
|
314
540
|
const workPath = path.join(context.worktree, 'WORK.md');
|
|
315
541
|
if (!existsSync(workPath)) {
|
|
316
542
|
return JSON.stringify({ error: 'WORK.md not found' });
|
|
317
543
|
}
|
|
318
544
|
const text = readFileSync(workPath, 'utf-8');
|
|
319
|
-
|
|
545
|
+
const rows = parseArtefactsTable(text);
|
|
546
|
+
const filtered = args.cycle ? rows.filter(r => r.cycle === args.cycle) : rows;
|
|
547
|
+
return JSON.stringify(filtered);
|
|
320
548
|
},
|
|
321
549
|
}),
|
|
322
550
|
|
|
@@ -329,11 +557,28 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
329
557
|
tag: tool.schema.string().describe('Tag for the feedback item'),
|
|
330
558
|
},
|
|
331
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
|
+
}
|
|
332
577
|
const workPath = path.join(context.worktree, 'WORK.md');
|
|
333
578
|
const content = readFileSync(workPath, 'utf-8');
|
|
334
|
-
const
|
|
335
|
-
writeFileSync(workPath,
|
|
336
|
-
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 });
|
|
337
582
|
},
|
|
338
583
|
}),
|
|
339
584
|
|
|
@@ -344,10 +589,18 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
344
589
|
index: tool.schema.number().describe('Zero-based index of the feedback item'),
|
|
345
590
|
},
|
|
346
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
|
+
}
|
|
347
599
|
const workPath = path.join(context.worktree, 'WORK.md');
|
|
348
600
|
const content = readFileSync(workPath, 'utf-8');
|
|
349
|
-
const
|
|
350
|
-
|
|
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');
|
|
351
604
|
return JSON.stringify({ ok: true });
|
|
352
605
|
},
|
|
353
606
|
}),
|
|
@@ -360,10 +613,18 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
360
613
|
reason: tool.schema.string().describe('Reason for wont-fix'),
|
|
361
614
|
},
|
|
362
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
|
+
}
|
|
363
623
|
const workPath = path.join(context.worktree, 'WORK.md');
|
|
364
624
|
const content = readFileSync(workPath, 'utf-8');
|
|
365
|
-
const
|
|
366
|
-
|
|
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');
|
|
367
628
|
return JSON.stringify({ ok: true });
|
|
368
629
|
},
|
|
369
630
|
}),
|
|
@@ -377,10 +638,18 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
377
638
|
reason: tool.schema.string().optional().describe('Reason (required if rejected)'),
|
|
378
639
|
},
|
|
379
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
|
+
}
|
|
380
648
|
const workPath = path.join(context.worktree, 'WORK.md');
|
|
381
649
|
const content = readFileSync(workPath, 'utf-8');
|
|
382
|
-
const
|
|
383
|
-
|
|
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');
|
|
384
653
|
return JSON.stringify({ ok: true });
|
|
385
654
|
},
|
|
386
655
|
}),
|
|
@@ -405,13 +674,21 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
405
674
|
|
|
406
675
|
// ── Sort tool ──
|
|
407
676
|
foundry_sort: tool({
|
|
408
|
-
description: '
|
|
677
|
+
description: 'Determine the next stage for the current cycle and (if dispatchable) mint a single-use token.',
|
|
409
678
|
args: {
|
|
410
679
|
cycleDef: tool.schema.string().optional().describe('Path to cycle definition file'),
|
|
411
680
|
},
|
|
412
681
|
async execute(args, context) {
|
|
413
682
|
const io = makeIO(context.worktree);
|
|
414
|
-
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);
|
|
415
692
|
return JSON.stringify(result);
|
|
416
693
|
},
|
|
417
694
|
}),
|
|
@@ -424,8 +701,13 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
424
701
|
description: tool.schema.string().describe('Branch description suffix'),
|
|
425
702
|
},
|
|
426
703
|
async execute(args, context) {
|
|
427
|
-
const
|
|
428
|
-
|
|
704
|
+
const io = makeIO(context.worktree);
|
|
705
|
+
const guard = requireNoActiveStage(io);
|
|
706
|
+
if (!guard.ok) return JSON.stringify({ error: `foundry_git_branch ${guard.error}` });
|
|
707
|
+
const flowSlug = slugify(args.flowId);
|
|
708
|
+
const descSlug = slugify(args.description);
|
|
709
|
+
const branch = `work/${flowSlug}-${descSlug}`;
|
|
710
|
+
execFileSync('git', ['checkout', '-b', branch], { cwd: context.worktree, encoding: 'utf8', stdio: 'pipe' });
|
|
429
711
|
return JSON.stringify({ ok: true, branch });
|
|
430
712
|
},
|
|
431
713
|
}),
|
|
@@ -438,6 +720,9 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
438
720
|
description: tool.schema.string().describe('Commit description'),
|
|
439
721
|
},
|
|
440
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}` });
|
|
441
726
|
execSync('git add .', { cwd: context.worktree, encoding: 'utf8' });
|
|
442
727
|
const msg = `[${args.cycle}] ${args.stage}: ${args.description}`;
|
|
443
728
|
execSync(`git commit -m "${msg.replace(/"/g, '\\"')}"`, { cwd: context.worktree, encoding: 'utf8' });
|
|
@@ -453,6 +738,9 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
453
738
|
baseBranch: tool.schema.string().optional().describe('Target branch (default: main)'),
|
|
454
739
|
},
|
|
455
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}` });
|
|
456
744
|
const base = args.baseBranch || 'main';
|
|
457
745
|
const cwd = context.worktree;
|
|
458
746
|
const opts = { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] };
|
|
@@ -608,4 +896,8 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
608
896
|
}),
|
|
609
897
|
},
|
|
610
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;
|
|
611
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.
|
|
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",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"node": ">=18.3.0"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
|
-
"test": "node --test
|
|
28
|
+
"test": "node --test"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@opencode-ai/plugin": "^1.4.0",
|
|
@@ -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;
|