@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.
- package/dist/.opencode/plugins/foundry-tools/helpers.js +18 -39
- package/dist/.opencode/plugins/foundry-tools/orchestrate-tool.js +22 -22
- package/dist/CHANGELOG.md +23 -0
- package/dist/scripts/appraise-module.js +61 -100
- package/dist/scripts/lib/config-validators/helpers.js +4 -2
- package/dist/scripts/lib/config.js +2 -1
- package/dist/scripts/lib/failed-flow.js +2 -1
- package/dist/scripts/lib/memory/admin/rename-entity-type.js +6 -6
- package/dist/scripts/lib/memory/frontmatter.js +28 -30
- package/dist/scripts/lib/workfile.js +4 -12
- package/dist/scripts/orchestrate-phases.js +2 -1
- package/dist/scripts/orchestrate.js +3 -4
- package/dist/skills/add-flow/SKILL.md +2 -0
- package/package.json +2 -1
|
@@ -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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
52
|
-
const
|
|
53
|
-
|
|
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
|
|
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
|
|
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
|
-
'
|
|
300
|
-
'
|
|
301
|
-
'
|
|
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
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
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.
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
const
|
|
329
|
-
|
|
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
|
|
344
|
-
.map(parseRawEntry)
|
|
345
|
-
.filter(e => e !== null);
|
|
330
|
+
return parseFallback(text);
|
|
346
331
|
}
|
|
347
332
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
354
|
+
function isCompleteIssue(obj) {
|
|
355
|
+
return obj && obj.file && obj.law && obj.issue;
|
|
356
|
+
}
|
|
382
357
|
|
|
383
|
-
|
|
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
|
-
|
|
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
|
|
402
|
-
|
|
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
|
|
379
|
+
return issues.filter(isCompleteIssue);
|
|
407
380
|
}
|
|
408
381
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
|
419
|
-
if (
|
|
386
|
+
const colon = trimmed.indexOf(':');
|
|
387
|
+
if (colon < 1) return null;
|
|
420
388
|
|
|
421
|
-
const
|
|
422
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
43
|
+
|
|
58
44
|
return {
|
|
59
|
-
frontmatter: normaliseFrontmatter(
|
|
60
|
-
body:
|
|
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
|
|
19
|
-
|
|
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.
|
|
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.
|
|
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
|
|
42
|
-
|
|
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
|
+
"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
|
},
|