@really-knows-ai/foundry 1.2.1 → 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.
@@ -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 (registers skills)
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/ # validation support 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
- No separate runner script. 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`. This keeps everything in one format.
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 foundry cycle checks the git diff to ensure each stage only touched files it was allowed to.
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`) | foundry flow skill | nobody |
99
- | Frontmatter (`cycle`, `stages`, `max-iterations`) | foundry cycle skill | foundry cycle skill (reset on each new cycle) |
100
- | Goal | foundry flow skill | nobody |
101
- | Artefacts | forge skill (registers new) | foundry cycle skill (status changes) |
102
- | Feedback | quench skill, appraise skill, hitl skill | forge skill (actioned/wont-fix), quench/appraise/hitl skill (approved/rejected) |
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 script reads this to determine what has happened in the current foundry cycle
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.2.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/sort.test.js"
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
+ }