@really-knows-ai/foundry 1.2.2 → 1.3.0
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 +408 -1
- package/README.md +31 -5
- package/docs/concepts.md +5 -1
- package/docs/work-spec.md +7 -7
- package/package.json +2 -2
- package/scripts/lib/artefacts.js +118 -0
- package/scripts/lib/config.js +154 -0
- package/scripts/lib/feedback.js +285 -0
- package/scripts/lib/history.js +47 -0
- package/scripts/lib/workfile.js +53 -0
- package/scripts/sort.js +54 -196
- package/skills/appraise/SKILL.md +24 -83
- package/skills/cycle/SKILL.md +25 -62
- package/skills/flow/SKILL.md +12 -38
- package/skills/forge/SKILL.md +25 -41
- package/skills/hitl/SKILL.md +18 -41
- package/skills/quench/SKILL.md +15 -44
- package/skills/sort/SKILL.md +20 -53
|
@@ -9,7 +9,16 @@
|
|
|
9
9
|
|
|
10
10
|
import path from 'path';
|
|
11
11
|
import fs from 'fs';
|
|
12
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync } from 'fs';
|
|
12
13
|
import { fileURLToPath } from 'url';
|
|
14
|
+
import { tool } from '@opencode-ai/plugin';
|
|
15
|
+
import { loadHistory, appendEntry, getIteration } from '../../scripts/lib/history.js';
|
|
16
|
+
import { parseFrontmatter, createWorkfile, setFrontmatterField, getFrontmatterField } from '../../scripts/lib/workfile.js';
|
|
17
|
+
import { parseArtefactsTable, addArtefactRow, setArtefactStatus } from '../../scripts/lib/artefacts.js';
|
|
18
|
+
import { addFeedbackItem, actionFeedbackItem, wontfixFeedbackItem, resolveFeedbackItem, listFeedback } from '../../scripts/lib/feedback.js';
|
|
19
|
+
import { getCycleDefinition, getArtefactType, getLaws, getValidation, getAppraisers, getFlow, selectAppraisers } from '../../scripts/lib/config.js';
|
|
20
|
+
import { runSort } from '../../scripts/sort.js';
|
|
21
|
+
import { execSync } from 'child_process';
|
|
13
22
|
|
|
14
23
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
15
24
|
const packageRoot = path.resolve(__dirname, '../..');
|
|
@@ -48,6 +57,16 @@ Scripts are located at: ${path.join(packageRoot, 'scripts')}
|
|
|
48
57
|
</FOUNDRY_CONTEXT>`;
|
|
49
58
|
}
|
|
50
59
|
|
|
60
|
+
function makeIO(directory) {
|
|
61
|
+
const resolve = (p) => path.isAbsolute(p) ? p : path.join(directory, p);
|
|
62
|
+
return {
|
|
63
|
+
exists: (p) => existsSync(resolve(p)),
|
|
64
|
+
readFile: (p) => readFileSync(resolve(p), 'utf-8'),
|
|
65
|
+
writeFile: (p, content) => writeFileSync(resolve(p), content, 'utf-8'),
|
|
66
|
+
readDir: (p) => readdirSync(resolve(p)),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
51
70
|
export const FoundryPlugin = async ({ directory }) => {
|
|
52
71
|
return {
|
|
53
72
|
config: async (config) => {
|
|
@@ -71,6 +90,394 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
71
90
|
|
|
72
91
|
const ref = firstUser.parts[0];
|
|
73
92
|
firstUser.parts.unshift({ ...ref, type: 'text', text: bootstrap });
|
|
74
|
-
}
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
tool: {
|
|
96
|
+
// ── History tools ──
|
|
97
|
+
foundry_history_append: tool({
|
|
98
|
+
description: 'Append an entry to the cycle history (WORK.history.yaml)',
|
|
99
|
+
args: {
|
|
100
|
+
cycle: tool.schema.string().describe('Cycle name'),
|
|
101
|
+
stage: tool.schema.string().describe('Stage name'),
|
|
102
|
+
comment: tool.schema.string().describe('Comment for this entry'),
|
|
103
|
+
},
|
|
104
|
+
async execute(args, context) {
|
|
105
|
+
const io = makeIO(context.worktree);
|
|
106
|
+
const historyPath = path.join(context.worktree, 'WORK.history.yaml');
|
|
107
|
+
const iteration = getIteration(historyPath, args.cycle, io);
|
|
108
|
+
appendEntry(historyPath, { cycle: args.cycle, stage: args.stage, iteration, comment: args.comment }, io);
|
|
109
|
+
return JSON.stringify({ ok: true, iteration });
|
|
110
|
+
},
|
|
111
|
+
}),
|
|
112
|
+
|
|
113
|
+
foundry_history_list: tool({
|
|
114
|
+
description: 'List history entries for a cycle',
|
|
115
|
+
args: {
|
|
116
|
+
cycle: tool.schema.string().describe('Cycle name'),
|
|
117
|
+
},
|
|
118
|
+
async execute(args, context) {
|
|
119
|
+
const io = makeIO(context.worktree);
|
|
120
|
+
const historyPath = path.join(context.worktree, 'WORK.history.yaml');
|
|
121
|
+
const entries = loadHistory(historyPath, args.cycle, io);
|
|
122
|
+
return JSON.stringify(entries);
|
|
123
|
+
},
|
|
124
|
+
}),
|
|
125
|
+
|
|
126
|
+
// ── Workfile tools ──
|
|
127
|
+
foundry_workfile_create: tool({
|
|
128
|
+
description: 'Create WORK.md with frontmatter and goal',
|
|
129
|
+
args: {
|
|
130
|
+
flow: tool.schema.string().describe('Flow name'),
|
|
131
|
+
cycle: tool.schema.string().describe('Cycle name'),
|
|
132
|
+
stages: tool.schema.array(tool.schema.string()).describe('Ordered stage names'),
|
|
133
|
+
maxIterations: tool.schema.number().describe('Maximum iterations'),
|
|
134
|
+
goal: tool.schema.string().describe('Goal text'),
|
|
135
|
+
models: tool.schema.record(tool.schema.string()).optional().describe('Per-stage model overrides'),
|
|
136
|
+
},
|
|
137
|
+
async execute(args, context) {
|
|
138
|
+
const workPath = path.join(context.worktree, 'WORK.md');
|
|
139
|
+
if (existsSync(workPath)) {
|
|
140
|
+
return JSON.stringify({ error: 'WORK.md already exists' });
|
|
141
|
+
}
|
|
142
|
+
const fm = { flow: args.flow, cycle: args.cycle, stages: args.stages, maxIterations: args.maxIterations };
|
|
143
|
+
if (args.models) fm.models = args.models;
|
|
144
|
+
const content = createWorkfile(fm, args.goal);
|
|
145
|
+
writeFileSync(workPath, content, 'utf-8');
|
|
146
|
+
return JSON.stringify({ ok: true });
|
|
147
|
+
},
|
|
148
|
+
}),
|
|
149
|
+
|
|
150
|
+
foundry_workfile_get: tool({
|
|
151
|
+
description: 'Read WORK.md and return frontmatter + goal',
|
|
152
|
+
args: {},
|
|
153
|
+
async execute(_args, context) {
|
|
154
|
+
const workPath = path.join(context.worktree, 'WORK.md');
|
|
155
|
+
if (!existsSync(workPath)) {
|
|
156
|
+
return JSON.stringify({ error: 'WORK.md not found' });
|
|
157
|
+
}
|
|
158
|
+
const text = readFileSync(workPath, 'utf-8');
|
|
159
|
+
const fm = parseFrontmatter(text);
|
|
160
|
+
const goalMatch = text.match(/# Goal\n\n([\s\S]*?)(?=\n\||\n##|$)/);
|
|
161
|
+
const goal = goalMatch ? goalMatch[1].trim() : '';
|
|
162
|
+
return JSON.stringify({ ...fm, goal });
|
|
163
|
+
},
|
|
164
|
+
}),
|
|
165
|
+
|
|
166
|
+
foundry_workfile_set: tool({
|
|
167
|
+
description: 'Update a single frontmatter field in WORK.md',
|
|
168
|
+
args: {
|
|
169
|
+
key: tool.schema.string().describe('Frontmatter key'),
|
|
170
|
+
value: tool.schema.any().describe('Value to set'),
|
|
171
|
+
},
|
|
172
|
+
async execute(args, context) {
|
|
173
|
+
const workPath = path.join(context.worktree, 'WORK.md');
|
|
174
|
+
if (!existsSync(workPath)) {
|
|
175
|
+
return JSON.stringify({ error: 'WORK.md not found' });
|
|
176
|
+
}
|
|
177
|
+
const text = readFileSync(workPath, 'utf-8');
|
|
178
|
+
const updated = setFrontmatterField(text, args.key, args.value);
|
|
179
|
+
writeFileSync(workPath, updated, 'utf-8');
|
|
180
|
+
return JSON.stringify({ ok: true });
|
|
181
|
+
},
|
|
182
|
+
}),
|
|
183
|
+
|
|
184
|
+
foundry_workfile_delete: tool({
|
|
185
|
+
description: 'Delete WORK.md',
|
|
186
|
+
args: {},
|
|
187
|
+
async execute(_args, context) {
|
|
188
|
+
const workPath = path.join(context.worktree, 'WORK.md');
|
|
189
|
+
if (existsSync(workPath)) {
|
|
190
|
+
unlinkSync(workPath);
|
|
191
|
+
}
|
|
192
|
+
return JSON.stringify({ ok: true });
|
|
193
|
+
},
|
|
194
|
+
}),
|
|
195
|
+
|
|
196
|
+
// ── Artefacts tools ──
|
|
197
|
+
foundry_artefacts_add: tool({
|
|
198
|
+
description: 'Add an artefact row to the WORK.md table',
|
|
199
|
+
args: {
|
|
200
|
+
file: tool.schema.string().describe('Artefact file path'),
|
|
201
|
+
type: tool.schema.string().describe('Artefact type'),
|
|
202
|
+
cycle: tool.schema.string().describe('Cycle name'),
|
|
203
|
+
status: tool.schema.string().optional().describe('Status (default: draft)'),
|
|
204
|
+
},
|
|
205
|
+
async execute(args, context) {
|
|
206
|
+
const workPath = path.join(context.worktree, 'WORK.md');
|
|
207
|
+
const text = readFileSync(workPath, 'utf-8');
|
|
208
|
+
const updated = addArtefactRow(text, { file: args.file, type: args.type, cycle: args.cycle, status: args.status || 'draft' });
|
|
209
|
+
writeFileSync(workPath, updated, 'utf-8');
|
|
210
|
+
return JSON.stringify({ ok: true });
|
|
211
|
+
},
|
|
212
|
+
}),
|
|
213
|
+
|
|
214
|
+
foundry_artefacts_set_status: tool({
|
|
215
|
+
description: 'Update the status of an artefact in WORK.md',
|
|
216
|
+
args: {
|
|
217
|
+
file: tool.schema.string().describe('Artefact file path'),
|
|
218
|
+
status: tool.schema.string().describe('New status'),
|
|
219
|
+
},
|
|
220
|
+
async execute(args, context) {
|
|
221
|
+
const workPath = path.join(context.worktree, 'WORK.md');
|
|
222
|
+
const text = readFileSync(workPath, 'utf-8');
|
|
223
|
+
const updated = setArtefactStatus(text, args.file, args.status);
|
|
224
|
+
writeFileSync(workPath, updated, 'utf-8');
|
|
225
|
+
return JSON.stringify({ ok: true });
|
|
226
|
+
},
|
|
227
|
+
}),
|
|
228
|
+
|
|
229
|
+
foundry_artefacts_list: tool({
|
|
230
|
+
description: 'List all artefacts from the WORK.md table',
|
|
231
|
+
args: {},
|
|
232
|
+
async execute(_args, context) {
|
|
233
|
+
const workPath = path.join(context.worktree, 'WORK.md');
|
|
234
|
+
if (!existsSync(workPath)) {
|
|
235
|
+
return JSON.stringify({ error: 'WORK.md not found' });
|
|
236
|
+
}
|
|
237
|
+
const text = readFileSync(workPath, 'utf-8');
|
|
238
|
+
return JSON.stringify(parseArtefactsTable(text));
|
|
239
|
+
},
|
|
240
|
+
}),
|
|
241
|
+
|
|
242
|
+
// ── Feedback tools ──
|
|
243
|
+
foundry_feedback_add: tool({
|
|
244
|
+
description: 'Add a feedback item to WORK.md under a file heading',
|
|
245
|
+
args: {
|
|
246
|
+
file: tool.schema.string().describe('Artefact file path'),
|
|
247
|
+
text: tool.schema.string().describe('Feedback text'),
|
|
248
|
+
tag: tool.schema.string().describe('Tag for the feedback item'),
|
|
249
|
+
},
|
|
250
|
+
async execute(args, context) {
|
|
251
|
+
const workPath = path.join(context.worktree, 'WORK.md');
|
|
252
|
+
const content = readFileSync(workPath, 'utf-8');
|
|
253
|
+
const updated = addFeedbackItem(content, args.file, args.text, args.tag);
|
|
254
|
+
writeFileSync(workPath, updated, 'utf-8');
|
|
255
|
+
return JSON.stringify({ ok: true });
|
|
256
|
+
},
|
|
257
|
+
}),
|
|
258
|
+
|
|
259
|
+
foundry_feedback_action: tool({
|
|
260
|
+
description: 'Mark a feedback item as actioned [x]',
|
|
261
|
+
args: {
|
|
262
|
+
file: tool.schema.string().describe('Artefact file path'),
|
|
263
|
+
index: tool.schema.number().describe('Zero-based index of the feedback item'),
|
|
264
|
+
},
|
|
265
|
+
async execute(args, context) {
|
|
266
|
+
const workPath = path.join(context.worktree, 'WORK.md');
|
|
267
|
+
const content = readFileSync(workPath, 'utf-8');
|
|
268
|
+
const updated = actionFeedbackItem(content, args.file, args.index);
|
|
269
|
+
writeFileSync(workPath, updated, 'utf-8');
|
|
270
|
+
return JSON.stringify({ ok: true });
|
|
271
|
+
},
|
|
272
|
+
}),
|
|
273
|
+
|
|
274
|
+
foundry_feedback_wontfix: tool({
|
|
275
|
+
description: 'Mark a feedback item as wont-fix [~] with reason',
|
|
276
|
+
args: {
|
|
277
|
+
file: tool.schema.string().describe('Artefact file path'),
|
|
278
|
+
index: tool.schema.number().describe('Zero-based index of the feedback item'),
|
|
279
|
+
reason: tool.schema.string().describe('Reason for wont-fix'),
|
|
280
|
+
},
|
|
281
|
+
async execute(args, context) {
|
|
282
|
+
const workPath = path.join(context.worktree, 'WORK.md');
|
|
283
|
+
const content = readFileSync(workPath, 'utf-8');
|
|
284
|
+
const updated = wontfixFeedbackItem(content, args.file, args.index, args.reason);
|
|
285
|
+
writeFileSync(workPath, updated, 'utf-8');
|
|
286
|
+
return JSON.stringify({ ok: true });
|
|
287
|
+
},
|
|
288
|
+
}),
|
|
289
|
+
|
|
290
|
+
foundry_feedback_resolve: tool({
|
|
291
|
+
description: 'Resolve a feedback item (approved or rejected)',
|
|
292
|
+
args: {
|
|
293
|
+
file: tool.schema.string().describe('Artefact file path'),
|
|
294
|
+
index: tool.schema.number().describe('Zero-based index of the feedback item'),
|
|
295
|
+
resolution: tool.schema.enum(['approved', 'rejected']).describe('Resolution type'),
|
|
296
|
+
reason: tool.schema.string().optional().describe('Reason (required if rejected)'),
|
|
297
|
+
},
|
|
298
|
+
async execute(args, context) {
|
|
299
|
+
const workPath = path.join(context.worktree, 'WORK.md');
|
|
300
|
+
const content = readFileSync(workPath, 'utf-8');
|
|
301
|
+
const updated = resolveFeedbackItem(content, args.file, args.index, args.resolution, args.reason);
|
|
302
|
+
writeFileSync(workPath, updated, 'utf-8');
|
|
303
|
+
return JSON.stringify({ ok: true });
|
|
304
|
+
},
|
|
305
|
+
}),
|
|
306
|
+
|
|
307
|
+
foundry_feedback_list: tool({
|
|
308
|
+
description: 'List feedback items, optionally filtered by file',
|
|
309
|
+
args: {
|
|
310
|
+
file: tool.schema.string().optional().describe('Filter by artefact file path'),
|
|
311
|
+
},
|
|
312
|
+
async execute(args, context) {
|
|
313
|
+
const workPath = path.join(context.worktree, 'WORK.md');
|
|
314
|
+
if (!existsSync(workPath)) {
|
|
315
|
+
return JSON.stringify({ error: 'WORK.md not found' });
|
|
316
|
+
}
|
|
317
|
+
const text = readFileSync(workPath, 'utf-8');
|
|
318
|
+
const fm = parseFrontmatter(text);
|
|
319
|
+
const artefacts = parseArtefactsTable(text);
|
|
320
|
+
const cycle = fm.cycle || '';
|
|
321
|
+
return JSON.stringify(listFeedback(text, cycle, artefacts, args.file));
|
|
322
|
+
},
|
|
323
|
+
}),
|
|
324
|
+
|
|
325
|
+
// ── Sort tool ──
|
|
326
|
+
foundry_sort: tool({
|
|
327
|
+
description: 'Run sort routing to determine the next stage',
|
|
328
|
+
args: {
|
|
329
|
+
cycleDef: tool.schema.string().optional().describe('Path to cycle definition file'),
|
|
330
|
+
},
|
|
331
|
+
async execute(args, context) {
|
|
332
|
+
const io = makeIO(context.worktree);
|
|
333
|
+
const result = runSort({ cycleDef: args.cycleDef }, io);
|
|
334
|
+
return JSON.stringify(result);
|
|
335
|
+
},
|
|
336
|
+
}),
|
|
337
|
+
|
|
338
|
+
// ── Git tools ──
|
|
339
|
+
foundry_git_branch: tool({
|
|
340
|
+
description: 'Create and checkout a work branch for a flow',
|
|
341
|
+
args: {
|
|
342
|
+
flowId: tool.schema.string().describe('Flow ID'),
|
|
343
|
+
description: tool.schema.string().describe('Branch description suffix'),
|
|
344
|
+
},
|
|
345
|
+
async execute(args, context) {
|
|
346
|
+
const branch = `work/${args.flowId}-${args.description}`;
|
|
347
|
+
execSync(`git checkout -b ${branch}`, { cwd: context.worktree, encoding: 'utf8' });
|
|
348
|
+
return JSON.stringify({ ok: true, branch });
|
|
349
|
+
},
|
|
350
|
+
}),
|
|
351
|
+
|
|
352
|
+
foundry_git_commit: tool({
|
|
353
|
+
description: 'Stage all changes and commit with a cycle-prefixed message',
|
|
354
|
+
args: {
|
|
355
|
+
cycle: tool.schema.string().describe('Cycle name'),
|
|
356
|
+
stage: tool.schema.string().describe('Stage name'),
|
|
357
|
+
description: tool.schema.string().describe('Commit description'),
|
|
358
|
+
},
|
|
359
|
+
async execute(args, context) {
|
|
360
|
+
execSync('git add .', { cwd: context.worktree, encoding: 'utf8' });
|
|
361
|
+
const msg = `[${args.cycle}] ${args.stage}: ${args.description}`;
|
|
362
|
+
execSync(`git commit -m "${msg.replace(/"/g, '\\"')}"`, { cwd: context.worktree, encoding: 'utf8' });
|
|
363
|
+
const hash = execSync('git rev-parse --short HEAD', { cwd: context.worktree, encoding: 'utf8' }).trim();
|
|
364
|
+
return JSON.stringify({ ok: true, hash });
|
|
365
|
+
},
|
|
366
|
+
}),
|
|
367
|
+
|
|
368
|
+
// ── Config tools ──
|
|
369
|
+
foundry_config_cycle: tool({
|
|
370
|
+
description: 'Get a cycle definition from foundry config',
|
|
371
|
+
args: {
|
|
372
|
+
cycleId: tool.schema.string().describe('Cycle ID'),
|
|
373
|
+
},
|
|
374
|
+
async execute(args, context) {
|
|
375
|
+
const io = makeIO(context.worktree);
|
|
376
|
+
const result = await getCycleDefinition('foundry', args.cycleId, io);
|
|
377
|
+
return JSON.stringify(result);
|
|
378
|
+
},
|
|
379
|
+
}),
|
|
380
|
+
|
|
381
|
+
foundry_config_artefact_type: tool({
|
|
382
|
+
description: 'Get an artefact type definition',
|
|
383
|
+
args: {
|
|
384
|
+
typeId: tool.schema.string().describe('Artefact type ID'),
|
|
385
|
+
},
|
|
386
|
+
async execute(args, context) {
|
|
387
|
+
const io = makeIO(context.worktree);
|
|
388
|
+
const result = await getArtefactType('foundry', args.typeId, io);
|
|
389
|
+
return JSON.stringify(result);
|
|
390
|
+
},
|
|
391
|
+
}),
|
|
392
|
+
|
|
393
|
+
foundry_config_laws: tool({
|
|
394
|
+
description: 'Get laws, optionally filtered by artefact type',
|
|
395
|
+
args: {
|
|
396
|
+
typeId: tool.schema.string().optional().describe('Artefact type ID'),
|
|
397
|
+
},
|
|
398
|
+
async execute(args, context) {
|
|
399
|
+
const io = makeIO(context.worktree);
|
|
400
|
+
const result = args.typeId
|
|
401
|
+
? await getLaws('foundry', args.typeId, io)
|
|
402
|
+
: await getLaws('foundry', io);
|
|
403
|
+
return JSON.stringify(result);
|
|
404
|
+
},
|
|
405
|
+
}),
|
|
406
|
+
|
|
407
|
+
foundry_config_validation: tool({
|
|
408
|
+
description: 'Get validation commands for an artefact type',
|
|
409
|
+
args: {
|
|
410
|
+
typeId: tool.schema.string().describe('Artefact type ID'),
|
|
411
|
+
},
|
|
412
|
+
async execute(args, context) {
|
|
413
|
+
const io = makeIO(context.worktree);
|
|
414
|
+
const result = await getValidation('foundry', args.typeId, io);
|
|
415
|
+
return JSON.stringify(result);
|
|
416
|
+
},
|
|
417
|
+
}),
|
|
418
|
+
|
|
419
|
+
foundry_config_appraisers: tool({
|
|
420
|
+
description: 'List all appraisers',
|
|
421
|
+
args: {},
|
|
422
|
+
async execute(_args, context) {
|
|
423
|
+
const io = makeIO(context.worktree);
|
|
424
|
+
const result = await getAppraisers('foundry', io);
|
|
425
|
+
return JSON.stringify(result);
|
|
426
|
+
},
|
|
427
|
+
}),
|
|
428
|
+
|
|
429
|
+
foundry_config_flow: tool({
|
|
430
|
+
description: 'Get a flow definition',
|
|
431
|
+
args: {
|
|
432
|
+
flowId: tool.schema.string().describe('Flow ID'),
|
|
433
|
+
},
|
|
434
|
+
async execute(args, context) {
|
|
435
|
+
const io = makeIO(context.worktree);
|
|
436
|
+
const result = await getFlow('foundry', args.flowId, io);
|
|
437
|
+
return JSON.stringify(result);
|
|
438
|
+
},
|
|
439
|
+
}),
|
|
440
|
+
|
|
441
|
+
// ── Validate tool ──
|
|
442
|
+
foundry_validate_run: tool({
|
|
443
|
+
description: 'Run validation commands for an artefact type against a file',
|
|
444
|
+
args: {
|
|
445
|
+
typeId: tool.schema.string().describe('Artefact type ID'),
|
|
446
|
+
file: tool.schema.string().describe('File path to validate'),
|
|
447
|
+
},
|
|
448
|
+
async execute(args, context) {
|
|
449
|
+
const io = makeIO(context.worktree);
|
|
450
|
+
const commands = await getValidation('foundry', args.typeId, io);
|
|
451
|
+
if (!commands) return JSON.stringify({ error: 'No validation defined for type: ' + args.typeId });
|
|
452
|
+
const results = [];
|
|
453
|
+
for (const cmd of commands) {
|
|
454
|
+
const expanded = cmd.replace(/\{file\}/g, args.file);
|
|
455
|
+
try {
|
|
456
|
+
const output = execSync(expanded, { cwd: context.worktree, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
457
|
+
results.push({ command: expanded, passed: true, output: output.trim() });
|
|
458
|
+
} catch (err) {
|
|
459
|
+
results.push({ command: expanded, passed: false, output: (err.stderr || err.stdout || err.message || '').trim() });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return JSON.stringify(results);
|
|
463
|
+
},
|
|
464
|
+
}),
|
|
465
|
+
|
|
466
|
+
// ── Appraiser selection tool ──
|
|
467
|
+
foundry_appraisers_select: tool({
|
|
468
|
+
description: 'Select appraisers for an artefact type',
|
|
469
|
+
args: {
|
|
470
|
+
typeId: tool.schema.string().describe('Artefact type ID'),
|
|
471
|
+
count: tool.schema.number().optional().describe('Number of appraisers to select'),
|
|
472
|
+
},
|
|
473
|
+
async execute(args, context) {
|
|
474
|
+
const io = makeIO(context.worktree);
|
|
475
|
+
const result = args.count
|
|
476
|
+
? await selectAppraisers('foundry', args.typeId, args.count, io)
|
|
477
|
+
: await selectAppraisers('foundry', args.typeId, io);
|
|
478
|
+
return JSON.stringify(result);
|
|
479
|
+
},
|
|
480
|
+
}),
|
|
481
|
+
},
|
|
75
482
|
};
|
|
76
483
|
};
|
package/README.md
CHANGED
|
@@ -53,6 +53,23 @@ A **foundry flow** runs one or more **foundry cycles** in sequence. Each cycle p
|
|
|
53
53
|
|
|
54
54
|
All state lives in `WORK.md` on a dedicated work branch. Every stage micro-commits, and file modification enforcement ensures stages only touch what they're allowed to.
|
|
55
55
|
|
|
56
|
+
## Custom tools
|
|
57
|
+
|
|
58
|
+
The Foundry plugin exposes 25 custom tools that handle all deterministic pipeline operations. Skills call these tools instead of manipulating files directly — this eliminates LLM interpretation of file formats and ensures consistent state management.
|
|
59
|
+
|
|
60
|
+
| Category | Tools |
|
|
61
|
+
|----------|-------|
|
|
62
|
+
| **Workfile** | `foundry_workfile_create`, `foundry_workfile_get`, `foundry_workfile_set`, `foundry_workfile_delete` |
|
|
63
|
+
| **Artefacts** | `foundry_artefacts_add`, `foundry_artefacts_list`, `foundry_artefacts_set_status` |
|
|
64
|
+
| **Feedback** | `foundry_feedback_add`, `foundry_feedback_action`, `foundry_feedback_wontfix`, `foundry_feedback_resolve`, `foundry_feedback_list` |
|
|
65
|
+
| **History** | `foundry_history_append`, `foundry_history_list` |
|
|
66
|
+
| **Sort** | `foundry_sort` |
|
|
67
|
+
| **Config** | `foundry_config_cycle`, `foundry_config_artefact_type`, `foundry_config_laws`, `foundry_config_validation`, `foundry_config_appraisers`, `foundry_config_flow` |
|
|
68
|
+
| **Validation** | `foundry_validate_run`, `foundry_appraisers_select` |
|
|
69
|
+
| **Git** | `foundry_git_branch`, `foundry_git_commit` |
|
|
70
|
+
|
|
71
|
+
Tools are backed by shared library modules in `scripts/lib/` that use injectable I/O for testability. The sort routing engine (`scripts/sort.js`) exports `runSort()` for the sort tool.
|
|
72
|
+
|
|
56
73
|
## Core concepts
|
|
57
74
|
|
|
58
75
|
### Foundry Flows
|
|
@@ -170,7 +187,7 @@ All helper skills are interactive — they walk you through the process, check f
|
|
|
170
187
|
@really-knows-ai/foundry
|
|
171
188
|
├── .opencode/
|
|
172
189
|
│ └── plugins/
|
|
173
|
-
│ └── foundry.js # OpenCode plugin (
|
|
190
|
+
│ └── foundry.js # OpenCode plugin (skills + 25 custom tools)
|
|
174
191
|
├── skills/ # skill definitions (the pipeline)
|
|
175
192
|
│ ├── forge/
|
|
176
193
|
│ ├── quench/
|
|
@@ -185,7 +202,16 @@ All helper skills are interactive — they walk you through the process, check f
|
|
|
185
202
|
│ ├── add-flow/
|
|
186
203
|
│ ├── sort/
|
|
187
204
|
│ └── hitl/
|
|
188
|
-
├── scripts/ #
|
|
205
|
+
├── scripts/ # shared library and routing engine
|
|
206
|
+
│ ├── lib/
|
|
207
|
+
│ │ ├── workfile.js # WORK.md frontmatter parsing/writing
|
|
208
|
+
│ │ ├── artefacts.js # artefacts table operations
|
|
209
|
+
│ │ ├── history.js # WORK.history.yaml operations
|
|
210
|
+
│ │ ├── feedback.js # feedback lifecycle operations
|
|
211
|
+
│ │ ├── config.js # foundry/ config readers
|
|
212
|
+
│ │ └── tags.js # tag extraction
|
|
213
|
+
│ └── sort.js # deterministic routing engine (exports runSort)
|
|
214
|
+
├── tests/ # test suite (node:test)
|
|
189
215
|
├── docs/ # concept docs and specs
|
|
190
216
|
├── package.json
|
|
191
217
|
└── README.md
|
|
@@ -217,13 +243,13 @@ your-project/
|
|
|
217
243
|
|
|
218
244
|
Flow definitions, cycle definitions, artefact types, laws, appraiser personalities, skills — all markdown. Readable by humans, consumable by LLMs, versionable in git. No config files, no databases, no custom formats.
|
|
219
245
|
|
|
220
|
-
### Skills are the pipeline
|
|
246
|
+
### Skills are the pipeline, tools are the machinery
|
|
221
247
|
|
|
222
|
-
|
|
248
|
+
Composition happens via skills referencing other skills. The `flow` skill reads a flow definition and invokes the `cycle` skill. The `cycle` skill invokes `forge`, `quench`, and `appraise`. Skills handle creative and subjective work; deterministic operations (parsing, routing, state updates) are handled by custom tools backed by shared library code.
|
|
223
249
|
|
|
224
250
|
### WORK.md as shared state
|
|
225
251
|
|
|
226
|
-
All communication between stages goes through WORK.md. No stage passes output directly to another. This gives a complete audit trail, makes the process resumable, and means any stage can be re-run independently.
|
|
252
|
+
All communication between stages goes through WORK.md. No stage passes output directly to another — all reads and writes go through the `foundry_workfile_*`, `foundry_artefacts_*`, and `foundry_feedback_*` tools. This gives a complete audit trail, makes the process resumable, and means any stage can be re-run independently.
|
|
227
253
|
|
|
228
254
|
### Feedback as checklist items
|
|
229
255
|
|
package/docs/concepts.md
CHANGED
|
@@ -52,4 +52,8 @@ Human-in-the-loop checkpoint. A stage type that pauses the foundry cycle and req
|
|
|
52
52
|
|
|
53
53
|
## Micro commit
|
|
54
54
|
|
|
55
|
-
Every stage ends with a commit. This enables file modification enforcement — the
|
|
55
|
+
Every stage ends with a commit (via the `foundry_git_commit` tool). This enables file modification enforcement — the sort tool checks the git diff to ensure each stage only touched files it was allowed to.
|
|
56
|
+
|
|
57
|
+
## Custom tools
|
|
58
|
+
|
|
59
|
+
All deterministic pipeline operations are exposed as custom tools via the Foundry plugin. Skills call tools instead of manipulating files directly. The tools are backed by shared library modules in `scripts/lib/` with injectable I/O for testability. This separation ensures that file format parsing, state transitions, and routing logic are handled by tested code rather than LLM interpretation.
|
package/docs/work-spec.md
CHANGED
|
@@ -95,11 +95,11 @@ Grouped by artefact file path. Each item is a checklist entry with a tag indicat
|
|
|
95
95
|
|
|
96
96
|
| Section | Written by | Updated by |
|
|
97
97
|
|---------|-----------|------------|
|
|
98
|
-
| Frontmatter (`flow`) |
|
|
99
|
-
| Frontmatter (`cycle`, `stages`, `max-iterations`) |
|
|
100
|
-
| Goal |
|
|
101
|
-
| Artefacts | forge skill
|
|
102
|
-
| Feedback | quench
|
|
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) |
|
|
100
|
+
| Goal | `foundry_workfile_create` (flow skill) | nobody |
|
|
101
|
+
| Artefacts | `foundry_artefacts_add` (forge skill) | `foundry_artefacts_set_status` (cycle skill) |
|
|
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
|
|
105
105
|
|
|
@@ -149,12 +149,12 @@ A separate file (`WORK.history.yaml`) alongside WORK.md. Append-only log of ever
|
|
|
149
149
|
|
|
150
150
|
- Append-only — never edit or delete entries
|
|
151
151
|
- Every stage skill appends an entry when it completes
|
|
152
|
-
- The sort
|
|
152
|
+
- The sort tool reads this to determine what has happened in the current foundry cycle
|
|
153
153
|
- Iteration is derived from counting forge entries for the current foundry cycle
|
|
154
154
|
|
|
155
155
|
### Who writes
|
|
156
156
|
|
|
157
|
-
Every stage skill (forge, quench, appraise, hitl) appends an entry when it finishes.
|
|
157
|
+
Every stage skill (forge, quench, appraise, hitl) appends an entry when it finishes via the `foundry_history_append` tool.
|
|
158
158
|
|
|
159
159
|
## Example
|
|
160
160
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@really-knows-ai/foundry",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
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 tests
|
|
28
|
+
"test": "node --test tests/**/*.test.js"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"js-yaml": "^4.1.0",
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artefacts table utilities for WORK.md.
|
|
3
|
+
*
|
|
4
|
+
* Parses, adds rows to, and updates status in the markdown artefacts table.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Parse the artefacts markdown table from text.
|
|
9
|
+
* @param {string} text
|
|
10
|
+
* @returns {Array<{file: string, type: string, cycle: string, status: string}>}
|
|
11
|
+
*/
|
|
12
|
+
export function parseArtefactsTable(text) {
|
|
13
|
+
const artefacts = [];
|
|
14
|
+
let inTable = false;
|
|
15
|
+
|
|
16
|
+
for (const line of text.split('\n')) {
|
|
17
|
+
const stripped = line.trim();
|
|
18
|
+
|
|
19
|
+
if (stripped.startsWith('| File')) {
|
|
20
|
+
inTable = true;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (inTable && stripped.startsWith('|---')) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (inTable && stripped.startsWith('|')) {
|
|
27
|
+
const cols = stripped.split('|').slice(1, -1).map(c => c.trim());
|
|
28
|
+
if (cols.length >= 4) {
|
|
29
|
+
artefacts.push({
|
|
30
|
+
file: cols[0],
|
|
31
|
+
type: cols[1],
|
|
32
|
+
cycle: cols[2],
|
|
33
|
+
status: cols[3],
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
} else if (inTable) {
|
|
37
|
+
inTable = false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return artefacts;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Add a row to the artefacts table.
|
|
46
|
+
* @param {string} text - Full WORK.md text
|
|
47
|
+
* @param {{file: string, type: string, cycle: string, status: string}} row
|
|
48
|
+
* @returns {string} Updated text
|
|
49
|
+
*/
|
|
50
|
+
export function addArtefactRow(text, { file, type, cycle, status }) {
|
|
51
|
+
const lines = text.split('\n');
|
|
52
|
+
let lastTableRow = -1;
|
|
53
|
+
let inTable = false;
|
|
54
|
+
|
|
55
|
+
for (let i = 0; i < lines.length; i++) {
|
|
56
|
+
const stripped = lines[i].trim();
|
|
57
|
+
if (stripped.startsWith('| File')) {
|
|
58
|
+
inTable = true;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (inTable && stripped.startsWith('|---')) {
|
|
62
|
+
if (lastTableRow < 0) lastTableRow = i; // insert after separator if no data rows
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (inTable && stripped.startsWith('|')) {
|
|
66
|
+
lastTableRow = i;
|
|
67
|
+
} else if (inTable) {
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (lastTableRow === -1) {
|
|
73
|
+
throw new Error('Artefacts table not found');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const newRow = `| ${file} | ${type} | ${cycle} | ${status} |`;
|
|
77
|
+
lines.splice(lastTableRow + 1, 0, newRow);
|
|
78
|
+
return lines.join('\n');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Update the status column for a specific file in the artefacts table.
|
|
83
|
+
* @param {string} text - Full WORK.md text
|
|
84
|
+
* @param {string} file - File name to match
|
|
85
|
+
* @param {string} newStatus - New status value
|
|
86
|
+
* @returns {string} Updated text
|
|
87
|
+
*/
|
|
88
|
+
export function setArtefactStatus(text, file, newStatus) {
|
|
89
|
+
const lines = text.split('\n');
|
|
90
|
+
let inTable = false;
|
|
91
|
+
let found = false;
|
|
92
|
+
|
|
93
|
+
for (let i = 0; i < lines.length; i++) {
|
|
94
|
+
const stripped = lines[i].trim();
|
|
95
|
+
if (stripped.startsWith('| File')) {
|
|
96
|
+
inTable = true;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (inTable && stripped.startsWith('|---')) continue;
|
|
100
|
+
if (inTable && stripped.startsWith('|')) {
|
|
101
|
+
const cols = stripped.split('|').slice(1, -1).map(c => c.trim());
|
|
102
|
+
if (cols.length >= 4 && cols[0] === file) {
|
|
103
|
+
cols[3] = newStatus;
|
|
104
|
+
lines[i] = '| ' + cols.join(' | ') + ' |';
|
|
105
|
+
found = true;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
} else if (inTable) {
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!found) {
|
|
114
|
+
throw new Error(`File not found in artefacts table: ${file}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return lines.join('\n');
|
|
118
|
+
}
|