@really-knows-ai/foundry 3.5.4 → 3.5.6

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) {
package/dist/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.5.6] - 2026-05-23
4
+
5
+ ### Fixed
6
+
7
+ - `deadlock-iterations` default changed from hardcoded 5 to the resolved `max-iterations` value, and defaults are clamped so deadlock is never unreachable. Deadlock validation rejects cycles where `deadlock-iterations > max-iterations` at setup time and cycle creation time.
8
+
9
+ ### Added
10
+
11
+ - Orchestrator routing responses now include a `reason` field explaining why sort chose the returned action (e.g. "found 1 unresolved feedback item(s) — routing to forge for revision (iteration 2 of 3)").
12
+
13
+ ## [3.5.5] - 2026-05-23
14
+
15
+ ### Fixed
16
+
17
+ - `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.
18
+ - Hand-rolled appraiser output parsing (120 lines) replaced with `js-yaml` plus a line-scanning fallback for non-clean LLM output.
19
+
20
+ ### Changed
21
+
22
+ - All hand-rolled YAML frontmatter regex extraction (5 patterns across 10 sites in 8 files) replaced with `gray-matter`.
23
+ - Flow frontmatter parsing in `helpers.js` switched from line-scanning to `gray-matter`.
24
+
3
25
  ## [3.5.4] - 2026-05-23
4
26
 
5
27
  ### Fixed
@@ -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
  // ---------------------------------------------------------------------------
@@ -29,6 +29,7 @@ export async function validate({ name, body, io }) {
29
29
  await checkOutputType(fm, io),
30
30
  ...await checkInputs(fm, io),
31
31
  ...await checkTargets(fm, io),
32
+ checkIterationLimits(fm),
32
33
  ].filter(Boolean);
33
34
 
34
35
  return errors.length ? { ok: false, errors } : { ok: true };
@@ -129,3 +130,12 @@ async function validateCycleRefs(targets, io) {
129
130
  }
130
131
  return errors;
131
132
  }
133
+
134
+ function checkIterationLimits(fm) {
135
+ const maxIt = fm['max-iterations'];
136
+ const dlIt = fm['deadlock-iterations'];
137
+ if (maxIt !== undefined && dlIt !== undefined && dlIt > maxIt) {
138
+ return `deadlock-iterations (${dlIt}) must be <= max-iterations (${maxIt}); deadlock would never trigger before the cycle blocks`;
139
+ }
140
+ return null;
141
+ }
@@ -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
@@ -0,0 +1,55 @@
1
+ import { baseStage } from './sort-routing.js';
2
+
3
+ const REASON_HANDLERS = {
4
+ forge: forgeReason,
5
+ assay: (d) => `starting cycle — routing to assay`,
6
+ quench: () => 'routing to quench for deterministic validation',
7
+ appraise: appraiseReason,
8
+ 'human-appraise': humanAppraiseReason,
9
+ done: () => 'all stages complete — no unresolved feedback',
10
+ blocked: blockedReason,
11
+ };
12
+
13
+ export function reasonForRoute(route, prep) {
14
+ const data = buildReasonData(route, prep);
15
+ const handler = REASON_HANDLERS[data.base] || defaultReason;
16
+ return handler(data);
17
+ }
18
+
19
+ function buildReasonData(route, prep) {
20
+ const base = baseStage(route);
21
+ const forgeCount = prep.history.filter(e => baseStage(e.stage || '') === 'forge').length;
22
+ const maxIt = prep.defaults.maxIterations;
23
+ const feedback = prep.feedback || [];
24
+ const openCount = feedback.filter(
25
+ f => f.state !== 'resolved' && f.state !== 'deadlocked',
26
+ ).length;
27
+ const dlCount = feedback.filter(f => f.state === 'deadlocked').length;
28
+ const needingForge = feedback.filter(
29
+ f => f.state === 'open' || f.state === 'rejected',
30
+ ).length;
31
+
32
+ return { base, route, forgeCount, maxIt, openCount, dlCount, needingForge, anyDeadlocked: prep.anyDeadlocked };
33
+ }
34
+
35
+ function forgeReason(d) {
36
+ if (d.forgeCount === 0) return `starting cycle — routing to forge (iteration 1 of ${d.maxIt})`;
37
+ return `found ${d.needingForge} unresolved feedback item(s) — routing to forge for revision (iteration ${d.forgeCount + 1} of ${d.maxIt})`;
38
+ }
39
+
40
+ function appraiseReason(d) {
41
+ if (d.anyDeadlocked) return `${d.dlCount} feedback item(s) deadlocked — routing to appraise for re-evaluation`;
42
+ return `quench passed with ${d.openCount} open feedback item(s) — routing to appraise`;
43
+ }
44
+
45
+ function humanAppraiseReason(d) {
46
+ return `${d.dlCount} feedback item(s) deadlocked after ${d.forgeCount} forge iteration(s) — routing to human for override`;
47
+ }
48
+
49
+ function blockedReason(d) {
50
+ return `max iterations (${d.maxIt}) reached after ${d.forgeCount} forge iteration(s) with ${d.openCount} unresolved feedback item(s)`;
51
+ }
52
+
53
+ function defaultReason(d) {
54
+ return `routing to ${d.route}`;
55
+ }
@@ -11,6 +11,8 @@
11
11
  // state is neither 'resolved' nor 'deadlocked'.
12
12
  const isOpenItem = (f) => f.state !== 'resolved' && f.state !== 'deadlocked';
13
13
 
14
+ export { isOpenItem };
15
+
14
16
  export function baseStage(stage) {
15
17
  return stage.split(':')[0];
16
18
  }
@@ -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
 
@@ -272,3 +272,15 @@ export function renderDispatchPrompt({ stage, cycle, token, cwd, filePatterns, o
272
272
  );
273
273
  return lines.join('\n');
274
274
  }
275
+
276
+ export function checkIterationLimits(cfm, cycleId) {
277
+ const maxIt = cfm['max-iterations'];
278
+ const dlIt = cfm['deadlock-iterations'];
279
+ if (maxIt !== undefined && dlIt !== undefined && dlIt > maxIt) {
280
+ return violation(
281
+ `cycle ${cycleId}: deadlock-iterations (${dlIt}) cannot exceed max-iterations (${maxIt})`,
282
+ ['WORK.md'],
283
+ );
284
+ }
285
+ return null;
286
+ }
@@ -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';
@@ -20,6 +21,7 @@ import {
20
21
  tryCommit,
21
22
  synthesizeStages,
22
23
  renderDispatchPrompt,
24
+ checkIterationLimits,
23
25
  } from './orchestrate-cycle.js';
24
26
  import {
25
27
  doneAction,
@@ -70,9 +72,15 @@ function getRouteBase(route) {
70
72
  }
71
73
 
72
74
  export async function handleSortResult(sortResult, ctx) {
73
- const { route, model, token } = sortResult;
75
+ const { route, model, token, reason } = sortResult;
74
76
  const routeBase = getRouteBase(route);
75
- if (isTerminalRoute(route)) return handleTerminalRoute(route, sortResult, ctx);
77
+ const result = await resolveRouteResult({ route, routeBase, model, token, ctx });
78
+ if (reason !== undefined) result.reason = reason;
79
+ return result;
80
+ }
81
+
82
+ async function resolveRouteResult({ route, routeBase, model, token, ctx }) {
83
+ if (isTerminalRoute(route)) return handleTerminalRoute(route, { route }, ctx);
76
84
  if (routeBase === 'quench' || routeBase === 'appraise') return violation(routeBase + ' route reached handleSortResult');
77
85
  if (routeBase === 'human-appraise') return humanAppraiseAction(route, token, ctx);
78
86
  return buildDispatchAction(route, model, token, ctx);
@@ -158,10 +166,11 @@ function resolveStages(cfm, cycleId, hasValidation, assayExtractors) {
158
166
  }
159
167
 
160
168
  function applyFmDefaults(newFm, cfm, assayExtractors) {
161
- newFm['max-iterations'] = cfm['max-iterations'] ?? 3;
169
+ const maxIt = cfm['max-iterations'] ?? 3;
170
+ newFm['max-iterations'] = maxIt;
162
171
  newFm['human-appraise'] = cfm['human-appraise'] === true;
163
172
  newFm['deadlock-appraise'] = cfm['deadlock-appraise'] !== false;
164
- newFm['deadlock-iterations'] = cfm['deadlock-iterations'] ?? 5;
173
+ newFm['deadlock-iterations'] = cfm['deadlock-iterations'] ?? maxIt;
165
174
  if (cfm.models) newFm.models = cfm.models;
166
175
  if (assayExtractors) newFm.assay = { extractors: assayExtractors };
167
176
  }
@@ -171,7 +180,7 @@ function buildNewFrontmatter(workContent, stages, cfm, assayExtractors) {
171
180
  const newFm = { ...fm };
172
181
  newFm.stages = stages;
173
182
  applyFmDefaults(newFm, cfm, assayExtractors);
174
- const body = workContent.replace(/^---\n[\s\S]+?\n---\n?/, '');
183
+ const body = matter(workContent).content;
175
184
  const fmBlock = writeFrontmatter(newFm);
176
185
  return body ? `${fmBlock}\n${body}` : fmBlock;
177
186
  }
@@ -217,6 +226,8 @@ async function completeSetup(ctx) {
217
226
  const hasValidation = ctx.lawsWithValidators && ctx.lawsWithValidators.length > 0;
218
227
  const stagesResult = resolveStages(ctx.cfm, ctx.cycleId, hasValidation, ctx.assayResult.extractors);
219
228
  if (stagesResult.error) return stagesResult.error;
229
+ const validityErr = checkIterationLimits(ctx.cfm, ctx.cycleId);
230
+ if (validityErr) return validityErr;
220
231
  const newWork = buildNewFrontmatter(ctx.workContent, stagesResult, ctx.cfm, ctx.assayResult.extractors);
221
232
  ctx.io.writeFile('WORK.md', newWork);
222
233
  return trySetupCommit(ctx);
@@ -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
  // ---------------------------------------------------------------------------
@@ -194,19 +193,23 @@ async function handleQuenchRoute(sortResult, preCheck, args, io) {
194
193
  return dispatchByRoute(nextSort, args, preCheck, io);
195
194
  }
196
195
 
197
- async function handleAppraiseGatherRoute(sortResult, preCheck, args, io) {
198
- writeStageRecord(io, preCheck.cycleId, sortResult.route);
199
- const result = await gatherAppraiseContext(buildAppraiseCtx(preCheck.cycleId, args, io));
200
- if (result.action === 'violation') { clearActiveStage(io); return result; }
196
+ async function dispatchAppraiseOrConsolidate(sortResult, preCheck, args, io, result) {
201
197
  if (!result.tasks || result.tasks.length === 0) {
202
- // No appraisers/artefacts — consolidate with empty results to advance
203
198
  return handleAppraiseConsolidateRoute(sortResult, preCheck, { ...args, lastResults: [] }, io);
204
199
  }
205
200
  const validationErr = validateDispatchMulti(result);
206
201
  if (validationErr) return validationErr;
202
+ if (sortResult.reason !== undefined) result.reason = sortResult.reason;
207
203
  return result;
208
204
  }
209
205
 
206
+ async function handleAppraiseGatherRoute(sortResult, preCheck, args, io) {
207
+ writeStageRecord(io, preCheck.cycleId, sortResult.route);
208
+ const result = await gatherAppraiseContext(buildAppraiseCtx(preCheck.cycleId, args, io));
209
+ if (result.action === 'violation') { clearActiveStage(io); return result; }
210
+ return dispatchAppraiseOrConsolidate(sortResult, preCheck, args, io, result);
211
+ }
212
+
210
213
  async function handleAppraiseConsolidateRoute(sortResult, preCheck, args, io) {
211
214
  const ctx = buildAppraiseCtx(preCheck.cycleId, args, io);
212
215
  const result = await consolidateAppraise(ctx, args.lastResults);
@@ -22,6 +22,7 @@ import {
22
22
  findFirst,
23
23
  determineRoute,
24
24
  } from './lib/sort-routing.js';
25
+ import { reasonForRoute } from './lib/sort-reason.js';
25
26
  import {
26
27
  defaultIO,
27
28
  checkModifiedFiles,
@@ -85,11 +86,12 @@ function validateWorkMd(workPath, io) {
85
86
  }
86
87
 
87
88
  function extractFrontmatterDefaults(frontmatter) {
89
+ const maxIt = frontmatter['max-iterations'] ?? 3;
88
90
  return {
89
- maxIterations: frontmatter['max-iterations'] ?? 3,
91
+ maxIterations: maxIt,
90
92
  humanAppraiseEnabled: frontmatter['human-appraise'] === true,
91
93
  deadlockAppraise: frontmatter['deadlock-appraise'] !== false,
92
- deadlockIterations: frontmatter['deadlock-iterations'] ?? 5,
94
+ deadlockIterations: frontmatter['deadlock-iterations'] ?? maxIt,
93
95
  };
94
96
  }
95
97
 
@@ -184,8 +186,8 @@ function checkModel(route, frontmatter, agentsDir, io, defaultModel) {
184
186
  return { model: typeof modelResult === 'string' ? modelResult : null };
185
187
  }
186
188
 
187
- function mintToken({ route, model, mint, cycle, now, ulid }) {
188
- const result = { route, ...(model ? { model } : {}) };
189
+ function mintToken({ route, model, mint, cycle, now, ulid, reason }) {
190
+ const result = { route, ...(model ? { model } : {}), reason };
189
191
  if (mint && isDispatchableRoute(route)) {
190
192
  const token = mint({ route, cycle, exp: now + 10 * 60 * 1000, nonce: ulid(now) });
191
193
  if (token) result.token = token;
@@ -268,6 +270,7 @@ export function runSort(args = {}, io = defaultIO) {
268
270
 
269
271
  return mintToken({
270
272
  route, model: modelCheck.model, mint: opts.mint, cycle: prep.cycle, now: opts.now, ulid: opts.ulid,
273
+ reason: reasonForRoute(route, prep),
271
274
  });
272
275
  }
273
276
 
@@ -85,7 +85,7 @@ If the parent flow or required artefact type is missing and the user's goal clea
85
85
  **Optional clusters** — After each cluster, ask whether the user wants to configure it; if not, skip:
86
86
 
87
87
  - **Routing**: `inputs` (input contract: `{type: "any-of"|"all-of", artefacts: string[]}`; omit for source cycles with no upstream artefact dependency), `targets` (cycle IDs to route to after completion), `maxIterations` (maximum iterations before forced progression)
88
- - **Human-appraise**: `humanAppraise` (boolean, default false) — human reviews every iteration; `deadlockAppraise` (boolean, default true) — human is pulled in when LLM appraisers deadlock; `deadlockIterations` (number, default 5) — deadlock threshold. Only applies when either appraise is enabled.
88
+ - **Human-appraise**: `humanAppraise` (boolean, default false) — human reviews every iteration; `deadlockAppraise` (boolean, default true) — human is pulled in when LLM appraisers deadlock; `deadlockIterations` (number, defaults to `max-iterations` value) — deadlock threshold. Only applies when either appraise is enabled.
89
89
  - **Memory and models**: `assay` (assay configuration), `memory` (memory configuration), `models` (stage-specific model overrides, e.g. `{forge: "openai/gpt-4o", appraise: "openai/gpt-4o"}`). For models, offer each stage (forge, quench, appraise) individually. If the user has no preference, omit the `models` map and use the session defaults.
90
90
 
91
91
  ### 2. Plan
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@really-knows-ai/foundry",
3
- "version": "3.5.4",
3
+ "version": "3.5.6",
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
  },