@really-knows-ai/foundry 3.5.3 → 3.5.5

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.
@@ -3,6 +3,7 @@
3
3
  import path from 'path';
4
4
  import { readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync, mkdirSync, renameSync, rmSync, statSync } from 'fs';
5
5
  import { execFileSync } from 'child_process';
6
+ import matter from 'gray-matter';
6
7
  import { getCycleDefinition } from '../../../scripts/lib/config.js';
7
8
  import { getOrOpenStore, getContext } from '../../../scripts/lib/memory/singleton.js';
8
9
  import { resolvePermissions } from '../../../scripts/lib/memory/permissions.js';
@@ -13,49 +14,27 @@ import { requireOnFlowBranch } from '../../../scripts/lib/branch-guard.js';
13
14
  // Track flow files we've already warned about to avoid spamming stderr
14
15
  const warnedFlowFiles = new Set();
15
16
 
16
- // -- Frontmatter parsing helpers --
17
-
18
- function isFieldSeparator(char) {
19
- return char === ' ' || char === '\t';
20
- }
21
-
22
- function parseFrontmatterField(fm, fieldName) {
23
- const prefix = `${fieldName}:`;
24
- for (const line of fm.split('\n')) {
25
- if (!line.startsWith(prefix)) continue;
26
- const rest = line.slice(prefix.length);
27
- if (rest === '' || isFieldSeparator(rest[0])) {
28
- return rest.trim();
29
- }
30
- }
31
- return null;
17
+ function parseFlowFrontmatter(text, entry) {
18
+ const fm = extractFrontmatter(text);
19
+ if (!fm) return null;
20
+ const id = fm.id || entry.replace(/\.md$/, '');
21
+ return {
22
+ id,
23
+ name: fm.name || id,
24
+ startingCycles: resolveStartingCycles(fm),
25
+ };
32
26
  }
33
27
 
34
- function parseStartingCycles(fm) {
35
- const lines = fm.split('\n');
36
- const scIndex = lines.findIndex(line => line.trimEnd() === 'starting-cycles:');
37
- if (scIndex < 0) return [];
38
- const items = [];
39
- for (let i = scIndex + 1; i < lines.length; i++) {
40
- const trimmed = lines[i].trimStart();
41
- if (trimmed.startsWith('-')) {
42
- const content = trimmed.slice(1).trimStart();
43
- if (content) items.push(content);
44
- } else {
45
- break;
46
- }
47
- }
48
- return items;
28
+ function extractFrontmatter(text) {
29
+ const parsed = matter(text);
30
+ return parsed.data && typeof parsed.data === 'object' && Object.keys(parsed.data).length > 0
31
+ ? parsed.data
32
+ : null;
49
33
  }
50
34
 
51
- function parseFlowFrontmatter(text, entry) {
52
- const fmMatch = text.match(/^---\n([\s\S]*?)\n---/);
53
- if (!fmMatch) return null;
54
- const fm = fmMatch[1];
55
- const id = parseFrontmatterField(fm, 'id') || entry.replace(/\.md$/, '');
56
- const name = parseFrontmatterField(fm, 'name') || id;
57
- const startingCycles = parseStartingCycles(fm);
58
- return { id, name, startingCycles };
35
+ function resolveStartingCycles(fm) {
36
+ const sc = fm['starting-cycles'];
37
+ return Array.isArray(sc) ? sc : [];
59
38
  }
60
39
 
61
40
  function parseFlowFile(entry, flowsDir) {
@@ -87,18 +87,28 @@ async function injectDispatchPromptExtras(result, cwd) {
87
87
  result.prompt = `${result.prompt}\n\n${extras}`;
88
88
  }
89
89
 
90
+ function buildOrchestrateArgs(tool) {
91
+ return {
92
+ lastResult: tool.schema.object({
93
+ ok: tool.schema.boolean(),
94
+ error: tool.schema.string().optional(),
95
+ }).optional().describe('Result of a single-subagent dispatch or human-appraise stage'),
96
+ lastResults: tool.schema.array(tool.schema.object({
97
+ ok: tool.schema.boolean(),
98
+ output: tool.schema.string().optional(),
99
+ error: tool.schema.string().optional(),
100
+ })).optional().describe('Results of a dispatch_multi (appraise) — one entry per completed appraiser task'),
101
+ cycleDef: tool.schema.string().optional().describe('Test-mode cycle definition override (path to cycle file)'),
102
+ baseBranch: tool.schema.string().optional().describe('Git base branch for artefact diff comparison (default "main")'),
103
+ defaultModel: tool.schema.string().optional().describe('Fallback model for stages with no explicit model in the cycle definition (e.g. "opencode-go/deepseek-v4-flash")'),
104
+ };
105
+ }
106
+
90
107
  export function createOrchestrateTool({ tool, pending }) {
91
108
  return {
92
109
  foundry_orchestrate: tool({
93
- description: 'Run the next step of the current cycle. Call with no args on first invocation; call with lastResult={ok,error?} after a dispatch/human_appraise completes. Returns {action, ...} describing what the caller should do next.',
94
- args: {
95
- lastResult: tool.schema.object({
96
- ok: tool.schema.boolean(),
97
- error: tool.schema.string().optional(),
98
- }).optional(),
99
- cycleDef: tool.schema.string().optional().describe('Test-mode cycle definition override (path to cycle file)'),
100
- defaultModel: tool.schema.string().optional().describe('Fallback model for stages with no explicit model in the cycle definition (e.g. "opencode-go/deepseek-v4-flash")'),
101
- },
110
+ description: 'Run the next step of the current cycle. Call with no args on first invocation. After a dispatch or human_appraise, pass lastResult={ok,error?}. After a dispatch_multi (appraise), pass lastResults as an array of {ok,output?,error?} — one entry per completed task. Returns {action, ...} describing what the caller should do next.',
111
+ args: buildOrchestrateArgs(tool),
102
112
 
103
113
  async execute(args, context) {
104
114
  const { runOrchestrate } = await import('../../../scripts/orchestrate.js');
@@ -107,21 +117,9 @@ export function createOrchestrateTool({ tool, pending }) {
107
117
  const secret = readOrCreateSecret(context.worktree);
108
118
 
109
119
  try {
110
- // Branch guard. Kept inline because the orchestrate tool surfaces all errors through its violation
111
- // envelope (see comment on the failed-flow guard below). A
112
- // wrong-branch refusal is a more fundamental error than failed
113
- // flow, so it runs first.
114
120
  const branchGuard = requireOnFlowBranch({ exec: makeExec(cwd) });
115
121
  if (!branchGuard.ok) return JSON.stringify({ error: `foundry_orchestrate: ${branchGuard.error}` });
116
122
 
117
- // Failed-flow guard. Kept inline to preserve the violation envelope.
118
- // because requireNotFailed parses WORK.md frontmatter, which throws
119
- // on malformed YAML. The surrounding try/catch (line 30) converts
120
- // that throw into a violation-shaped envelope per the contract
121
- // exercised by tests/plugin/orchestrate-wrapper.test.js. A guarded()
122
- // wrapper would let the throw escape to a plain { error } envelope
123
- // and break that contract. orchestrate-tool is the one Phase 1.5
124
- // exception to the inline-gate refactor.
125
123
  const failedGuard = requireNotFailed(io);
126
124
  if (!failedGuard.ok) return JSON.stringify({ error: `foundry_orchestrate: ${failedGuard.error}` });
127
125
 
@@ -132,7 +130,9 @@ export function createOrchestrateTool({ tool, pending }) {
132
130
  const result = await runOrchestrate({
133
131
  cwd, cycleDef: args.cycleDef, git, mint, finalize,
134
132
  now: () => Date.now(),
135
- lastResult: args.lastResult ?? null,
133
+ lastResult: args.lastResult,
134
+ lastResults: args.lastResults,
135
+ baseBranch: args.baseBranch,
136
136
  defaultModel: args.defaultModel,
137
137
  }, io);
138
138
 
package/dist/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.5.5] - 2026-05-23
4
+
5
+ ### Fixed
6
+
7
+ - `buildAppraiserPrompt` output format aligned with the YAML parser — indented continuation fields instead of dash-prefixed. Previously, an appraiser reporting an issue with the exact prompt format would have every field split into separate entries and silently discarded.
8
+ - Hand-rolled appraiser output parsing (120 lines) replaced with `js-yaml` plus a line-scanning fallback for non-clean LLM output.
9
+
10
+ ### Changed
11
+
12
+ - All hand-rolled YAML frontmatter regex extraction (5 patterns across 10 sites in 8 files) replaced with `gray-matter`.
13
+ - Flow frontmatter parsing in `helpers.js` switched from line-scanning to `gray-matter`.
14
+
15
+ ## [3.5.4] - 2026-05-23
16
+
17
+ ### Fixed
18
+
19
+ - `lastResults` was handled by the orchestration engine but invisible to the tool interface — the schema, description, and execute bridge all omitted it, making the appraise consolidation path unreachable through `foundry_orchestrate`.
20
+ - `baseBranch` was computed by the engine but dropped at the tool boundary.
21
+
22
+ ### Changed
23
+
24
+ - Clarified the commit model in the add-flow skill so agents do not re-check whether config file edits were committed.
25
+
3
26
  ## [3.5.3] - 2026-05-23
4
27
 
5
28
  ### Added
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { getArtefactFiles } from './lib/artefacts.js';
15
15
  import { selectAppraisers, getLaws, getCycleDefinition } from './lib/config.js';
16
+ import yaml from 'js-yaml';
16
17
 
17
18
  // ---------------------------------------------------------------------------
18
19
  // Public API — gather
@@ -296,9 +297,9 @@ function buildAppraiserPrompt({ appraiser, artefact, laws }) {
296
297
  '',
297
298
  'Return a list of issues. For each issue:',
298
299
  `- file: ${artefact.file}`,
299
- '- law: <law-id>',
300
- '- issue: <description>',
301
- '- evidence: <quote from artefact>',
300
+ ' law: <law-id>',
301
+ ' issue: <description>',
302
+ ' evidence: <quote from artefact>',
302
303
  '',
303
304
  'If there are no issues, return an empty list.',
304
305
  ];
@@ -313,127 +314,87 @@ function buildAppraiserPrompt({ appraiser, artefact, laws }) {
313
314
  /**
314
315
  * Parse a structured issue list from an appraiser subagent output.
315
316
  *
316
- * Expected per-issue format (YAML list):
317
- * - file: <path>
318
- * law: <law-id>
319
- * issue: <description>
320
- * evidence: <quote>
317
+ * LLM output is free-form text that may contain a YAML list of issues.
318
+ * Tries js-yaml first; falls back to line-scanning when the output is
319
+ * not clean YAML (LLMs may include surrounding text, quotes in bare
320
+ * strings, or other quirks that trip up a strict YAML parser).
321
321
  *
322
- * Returns an array of { file, law, issue, evidence } objects. Entries that
323
- * lack a file, law, or issue field are silently skipped.
322
+ * Returns an array of { file, law, issue, evidence } objects.
324
323
  */
325
324
  function parseAppraiserOutput(output) {
326
- // Split output into entries on boundaries where a new line starts a
327
- // list entry ("- "). Avoids regex to prevent sonarjs/slow-regex.
328
- const entries = [];
329
- let buffer = '';
330
-
331
- for (const line of output.split('\n')) {
332
- if (buffer && isListEntryStart(line)) {
333
- entries.push(buffer);
334
- buffer = line;
335
- continue;
336
- }
337
-
338
- buffer = concatLine(buffer, line);
339
- }
340
-
341
- if (buffer) entries.push(buffer);
325
+ const text = output || '';
326
+ const yamlBlock = extractYamlBlock(text);
327
+ const issues = tryYamlParse(yamlBlock);
328
+ if (issues) return issues;
342
329
 
343
- return entries
344
- .map(parseRawEntry)
345
- .filter(e => e !== null);
330
+ return parseFallback(text);
346
331
  }
347
332
 
348
- /**
349
- * Append a line to the current buffer string.
350
- */
351
- function concatLine(buffer, line) {
352
- if (!buffer) return line;
353
- return `${buffer}\n${line}`;
333
+ function extractYamlBlock(text) {
334
+ if (text.startsWith('- file:')) return text;
335
+ const afterNewline = text.indexOf('\n- file:');
336
+ if (afterNewline >= 0) return text.slice(afterNewline + 1);
337
+ return text;
354
338
  }
355
339
 
356
- /**
357
- * True when a line marks the start of a new YAML list entry (starts with
358
- * "- " after optional whitespace).
359
- */
360
- function isListEntryStart(line) {
361
- for (let i = 0; i < line.length; i++) {
362
- const ch = line[i];
363
- if (ch === ' ' || ch === '\t') continue;
364
- return ch === '-' && line[i + 1] === ' ';
365
- }
366
- return false;
340
+ function tryYamlParse(yamlBlock) {
341
+ try {
342
+ const parsed = yaml.load(yamlBlock);
343
+ if (Array.isArray(parsed)) {
344
+ return parsed
345
+ .filter(e => e && typeof e === 'object' && e.file && e.law && e.issue)
346
+ .map(e => ({ file: e.file, law: e.law, issue: e.issue, evidence: e.evidence || '' }));
347
+ }
348
+ } catch { /* fall through to fallback */ }
349
+ return null;
367
350
  }
368
351
 
369
- /**
370
- * Parse a single raw entry block into an issue object, or null when
371
- * required fields are missing.
372
- */
373
- function parseRawEntry(raw) {
374
- const block = raw.trim();
375
-
376
- const file = extractField(block, 'file');
377
- const law = extractField(block, 'law');
378
- const issue = extractField(block, 'issue');
379
- const evidence = extractField(block, 'evidence');
352
+ const FALLBACK_FIELDS = new Set(['law', 'issue', 'evidence']);
380
353
 
381
- if (!file || !law || !issue) return null;
354
+ function isCompleteIssue(obj) {
355
+ return obj && obj.file && obj.law && obj.issue;
356
+ }
382
357
 
383
- return { file, law, issue, evidence: evidence || '' };
358
+ function applyFallbackField(kv, entry, issues) {
359
+ if (kv.key === 'file') {
360
+ const e = { file: kv.value, law: '', issue: '', evidence: '' };
361
+ issues.push(e);
362
+ return e;
363
+ }
364
+ if (entry && FALLBACK_FIELDS.has(kv.key)) {
365
+ entry[kv.key] = kv.value;
366
+ }
367
+ return entry;
384
368
  }
385
369
 
386
- /**
387
- * Extract a YAML list item field value.
388
- *
389
- * Matches lines like:
390
- * - file: value
391
- * law: value
392
- *
393
- * The field name may be preceded by optional whitespace and/or a "- " list
394
- * marker. Returns the value portion, trimmed.
395
- */
396
- function extractField(text, key) {
397
- // Walk lines to find "key: value" preceded only by whitespace or a "- "
398
- // list marker. Avoids regex quantifiers that trigger sonarjs/slow-regex.
370
+ function parseFallback(text) {
371
+ const issues = [];
372
+ let entry = null;
399
373
 
400
374
  for (const line of text.split('\n')) {
401
- const trimmed = line.trim();
402
- const value = tryExtractKey(trimmed, key);
403
- if (value !== null) return value;
375
+ const kv = parseFallbackLine(line);
376
+ if (kv) entry = applyFallbackField(kv, entry, issues);
404
377
  }
405
378
 
406
- return null;
379
+ return issues.filter(isCompleteIssue);
407
380
  }
408
381
 
409
- /**
410
- * Given a single trimmed line, try to extract the value for key.
411
- * Returns null if the pattern is not found.
412
- */
413
- function tryExtractKey(line, key) {
414
- const needle = `${key}:`;
415
- const idx = line.indexOf(needle);
416
- if (idx < 0) return null;
382
+ function parseFallbackLine(line) {
383
+ const trimmed = line.trim();
384
+ if (!trimmed) return null;
417
385
 
418
- const before = line.slice(0, idx);
419
- if (before.length > 0 && !isLegalPrefix(before)) return null;
386
+ const colon = trimmed.indexOf(':');
387
+ if (colon < 1) return null;
420
388
 
421
- const value = line.slice(idx + needle.length).trim();
422
- return value || null;
389
+ const key = stripDash(trimmed.slice(0, colon));
390
+ return {
391
+ key: key.trim(),
392
+ value: trimmed.slice(colon + 1).trim(),
393
+ };
423
394
  }
424
395
 
425
- /**
426
- * True when the text before a key: on a line is either all whitespace or
427
- * the "- " list marker.
428
- */
429
- function isLegalPrefix(before) {
430
- if (before === '- ') return true;
431
-
432
- for (let i = 0; i < before.length; i++) {
433
- if (before[i] !== ' ' && before[i] !== '\t') return false;
434
- }
435
-
436
- return true;
396
+ function stripDash(s) {
397
+ return s.startsWith('- ') ? s.slice(2) : s;
437
398
  }
438
399
 
439
400
  // ---------------------------------------------------------------------------
@@ -1,4 +1,5 @@
1
1
  import { parseFrontmatter } from '../workfile.js';
2
+ import matter from 'gray-matter';
2
3
 
3
4
  /**
4
5
  * Shared helpers for config validators.
@@ -10,7 +11,8 @@ import { parseFrontmatter } from '../workfile.js';
10
11
  * @returns {{ok: true, fm: object} | {ok: false, errors: string[]}}
11
12
  */
12
13
  export function tryParseFrontmatter(body) {
13
- if (!/^---\n[\s\S]*?\n---/.test(body)) {
14
+ const { data } = matter(body);
15
+ if (!data || typeof data !== 'object' || Object.keys(data).length === 0) {
14
16
  return { ok: false, errors: ['frontmatter is missing or unparseable'] };
15
17
  }
16
18
  try {
@@ -65,7 +67,7 @@ export function validateNameMatch(fm, name) {
65
67
  * @returns {string}
66
68
  */
67
69
  export function bodyAfterFrontmatter(body) {
68
- return body.replace(/^---\n[\s\S]*?\n---\n?/, '').trim();
70
+ return matter(body).content.trim();
69
71
  }
70
72
 
71
73
  /**
@@ -4,10 +4,11 @@
4
4
 
5
5
  import { join } from 'path';
6
6
  import { parseFrontmatter } from './workfile.js';
7
+ import matter from 'gray-matter';
7
8
 
8
9
  function parseDoc(text) {
9
10
  const frontmatter = parseFrontmatter(text);
10
- const body = text.replace(/^---\n.+?\n---\n?/s, '').trim();
11
+ const body = matter(text).content.trim();
11
12
  return { frontmatter, body };
12
13
  }
13
14
 
@@ -28,6 +28,7 @@
28
28
  * is clean.
29
29
  */
30
30
  import { parseFrontmatter, setFrontmatterField, writeFrontmatter } from './workfile.js';
31
+ import matter from 'gray-matter';
31
32
 
32
33
  const MAX_REASON_LEN = 500;
33
34
 
@@ -126,6 +127,6 @@ export function clearWorkfileFailed(io) {
126
127
 
127
128
  // Rebuild the file with cleaned frontmatter
128
129
  const fmBlock = writeFrontmatter(fm);
129
- const body = text.replace(/^---\n.+?\n---\n?/s, '');
130
+ const body = matter(text).content;
130
131
  io.writeFile('WORK.md', body ? `${fmBlock}\n${body}` : fmBlock);
131
132
  }
@@ -5,10 +5,10 @@ import { parseEdgeRows, serialiseEdgeRows, parseEntityRows, serialiseEntityRows
5
5
  import { invalidateStore } from '../singleton.js';
6
6
  import { parseFrontmatter } from '../frontmatter.js';
7
7
  import { renderEdgeFrontmatter, composeMarkdown } from './helpers.js';
8
+ import matter from 'gray-matter';
9
+ import yaml from 'js-yaml';
8
10
 
9
11
  const IDENT = /^[a-z][a-z0-9_]*$/;
10
- const TYPE_LINE = /^type:\s*\S.*$/m;
11
- const FRONTMATTER_BLOCK = /^---\r?\n([\s\S]*?)\r?\n---/;
12
12
 
13
13
  function assertValidIdentifier(id) {
14
14
  if (!IDENT.test(id)) throw new Error(`invalid identifier: '${id}'`);
@@ -37,10 +37,10 @@ function validateRename(schema, from, to) {
37
37
  async function rewriteEntityTypeFile(from, to, p, io) {
38
38
  const oldFile = p.entityTypeFile(from);
39
39
  const text = await io.readFile(oldFile);
40
- const newText = text.replace(FRONTMATTER_BLOCK, (_, fm) => {
41
- const replaced = fm.replace(TYPE_LINE, `type: ${to}`);
42
- return `---\n${replaced}\n---`;
43
- });
40
+ const { data, content } = matter(text);
41
+ const fm = { ...data, type: to };
42
+ const fmBlock = `---\n${yaml.dump(fm, { lineWidth: -1, sortKeys: false }).trim()}\n---`;
43
+ const newText = content ? `${fmBlock}\n${content}` : fmBlock;
44
44
  await io.writeFile(p.entityTypeFile(to), newText);
45
45
  await io.unlink(oldFile);
46
46
  }
@@ -1,31 +1,9 @@
1
+ import matter from 'gray-matter';
1
2
  import yaml from 'js-yaml';
2
3
 
3
- /**
4
- * Safely parse a YAML string, rethrowing with a filename-prefixed message
5
- * on failure so errors are actionable.
6
- *
7
- * @param {string} yamlStr
8
- * @param {string} filename
9
- * @returns {unknown}
10
- */
11
- function safeYamlLoad(yamlStr, filename) {
12
- try {
13
- return yaml.load(yamlStr);
14
- } catch (err) {
15
- const msg = err?.message ?? String(err);
16
- throw new Error(`${filename}: malformed YAML frontmatter: ${msg}`, { cause: err });
17
- }
18
- }
19
-
20
- /**
21
- * Normalise a parsed YAML value into a frontmatter object.
22
- *
23
- * @param {unknown} parsed
24
- * @returns {object}
25
- */
26
4
  function normaliseFrontmatter(parsed) {
27
5
  if (parsed && typeof parsed === 'object') {
28
- return /** @type {object} */ (parsed);
6
+ return { ...parsed };
29
7
  }
30
8
  return {};
31
9
  }
@@ -49,19 +27,39 @@ function normaliseFrontmatter(parsed) {
49
27
  * @param {{ filename?: string }} [opts]
50
28
  * @returns {{ frontmatter: object, body: string, hasFrontmatter: boolean }}
51
29
  */
52
- export function parseFrontmatter(text, { filename = '<unknown>' } = {}) {
53
- const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
54
- if (!m) {
30
+ function hasData(d) {
31
+ return d && (typeof d === 'object' ? Object.keys(d).length > 0 : true);
32
+ }
33
+
34
+ export function parseFrontmatter(text, { filename } = {}) {
35
+ const result = tryMatter(text, filename);
36
+ if (!result) {
37
+ return { frontmatter: {}, body: text, hasFrontmatter: false };
38
+ }
39
+
40
+ if (!hasData(result.data)) {
55
41
  return { frontmatter: {}, body: text, hasFrontmatter: false };
56
42
  }
57
- const parsed = safeYamlLoad(m[1], filename);
43
+
58
44
  return {
59
- frontmatter: normaliseFrontmatter(parsed),
60
- body: m[2] ?? '',
45
+ frontmatter: normaliseFrontmatter(result.data),
46
+ body: result.content,
61
47
  hasFrontmatter: true,
62
48
  };
63
49
  }
64
50
 
51
+ function tryMatter(text, filename) {
52
+ try {
53
+ return matter(text);
54
+ } catch (err) {
55
+ if (filename) {
56
+ const msg = err?.message ?? String(err);
57
+ console.warn(`${filename}: malformed YAML frontmatter: ${msg}`);
58
+ }
59
+ return null;
60
+ }
61
+ }
62
+
65
63
  /**
66
64
  * Render a markdown document from a frontmatter object and a body string.
67
65
  * Uses `yaml.dump` — callers that need a specific key order (e.g. edge type
@@ -3,23 +3,15 @@
3
3
  */
4
4
 
5
5
  import yaml from 'js-yaml';
6
+ import matter from 'gray-matter';
6
7
 
7
8
  // ---------------------------------------------------------------------------
8
9
  // Frontmatter parsing
9
10
  // ---------------------------------------------------------------------------
10
11
 
11
- /**
12
- * Parse YAML frontmatter from a markdown document.
13
- * NOTE: Intentionally duplicates logic from memory/frontmatter.js for
14
- * different use cases. See memory/frontmatter.js for the canonical version
15
- * with full error handling and line-ending normalisation.
16
- */
17
12
  export function parseFrontmatter(text) {
18
- const match = text.match(/^---\r?\n(.+?)\r?\n---/s);
19
- if (!match) return {};
20
- const fm = yaml.load(match[1]) || {};
21
- // Normalize: on-disk canonical key is `max-iterations` (kebab).
22
- // Tolerate legacy `maxIterations` (camel) by rewriting on read.
13
+ const { data } = matter(text);
14
+ const fm = { ...data };
23
15
  if (fm.maxIterations !== undefined) {
24
16
  if (fm['max-iterations'] === undefined) {
25
17
  fm['max-iterations'] = fm.maxIterations;
@@ -53,7 +45,7 @@ export function setFrontmatterField(text, key, value) {
53
45
  const fmBlock = writeFrontmatter(fm);
54
46
 
55
47
  // Strip existing frontmatter (if any) and prepend new one
56
- const body = text.replace(/^---\r?\n.+?\r?\n---\r?\n?/s, '');
48
+ const body = matter(text).content;
57
49
  return body ? `${fmBlock}\n${body}` : fmBlock;
58
50
  }
59
51
 
@@ -7,6 +7,7 @@ import {
7
7
  getLawsForQuench,
8
8
  } from './lib/config.js';
9
9
  import { parseFrontmatter, writeFrontmatter } from './lib/workfile.js';
10
+ import matter from 'gray-matter';
10
11
  import { clearActiveStage, clearLastStage } from './lib/state.js';
11
12
  import { appendEntry, getIteration } from './lib/history.js';
12
13
  import { stageBaseOf } from './lib/stage-guard.js';
@@ -171,7 +172,7 @@ function buildNewFrontmatter(workContent, stages, cfm, assayExtractors) {
171
172
  const newFm = { ...fm };
172
173
  newFm.stages = stages;
173
174
  applyFmDefaults(newFm, cfm, assayExtractors);
174
- const body = workContent.replace(/^---\n[\s\S]+?\n---\n?/, '');
175
+ const body = matter(workContent).content;
175
176
  const fmBlock = writeFrontmatter(newFm);
176
177
  return body ? `${fmBlock}\n${body}` : fmBlock;
177
178
  }
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { runSort } from './sort.js';
6
6
  import { parseFrontmatter } from './lib/workfile.js';
7
+ import matter from 'gray-matter';
7
8
  import { readActiveStage, readLastStage, writeActiveStage, clearActiveStage } from './lib/state.js';
8
9
  import { stageBaseOf } from './lib/stage-guard.js';
9
10
  import { ulid as defaultUlid } from './lib/ulid.js';
@@ -38,10 +39,8 @@ export { readCycleTargets, readForgeFilePatterns };
38
39
  export { handleSortResult as __handleSortResultForTest };
39
40
 
40
41
  export function needsSetup(workMdContent) {
41
- const match = workMdContent.match(/^---\n([\s\S]*?)\n---/);
42
- if (!match) return true;
43
- const fm = match[1];
44
- return !/^stages:/m.test(fm);
42
+ const { data } = matter(workMdContent);
43
+ return !data || !data.stages;
45
44
  }
46
45
 
47
46
  // ---------------------------------------------------------------------------
@@ -103,6 +103,8 @@ If the user rejects the plan, return to the Understand phase and adjust.
103
103
 
104
104
  For each dependency, invoke the sub-skill's protocol with the captured context object. The context object for each sub-skill matches the args of the corresponding `foundry_config_create_*` tool, with fields populated from the Understand and Gather phases.
105
105
 
106
+ Each `foundry_config_create_*` tool commits every pending change under `foundry/`, not just the file it creates. If you edit a config file directly between tool calls (for example, to add appraiser configuration to an artefact type after those appraisers are created), the next `foundry_config_create_*` call picks it up. After the final tool call `git status` is always clean — no further checks are needed.
107
+
106
108
  Build order (dependency order):
107
109
 
108
110
  1. **Artefact types**: For each new artefact type, invoke the `add-artefact-type` protocol with the captured context. Example:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@really-knows-ai/foundry",
3
- "version": "3.5.3",
3
+ "version": "3.5.5",
4
4
  "description": "A skill-driven framework for governed artefact generation with AI coding tools. Define your own artefact types, laws, and flows — Foundry handles the forge → quench → appraise pipeline with deterministic routing, quality gates, and iterative refinement.",
5
5
  "type": "module",
6
6
  "main": "dist/.opencode/plugins/foundry.js",
@@ -26,6 +26,7 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "@opencode-ai/plugin": "^1.4.0",
29
+ "gray-matter": "^4.0.3",
29
30
  "js-yaml": "^4.1.0",
30
31
  "minimatch": "^10.2.5"
31
32
  },