factoryhub 0.0.0-stage → 0.1.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.
Files changed (99) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +16 -0
  3. package/README.md +189 -2
  4. package/factories/README.md +17 -0
  5. package/factories/client-file-readiness/README.md +3 -0
  6. package/factories/client-file-readiness/agents/analyst/agent.md +10 -0
  7. package/factories/client-file-readiness/agents/reviewer/agent.md +10 -0
  8. package/factories/client-file-readiness/agents/writer/agent.md +11 -0
  9. package/factories/client-file-readiness/factory.yaml +12 -0
  10. package/factories/client-file-readiness/flow.yaml +56 -0
  11. package/factories/competitor-teardown/README.md +3 -0
  12. package/factories/competitor-teardown/agents/analyst/agent.md +10 -0
  13. package/factories/competitor-teardown/agents/researcher/agent.md +11 -0
  14. package/factories/competitor-teardown/agents/reviewer/agent.md +10 -0
  15. package/factories/competitor-teardown/agents/writer/agent.md +11 -0
  16. package/factories/competitor-teardown/factory.yaml +11 -0
  17. package/factories/competitor-teardown/flow.yaml +75 -0
  18. package/factories/homepage-audit/README.md +3 -0
  19. package/factories/homepage-audit/agents/analyst/agent.md +10 -0
  20. package/factories/homepage-audit/agents/researcher/agent.md +11 -0
  21. package/factories/homepage-audit/agents/reviewer/agent.md +10 -0
  22. package/factories/homepage-audit/agents/writer/agent.md +11 -0
  23. package/factories/homepage-audit/factory.yaml +11 -0
  24. package/factories/homepage-audit/flow.yaml +77 -0
  25. package/factories/inbox-triage/README.md +3 -0
  26. package/factories/inbox-triage/agents/analyst/agent.md +10 -0
  27. package/factories/inbox-triage/agents/reviewer/agent.md +10 -0
  28. package/factories/inbox-triage/agents/writer/agent.md +11 -0
  29. package/factories/inbox-triage/factory.yaml +11 -0
  30. package/factories/inbox-triage/flow.yaml +57 -0
  31. package/factories/index.json +356 -0
  32. package/factories/launch-kit/README.md +3 -0
  33. package/factories/launch-kit/agents/reviewer/agent.md +10 -0
  34. package/factories/launch-kit/agents/strategist/agent.md +10 -0
  35. package/factories/launch-kit/agents/writer/agent.md +11 -0
  36. package/factories/launch-kit/factory.yaml +12 -0
  37. package/factories/launch-kit/flow.yaml +75 -0
  38. package/factories/meeting-to-actions/CHANGELOG.md +7 -0
  39. package/factories/meeting-to-actions/README.md +15 -0
  40. package/factories/meeting-to-actions/agents/analyst/agent.md +10 -0
  41. package/factories/meeting-to-actions/agents/reviewer/agent.md +10 -0
  42. package/factories/meeting-to-actions/agents/writer/agent.md +11 -0
  43. package/factories/meeting-to-actions/factory.yaml +11 -0
  44. package/factories/meeting-to-actions/flow.yaml +53 -0
  45. package/factories/meeting-to-actions/mindburn.requirements.yaml +8 -0
  46. package/factories/meeting-to-actions/mindburn.tests.yaml +16 -0
  47. package/factories/outreach-pack/README.md +3 -0
  48. package/factories/outreach-pack/agents/reviewer/agent.md +5 -0
  49. package/factories/outreach-pack/agents/writer/agent.md +5 -0
  50. package/factories/outreach-pack/factory.yaml +9 -0
  51. package/factories/outreach-pack/flow.yaml +42 -0
  52. package/factories/prospect-brief/README.md +3 -0
  53. package/factories/prospect-brief/agents/researcher/agent.md +11 -0
  54. package/factories/prospect-brief/agents/reviewer/agent.md +10 -0
  55. package/factories/prospect-brief/agents/strategist/agent.md +10 -0
  56. package/factories/prospect-brief/agents/writer/agent.md +11 -0
  57. package/factories/prospect-brief/factory.yaml +11 -0
  58. package/factories/prospect-brief/flow.yaml +79 -0
  59. package/package.json +79 -4
  60. package/packages/cli/bin/factoryhub.mjs +8 -0
  61. package/packages/cli/package.json +29 -0
  62. package/packages/cli/src/args.mjs +47 -0
  63. package/packages/cli/src/commands/export.mjs +55 -0
  64. package/packages/cli/src/commands/get.mjs +38 -0
  65. package/packages/cli/src/commands/inspect.mjs +66 -0
  66. package/packages/cli/src/commands/list.mjs +29 -0
  67. package/packages/cli/src/commands/mcp.mjs +26 -0
  68. package/packages/cli/src/commands/new.mjs +148 -0
  69. package/packages/cli/src/commands/pack.mjs +97 -0
  70. package/packages/cli/src/commands/resume.mjs +67 -0
  71. package/packages/cli/src/commands/run.mjs +190 -0
  72. package/packages/cli/src/commands/search.mjs +31 -0
  73. package/packages/cli/src/commands/validate.mjs +81 -0
  74. package/packages/cli/src/main.mjs +68 -0
  75. package/packages/cli/src/util.mjs +21 -0
  76. package/packages/engine/lib/catalog.mjs +50 -0
  77. package/packages/engine/lib/hub.mjs +71 -0
  78. package/packages/engine/lib/node.mjs +114 -0
  79. package/packages/engine/lib/providers.mjs +52 -0
  80. package/packages/engine/lib/run.mjs +131 -0
  81. package/packages/engine/lib/skill.mjs +229 -0
  82. package/packages/engine/package.json +39 -0
  83. package/packages/engine/src/author-tests.mjs +78 -0
  84. package/packages/engine/src/compose.mjs +150 -0
  85. package/packages/engine/src/cost.mjs +41 -0
  86. package/packages/engine/src/diagnostics.mjs +21 -0
  87. package/packages/engine/src/factory-engine.mjs +1762 -0
  88. package/packages/engine/src/index.mjs +9 -0
  89. package/packages/engine/src/markdown.mjs +97 -0
  90. package/packages/engine/src/package.mjs +116 -0
  91. package/packages/engine/src/run.mjs +232 -0
  92. package/packages/mcp/bin/factoryhub-mcp.mjs +6 -0
  93. package/packages/mcp/package.json +36 -0
  94. package/packages/mcp/src/server.mjs +280 -0
  95. package/spec/README.md +36 -0
  96. package/spec/factory-format.md +621 -0
  97. package/spec/schema/factory.schema.json +43 -0
  98. package/spec/schema/flow.schema.json +169 -0
  99. package/spec/skill-export.md +126 -0
@@ -0,0 +1,1762 @@
1
+ // quantum_posture: run receipts are signed with classical Ed25519, which is not post-quantum. They attest what a run did at the time; nothing long-lived or secret is protected by the signature.
2
+ // The factory engine: reads a factory bundle, runs its flow, writes a receipt.
3
+ // One file, no dependencies. The same code runs on factory.mindburn.org, in the
4
+ // browser with your own model, and from `node run.mjs` on your machine.
5
+ // SPDX-License-Identifier: Apache-2.0. The format is specified in spec/ of the factoryhub repository.
6
+
7
+ export const FLOW_VERSION = 'mindburn.flow/v1';
8
+ export const BUNDLE_FORMAT = 'mindburn.factory.bundle/v1';
9
+ export const REQUIRED_FEATURES = ['scoped-context', 'multiple-outputs'];
10
+ export const STEP_KINDS = ['work', 'check', 'approve', 'deliver'];
11
+ export const TOOLS = ['web_search', 'web_fetch'];
12
+ export const INPUT_TYPES = ['text', 'longtext', 'url'];
13
+ export const INPUT_LIMITS = { text: 2000, longtext: 40000, url: 2000, total: 80000 };
14
+ // What a step may say in flow.yaml, and the names other harnesses use for
15
+ // tools and connections: those count as tools this runner would have to have.
16
+ const STEP_FIELDS = ['title', 'agent', 'kind', 'needs', 'checks', 'shows', 'tools', 'task', 'rounds', 'context', 'criteria'];
17
+ const NEEDS_FIELDS = ['mcp', 'mcpServers', 'mcp_servers', 'connectors', 'plugins', 'skills'];
18
+ const EXECUTION_FIELDS = [...NEEDS_FIELDS, 'tools', 'secrets', 'networkPolicy', 'extends', 'params', 'runner', 'environmentId', 'workerHost', 'credentialStrategy', 'required_repos', 'spawnableBy', 'repositories', 'cloudProviders', 'providers', 'integrations'];
19
+ const hasRequirement = value => value !== undefined && value !== null && value !== ''
20
+ && !(Array.isArray(value) && value.length === 0)
21
+ && !(typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0);
22
+ // How a tool asked for under one of those fields is named in a step's `missing`.
23
+ const neededName = (field, item) => `${field}: ${typeof item === 'string' ? item : JSON.stringify(item)}`.slice(0, 80);
24
+
25
+ // Takes one tool, named as a step's `tools` or `missing` names it, out of that
26
+ // step's entry in flow.yaml, whichever field asks for it. A field left empty goes.
27
+ export function dropTool(spec, tool) {
28
+ for (const field of ['tools', ...NEEDS_FIELDS]) {
29
+ const items = [].concat(spec[field] ?? []);
30
+ const left = items.filter((item) => (field === 'tools' ? String(item).trim() : neededName(field, item)) !== tool);
31
+ if (left.length === items.length) continue;
32
+ if (left.length) spec[field] = left;
33
+ else delete spec[field];
34
+ }
35
+ return spec;
36
+ }
37
+
38
+ export function slugify(text) {
39
+ return String(text || '').toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64);
40
+ }
41
+ const idOf = (text) => slugify(text).replace(/-/g, '_');
42
+
43
+ // ------------------------------------------------------------------ yaml ----
44
+ // The YAML a factory uses: nested maps, lists, inline lists, quoted and plain
45
+ // scalars, comments, and block text under `|` or `>`. Anchors, tags and
46
+ // multi-document files are not supported and are reported as errors.
47
+
48
+ function scalar(text) {
49
+ const value = text.trim();
50
+ if (value === '' || value === '~' || value === 'null') return null;
51
+ if (value === 'true') return true;
52
+ if (value === 'false') return false;
53
+ if (/^-?\d+(\.\d+)?$/.test(value)) return Number(value);
54
+ if (value.startsWith('"') && value.endsWith('"') && value.length > 1) { try { return JSON.parse(value); } catch { return value.slice(1, -1); } }
55
+ if (value.startsWith("'") && value.endsWith("'") && value.length > 1) return value.slice(1, -1).replace(/''/g, "'");
56
+ if (value === '[]') return [];
57
+ if (value === '{}') return {};
58
+ if (value.startsWith('[') && value.endsWith(']')) return splitInline(value.slice(1, -1)).map(scalar);
59
+ if (value.startsWith('{') && value.endsWith('}')) {
60
+ const entries = splitInline(value.slice(1, -1)).map((pair) => { const at = pair.indexOf(':'); if (at < 0) throw new Error('An inline YAML map needs key: value entries.'); return [String(scalar(pair.slice(0, at))), scalar(pair.slice(at + 1))]; });
61
+ if (new Set(entries.map(([key]) => key)).size !== entries.length) throw new Error('An inline YAML map repeats a key.');
62
+ return Object.fromEntries(entries);
63
+ }
64
+ if (/^[&*!]/.test(value)) throw new Error(`YAML anchors and tags are not supported: ${value.slice(0, 30)}`);
65
+ return value;
66
+ }
67
+
68
+ function splitInline(text) {
69
+ const parts = [];
70
+ let depth = 0;
71
+ let quote = '';
72
+ let current = '';
73
+ for (const char of text) {
74
+ if (quote) { current += char; if (char === quote) quote = ''; continue; }
75
+ if (char === '"' || char === "'") { quote = char; current += char; continue; }
76
+ if (char === '[' || char === '{') depth += 1;
77
+ if (char === ']' || char === '}') depth -= 1;
78
+ if (char === ',' && depth === 0) { parts.push(current); current = ''; continue; }
79
+ current += char;
80
+ }
81
+ if (current.trim()) parts.push(current);
82
+ return parts;
83
+ }
84
+
85
+ function stripComment(line) {
86
+ let quote = '';
87
+ for (let i = 0; i < line.length; i += 1) {
88
+ const char = line[i];
89
+ if (quote) { if (char === quote) quote = ''; continue; }
90
+ if (char === '"' || char === "'") quote = char;
91
+ else if (char === '#' && (i === 0 || /\s/.test(line[i - 1]))) return line.slice(0, i);
92
+ }
93
+ return line;
94
+ }
95
+
96
+ // How many lines of a YAML file carry a comment.
97
+ export const countComments = (text) => String(text || '').split('\n').filter((line) => stripComment(line) !== line).length;
98
+
99
+ export function parseYaml(text) {
100
+ const lines = String(text || '').replace(/\r\n/g, '\n').replace(/\t/g, ' ').split('\n');
101
+ let at = 0;
102
+ const indentOf = (line) => line.match(/^ */)[0].length;
103
+ const blank = (line) => !stripComment(line).trim();
104
+ const skip = () => { while (at < lines.length && blank(lines[at])) at += 1; };
105
+
106
+ function block(parentIndent, style) {
107
+ const collected = [];
108
+ let indent = null;
109
+ while (at < lines.length) {
110
+ const line = lines[at];
111
+ if (!line.trim()) { collected.push(''); at += 1; continue; }
112
+ const current = indentOf(line);
113
+ if (current <= parentIndent) break;
114
+ if (indent === null) indent = current;
115
+ collected.push(line.slice(Math.min(indent, current)));
116
+ at += 1;
117
+ }
118
+ while (collected.length && collected[collected.length - 1] === '') collected.pop();
119
+ const joined = style[0] === '|' ? collected.join('\n') : collected.join('\n').replace(/([^\n])\n(?!\n)/g, '$1 ').replace(/\n\n/g, '\n');
120
+ return style.endsWith('-') ? joined : `${joined}\n`;
121
+ }
122
+
123
+ function value(rest, indent) {
124
+ const trimmed = stripComment(rest).trim();
125
+ if (/^[|>][+-]?$/.test(trimmed)) return block(indent, trimmed);
126
+ if (trimmed === '') {
127
+ skip();
128
+ if (at < lines.length && (indentOf(lines[at]) > indent || (indentOf(lines[at]) === indent && lines[at].trim().startsWith('- ')))) return node(indentOf(lines[at]));
129
+ return null;
130
+ }
131
+ return scalar(trimmed);
132
+ }
133
+
134
+ function node(indent) {
135
+ skip();
136
+ if (at >= lines.length) return null;
137
+ if (lines[at].trim().startsWith('- ') || lines[at].trim() === '-') {
138
+ const list = [];
139
+ while (at < lines.length) {
140
+ skip();
141
+ if (at >= lines.length || indentOf(lines[at]) !== indent || !/^-( |$)/.test(lines[at].trim())) break;
142
+ const rest = lines[at].slice(indent + 1);
143
+ const inner = rest.replace(/^ /, '');
144
+ if (/^[A-Za-z0-9_."'-]+:( |$)/.test(stripComment(inner).trim()) && !/^["'].*["']$/.test(stripComment(inner).trim())) {
145
+ lines[at] = `${' '.repeat(indent + 2)}${inner}`;
146
+ list.push(node(indent + 2));
147
+ } else {
148
+ at += 1;
149
+ list.push(value(inner, indent));
150
+ }
151
+ }
152
+ return list;
153
+ }
154
+ const map = {};
155
+ while (at < lines.length) {
156
+ skip();
157
+ if (at >= lines.length || indentOf(lines[at]) !== indent) break;
158
+ if (lines[at] === '...') break;
159
+ if (lines[at] === '---') throw new Error('Multiple YAML documents are not supported. Use one document per file.');
160
+ const line = lines[at].slice(indent);
161
+ const match = line.match(/^("[^"]*"|'[^']*'|[^:#]+?):( |$)(.*)$/);
162
+ if (!match) throw new Error(`Line ${at + 1} is not "key: value": ${line.trim().slice(0, 60)}`);
163
+ at += 1;
164
+ const key = String(scalar(match[1]));
165
+ if (Object.hasOwn(map, key)) throw new Error(`Line ${at} repeats the key "${key}": each name can appear once.`);
166
+ Object.defineProperty(map, key, { value: value(match[3] || '', indent), enumerable: true, configurable: true, writable: true });
167
+ }
168
+ return map;
169
+ }
170
+
171
+ skip();
172
+ if (lines[at] === '---') { at += 1; skip(); }
173
+ const result = node(at < lines.length ? indentOf(lines[at]) : 0);
174
+ skip();
175
+ if (lines[at] === '...') { at += 1; skip(); }
176
+ if (at < lines.length) throw new Error(`Line ${at + 1} has unexpected indentation: ${lines[at].trim().slice(0, 60)}`);
177
+ return result;
178
+ }
179
+
180
+ const plainSafe = (text) => /^[A-Za-z0-9_./@()+-][A-Za-z0-9_./@ ()+'-]*$/.test(text) && !/^(true|false|null|~|-?\d+(\.\d+)?)$/.test(text) && !text.endsWith(' ') && !text.includes(': ') && !text.startsWith('- ');
181
+
182
+ export function toYaml(value, indent = 0) {
183
+ const pad = ' '.repeat(indent);
184
+ const inline = (item) => {
185
+ if (item === null || item === undefined) return 'null';
186
+ if (typeof item === 'string') return plainSafe(item) ? item : JSON.stringify(item);
187
+ if (Array.isArray(item)) return `[${item.map(inline).join(', ')}]`;
188
+ return String(item);
189
+ };
190
+ if (Array.isArray(value)) {
191
+ if (!value.length) return `${pad}[]\n`;
192
+ return value.map((item) => (item && typeof item === 'object' && !Array.isArray(item)
193
+ ? `${pad}- ${toYaml(item, indent + 2).trimStart()}`
194
+ : `${pad}- ${inline(item)}\n`)).join('');
195
+ }
196
+ if (value && typeof value === 'object') {
197
+ return Object.entries(value).filter(([, item]) => item !== undefined).map(([key, item]) => {
198
+ const name = /^[A-Za-z_][A-Za-z0-9_.-]*$/.test(key) && !/^(true|false|null)$/.test(key) ? key : JSON.stringify(key);
199
+ // Text over several lines is written as a block; `|-` when it does not
200
+ // end with a line break, so it reads back exactly as it was.
201
+ if (typeof item === 'string' && item.includes('\n') && !/^\s/.test(item) && !/\n\n$/.test(item) && !/[ \t]\n/.test(item)) {
202
+ const ends = item.endsWith('\n');
203
+ return `${pad}${name}: ${ends ? '|' : '|-'}\n${(ends ? item.slice(0, -1) : item).split('\n').map((line) => (line ? `${pad} ${line}` : '')).join('\n')}\n`;
204
+ }
205
+ if (typeof item === 'string' && item.includes('\n')) return `${pad}${name}: ${JSON.stringify(item)}\n`;
206
+ if (Array.isArray(item)) {
207
+ if (item.every((entry) => entry === null || typeof entry !== 'object') && item.join(', ').length < 70 && item.every((entry) => typeof entry !== 'string' || plainSafe(entry))) return `${pad}${name}: ${inline(item)}\n`;
208
+ return `${pad}${name}:\n${toYaml(item, indent + 2)}`;
209
+ }
210
+ if (item && typeof item === 'object') return Object.keys(item).length ? `${pad}${name}:\n${toYaml(item, indent + 2)}` : `${pad}${name}: {}\n`;
211
+ return `${pad}${name}: ${inline(item)}\n`;
212
+ }).join('');
213
+ }
214
+ return `${pad}${inline(value)}\n`;
215
+ }
216
+
217
+ // ---------------------------------------------------------------- format ----
218
+ // A factory is a bundle of text files:
219
+ // factory.yaml name, description, default harness
220
+ // agents/<name>/agent.md one file per agent: its role and standing instructions
221
+ // flow.yaml the steps, who does each, what each needs, and the inputs
222
+
223
+ export function parseFrontmatter(text) {
224
+ const normalized = String(text || '').replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
225
+ const match = normalized.match(/^---\n((?:[^\n]*\n)*?)---(?:\n|$)/);
226
+ if (!match) return { meta: {}, body: normalized.trim(), ...(normalized.startsWith('---\n') ? { error: 'Agent settings need a closing --- delimiter.' } : {}) };
227
+ let meta = {};
228
+ try { meta = parseYaml(match[1]) || {}; } catch {
229
+ return { meta: {}, body: normalized.slice(match[0].length).trim(), error: 'Agent settings contain invalid or unsupported YAML.' };
230
+ }
231
+ return { meta, body: normalized.slice(match[0].length).trim() };
232
+ }
233
+
234
+ export const AGENT_PATH = /^agents\/([a-z0-9][a-z0-9_-]{0,40})\/agent\.md$/;
235
+
236
+ export function normalizeFiles(files) {
237
+ const out = {};
238
+ for (const [path, content] of Object.entries(files || {})) out[path] = String(content ?? '').replace(/\r\n/g, '\n');
239
+ return Object.fromEntries(Object.keys(out).sort().map((path) => [path, out[path]]));
240
+ }
241
+
242
+ function levelsOf(steps) {
243
+ const byId = Object.fromEntries(steps.map((step) => [step.id, step]));
244
+ const level = {};
245
+ const visit = (id, trail) => {
246
+ if (level[id] !== undefined) return level[id];
247
+ if (trail.has(id)) throw new Error(`Steps depend on each other in a circle through "${id}".`);
248
+ trail.add(id);
249
+ const value = byId[id].needs.length ? Math.max(...byId[id].needs.map((need) => visit(need, trail))) + 1 : 0;
250
+ trail.delete(id);
251
+ level[id] = value;
252
+ return value;
253
+ };
254
+ steps.forEach((step) => visit(step.id, new Set()));
255
+ return level;
256
+ }
257
+
258
+ export function ancestorsOf(factory, id) {
259
+ const byId = Object.fromEntries(factory.steps.map((step) => [step.id, step]));
260
+ const seen = new Set();
261
+ const walk = (current) => { for (const need of byId[current]?.needs || []) { if (!seen.has(need)) { seen.add(need); walk(need); } } };
262
+ walk(id);
263
+ return factory.steps.filter((step) => seen.has(step.id)).map((step) => step.id);
264
+ }
265
+
266
+ export function dependentsOf(factory, id) {
267
+ return factory.steps.filter((step) => step.id !== id && ancestorsOf(factory, step.id).includes(id)).map((step) => step.id);
268
+ }
269
+
270
+ export function parseBundle(input) {
271
+ const files = normalizeFiles(input);
272
+ const errors = [];
273
+ const warnings = [];
274
+ const factory = { name: '', description: '', title: '', author: '', version: '1.0.0', license: '', tags: [], time: '', harness: null, inputs: [], steps: [], agents: {}, done: [], blockers: [], flowValid: false, runnable: false, files };
275
+ factory.unsupportedRequirements = [];
276
+ const requirements = (settings, source, prefix = '') => {
277
+ if (!settings || typeof settings !== 'object') return;
278
+ for (const field of EXECUTION_FIELDS) {
279
+ // An explicitly empty network policy still restricts egress. Never
280
+ // mistake it for an absent requirement. Do not expose configured values.
281
+ if ((['networkPolicy', 'spawnableBy'].includes(field) && Object.hasOwn(settings, field)) || hasRequirement(settings[field])) {
282
+ factory.unsupportedRequirements.push({ source, setting: prefix + field });
283
+ }
284
+ }
285
+ if (hasRequirement(settings.harness?.auth)) factory.unsupportedRequirements.push({ source, setting: `${prefix}harness.auth` });
286
+ };
287
+ const agentSettings = new Map();
288
+
289
+ let config = null;
290
+ if (!files['factory.yaml']) errors.push('The bundle needs a factory.yaml file.');
291
+ else {
292
+ try { config = parseYaml(files['factory.yaml']); } catch (error) { errors.push(`factory.yaml: ${error.message}`); }
293
+ }
294
+ if (config && typeof config === 'object') {
295
+ factory.name = slugify(config.name);
296
+ // A wrapped description reads as paragraphs, not as line breaks.
297
+ factory.description = String(config.description || '').trim().replace(/([^\n])\n(?!\n)/g, '$1 ');
298
+ factory.harness = config.agentDefaults?.harness || (config.agentDefaults?.model ? { model: config.agentDefaults.model } : null);
299
+ requirements(config, 'factory.yaml');
300
+ requirements(config.agentDefaults, 'factory.yaml', 'agentDefaults.');
301
+ if (!config.schemaVersion) errors.push('factory.yaml needs `schemaVersion: v1alpha1`.');
302
+ if (!config.name) errors.push('factory.yaml needs a `name`.');
303
+ else if (String(config.name) !== factory.name) errors.push(`factory.yaml \`name\` must be lowercase letters, digits and hyphens: try \`${factory.name}\`.`);
304
+ if (!factory.description) errors.push('factory.yaml needs a `description`: what the factory produces and when to use it.');
305
+ }
306
+
307
+ for (const [path, content] of Object.entries(files)) {
308
+ const match = path.match(AGENT_PATH);
309
+ if (!match) continue;
310
+ const { meta, body, error } = parseFrontmatter(content);
311
+ if (error) errors.push(`${path}: ${error}`);
312
+ agentSettings.set(match[1], { meta, path });
313
+ factory.agents[match[1]] = { name: match[1], description: String(meta.description || ''), type: String(meta.agentType || 'CUSTOM').toUpperCase(), body };
314
+ }
315
+ if (Object.keys(factory.agents).length > 40) errors.push('A factory has at most 40 agents.');
316
+
317
+ let flow = null;
318
+ if (files['flow.yaml']) {
319
+ try { flow = parseYaml(files['flow.yaml']); } catch (error) { errors.push(`flow.yaml: ${error.message}`); }
320
+ } else {
321
+ warnings.push('This bundle has no flow.yaml, so it can be read, shared and taken away, but the hub cannot run it. Add a flow to make it runnable.');
322
+ }
323
+
324
+ if (flow && typeof flow === 'object') {
325
+ if (flow.requires !== undefined && (!Array.isArray(flow.requires) || flow.requires.some((feature) => !REQUIRED_FEATURES.includes(feature)))) errors.push('This flow requires unsupported execution features. Use a compatible runner.');
326
+ factory.title = String(flow.title || '');
327
+ factory.author = String(flow.author || '');
328
+ factory.version = String(flow.version ?? '1.0.0');
329
+ factory.license = String(flow.license || '');
330
+ factory.time = String(flow.time || '');
331
+ factory.tags = (Array.isArray(flow.tags) ? flow.tags : String(flow.tags || '').split(',')).map((tag) => String(tag).trim().toLowerCase()).filter(Boolean);
332
+ factory.done = (Array.isArray(flow.done) ? flow.done : []).map((item) => (item && typeof item === 'object' ? Object.entries(item).map(([key, value]) => `${key}: ${value}`).join(', ') : String(item)));
333
+ if (flow.flow && flow.flow !== FLOW_VERSION) warnings.push(`flow.yaml says "${flow.flow}"; this runner reads ${FLOW_VERSION}.`);
334
+
335
+ if (flow.inputs && (typeof flow.inputs !== 'object' || Array.isArray(flow.inputs))) errors.push('flow.yaml inputs must be a map of named inputs.');
336
+ const inputIds = new Set();
337
+ for (const [key, spec] of Object.entries(flow.inputs || {})) {
338
+ const detail = spec && typeof spec === 'object' ? spec : { help: String(spec || '') };
339
+ const type = INPUT_TYPES.includes(detail.type) ? detail.type : 'text';
340
+ const id = idOf(key);
341
+ if (!id) errors.push(`Input "${key}" needs a name containing letters or digits.`);
342
+ else if (inputIds.has(id)) errors.push(`Input "${key}" repeats the normalized input name "${id}".`);
343
+ inputIds.add(id);
344
+ factory.inputs.push({ key: id, label: String(detail.label || key).replace(/_/g, ' ').replace(/^./, (c) => c.toUpperCase()), type, required: detail.optional !== true && detail.required !== false, help: String(detail.help || ''), ...(detail.visibility !== undefined ? { visibility: detail.visibility } : {}) });
345
+ if (detail.visibility !== undefined && !['public', 'private'].includes(detail.visibility)) errors.push(`Input "${key}" visibility must be public or private.`);
346
+ }
347
+ if (factory.inputs.length > 8) errors.push('A factory asks for at most 8 inputs.');
348
+
349
+ const entries = Object.entries(flow.steps || {});
350
+ if (!entries.length) errors.push('flow.yaml needs at least one step under `steps:`.');
351
+ if (entries.length > 24) errors.push('A flow has at most 24 steps. Split bigger work into two factories.');
352
+ let previous = null;
353
+ for (const [key, raw] of entries) {
354
+ const spec = raw && typeof raw === 'object' ? raw : {};
355
+ const id = idOf(key);
356
+ if (!id) errors.push(`Step "${key}" needs a name containing letters or digits.`);
357
+ const kind = String(spec.kind || 'work').toLowerCase();
358
+ const needs = spec.needs === undefined || spec.needs === null ? (previous ? [previous] : []) : (Array.isArray(spec.needs) ? spec.needs : [spec.needs]).map(idOf);
359
+ // Every tool the step asks for, however it is written. The ones this
360
+ // runner has go in `tools`; the rest stay visible in `missing`.
361
+ const asked = [].concat(spec.tools ?? []).map((tool) => String(tool).trim()).filter(Boolean);
362
+ // A step can also name what it needs under another harness's words.
363
+ for (const field of NEEDS_FIELDS) for (const item of [].concat(spec[field] ?? [])) asked.push(neededName(field, item));
364
+ const unread = Object.keys(spec).filter((field) => !STEP_FIELDS.includes(field) && !NEEDS_FIELDS.includes(field));
365
+ if (unread.length) warnings.push(`The step "${String(spec.title || key)}" has settings this runner does not read: ${unread.join(', ')}. They are kept in the file and have no effect here.`);
366
+ const step = {
367
+ id,
368
+ title: String(spec.title || key).replace(/_/g, ' '),
369
+ agent: spec.agent ? slugify(spec.agent) : (kind === 'approve' ? 'owner' : ''),
370
+ kind,
371
+ needs,
372
+ target: spec.checks || spec.shows ? idOf(spec.checks || spec.shows) : null,
373
+ tools: asked.filter((tool) => TOOLS.includes(tool)),
374
+ missing: [...new Set(asked.filter((tool) => !TOOLS.includes(tool)))],
375
+ task: String(spec.task || '').trim(),
376
+ rounds: Number.isInteger(spec.rounds) ? Math.max(0, Math.min(5, spec.rounds)) : (kind === 'approve' ? 3 : 1),
377
+ };
378
+ if (spec.context !== undefined) {
379
+ const context = spec.context;
380
+ const map = (value) => value && typeof value === 'object' && !Array.isArray(value);
381
+ if (!map(context) || Object.keys(context).some((key) => !['inputs', 'artifacts', 'public'].includes(key)) || (context.public !== undefined && typeof context.public !== 'boolean')) errors.push(`Step "${id}" has an invalid context.`);
382
+ else {
383
+ step.context = { inputs: {}, artifacts: {}, ...(context.public !== undefined ? { public: context.public } : {}) };
384
+ for (const field of ['inputs', 'artifacts']) {
385
+ if (context[field] !== undefined && !map(context[field])) { errors.push(`Step "${id}" context.${field} must map names to references.`); continue; }
386
+ for (const [alias, ref] of Object.entries(context[field] || {})) {
387
+ if (!/^[a-z][a-z0-9_]{0,63}$/.test(alias) || typeof ref !== 'string' || ref !== idOf(ref)) errors.push(`Step "${id}" has an invalid ${field} binding.`);
388
+ else step.context[field][alias] = ref;
389
+ }
390
+ }
391
+ if (Object.keys(step.context.inputs).some((key) => Object.hasOwn(step.context.artifacts, key))) errors.push(`Step "${id}" repeats a context binding name.`);
392
+ }
393
+ }
394
+ if (spec.criteria !== undefined) {
395
+ if (!Array.isArray(spec.criteria) || spec.criteria.some((item) => typeof item !== 'string' || !item.trim())) errors.push(`Step "${id}" criteria must be a list of text.`);
396
+ else step.criteria = [...spec.criteria];
397
+ }
398
+ factory.steps.push(step);
399
+ previous = id;
400
+ }
401
+
402
+ const ids = factory.steps.map((step) => step.id);
403
+ if (new Set(ids).size !== ids.length) errors.push('Two steps share the same name.');
404
+ for (const step of factory.steps) {
405
+ if (!STEP_KINDS.includes(step.kind)) errors.push(`Step "${step.id}" has kind "${step.kind}". Use one of: ${STEP_KINDS.join(', ')}.`);
406
+ for (const need of step.needs) if (!ids.includes(need)) errors.push(`Step "${step.id}" needs "${need}", which is not a step.`);
407
+ if ((step.kind === 'work' || step.kind === 'deliver') && !step.task) errors.push(`Step "${step.id}" needs a \`task\`: what to do, in plain words.`);
408
+ if (step.kind !== 'approve') {
409
+ if (!step.agent) errors.push(`Step "${step.id}" needs an \`agent\`.`);
410
+ else if (!factory.agents[step.agent]) errors.push(`Step "${step.id}" is done by "${step.agent}", but there is no agents/${step.agent}/agent.md.`);
411
+ }
412
+ }
413
+ if (!errors.length) {
414
+ try { levelsOf(factory.steps); } catch (error) { errors.push(error.message); }
415
+ }
416
+ if (!errors.length) {
417
+ for (const step of factory.steps) {
418
+ if (step.kind !== 'check' && step.kind !== 'approve') continue;
419
+ const upstream = ancestorsOf(factory, step.id).filter((id) => ['work', 'deliver'].includes(factory.steps.find((item) => item.id === id).kind));
420
+ if (!step.target) step.target = step.needs.find((need) => upstream.includes(need)) || upstream[upstream.length - 1] || null;
421
+ if (!step.target) errors.push(`Step "${step.id}" (${step.kind}) has no earlier work to ${step.kind === 'check' ? 'check' : 'show'}.`);
422
+ else if (!upstream.includes(step.target)) errors.push(`Step "${step.id}" points at "${step.target}", which does not come before it.`);
423
+ }
424
+ if (!factory.steps.some((step) => step.kind === 'deliver' || step.kind === 'work')) errors.push('A flow needs at least one work or deliver step.');
425
+ }
426
+ if (!errors.length) {
427
+ for (const step of factory.steps) {
428
+ if (!step.context) continue;
429
+ for (const ref of Object.values(step.context.inputs)) if (!inputIds.has(ref)) errors.push(`Step "${step.id}" binds unknown input "${ref}".`);
430
+ const ancestors = ancestorsOf(factory, step.id);
431
+ for (const ref of Object.values(step.context.artifacts)) {
432
+ if (!ancestors.includes(ref) || !['work', 'deliver'].includes(factory.steps.find((item) => item.id === ref)?.kind)) errors.push(`Step "${step.id}" artifact "${ref}" must be earlier work in its dependencies.`);
433
+ }
434
+ }
435
+ }
436
+ if (!errors.length) {
437
+ const privateSteps = new Set();
438
+ const sources = new Map();
439
+ for (const step of factory.steps) {
440
+ const inputs = step.context ? Object.values(step.context.inputs) : factory.inputs.map((item) => item.key);
441
+ const artifacts = step.context ? Object.values(step.context.artifacts) : ancestorsOf(factory, step.id);
442
+ if (step.target) artifacts.push(step.target);
443
+ sources.set(step.id, artifacts);
444
+ // A person's revision note has no public-data declaration.
445
+ if (step.kind === 'approve' || inputs.some((key) => factory.inputs.find((item) => item.key === key)?.visibility !== 'public')) privateSteps.add(step.id);
446
+ }
447
+ // Reviews and owner revisions feed their subject back. These data edges
448
+ // form cycles, so propagate privacy to a fixed point instead of assuming
449
+ // the execution DAG alone describes every route data can take.
450
+ for (const step of factory.steps) if (step.target) sources.get(step.target).push(step.id);
451
+ let changed;
452
+ do {
453
+ changed = false;
454
+ for (const step of factory.steps) {
455
+ if (!privateSteps.has(step.id) && sources.get(step.id).some((id) => privateSteps.has(id))) { privateSteps.add(step.id); changed = true; }
456
+ }
457
+ } while (changed);
458
+ const isPublic = (id) => !privateSteps.has(id);
459
+ for (const step of factory.steps) {
460
+ if (step.context?.public && !isPublic(step.id)) errors.push(`Public step "${step.id}" cannot receive private inputs or artifacts.`);
461
+ if (step.kind === 'check' && factory.steps.find((item) => item.id === step.target)?.context?.public && !isPublic(step.id)) errors.push(`Reviewer "${step.id}" cannot send private feedback to public work.`);
462
+ }
463
+ }
464
+ if (flow.outputs !== undefined) {
465
+ factory.outputs = [];
466
+ if (!Array.isArray(flow.outputs) || !flow.outputs.length || flow.outputs.length > 24) errors.push('outputs must declare between 1 and 24 artifacts.');
467
+ else for (const output of flow.outputs) {
468
+ if (!output || typeof output !== 'object' || Object.keys(output).some((key) => !['step', 'title'].includes(key)) || typeof output.title !== 'string' || !output.title.trim() || output.title.length > 200 || !factory.steps.some((step) => step.id === output.step && ['work', 'deliver'].includes(step.kind)) || factory.outputs.some((item) => item.step === output.step)) errors.push('Each output needs a unique work or deliver step and a title.');
469
+ else factory.outputs.push({ step: output.step, title: output.title });
470
+ }
471
+ }
472
+ if (flow.composition !== undefined) {
473
+ const value = flow.composition;
474
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some((key) => !['compiler', 'intent', 'sources', 'unmetCapabilities'].includes(key)) || value.compiler !== 'mindburn.compose/v1' || typeof value.intent !== 'string' || !Array.isArray(value.sources) || value.sources.some((source) => !source || typeof source.id !== 'string' || typeof source.name !== 'string' || typeof source.version !== 'string' || !/^[a-f0-9]{64}$/.test(source.sha256)) || !Array.isArray(value.unmetCapabilities) || value.unmetCapabilities.some((item) => typeof item !== 'string')) errors.push('Composition metadata must preserve a supported compiler, original intent, source hashes and unmet capabilities.');
475
+ else factory.composition = value;
476
+ }
477
+ // A flow that reads correctly but asks for a tool this runner does not
478
+ // have is valid source and is not run: a run without the tool would be a
479
+ // different factory from the one its author wrote.
480
+ factory.flowValid = errors.length === 0;
481
+ const usedAgents = new Set(factory.steps.map(step => step.agent));
482
+ for (const name of usedAgents) {
483
+ const settings = agentSettings.get(name);
484
+ if (settings) requirements(settings.meta, settings.path);
485
+ }
486
+ for (const path of Object.keys(files)) {
487
+ const skill = path.match(/^(?:agents\/([^/]+)\/)?skills\/[^/]+(?:\/(?:.*\/)?SKILL\.md|\.ya?ml)$/);
488
+ if (skill && (!skill[1] || usedAgents.has(skill[1]))) factory.unsupportedRequirements.push({ source: path, setting: 'skill' });
489
+ }
490
+ for (const step of factory.steps) {
491
+ if (step.missing.length) factory.blockers.push(`The step "${step.title}" needs ${step.missing.map((tool) => `\`${tool}\``).join(' and ')}, which this runner does not have (it has ${TOOLS.join(' and ')}). It can be read, shared and taken to a harness that has ${step.missing.length === 1 ? 'that tool' : 'those tools'}; to run it here, change the step.`);
492
+ }
493
+ for (const { source, setting } of factory.unsupportedRequirements) factory.blockers.push(`This factory requires \`${setting}\` in \`${source}\`, which this runner cannot apply. The source is preserved for a compatible runtime.`);
494
+ warnings.push(...factory.blockers);
495
+ factory.runnable = factory.flowValid && factory.blockers.length === 0;
496
+ if (files['agents/foreman/agent.md'] !== undefined && !isGeneratedForeman(files['agents/foreman/agent.md'])) warnings.push('agents/foreman/agent.md was written by the author, so it is kept as it is and is not rewritten from the flow. A harness without a flow file follows it; check that it still describes these steps.');
497
+ }
498
+ if (!factory.title) factory.title = factory.name.replace(/-/g, ' ').replace(/^./, (c) => c.toUpperCase());
499
+ return { ok: errors.length === 0, errors, warnings, factory };
500
+ }
501
+
502
+ // The step whose result is the deliverable: the last deliver step, or the last
503
+ // work step when the flow has no deliver.
504
+ export function finalStep(factory) {
505
+ const reversed = [...factory.steps].reverse();
506
+ return reversed.find((step) => step.kind === 'deliver') || reversed.find((step) => step.kind === 'work') || null;
507
+ }
508
+
509
+ // Positions for drawing the flow: a column per level, parallel steps stacked.
510
+ export function layout(factory) {
511
+ const level = levelsOf(factory.steps);
512
+ const columns = [];
513
+ for (const step of factory.steps) (columns[level[step.id]] ||= []).push(step.id);
514
+ return { level, columns };
515
+ }
516
+
517
+ // The foreman's instructions, written from the flow, so a harness that has no
518
+ // flow.yaml can read the workflow's instructions from prose.
519
+ export function foremanText(factory) {
520
+ const last = finalStep(factory);
521
+ const lines = factory.steps.map((step, index) => {
522
+ const scope = step.context ? ` Share only these bindings: inputs ${JSON.stringify(step.context.inputs)}, artifacts ${JSON.stringify(step.context.artifacts)}.${step.context.public ? ' This is public research: never add private facts.' : ''}` : '';
523
+ const after = (step.needs.length ? ` After ${step.needs.join(' and ')}.` : '') + scope;
524
+ if (step.kind === 'approve') return `${index + 1}. **${step.title}** (the owner decides). Show the owner the result of "${step.target}" and stop. Continue only when they approve; if they ask for changes, redo "${step.target}" with their note.${after}`;
525
+ if (step.kind === 'check') return `${index + 1}. **${step.title}** (agent: ${step.agent}). Review the result of "${step.target}" strictly against this: ${step.task || 'it does what was asked, with no invented facts.'} If it fails, send it back to be redone ${step.rounds === 1 ? 'once' : `up to ${step.rounds} times`} with the reasons.${after}`;
526
+ return `${index + 1}. **${step.title}** (agent: ${step.agent}${step.tools.length + (step.missing || []).length ? `; tools: ${[...step.tools, ...(step.missing || [])].join(', ')}` : ''}). ${step.task.split('\n')[0]}${after}`;
527
+ });
528
+ const parallel = factory.steps.filter((step) => factory.steps.some((other) => other.id !== step.id && other.needs.join() === step.needs.join() && other.needs.length)).length;
529
+ return `---
530
+ description: Coordinates the ${factory.name} factory from its flow.
531
+ agentType: FOREMAN
532
+ ---
533
+
534
+ # ${factory.title}
535
+
536
+ ${factory.description}
537
+
538
+ You coordinate this factory. Hand each step to the agent named for it, ${factory.steps.some((step) => step.context) ? 'share only its declared context bindings (checks also receive their exact subject), and collect its result. A dependency alone does not authorize sharing its text.' : 'give that agent the inputs and the results of the steps it needs, and collect its result.'}${parallel ? ' Steps that need the same earlier steps can run at the same time.' : ''} Never invent facts: agents use only the inputs, earlier results and pages they actually read.
539
+
540
+ ## What to ask the person for
541
+
542
+ ${factory.inputs.map((input) => `- **${input.key}**${input.required ? '' : ' (optional)'}: ${input.help}`).join('\n') || 'Nothing.'}
543
+
544
+ ## Steps
545
+
546
+ ${lines.join('\n')}
547
+
548
+ ${factory.outputs ? `Return each declared output unchanged: ${factory.outputs.map((item) => `${item.title} from ${item.step}`).join('; ')}. Combine them deterministically in declaration order; never rewrite them.` : last ? `Return the result of "${last.id}" (${last.title}) unchanged as the deliverable after all steps finish.` : 'There is no work or deliver step to return.'}${factory.done.length ? `\n\n## Done when\n\n${factory.done.map((line) => `- ${line}`).join('\n')}` : ''}
549
+
550
+ The exact, executable version of these steps is flow.yaml. To run it as written, with a receipt of every step: \`node run.mjs\` in this folder, or open the factory on https://factory.mindburn.org.
551
+ `;
552
+ }
553
+
554
+ export function toEnvelope(files, { status = 'existing-source', project = '', warnings = [], leftOut = [] } = {}) {
555
+ const normal = normalizeFiles(files);
556
+ return { format: BUNDLE_FORMAT, status, source: { project, factory_path: 'factory.yaml' }, files: Object.entries(normal).map(([path, content]) => ({ path, content })), ...(leftOut.length ? { left_out: leftOut } : {}), warnings, review_required: true, meta: { file_count: Object.keys(normal).length } };
557
+ }
558
+
559
+ const RESOURCE_NAME = '[a-z0-9][a-z0-9_-]{0,40}';
560
+ const SOURCE_PART = '[A-Za-z0-9][A-Za-z0-9_.-]{0,80}';
561
+ // Companion definitions travel with a factory; admitting a file grants no
562
+ // permission to execute it. Hidden paths, traversal and executable code stay out.
563
+ export const ALLOWED_PATH = new RegExp(`^(?:${[
564
+ 'factory\\.yaml', 'flow\\.yaml', 'mindburn\\.(?:package|requirements|tests|company|lock)\\.yaml', 'layout\\.json', '(?:README|LICENSE|LICENCE|NOTICE|CHANGELOG)(?:\\.(?:md|txt))?',
565
+ `agents/${RESOURCE_NAME}/agent\\.md`, `automations/${RESOURCE_NAME}/automation\\.md`, `scorers/${RESOURCE_NAME}/scorer\\.md`,
566
+ `(?:runners|routers|webhooks)/${RESOURCE_NAME}\\.yaml`,
567
+ `benchmarks/${RESOURCE_NAME}/(?:suite\\.yaml|tasks/${RESOURCE_NAME}\\.yaml)`,
568
+ `(?:agents/${RESOURCE_NAME}/)?skills/${RESOURCE_NAME}/(?:${SOURCE_PART}/){0,6}${SOURCE_PART}\\.md`,
569
+ `(?:agents/${RESOURCE_NAME}/)?skills/${RESOURCE_NAME}\\.ya?ml`, 'self-improvement/agent\\.md',
570
+ `(?:profiles|flows|schemas|prompts|assets|examples|checks|fixtures|capabilities|policies|adapters|ui)/(?:${SOURCE_PART}/){0,6}${SOURCE_PART}\\.(?:yaml|yml|json|md|txt|csv|svg)`,
571
+ ].join('|')})(?![\\s\\S])`);
572
+
573
+ // Reads a transfer bundle (from the import skill or a hub export) into files,
574
+ // checking paths, sizes and each file's recorded SHA-256.
575
+ export async function fromEnvelope(envelope) {
576
+ const problems = [];
577
+ if (!envelope || envelope.format !== BUNDLE_FORMAT || !Array.isArray(envelope.files)) return { files: {}, problems: [`This is not a ${BUNDLE_FORMAT} file.`], leftOut: [], status: null, warnings: [] };
578
+ const validWarnings = envelope.warnings === undefined || (Array.isArray(envelope.warnings) && envelope.warnings.every((warning) => typeof warning === 'string'));
579
+ if (!validWarnings) problems.push('Bundle warnings must be a list of text messages.');
580
+ const validLeftOut = envelope.left_out === undefined || (Array.isArray(envelope.left_out) && envelope.left_out.every((path) => typeof path === 'string'));
581
+ if (!validLeftOut) problems.push('Left-out paths must be a list of text paths.');
582
+ if (envelope.source?.project !== undefined && typeof envelope.source.project !== 'string') problems.push('The source project name must be text.');
583
+ const files = {};
584
+ // Files of the project that are not part of the factory: named by the
585
+ // exporter, or sent anyway. They are listed for the person, never stored.
586
+ const leftOut = (validLeftOut ? envelope.left_out || [] : []).slice(0, 60).map((path) => path.slice(0, 120));
587
+ let total = 0;
588
+ const seen = new Set();
589
+ if (envelope.files.length > 60) problems.push(`The bundle has ${envelope.files.length} files; a factory bundle has at most 60.`);
590
+ for (const entry of envelope.files.slice(0, 60)) {
591
+ if (!entry || typeof entry.path !== 'string' || typeof entry.content !== 'string') { problems.push('Every source entry needs a text path and text content.'); continue; }
592
+ const { path, content } = entry;
593
+ const bytes = new TextEncoder().encode(content).length;
594
+ total += bytes;
595
+ if (seen.has(path)) { problems.push(`"${path.slice(0, 80)}" appears more than once. Each file needs one exact version.`); continue; }
596
+ seen.add(path);
597
+ if (!ALLOWED_PATH.test(path)) {
598
+ if (!path || /^\/|(^|\/)\.\.?(\/|$)|\\|[\u0000-\u001f]/.test(path)) problems.push(`"${path.slice(0, 80)}" is not a file a factory bundle may contain.`);
599
+ else if (!leftOut.includes(path.slice(0, 120))) leftOut.push(path.slice(0, 120));
600
+ continue;
601
+ }
602
+ if (entry.sha256 !== undefined && (typeof entry.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(entry.sha256) || (await sha256(content)) !== entry.sha256)) { problems.push(`"${path}" does not match its recorded checksum.`); continue; }
603
+ if (entry.bytes !== undefined && entry.bytes !== bytes) { problems.push(`"${path}" does not match its recorded byte length.`); continue; }
604
+ files[path] = content;
605
+ }
606
+ if (total > 400000) problems.push('The bundle is larger than 400,000 UTF-8 bytes.');
607
+ return { files: normalizeFiles(files), problems, leftOut, status: envelope.status === 'inferred-draft' ? 'inferred-draft' : 'existing-source', warnings: validWarnings ? (envelope.warnings || []).slice(0, 10) : [] };
608
+ }
609
+
610
+ // ------------------------------------------------------------- hashing ----
611
+
612
+ const encoder = new TextEncoder();
613
+
614
+ export function toBase64Url(bytes) {
615
+ let binary = '';
616
+ for (const byte of new Uint8Array(bytes)) binary += String.fromCharCode(byte);
617
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
618
+ }
619
+
620
+ export function fromBase64Url(text) {
621
+ const binary = atob(text.replace(/-/g, '+').replace(/_/g, '/'));
622
+ return Uint8Array.from(binary, (char) => char.charCodeAt(0));
623
+ }
624
+
625
+ export async function sha256(text) {
626
+ const digest = await crypto.subtle.digest('SHA-256', encoder.encode(String(text)));
627
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
628
+ }
629
+
630
+ // Canonical JSON: object keys sorted at every level, no whitespace.
631
+ export function canonical(value) {
632
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
633
+ if (value && typeof value === 'object') {
634
+ return `{${Object.keys(value).sort().filter((key) => value[key] !== undefined).map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
635
+ }
636
+ return JSON.stringify(value ?? null);
637
+ }
638
+
639
+ // A bundle's identity: the hash of its files, whatever order they came in.
640
+ export async function bundleHash(files) {
641
+ return sha256(canonical(normalizeFiles(files)));
642
+ }
643
+
644
+ export async function factoryId(files) {
645
+ const parsed = parseBundle(files);
646
+ return `${parsed.factory.name || 'factory'}-${(await bundleHash(files)).slice(0, 10)}`;
647
+ }
648
+
649
+ // Verifies a receipt against the hub's published Ed25519 keys and, when the
650
+ // deliverable text is given, against its recorded hash.
651
+ export async function verifyReceipt(receipt, keys, deliverable) {
652
+ const result = { signed: false, signatureValid: false, deliverableMatches: null, keyId: null, problems: [] };
653
+ if (!receipt || receipt.kind !== 'factory-run-receipt') {
654
+ result.problems.push('This is not a factory run receipt.');
655
+ return result;
656
+ }
657
+ if (typeof deliverable === 'string') {
658
+ result.deliverableMatches = (await sha256(deliverable)) === receipt.deliverable_sha256;
659
+ if (!result.deliverableMatches) result.problems.push('The deliverable text does not match the hash in the receipt.');
660
+ }
661
+ const signature = receipt.signature;
662
+ if (!signature) {
663
+ result.problems.push('The receipt is not signed: it was written by a local run and is self-attested.');
664
+ return result;
665
+ }
666
+ result.signed = true;
667
+ result.keyId = signature.key_id;
668
+ const key = (keys || []).find((candidate) => candidate.key_id === signature.key_id);
669
+ if (!key) {
670
+ result.problems.push(`No published key with id ${signature.key_id}.`);
671
+ return result;
672
+ }
673
+ const { signature: _omit, ...unsigned } = receipt;
674
+ try {
675
+ const publicKey = await crypto.subtle.importKey('raw', fromBase64Url(key.public_key), { name: 'Ed25519' }, false, ['verify']);
676
+ result.signatureValid = await crypto.subtle.verify({ name: 'Ed25519' }, publicKey, fromBase64Url(signature.sig), encoder.encode(canonical(unsigned)));
677
+ } catch (error) {
678
+ result.problems.push(`Signature check could not run: ${error.message}`);
679
+ }
680
+ if (result.signed && !result.signatureValid && !result.problems.length) result.problems.push('The signature does not match the receipt contents.');
681
+ return result;
682
+ }
683
+
684
+ // ------------------------------------------------------------ web pages ----
685
+
686
+ const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', mdash: '—', ndash: '–', hellip: '…', rsquo: '’', lsquo: '‘', rdquo: '”', ldquo: '“', copy: '©', reg: '®', trade: '™' };
687
+
688
+ function decodeEntities(text) {
689
+ return text
690
+ .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
691
+ .replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(parseInt(code, 16)))
692
+ .replace(/&([a-z]+);/gi, (whole, name) => ENTITIES[name.toLowerCase()] ?? whole);
693
+ }
694
+
695
+ // Turns a web page into the text a reader would see, with headings and links
696
+ // kept, so a station can quote and cite it.
697
+ export function htmlToText(html, baseUrl, limit = 14000) {
698
+ let page = String(html || '');
699
+ const title = decodeEntities((page.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] || '').trim());
700
+ const description = decodeEntities(page.match(/<meta[^>]+name=["']description["'][^>]*content=["']([^"']*)["']/i)?.[1]
701
+ || page.match(/<meta[^>]+content=["']([^"']*)["'][^>]*name=["']description["']/i)?.[1] || '');
702
+ page = page
703
+ .replace(/<!--[\s\S]*?-->/g, ' ')
704
+ .replace(/<(script|style|noscript|svg|template|iframe|canvas)\b[\s\S]*?<\/\1>/gi, ' ')
705
+ .replace(/<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi, (_, level, inner) => `\n\n${'#'.repeat(Number(level))} ${inner.replace(/<[^>]+>/g, ' ')}\n\n`)
706
+ .replace(/<a\b[^>]*href=["']([^"'#][^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi, (_, href, inner) => {
707
+ const label = inner.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
708
+ if (!label) return ' ';
709
+ let target = href;
710
+ try { target = new URL(href, baseUrl).href; } catch { /* keep as written */ }
711
+ return /^https?:/.test(target) ? ` [${label}](${target}) ` : ` ${label} `;
712
+ })
713
+ .replace(/<li[^>]*>/gi, '\n- ')
714
+ .replace(/<(br|\/p|\/div|\/tr|\/section|\/article|\/header|\/footer|\/li|\/ul|\/ol|\/table)\b[^>]*>/gi, '\n')
715
+ .replace(/<\/t[dh]>/gi, ' | ')
716
+ .replace(/<[^>]+>/g, ' ');
717
+ page = decodeEntities(page).replace(/[ \t\f\v ]+/g, ' ').replace(/ ?\n ?/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
718
+ const head = [title && `Title: ${title}`, description && `Description: ${decodeEntities(description)}`].filter(Boolean).join('\n');
719
+ const text = `${head}\n\n${page}`.trim();
720
+ return text.length > limit ? `${text.slice(0, limit)}\n\n[Page cut here: ${text.length - limit} more characters not shown]` : text;
721
+ }
722
+
723
+ export function normalizeUrl(value) {
724
+ let text = String(value || '').trim();
725
+ if (!text) return null;
726
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) text = `https://${text}`;
727
+ try {
728
+ const url = new URL(text);
729
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
730
+ if (!url.hostname.includes('.')) return null;
731
+ return url.href;
732
+ } catch {
733
+ return null;
734
+ }
735
+ }
736
+
737
+ // True for an address a factory may read: a public http(s) host on a normal
738
+ // port. Local, private and literal-IP addresses are refused, so a factory (or a
739
+ // page it read) cannot make the runner fetch from the machine it runs on.
740
+ export function isPublicAddress(value) {
741
+ let url;
742
+ try { url = new URL(value); } catch { return false; }
743
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
744
+ if (url.port && !['80', '443'].includes(url.port)) return false;
745
+ if (url.username || url.password) return false;
746
+ const host = url.hostname.toLowerCase();
747
+ if (!host.includes('.') || host.endsWith('.')) return false;
748
+ if (/^[\d.]+$/.test(host) || host.includes(':') || host.startsWith('[')) return false;
749
+ return !/(^|\.)(localhost|local|internal|lan|home|corp|test|invalid|onion)$/.test(host);
750
+ }
751
+
752
+ // ------------------------------------------------------------ providers ----
753
+
754
+ // How long one model call may take before it is given up. Answers arrive
755
+ // whole, not streamed, and a long deliverable or a search turn takes minutes,
756
+ // so the limit is generous: it exists to end a call that will never answer.
757
+ // A model on someone's own machine may be slower still.
758
+ export const MODEL_TIMEOUT_MS = 300000;
759
+
760
+ const FAILURE_CODES = new Set(['provider_timeout', 'provider_network', 'provider_unavailable', 'provider_auth', 'provider_request', 'provider_response', 'output_limit', 'context_limit', 'call_limit', 'inputs', 'tools', 'flow', 'execution_failed', 'runner_failed']);
761
+ export function failureDetails(error) {
762
+ const code = FAILURE_CODES.has(error?.code) ? error.code : 'execution_failed';
763
+ const status = error?.status;
764
+ return { code, ...(Number.isInteger(status) && status >= 400 && status <= 599 ? { httpStatus: status } : {}) };
765
+ }
766
+ const failure = (code, message, status) => Object.assign(new Error(message), { code, ...(status ? { status } : {}) });
767
+
768
+ async function postJson(fetchImpl, url, headers, body, timeoutMs = MODEL_TIMEOUT_MS, attempt = 0) {
769
+ let response;
770
+ try {
771
+ response = await fetchImpl(url, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs) });
772
+ } catch (error) {
773
+ if (error?.name !== 'TimeoutError' && error?.name !== 'AbortError') throw failure('provider_network', 'The connection to the model provider failed. Try again.');
774
+ throw failure('provider_timeout', `The model did not answer within ${Math.round(timeoutMs / 1000)} seconds.`, 504);
775
+ }
776
+ if ((response.status === 429 || response.status >= 500) && attempt < 2) {
777
+ await new Promise((resolve) => setTimeout(resolve, 1500 * (attempt + 1)));
778
+ return postJson(fetchImpl, url, headers, body, timeoutMs, attempt + 1);
779
+ }
780
+ const payload = await response.json().catch(() => ({}));
781
+ if (!response.ok) {
782
+ const message = payload?.error?.message || payload?.error || `HTTP ${response.status}`;
783
+ const code = [401, 403].includes(response.status) ? 'provider_auth' : response.status === 429 || response.status >= 500 ? 'provider_unavailable' : 'provider_request';
784
+ throw failure(code, `The model provider answered ${response.status}: ${typeof message === 'string' ? message : JSON.stringify(message)}`, response.status);
785
+ }
786
+ return payload;
787
+ }
788
+
789
+ // Provider token counters stay separate: cache creation is part of input,
790
+ // but has a different price from uncached input and cache reads.
791
+ const tokenCount = (value) => Number.isSafeInteger(value) && value >= 0;
792
+ const modelName = (value) => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$/.test(value) ? value : null;
793
+ const usageCategory = (value, allowed) => value == null ? null : allowed.includes(value) ? value : 'unknown';
794
+ function providerUsage(payload, anthropic) {
795
+ const u = payload?.usage || {};
796
+ const input = anthropic ? u.input_tokens : u.prompt_tokens;
797
+ const output = anthropic ? u.output_tokens : u.completion_tokens;
798
+ const cacheReadInput = (anthropic ? u.cache_read_input_tokens : u.prompt_tokens_details?.cached_tokens) ?? 0;
799
+ const cacheWriteInput = anthropic ? u.cache_creation_input_tokens ?? 0 : 0;
800
+ const invalid = [input, output, cacheReadInput, cacheWriteInput].some((value) => value != null && !tokenCount(value));
801
+ return {
802
+ input: invalid ? NaN : (input ?? 0) + (anthropic ? cacheReadInput + cacheWriteInput : 0), output: invalid ? NaN : output ?? 0,
803
+ measured: tokenCount(input) && tokenCount(output) && tokenCount(cacheReadInput) && tokenCount(cacheWriteInput) && (anthropic || cacheReadInput <= input),
804
+ uncachedInput: input === undefined ? null : anthropic ? input : input - cacheReadInput,
805
+ cacheReadInput, cacheWriteInput,
806
+ cacheWrite5mInput: anthropic ? u.cache_creation?.ephemeral_5m_input_tokens ?? (cacheWriteInput ? null : 0) : 0,
807
+ cacheWrite1hInput: anthropic ? u.cache_creation?.ephemeral_1h_input_tokens ?? (cacheWriteInput ? null : 0) : 0,
808
+ searchRequests: anthropic ? u.server_tool_use?.web_search_requests ?? (Array.isArray(payload?.content) && payload.content.some((block) => block?.type === 'server_tool_use' && block.name === 'web_search') ? null : 0) : 0,
809
+ serviceTier: usageCategory(u.service_tier, ['standard', 'priority', 'batch']),
810
+ speed: usageCategory(u.speed, ['standard', 'fast']),
811
+ inferenceGeo: usageCategory(u.inference_geo, ['global', 'us']),
812
+ };
813
+ }
814
+
815
+ // Anthropic Messages API. Messages stay in this block format inside the engine.
816
+ export function anthropicProvider({ apiKey, model, workspaceId, fetchImpl = fetch, browser = false, baseUrl = 'https://api.anthropic.com', timeoutMs = MODEL_TIMEOUT_MS }) {
817
+ return {
818
+ name: 'anthropic',
819
+ billing: baseUrl.replace(/\/$/, '') === 'https://api.anthropic.com' ? 'anthropic-direct' : null,
820
+ model,
821
+ serverSearch: true,
822
+ async call({ system, messages, tools = [], search = 0, forceTool, maxTokens = 4096 }) {
823
+ const toolList = tools.map((tool) => ({ name: tool.name, description: tool.description, input_schema: tool.input_schema }));
824
+ if (search > 0) toolList.push({ type: 'web_search_20250305', name: 'web_search', max_uses: search });
825
+ const body = { model, max_tokens: maxTokens, system, messages };
826
+ if (toolList.length) body.tools = toolList;
827
+ if (forceTool) body.tool_choice = { type: 'tool', name: forceTool };
828
+ const headers = { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' };
829
+ if (workspaceId) headers['anthropic-workspace-id'] = workspaceId;
830
+ if (browser) headers['anthropic-dangerous-direct-browser-access'] = 'true';
831
+ let payload;
832
+ try {
833
+ payload = await postJson(fetchImpl, `${baseUrl}/v1/messages`, headers, body, timeoutMs);
834
+ } catch (error) {
835
+ // Some models refuse a forced tool: ask for it in words instead.
836
+ if (!forceTool || error.status !== 400 || !/tool_choice/.test(error.message)) throw error;
837
+ delete body.tool_choice;
838
+ body.system = `${system}\n\nAnswer only by calling the \`${forceTool}\` tool.`;
839
+ payload = await postJson(fetchImpl, `${baseUrl}/v1/messages`, headers, body, timeoutMs);
840
+ }
841
+ const usage = providerUsage(payload, true);
842
+ try {
843
+ if (!Array.isArray(payload?.content)) throw failure('provider_response', 'The model returned no message content.');
844
+ const content = payload.content;
845
+ return {
846
+ content,
847
+ stop: payload.stop_reason,
848
+ toolCalls: content.filter((block) => block.type === 'tool_use').map((block) => ({ id: block.id, name: block.name, input: block.input || {} })),
849
+ searches: content.filter((block) => block.type === 'server_tool_use' && block.name === 'web_search').map((block) => block.input?.query || ''),
850
+ model: modelName(payload.model), usage,
851
+ };
852
+ } catch (error) { error.providerUsage = usage; error.providerModel = modelName(payload?.model); throw error; }
853
+ },
854
+ };
855
+ }
856
+
857
+ // Any OpenAI-compatible chat completions endpoint: OpenAI, OpenRouter, Ollama,
858
+ // LM Studio, vLLM and the rest.
859
+ export function openaiProvider({ apiKey, model, baseUrl = 'https://api.openai.com/v1', fetchImpl = fetch, timeoutMs = 600000 }) {
860
+ const convert = (system, messages) => {
861
+ const out = [{ role: 'system', content: system }];
862
+ for (const message of messages) {
863
+ const blocks = typeof message.content === 'string' ? [{ type: 'text', text: message.content }] : message.content;
864
+ if (message.role === 'assistant') {
865
+ const calls = blocks.filter((block) => block.type === 'tool_use');
866
+ const entry = { role: 'assistant', content: blocks.filter((block) => block.type === 'text').map((block) => block.text).join('\n') || null };
867
+ if (calls.length) entry.tool_calls = calls.map((call) => ({ id: call.id, type: 'function', function: { name: call.name, arguments: JSON.stringify(call.input || {}) } }));
868
+ out.push(entry);
869
+ } else {
870
+ for (const block of blocks.filter((item) => item.type === 'tool_result')) {
871
+ out.push({ role: 'tool', tool_call_id: block.tool_use_id, content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content) });
872
+ }
873
+ const text = blocks.filter((block) => block.type === 'text').map((block) => block.text).join('\n');
874
+ if (text) out.push({ role: 'user', content: text });
875
+ }
876
+ }
877
+ return out;
878
+ };
879
+ return {
880
+ name: 'openai-compatible',
881
+ model,
882
+ serverSearch: false,
883
+ async call({ system, messages, tools = [], forceTool, maxTokens = 4096 }) {
884
+ const body = { model, messages: convert(system, messages), max_completion_tokens: maxTokens };
885
+ if (tools.length) body.tools = tools.map((tool) => ({ type: 'function', function: { name: tool.name, description: tool.description, parameters: tool.input_schema } }));
886
+ if (forceTool) body.tool_choice = { type: 'function', function: { name: forceTool } };
887
+ const headers = apiKey ? { authorization: `Bearer ${apiKey}` } : {};
888
+ let payload;
889
+ try {
890
+ payload = await postJson(fetchImpl, `${baseUrl.replace(/\/$/, '')}/chat/completions`, headers, body, timeoutMs);
891
+ } catch (error) {
892
+ // Older servers only know `max_tokens`.
893
+ if (error.status !== 400 || !/max_completion_tokens/.test(error.message)) throw error;
894
+ delete body.max_completion_tokens;
895
+ body.max_tokens = maxTokens;
896
+ payload = await postJson(fetchImpl, `${baseUrl.replace(/\/$/, '')}/chat/completions`, headers, body, timeoutMs);
897
+ }
898
+ const usage = providerUsage(payload, false);
899
+ try {
900
+ const message = payload?.choices?.[0]?.message;
901
+ if (!message || (message.content != null && typeof message.content !== 'string') || (message.tool_calls != null && !Array.isArray(message.tool_calls))) throw failure('provider_response', 'The model returned an invalid message.');
902
+ const content = [];
903
+ if (message.content) content.push({ type: 'text', text: message.content });
904
+ for (const call of message.tool_calls || []) {
905
+ let input = {};
906
+ try { input = JSON.parse(call.function?.arguments || '{}'); } catch { throw failure('provider_response', 'The model returned invalid tool arguments.'); }
907
+ content.push({ type: 'tool_use', id: call.id, name: call.function?.name, input });
908
+ }
909
+ return {
910
+ content,
911
+ stop: payload.choices?.[0]?.finish_reason,
912
+ toolCalls: content.filter((block) => block.type === 'tool_use').map((block) => ({ id: block.id, name: block.name, input: block.input })),
913
+ searches: [],
914
+ model: modelName(payload.model), usage,
915
+ };
916
+ } catch (error) { error.providerUsage = usage; error.providerModel = modelName(payload?.model); throw error; }
917
+ },
918
+ };
919
+ }
920
+
921
+ // --------------------------------------------------------------- prompts ----
922
+
923
+ const FETCH_TOOL = {
924
+ name: 'web_fetch',
925
+ description: 'Read one public web page and get its text, headings and links. Use it for every page you rely on. Give a full URL.',
926
+ input_schema: { type: 'object', properties: { url: { type: 'string', description: 'The full address of the page, starting with https://' } }, required: ['url'] },
927
+ };
928
+
929
+ const VERDICT_TOOL = {
930
+ name: 'verdict',
931
+ description: 'Record whether the result passes this check.',
932
+ input_schema: {
933
+ type: 'object',
934
+ properties: {
935
+ pass: { type: 'boolean', description: 'True only if every criterion is met.' },
936
+ issues: { type: 'array', items: { type: 'string' }, description: 'One short, concrete sentence per clear breach that must be fixed, naming where it is. Never list points that are acceptable. Empty when it passes.' },
937
+ note: { type: 'string', description: 'One sentence a non-technical person can read: what was checked and the result.' },
938
+ },
939
+ required: ['pass', 'issues', 'note'],
940
+ },
941
+ };
942
+
943
+ const HOUSE_RULES = `Rules for every agent in this factory:
944
+ - Use only the inputs, the results of earlier steps and pages you actually read. Never invent facts, names, numbers, quotes, prices or URLs.
945
+ - When something cannot be found or a page cannot be read, say so in the result in one plain sentence and carry on with what you have.
946
+ - When a claim comes from a web page, put the page address next to it as a Markdown link.
947
+ - Write for a busy person who is not technical: short sentences, plain words, specifics instead of generalities.
948
+ - Output only this step's result, in Markdown. No preamble, no sign-off, no remarks about yourself, the other agents or the process.`;
949
+
950
+ function clip(text, limit) {
951
+ const value = String(text ?? '');
952
+ return value.length > limit ? `${value.slice(0, limit)}\n[cut: ${value.length - limit} more characters]` : value;
953
+ }
954
+
955
+ function inputsBlock(factory, inputs) {
956
+ const lines = factory.inputs.map((input) => `### ${input.label}\n${inputs[input.key] || '(not given)'}`);
957
+ return lines.length ? `## What the person gave\n\n${lines.join('\n\n')}` : '## What the person gave\n\n(nothing: this factory takes no inputs)';
958
+ }
959
+
960
+ // Both writers and reviewers use exactly this scope. Dependencies control
961
+ // ordering and invalidation; a context binding controls what text is shared.
962
+ export function resolveContext(state, step, exclude = null) {
963
+ if (!step.context) {
964
+ const artifacts = ancestorsOf(state.factory, step.id).filter((id) => id !== exclude && state.artifacts[id] !== undefined).map((id) => ({ alias: id, id, title: state.factory.steps.find((item) => item.id === id).title, text: state.artifacts[id] }));
965
+ return { inputs: { ...state.inputs }, fields: state.factory.inputs, artifacts, explicit: false };
966
+ }
967
+ const inputs = {};
968
+ const fields = [];
969
+ for (const [alias, key] of Object.entries(step.context.inputs)) {
970
+ inputs[alias] = state.inputs[key] || '';
971
+ fields.push({ key: alias, label: alias });
972
+ }
973
+ const artifacts = Object.entries(step.context.artifacts).filter(([, id]) => id !== exclude).map(([alias, id]) => {
974
+ if (typeof state.artifacts[id] !== 'string' || state.steps[id]?.status !== 'done') throw new Error(`The bound artifact "${alias}" is unavailable or awaits revision.`);
975
+ return { alias, id, title: alias, text: state.artifacts[id] };
976
+ });
977
+ return { inputs, fields, artifacts, explicit: true };
978
+ }
979
+
980
+ function contextBlocks(context) {
981
+ return [inputsBlock({ inputs: context.fields }, context.inputs), context.artifacts.length ? `## Results from earlier steps\n\n${context.artifacts.map((item) => `### ${item.title} (${item.id})\n\n${item.text}`).join('\n\n')}` : ''];
982
+ }
983
+
984
+ function agentSystem(state, step, host) {
985
+ const { factory } = state;
986
+ const agent = factory.agents[step.agent];
987
+ const tools = [];
988
+ if (step.tools.includes('web_fetch')) tools.push('You can read public web pages with the web_fetch tool.');
989
+ if (step.tools.includes('web_search')) {
990
+ tools.push(host.provider.serverSearch
991
+ ? 'You can search the web with the web_search tool. Search first when you need to find pages, then read the best ones.'
992
+ : 'Web search is not available on this runner: work from the addresses in the inputs and the links on pages you read, and say so when that limits the result.');
993
+ }
994
+ return [
995
+ `You are "${step.agent}", one agent in the factory "${factory.title}". ${step.context ? 'Use only this step’s declared context.' : `The factory's promised outcome: ${factory.description}`}`,
996
+ agent?.body ? `## Your role\n\n${agent.body}` : '',
997
+ `Today is ${new Date(host.now()).toISOString().slice(0, 10)}.`,
998
+ step.kind === 'deliver'
999
+ ? factory.outputs ? 'This step writes one artifact in connected work. Complete this step’s task; do not rewrite other declared outputs.' : 'This step writes the final deliverable the person receives. It must be complete and stand on its own: they will not see the earlier steps.'
1000
+ : 'You do exactly this step and hand the result to the agents that come after you.',
1001
+ tools.join(' '),
1002
+ HOUSE_RULES,
1003
+ (step.criteria || (!factory.outputs && step.kind === 'deliver' ? factory.done : [])).length ? `Done when:\n${(step.criteria || factory.done).map((line) => `- ${line}`).join('\n')}` : '',
1004
+ ].filter(Boolean).join('\n\n');
1005
+ }
1006
+
1007
+ const FORMAT_GUIDE = `A factory is a small organisation of AI agents that turns what a person gives into a finished deliverable. You write it as files.
1008
+
1009
+ === factory.yaml ===
1010
+ name: lowercase-words-with-hyphens
1011
+ description: One or two sentences. What finished thing the person gets, and when to use it.
1012
+
1013
+ === flow.yaml ===
1014
+ flow: mindburn.flow/v1
1015
+ title: Short human title
1016
+ author: Community
1017
+ version: "1.0.0"
1018
+ license: MIT
1019
+ tags: [one, two]
1020
+ time: about 3 minutes
1021
+ inputs:
1022
+ input_name:
1023
+ type: url # text, longtext or url
1024
+ help: What to put here, with an example.
1025
+ another_input:
1026
+ type: text
1027
+ optional: true
1028
+ help: ...
1029
+ steps:
1030
+ step_name:
1031
+ title: Verb-first step title
1032
+ agent: researcher
1033
+ kind: work
1034
+ tools: [web_search, web_fetch]
1035
+ task: |
1036
+ What to do, in plain language: what to look at, what to produce, in what
1037
+ shape, and what to do when something is missing.
1038
+ other_step:
1039
+ title: ...
1040
+ agent: analyst
1041
+ kind: work
1042
+ needs: [step_name]
1043
+ task: |
1044
+ ...
1045
+ write_it:
1046
+ title: ...
1047
+ agent: writer
1048
+ kind: deliver
1049
+ needs: [other_step]
1050
+ task: |
1051
+ Exactly what the final deliverable contains, section by section.
1052
+ check_it:
1053
+ title: Check the final document
1054
+ agent: reviewer
1055
+ kind: check
1056
+ needs: [write_it]
1057
+ checks: write_it
1058
+ task: |
1059
+ Concrete criteria for the entire final document, compared with inputs and earlier evidence.
1060
+ done:
1061
+ - A checkable statement about the deliverable.
1062
+
1063
+ === agents/researcher/agent.md ===
1064
+ ---
1065
+ description: One line: what this agent is responsible for.
1066
+ agentType: CUSTOM
1067
+ ---
1068
+
1069
+ Who this agent is and how it works, in a short paragraph and a few standing rules. Role, not task: the task for each step is in flow.yaml.
1070
+
1071
+ RULES:
1072
+ - Step kinds: work (an agent does a job), check (a strict reviewer that sends weak work back to be redone), approve (the line stops and the person agrees or says what to change; use only where a human decision really matters, at most once), deliver (writes the final deliverable; exactly one, followed by its final check).
1073
+ - \`needs\` lists the steps whose results this step uses. Steps that need the same earlier steps run in parallel: use this when two pieces of work do not depend on each other (for example researching two sources, or writing two different pieces from the same brief). A step that needs several steps waits for all of them.
1074
+ - \`checks\` names the work or deliver step a check reviews; \`shows\` names the one an approve step shows the person. A failed check or a requested change sends that step back to be redone, and everything after it runs again.
1075
+ - Every agent named in a step needs its own agents/<name>/agent.md. Use 2 to 5 agents with distinct roles (for example researcher, analyst, writer, reviewer). The same agent can do several steps. Approve steps need no agent.
1076
+ - agentType is REVIEW for an agent that only checks, CUSTOM otherwise.
1077
+ - Tools exist only for work and deliver steps and only these two: web_search (find pages), web_fetch (read a page). Leave \`tools\` out when a step needs none. There is no email, file, CRM, calendar or code tool: design the factory so its deliverable is a document the person can use or send themselves.
1078
+ - 3 to 8 steps. Each does one job. Put one check after the final deliverable. Add an earlier check only when it prevents costly downstream work.
1079
+ - 1 to 4 inputs. Ask only for what the steps truly need.
1080
+ - Every task must be specific to this outcome: name the sections, the counts, the columns. Never write generic filler such as "analyze thoroughly".
1081
+ - Tasks must forbid invented facts wherever facts matter and say what to do when information is missing.
1082
+ - Do not promise anything the agents cannot do with text, web search and web reading.
1083
+ - Output only the files, each starting with its "=== path ===" line. No code fences, no commentary.`;
1084
+
1085
+ export function composePrompt(outcome, examples = []) {
1086
+ const system = `You design factories.\n\n${FORMAT_GUIDE}`;
1087
+ const user = `${examples.length ? `Here ${examples.length === 1 ? 'is a good factory' : 'are good factories'} to learn the level of specificity from:\n\n${examples.map((example) => `<example>\n${example}\n</example>`).join('\n\n')}\n\n` : ''}Design a factory for this outcome, in the person's own words:\n\n<outcome>\n${clip(outcome, 2000)}\n</outcome>`;
1088
+ return { system, user };
1089
+ }
1090
+
1091
+ export function filesToText(files, only) {
1092
+ return Object.entries(normalizeFiles(files)).filter(([path]) => path !== 'agents/foreman/agent.md' && path !== 'README.md' && (!only || only(path))).map(([path, content]) => `=== ${path} ===\n${content.trim()}`).join('\n\n');
1093
+ }
1094
+
1095
+ export function improvePrompt(files, request, context = '', focus = '') {
1096
+ return {
1097
+ system: `You revise factories.\n\n${FORMAT_GUIDE}\n\nYou are revising an existing factory. Keep everything that works, change only what the request needs, keep the same \`name\`, and raise the version in flow.yaml (1.0.0 becomes 1.1.0). Output only the files you change, each in full; a file you leave out stays exactly as it is. Never drop a tool, an input or a comment the request did not ask you to change, even a tool this format does not list. To remove an agent, output its file header followed by the single line (removed). After the files, add a line containing only %%% and then one or two plain sentences saying what you changed and why.`,
1098
+ user: `<factory>\n${filesToText(files)}\n</factory>\n\n${focus ? `<selected>\nThe person has this part selected: ${focus}\n</selected>\n\n` : ''}${context ? `<what_happened_in_a_run>\n${clip(context, 6000)}\n</what_happened_in_a_run>\n\n` : ''}<request>\n${clip(request, 2000)}\n</request>`,
1099
+ };
1100
+ }
1101
+
1102
+ export function explainPrompt(files, question, focus = '') {
1103
+ return {
1104
+ system: 'You explain a factory (a small organisation of AI agents defined by factory.yaml, flow.yaml and one agent.md per agent) to its owner, who may not be technical. Answer the question in plain words, in at most six short sentences or a short list. Refer to steps and agents by their titles. Do not propose rewriting files unless asked.',
1105
+ user: `<factory>\n${filesToText(files)}\n</factory>\n\n${focus ? `<selected>\n${focus}\n</selected>\n\n` : ''}<question>\n${clip(question, 2000)}\n</question>`,
1106
+ };
1107
+ }
1108
+
1109
+ // factory.yaml with a new description, changed as text: every other line,
1110
+ // comments and quoting included, stays as the author wrote it, and so does
1111
+ // the key, quoted or not. Null when the result would not read back as the
1112
+ // same fields with that description.
1113
+ function withDescription(source, description) {
1114
+ // Every file leaves cleanComposed with LF line ends (normalizeFiles).
1115
+ const text = source.replace(/\r\n/g, '\n');
1116
+ const lines = text.split('\n');
1117
+ // Measured as parseYaml reads indentation: a tab counts as two spaces.
1118
+ const indentOf = (line) => line.replace(/\t/g, ' ').match(/^ */)[0].length;
1119
+ const first = lines.find((line) => stripComment(line).trim() && line.trim() !== '---');
1120
+ const root = first === undefined ? 0 : indentOf(first);
1121
+ const written = toYaml({ description: `${description}\n` }, root).replace(/\n$/, '').split('\n');
1122
+ // The key as parseYaml matches one: description, "description" or 'description'.
1123
+ const keyOf = (line) => (indentOf(line) === root && /^ *[^ \t]/.test(line) ? line.slice(root).match(/^("[^"]*"|'[^']*'|[^:#]+?):( |$)(.*)$/) : null);
1124
+ const at = lines.findIndex((line) => { const match = keyOf(line); return Boolean(match) && scalar(match[1]) === 'description'; });
1125
+ let out;
1126
+ if (at < 0) out = `${text.replace(/\n*$/, '\n')}${written.join('\n')}\n`;
1127
+ else {
1128
+ const key = keyOf(lines[at]);
1129
+ const value = stripComment(key[3]).trim();
1130
+ let end = at + 1;
1131
+ if (/^[|>][+-]?$/.test(value)) {
1132
+ while (end < lines.length && (!lines[end].trim() || indentOf(lines[end]) > root)) end += 1;
1133
+ while (end > at + 1 && !lines[end - 1].trim()) end -= 1;
1134
+ } else {
1135
+ // A value written under its key: a list or a mapping.
1136
+ while (end < lines.length && indentOf(lines[end]) > root && stripComment(lines[end]).trim()) end += 1;
1137
+ }
1138
+ written[0] = `${lines[at].slice(0, root)}${key[1]}:${written[0].slice(root + 'description:'.length)}`;
1139
+ // A comment after the old value stays on the line.
1140
+ written[0] += lines[at].slice(stripComment(lines[at]).trimEnd().length);
1141
+ out = [...lines.slice(0, at), ...written, ...lines.slice(end)].join('\n');
1142
+ }
1143
+ return readsAs(out, { ...parseYaml(text), description: `${description}\n` }) ? out : null;
1144
+ }
1145
+
1146
+ const readsAs = (text, fields) => { try { return canonical(parseYaml(text)) === canonical(fields); } catch { return false; } };
1147
+
1148
+ // Turns a model's "=== path ===" answer into bundle files. A new factory gets
1149
+ // the fixed parts of factory.yaml filled in. A revision (`base` given) is a
1150
+ // patch: a file the model did not return stays exactly as it was, factory.yaml
1151
+ // keeps every field and line but the description, and only an agent file can
1152
+ // be removed, by returning it with the single line "(removed)". A tool the
1153
+ // base asks for and the Hub lacks is its author's requirement: a patch that
1154
+ // takes one out says so in its summary, and lists it in `dropped`.
1155
+ export function cleanComposed(text, base = null) {
1156
+ const [body, summary = ''] = String(text || '').split(/\n%%%\s*\n?/);
1157
+ const files = {};
1158
+ let current = null;
1159
+ for (const line of body.replace(/\r\n/g, '\n').split('\n')) {
1160
+ const header = line.match(/^=== *([A-Za-z0-9_./-]+) *===\s*$/);
1161
+ if (header) { current = header[1]; files[current] = []; continue; }
1162
+ if (current) files[current].push(line);
1163
+ }
1164
+ const out = {};
1165
+ const removed = [];
1166
+ for (const [path, lines] of Object.entries(files)) {
1167
+ const content = lines.join('\n').replace(/^```[a-z]*\n/i, '').replace(/\n```\s*$/, '').trim();
1168
+ if (!ALLOWED_PATH.test(path) || !content) continue;
1169
+ if (base && content === '(removed)') { if (AGENT_PATH.test(path) && path !== 'agents/foreman/agent.md') removed.push(path); continue; }
1170
+ // A file returned as it was is not a change, whatever the model did to its last line break.
1171
+ out[path] = base && base[path] !== undefined && base[path].trim() === content ? base[path] : `${content}\n`;
1172
+ }
1173
+ const notes = [];
1174
+ if (out['factory.yaml']) {
1175
+ let config = {};
1176
+ try { config = parseYaml(out['factory.yaml']) || {}; } catch { /* reported by parseBundle */ }
1177
+ if (base && base['factory.yaml'] !== undefined) {
1178
+ const before = base['factory.yaml'];
1179
+ let kept = null;
1180
+ try { kept = parseYaml(before); } catch { /* reported by parseBundle */ }
1181
+ const mapping = Boolean(kept) && typeof kept === 'object' && !Array.isArray(kept);
1182
+ const description = String(config.description || '').trim();
1183
+ // The author's factory.yaml is theirs: repositories, harness, comments
1184
+ // and any field this runner does not read stay. Only the description can
1185
+ // change: in place, or else by writing the file again from its fields.
1186
+ // The summary says what did not come through, so it never claims a
1187
+ // change the files do not have.
1188
+ out['factory.yaml'] = before;
1189
+ if (description && (!mapping || String(kept.description || '').trim() !== description)) {
1190
+ const fields = mapping ? { ...kept, description: `${description}\n` } : null;
1191
+ const inPlace = fields && withDescription(before, description);
1192
+ const rewritten = fields && !inPlace ? toYaml(fields) : null;
1193
+ if (inPlace) out['factory.yaml'] = inPlace;
1194
+ else if (rewritten && readsAs(rewritten, fields)) {
1195
+ out['factory.yaml'] = rewritten;
1196
+ if (countComments(before)) notes.push('factory.yaml was written again to change its description, so its comments were not kept.');
1197
+ } else notes.push('The description in factory.yaml could not be changed, so it stays as it was.');
1198
+ }
1199
+ // A field the answer gives another value is a change that does not come through.
1200
+ const asked = config && typeof config === 'object' && !Array.isArray(config) ? Object.keys(config).filter((key) => key !== 'description') : [];
1201
+ if (mapping && asked.some((key) => canonical(config[key]) !== canonical(kept[key]))) notes.push('Only the description of factory.yaml changes in a revision: its other fields stay as they were.');
1202
+ } else {
1203
+ out['factory.yaml'] = toYaml({ schemaVersion: 'v1alpha1', name: slugify(config.name), description: String(config.description || '').trim(), repositories: [], agentDefaults: config.agentDefaults || { harness: { type: 'claude', model: 'sonnet' } } });
1204
+ }
1205
+ }
1206
+ const merged = base ? { ...normalizeFiles(base), ...out } : out;
1207
+ for (const path of removed) delete merged[path];
1208
+ const dropped = [];
1209
+ if (base) {
1210
+ const before = parseBundle(base).factory;
1211
+ const after = parseBundle(merged).factory;
1212
+ const kept = new Set(after.steps.flatMap((step) => step.missing || []));
1213
+ for (const step of before.steps) {
1214
+ const gone = (step.missing || []).filter((tool) => !kept.has(tool));
1215
+ if (!gone.length) continue;
1216
+ dropped.push(...gone);
1217
+ notes.push(`This change also removes ${gone.join(' and ')} from the step "${step.title}". The Hub does not have ${gone.length === 1 ? 'that tool' : 'those tools'}, so the step will run without ${gone.length === 1 ? 'it' : 'them'}.`);
1218
+ }
1219
+ for (const requirement of before.unsupportedRequirements) {
1220
+ if (after.unsupportedRequirements.some(item => item.source === requirement.source && item.setting === requirement.setting)) continue;
1221
+ const name = `${requirement.setting} (${requirement.source})`;
1222
+ dropped.push(name);
1223
+ notes.push(`This change also removes the requirement ${name}. Review the changed behavior before running.`);
1224
+ }
1225
+ }
1226
+ return { files: withForeman(merged), summary: [summary.trim(), ...notes].filter(Boolean).join(' '), removed, dropped };
1227
+ }
1228
+
1229
+ // The Hub writes agents/foreman/agent.md from the flow and marks it with these
1230
+ // two lines. A foreman without them was written by the author.
1231
+ export function isGeneratedForeman(text) {
1232
+ const value = String(text || '');
1233
+ return /^description: Coordinates the .* factory from its flow\.$/m.test(value) && value.includes('The exact, executable version of these steps is flow.yaml.');
1234
+ }
1235
+
1236
+ // Rewrites the Hub's own foreman from the flow. An author's foreman is never
1237
+ // replaced: it stays byte for byte, and parseBundle says it may not match the flow.
1238
+ export function withForeman(files) {
1239
+ if (files['agents/foreman/agent.md'] !== undefined && !isGeneratedForeman(files['agents/foreman/agent.md'])) return normalizeFiles(files);
1240
+ const out = { ...files };
1241
+ delete out['agents/foreman/agent.md'];
1242
+ const parsed = parseBundle(out);
1243
+ if (parsed.factory.steps.length && !parsed.errors.length) out['agents/foreman/agent.md'] = foremanText(parsed.factory);
1244
+ else if (files['agents/foreman/agent.md']) out['agents/foreman/agent.md'] = files['agents/foreman/agent.md'];
1245
+ return normalizeFiles(out);
1246
+ }
1247
+
1248
+ // ------------------------------------------------------------------ run ----
1249
+
1250
+ export const DEFAULT_LIMITS = { llmCalls: 60, fetches: 20, searchesPerStep: 4, turnsPerStep: 8, parallel: 4, contextChars: 160000 };
1251
+
1252
+ export function newRun({ id, factory, inputs, runner }) {
1253
+ return {
1254
+ v: 2,
1255
+ executionVersion: 1,
1256
+ id,
1257
+ factory,
1258
+ inputs: inputs || {},
1259
+ runner: runner || { mode: 'local' },
1260
+ status: 'running',
1261
+ steps: Object.fromEntries(factory.steps.map((step) => [step.id, { status: 'pending', attempts: 0, fails: 0, feedback: null }])),
1262
+ artifacts: {},
1263
+ approval: null,
1264
+ records: [],
1265
+ usage: { input: 0, output: 0, llmCalls: 0, fetches: 0, searches: 0 },
1266
+ started: null,
1267
+ finished: null,
1268
+ deliverable: null,
1269
+ error: null,
1270
+ };
1271
+ }
1272
+
1273
+ // JSON Schema covers the shape; the annotations retain normalization and
1274
+ // UTF-16/combined limits enforced by checkInputs, which remains authoritative.
1275
+ export function inputSchema(factory) {
1276
+ return {
1277
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
1278
+ title: `${factory.title} inputs`,
1279
+ type: 'object',
1280
+ additionalProperties: false,
1281
+ properties: Object.fromEntries(factory.inputs.map((input) => [input.key, {
1282
+ type: 'string', title: input.label,
1283
+ ...(input.help ? { description: input.help } : {}),
1284
+ maxLength: INPUT_LIMITS[input.type],
1285
+ ...(input.required ? { pattern: '\\S' } : {}),
1286
+ 'x-factory-input-type': input.type,
1287
+ ...(input.visibility ? { 'x-factory-visibility': input.visibility } : {}),
1288
+ ...(input.type === 'url' ? { 'x-factory-normalize': 'An HTTP(S) address; bare domains are accepted. Validated by checkInputs. Public-address restrictions apply when fetching.' } : {}),
1289
+ }])),
1290
+ required: factory.inputs.filter((input) => input.required).map((input) => input.key),
1291
+ 'x-factory-validation': { function: 'checkInputs', lengthUnit: 'UTF-16 code units', maxTotalLength: INPUT_LIMITS.total },
1292
+ };
1293
+ }
1294
+
1295
+ export function checkInputs(factory, inputs) {
1296
+ const problems = [];
1297
+ const clean = {};
1298
+ if (!inputs || typeof inputs !== 'object' || Array.isArray(inputs)) return { ok: false, problems: ['Inputs must be named text fields.'], inputs: clean };
1299
+ const keys = new Set(factory.inputs.map((input) => input.key));
1300
+ for (const key of Object.keys(inputs)) if (!keys.has(key)) problems.push(`Unknown input "${key}".`);
1301
+ let total = 0;
1302
+ for (const input of factory.inputs) {
1303
+ const value = Object.hasOwn(inputs, input.key) ? inputs[input.key] : '';
1304
+ if (typeof value !== 'string') { problems.push(`"${input.label}" must be text.`); continue; }
1305
+ total += value.length;
1306
+ if (value.length > INPUT_LIMITS[input.type]) { problems.push(`"${input.label}" is limited to ${INPUT_LIMITS[input.type].toLocaleString('en-US')} characters. Shorten it; nothing has been cut.`); continue; }
1307
+ if (!value.trim()) {
1308
+ if (input.required) problems.push(`"${input.label}" is needed.`);
1309
+ continue;
1310
+ }
1311
+ if (input.type === 'url') {
1312
+ const url = normalizeUrl(value);
1313
+ if (!url) { problems.push(`"${input.label}" should be a website address, like example.com.`); continue; }
1314
+ clean[input.key] = url;
1315
+ } else {
1316
+ clean[input.key] = value;
1317
+ }
1318
+ }
1319
+ if (total > INPUT_LIMITS.total) problems.push(`Inputs together are limited to ${INPUT_LIMITS.total.toLocaleString('en-US')} characters. Shorten them; nothing has been cut.`);
1320
+ return { ok: problems.length === 0, problems, inputs: clean };
1321
+ }
1322
+
1323
+ function finalText(content) {
1324
+ let lastTool = -1;
1325
+ content.forEach((block, index) => { if (block.type !== 'text') lastTool = index; });
1326
+ return content.slice(lastTool + 1).filter((block) => block.type === 'text').map((block) => block.text).join('').trim()
1327
+ || content.filter((block) => block.type === 'text').map((block) => block.text).join('\n').trim();
1328
+ }
1329
+
1330
+ async function callModel(state, host, request, provider = host.provider) {
1331
+ if (request.system.length + JSON.stringify(request.messages).length > (host.limits.contextChars ?? DEFAULT_LIMITS.contextChars)) throw failure('context_limit', 'The full inputs and evidence exceed this runner’s context limit. Shorten the inputs or split the factory; no text was cut.');
1332
+ if (state.usage.llmCalls >= host.limits.llmCalls) throw failure('call_limit', 'This run reached its step budget before finishing.');
1333
+ // A resumed older run retains its unrecorded calls instead of pretending
1334
+ // that a new per-call ledger describes the whole run.
1335
+ state.usage.metering ||= { version: 1, unrecorded: state.usage.llmCalls, calls: [] };
1336
+ const entry = { provider: ['anthropic', 'openai-compatible'].includes(provider.name) ? provider.name : 'other',
1337
+ billing: provider.billing === 'anthropic-direct' ? provider.billing : null, requestedModel: modelName(provider.model), model: null, measured: false, input: null, output: null };
1338
+ state.usage.metering.calls.push(entry);
1339
+ state.usage.llmCalls += 1;
1340
+ const record = (usage, model) => {
1341
+ entry.model = modelName(model);
1342
+ if (usage && [usage.input, usage.output].every(tokenCount)) {
1343
+ state.usage.input += usage.input; state.usage.output += usage.output;
1344
+ entry.input = usage.input; entry.output = usage.output; entry.measured = usage.measured !== false;
1345
+ }
1346
+ for (const key of ['uncachedInput', 'cacheReadInput', 'cacheWriteInput', 'cacheWrite5mInput', 'cacheWrite1hInput', 'searchRequests']) entry[key] = tokenCount(usage?.[key]) ? usage[key] : null;
1347
+ entry.serviceTier = usageCategory(usage?.serviceTier, ['standard', 'priority', 'batch']);
1348
+ entry.speed = usageCategory(usage?.speed, ['standard', 'fast']);
1349
+ entry.inferenceGeo = usageCategory(usage?.inferenceGeo, ['global', 'us']);
1350
+ };
1351
+ let response;
1352
+ try { response = await provider.call(request); }
1353
+ catch (error) { record(error?.providerUsage, error?.providerModel); throw error; }
1354
+ // Rejected and malformed answers still cost tokens. Count the reported
1355
+ // usage before validating their contents; absent usage stays unknown.
1356
+ record(response?.usage, response?.model);
1357
+ if (!response || !Array.isArray(response.content) || !Array.isArray(response.toolCalls) || !Array.isArray(response.searches)) throw failure('provider_response', 'The model returned an invalid response.');
1358
+ if (!['end_turn', 'stop', 'stop_sequence', 'tool_use', 'tool_calls', 'pause_turn'].includes(response.stop)) throw failure(['length', 'max_tokens'].includes(response.stop) ? 'output_limit' : 'provider_response', `The model did not finish its response (${response.stop || 'missing completion reason'}). No completed result was accepted.`);
1359
+ if (response.content.some((block) => !block || typeof block.type !== 'string' || (block.type === 'text' && typeof block.text !== 'string'))) throw failure('provider_response', 'The model returned invalid content.');
1360
+ if (response.toolCalls.some((call) => !call || typeof call.id !== 'string' || !call.id || typeof call.name !== 'string' || !call.input || typeof call.input !== 'object' || Array.isArray(call.input))) throw failure('provider_response', 'The model returned invalid tool arguments.');
1361
+ if (new Set(response.toolCalls.map((call) => call.id)).size !== response.toolCalls.length) throw failure('provider_response', 'The model repeated a tool call identifier.');
1362
+ if (response.toolCalls.some((call) => !(request.tools || []).some((tool) => tool.name === call.name))) throw failure('provider_response', 'The model requested a tool that this step did not offer.');
1363
+ if (response.toolCalls.some((call) => call.name === 'web_fetch' && (typeof call.input.url !== 'string' || !call.input.url.trim()))) throw failure('provider_response', 'The model returned a page request without a text URL.');
1364
+ if (response.searches.length > (request.search || 0)) throw failure('provider_response', 'The model searched beyond the tools or search budget offered by this step.');
1365
+ if (['tool_use', 'tool_calls'].includes(response.stop) && !response.toolCalls.length) throw failure('provider_response', 'The model stopped for a tool without a valid tool call.');
1366
+ if (response.toolCalls.length && !['tool_use', 'tool_calls'].includes(response.stop)) throw failure('provider_response', 'The model returned tools with an inconsistent completion reason.');
1367
+ if (response.stop === 'pause_turn' && !provider.serverSearch) throw failure('provider_response', 'This model returned an unsupported paused response.');
1368
+ if (!response.usage || ![response.usage.input, response.usage.output].every(tokenCount)) throw failure('provider_response', 'The model returned invalid usage accounting.');
1369
+ return response;
1370
+ }
1371
+
1372
+ async function doWork(state, step, host, record) {
1373
+ const feedback = state.steps[step.id].feedback;
1374
+ const context = resolveContext(state, step);
1375
+ if (context.explicit) for (const artifact of context.artifacts) {
1376
+ const evidence = latestRecords(state).filter((item) => item.id === artifact.id || item.review?.target === artifact.id);
1377
+ if (evidence.some((item) => item.limitations?.length || (item.review && (item.status !== 'passed' || !item.review.complete)))) record.limitations.push(`Bound evidence "${artifact.alias}" has unresolved review or evidence limitations.`);
1378
+ }
1379
+ const parts = [...contextBlocks(context), `## Your step: ${step.title}\n\n${step.task}`];
1380
+ if (feedback) {
1381
+ parts.push(`## Your earlier result was sent back\n\n${feedback.from === 'person' ? 'The person asked for this change' : 'The reviewer found these problems'}:\n\n${feedback.text}\n\nYour earlier result:\n\n${feedback.previous}\n\nWrite the full corrected result.`);
1382
+ }
1383
+ const messages = [{ role: 'user', content: [{ type: 'text', text: parts.filter(Boolean).join('\n\n') }] }];
1384
+ const tools = step.tools.includes('web_fetch') ? [FETCH_TOOL] : [];
1385
+ const search = step.tools.includes('web_search') && host.provider.serverSearch ? host.limits.searchesPerStep : 0;
1386
+ if (step.tools.includes('web_search') && !host.provider.serverSearch) record.limitations.push('Web search was unavailable on this runner.');
1387
+ const system = agentSystem(state, step, host);
1388
+ const maxTokens = step.kind === 'deliver' ? 8000 : 5000;
1389
+
1390
+ let searched = 0;
1391
+ for (let turn = 0; turn < host.limits.turnsPerStep; turn += 1) {
1392
+ const last = turn === host.limits.turnsPerStep - 1;
1393
+ const response = await callModel(state, host, last ? { system, messages: [...messages, { role: 'user', content: [{ type: 'text', text: 'Write the step result now with what you have.' }] }], maxTokens } : { system, messages, tools, search: Math.max(0, search - searched), maxTokens });
1394
+ for (const query of response.searches) {
1395
+ searched += 1;
1396
+ state.usage.searches += 1;
1397
+ record.tools.push({ tool: 'web_search', target: query });
1398
+ await host.emit({ type: 'tool', step: step.id, tool: 'web_search', target: query });
1399
+ }
1400
+ if (last && (response.stop === 'pause_turn' || response.toolCalls.length)) throw new Error('The model did not finish within this step’s turn limit.');
1401
+ if (response.stop !== 'pause_turn' && !response.toolCalls.length) return finalText(response.content);
1402
+ messages.push({ role: 'assistant', content: response.content });
1403
+ if (response.stop === 'pause_turn') continue;
1404
+ const results = [];
1405
+ for (const call of response.toolCalls) {
1406
+ let output;
1407
+ if (call.name !== 'web_fetch') {
1408
+ output = 'That tool does not exist in this factory.';
1409
+ } else if (state.usage.fetches >= host.limits.fetches) {
1410
+ output = 'The page budget for this run is used up. Work with what you have.';
1411
+ record.limitations.push(output);
1412
+ } else {
1413
+ const url = normalizeUrl(call.input?.url);
1414
+ state.usage.fetches += 1;
1415
+ await host.emit({ type: 'tool', step: step.id, tool: 'web_fetch', target: url || String(call.input?.url || '') });
1416
+ if (!url) {
1417
+ output = 'That is not a public web address.';
1418
+ record.limitations.push('A requested page address was invalid.');
1419
+ } else {
1420
+ try {
1421
+ output = await host.fetchPage(url);
1422
+ if (/\[Page cut here:/.test(output)) record.limitations.push(`Only part of ${url} was available to read.`);
1423
+ record.tools.push({ tool: 'web_fetch', target: url, ok: true });
1424
+ } catch (error) {
1425
+ output = `The page could not be read: ${error.message}`;
1426
+ record.limitations.push(`The page ${url} could not be read.`);
1427
+ record.tools.push({ tool: 'web_fetch', target: url, ok: false });
1428
+ }
1429
+ }
1430
+ }
1431
+ results.push({ type: 'tool_result', tool_use_id: call.id, content: output });
1432
+ }
1433
+ messages.push({ role: 'user', content: results });
1434
+ }
1435
+ return '';
1436
+ }
1437
+
1438
+ const latestRecords = (state) => [...new Map(state.records.map((record) => [record.id, record])).values()];
1439
+
1440
+ async function doCheck(state, step, host, record) {
1441
+ const subject = state.factory.steps.find((item) => item.id === step.target);
1442
+ const agent = state.factory.agents[step.agent];
1443
+ const subjectText = state.artifacts[subject.id];
1444
+ if (typeof subjectText !== 'string' || !subjectText) throw new Error('The review has no subject text to check.');
1445
+ const context = resolveContext(state, step, subject.id);
1446
+ const earlier = [...new Set(context.artifacts.map((item) => item.id))];
1447
+ const ancestorIds = new Set(context.explicit ? [subject.id, ...earlier] : ancestorsOf(state.factory, step.id));
1448
+ const limitations = [...new Set(latestRecords(state).filter((item) => ancestorIds.has(item.id)).flatMap((item) => item.limitations || []))];
1449
+ for (const item of state.factory.steps.filter((item) => ancestorIds.has(item.id) && ['work', 'deliver'].includes(item.kind))) {
1450
+ if (typeof state.artifacts[item.id] !== 'string') limitations.push(`Evidence from "${item.title}" is unavailable.`);
1451
+ }
1452
+ if (!state.executionVersion) limitations.push('Earlier work used a runner that did not record complete evidence coverage.');
1453
+ const reviewer = host.checkProvider || host.provider;
1454
+ record.review = { target: subject.id, checked_sha256: await sha256(subjectText), provider: reviewer.name || null, model: reviewer.model || null,
1455
+ inputs_sha256: await sha256(canonical(context.inputs)), evidence: await Promise.all(earlier.map(async (id) => ({ id, sha256: await sha256(state.artifacts[id]) }))), complete: limitations.length === 0, limitations };
1456
+ const system = [
1457
+ `You are "${step.agent}", the reviewer for the step "${step.title}" in the factory "${state.factory.title}". ${step.context ? 'Use only the declared review context.' : `The factory's promised outcome: ${state.factory.description}`}`,
1458
+ agent?.body ? `## Your role\n\n${agent.body}` : '',
1459
+ `${(state.factory.outputs ? state.factory.outputs.some((output) => output.step === subject.id) : subject.id === finalStep(state.factory)?.id) ? 'This is an exact final output the person will receive.' : 'This is an intermediate result: later steps add the rest.'} Judge the result strictly against its task and the criteria you are given, comparing it with the original inputs and earlier evidence. You cannot browse; earlier results are supplied evidence, not independent verification of the web. Respect disclosed missing evidence: require honest limitations rather than invented facts. Pass work that meets every criterion even if you would have written it differently. Fail it only for a clear, specific breach you can point to. Then call the verdict tool.`,
1460
+ ].filter(Boolean).join('\n\n');
1461
+ const text = [
1462
+ ...contextBlocks(context),
1463
+ limitations.length ? `## Evidence limitations\n\n${limitations.map((item) => `- ${item}`).join('\n')}` : '',
1464
+ `## What the subject step was asked to do\n\n${subject.task}`,
1465
+ `## The result to check (from the step "${subject.title}")\n\n${subjectText}`,
1466
+ `## Criteria\n\n${step.task || 'The result does what its step was asked to do, with no invented facts.'}`,
1467
+ // Only the final deliverable answers for the factory-wide promises.
1468
+ (subject.criteria || (!state.factory.outputs && subject.id === finalStep(state.factory)?.id ? state.factory.done : [])).length ? `## Done when\n\n${(subject.criteria || state.factory.done).map((line) => `- ${line}`).join('\n')}` : '',
1469
+ ].filter(Boolean).join('\n\n');
1470
+ const read = (response) => {
1471
+ if (response.stop === 'pause_turn') throw new Error('The reviewer did not finish its response.');
1472
+ if (response.toolCalls.length > 1) return null;
1473
+ let parsed = response.toolCalls[0]?.input;
1474
+ try {
1475
+ if (!parsed) parsed = JSON.parse(finalText(response.content));
1476
+ if (!parsed || typeof parsed.pass !== 'boolean' || !Array.isArray(parsed.issues) || parsed.issues.length > 8 || parsed.issues.some((issue) => typeof issue !== 'string' || !issue.trim()) || typeof parsed.note !== 'string' || !parsed.note.trim() || (parsed.pass && parsed.issues.length)) return null;
1477
+ return { pass: parsed.pass, issues: parsed.issues, note: parsed.note };
1478
+ } catch { return null; }
1479
+ };
1480
+ const messages = [{ role: 'user', content: [{ type: 'text', text }] }];
1481
+ const request = { system, messages, tools: [VERDICT_TOOL], forceTool: 'verdict', maxTokens: 1500 };
1482
+ record.review.context_sha256 = await sha256(canonical(request));
1483
+ const first = await callModel(state, host, request, reviewer);
1484
+ let verdict = read(first);
1485
+ if (!verdict) {
1486
+ // A reviewer that wrote prose instead of a verdict is asked once more.
1487
+ const retryRequest = { system, messages: [...messages, { role: 'assistant', content: [{ type: 'text', text: finalText(first.content) || 'I have reviewed the result.' }] }, { role: 'user', content: [{ type: 'text', text: 'Now give your verdict as JSON only, with no other text: {"pass": true or false, "issues": ["one sentence per clear breach"], "note": "one sentence"}' }] }], maxTokens: 1000 };
1488
+ record.review.context_sha256 = await sha256(canonical(retryRequest));
1489
+ const again = await callModel(state, host, retryRequest, reviewer);
1490
+ verdict = read(again);
1491
+ }
1492
+ return verdict || { pass: null, issues: [], note: 'The reviewer did not return a valid verdict; this result is unreviewed.', unreviewed: true };
1493
+ }
1494
+
1495
+ // Sends a step back to be redone: it and everything built on it run again.
1496
+ function sendBack(state, target, feedback) {
1497
+ for (const id of [target, ...dependentsOf(state.factory, target)]) {
1498
+ if (state.steps[id].status !== 'pending') state.steps[id].status = 'pending';
1499
+ }
1500
+ // Two reviewers can send the same work back in one round: keep both sets of reasons.
1501
+ const pending = state.steps[target].feedback;
1502
+ state.steps[target].feedback = pending && pending.previous === state.artifacts[target]
1503
+ ? { ...pending, text: `${pending.text}\n${feedback.text}`, from: pending.from === 'person' || feedback.from === 'person' ? 'person' : 'check' }
1504
+ : { ...feedback, previous: state.artifacts[target] };
1505
+ }
1506
+
1507
+ async function runStep(state, step, host) {
1508
+ const slot = state.steps[step.id];
1509
+ slot.status = 'running';
1510
+ slot.attempts += 1;
1511
+ const record = { id: step.id, title: step.title, agent: step.agent, kind: step.kind, attempt: slot.attempts, started: host.now(), tools: [], limitations: [] };
1512
+ const outcome = { step, record, sendBack: null, error: null };
1513
+ try {
1514
+ if (step.kind === 'check') {
1515
+ await host.emit({ type: 'step_started', step: step.id, kind: 'check', title: step.title, agent: step.agent, attempt: slot.attempts });
1516
+ const verdict = await doCheck(state, step, host, record);
1517
+ record.verdict = verdict;
1518
+ if (verdict.unreviewed) record.status = 'unreviewed';
1519
+ else if (verdict.pass) record.status = 'passed';
1520
+ else if (slot.fails < step.rounds) {
1521
+ slot.fails += 1;
1522
+ record.status = 'sent_back';
1523
+ outcome.sendBack = { target: step.target, from: 'check', text: verdict.issues.map((issue) => `- ${issue}`).join('\n') || verdict.note };
1524
+ } else record.status = 'flagged';
1525
+ await host.emit({ type: 'check', step: step.id, target: step.target, pass: verdict.pass, status: record.status, issues: verdict.issues, note: verdict.note });
1526
+ } else {
1527
+ await host.emit({ type: 'step_started', step: step.id, kind: step.kind, title: step.title, agent: step.agent, attempt: slot.attempts, redo: Boolean(slot.feedback) });
1528
+ const output = await doWork(state, step, host, record);
1529
+ if (!output) throw new Error(`The step "${step.title}" produced nothing.`);
1530
+ state.artifacts[step.id] = output;
1531
+ slot.feedback = null;
1532
+ record.status = 'done';
1533
+ record.output_sha256 = await sha256(output);
1534
+ await host.emit({ type: 'step_done', step: step.id, kind: step.kind, chars: output.length });
1535
+ }
1536
+ } catch (error) {
1537
+ record.status = 'failed';
1538
+ outcome.error = String(error.message || error);
1539
+ outcome.failure = failureDetails(error);
1540
+ }
1541
+ record.finished = host.now();
1542
+ return outcome;
1543
+ }
1544
+
1545
+ // Moves the factory forward by one round: every step that is ready runs, in
1546
+ // parallel. `host` supplies: provider, fetchPage(url), emit(event), now(), limits.
1547
+ export async function advance(state, host) {
1548
+ if (state.status !== 'running') return state;
1549
+ // Re-read preserved source even for restored runs parsed by an older engine.
1550
+ // UI admission and a cached runnable flag are not execution boundaries.
1551
+ const source = parseBundle(state.factory.files);
1552
+ if (!source.ok) return fail(state, host, null, source.errors.join(' '), { code: 'invalid_factory' });
1553
+ if (!state.started) {
1554
+ state.started = host.now();
1555
+ await host.emit({ type: 'run_started', factory: state.factory.name, steps: state.factory.steps.length });
1556
+ const checked = checkInputs(state.factory, state.inputs);
1557
+ if (!checked.ok) return fail(state, host, null, checked.problems.join(' '), { code: 'inputs' });
1558
+ state.inputs = checked.inputs;
1559
+ // Checked again here, whoever started the run: a factory that asks for a
1560
+ // tool this runner lacks is not run at all, since without it the run
1561
+ // would be a different factory from the one its author wrote.
1562
+ const lacking = state.factory.steps.find((step) => step.missing?.length);
1563
+ if (lacking) return fail(state, host, lacking.id, `The step "${lacking.title}" needs ${lacking.missing.join(' and ')}, which this runner does not have. Nothing was run.`, { code: 'tools' });
1564
+ }
1565
+ if (source.factory.unsupportedRequirements.length) return fail(state, host, null, source.factory.blockers.join(' '), { code: 'requirements' });
1566
+ const { steps } = state.factory;
1567
+ const ready = steps.filter((step) => state.steps[step.id].status === 'pending' && step.needs.every((need) => state.steps[need].status === 'done'));
1568
+ if (!ready.length) {
1569
+ if (steps.every((step) => state.steps[step.id].status === 'done')) return finish(state, host);
1570
+ return fail(state, host, null, 'The flow cannot continue: a step is waiting on work that will never finish.', { code: 'flow' });
1571
+ }
1572
+
1573
+ const working = ready.filter((step) => step.kind !== 'approve').slice(0, host.limits.parallel || 4);
1574
+ if (working.length) {
1575
+ const outcomes = await Promise.all(working.map((step) => runStep(state, step, host)));
1576
+ for (const outcome of outcomes) state.records.push(outcome.record);
1577
+ const failed = outcomes.find((outcome) => outcome.error);
1578
+ if (failed) return fail(state, host, failed.step.id, failed.error, failed.failure);
1579
+ for (const outcome of outcomes) if (!outcome.sendBack) state.steps[outcome.step.id].status = 'done';
1580
+ for (const outcome of outcomes) if (outcome.sendBack) { state.steps[outcome.step.id].status = 'pending'; sendBack(state, outcome.sendBack.target, outcome.sendBack); }
1581
+ return state;
1582
+ }
1583
+
1584
+ const step = ready[0];
1585
+ const slot = state.steps[step.id];
1586
+ const decision = state.approval && state.approval.step === step.id ? state.approval.decision : null;
1587
+ if (!decision) {
1588
+ state.status = 'waiting';
1589
+ state.approval = { step: step.id, title: step.title, ask: step.task, artifact: step.target, artifact_sha256: await sha256(state.artifacts[step.target] || ''), decision: null, revisionsLeft: Math.max(0, step.rounds - slot.fails) };
1590
+ await ensureApprovalIdentity(state);
1591
+ await host.emit({ type: 'approval_needed', step: step.id, title: step.title, artifact: step.target });
1592
+ return state;
1593
+ }
1594
+ slot.attempts += 1;
1595
+ const record = { id: step.id, title: step.title, agent: 'owner', kind: 'approve', attempt: slot.attempts, started: host.now(), finished: host.now(), tools: [], decision: decision.action };
1596
+ // What the person had in front of them when they decided.
1597
+ if (decision.seen) record.seen_sha256 = decision.seen;
1598
+ if (decision.approvalId) record.approval_id = decision.approvalId;
1599
+ state.approval = null;
1600
+ if (decision.action === 'reject') {
1601
+ record.status = 'rejected';
1602
+ record.note = String(decision.note || '').slice(0, 4000);
1603
+ state.records.push(record);
1604
+ state.status = 'stopped';
1605
+ state.stopped = { step: step.id, title: step.title, note: record.note };
1606
+ state.finished = host.now();
1607
+ await host.emit({ type: 'approval', step: step.id, status: record.status });
1608
+ await host.emit({ type: 'run_stopped', step: step.id, title: step.title });
1609
+ return state;
1610
+ }
1611
+ if (decision.action === 'revise') {
1612
+ slot.fails += 1;
1613
+ record.status = 'revision_asked';
1614
+ sendBack(state, step.target, { from: 'person', text: String(decision.note || '').slice(0, 4000) });
1615
+ } else {
1616
+ record.status = 'approved';
1617
+ record.output_sha256 = await sha256(state.artifacts[step.target]);
1618
+ slot.status = 'done';
1619
+ }
1620
+ state.records.push(record);
1621
+ await host.emit({ type: 'approval', step: step.id, status: record.status });
1622
+ return state;
1623
+ }
1624
+
1625
+ export const DECISIONS = ['approve', 'revise', 'reject'];
1626
+
1627
+ // Stable across reads/restarts, different for each review occurrence even if
1628
+ // a revision returns identical text. Restored pending runs need no storage
1629
+ // migration: their persisted step attempt identifies the same occurrence.
1630
+ export async function ensureApprovalIdentity(state) {
1631
+ if (state.status === 'waiting' && state.approval && !state.approval.id) {
1632
+ state.approval.id = await sha256(canonical([state.id, state.approval.step, (state.steps[state.approval.step]?.attempts || 0) + 1]));
1633
+ }
1634
+ }
1635
+
1636
+ // Why a decision cannot be taken, or '' when it can. A decision is one of
1637
+ // approve, revise or reject and nothing else: an empty or misspelt answer is
1638
+ // never read as approval. It names the hash of the text the person was shown
1639
+ // (`seen`) and the review occurrence (`approvalId`), so a delayed decision
1640
+ // cannot approve a later review of identical text.
1641
+ export function decisionProblem(state, decision) {
1642
+ if (state.status !== 'waiting' || !state.approval) return 'not_waiting';
1643
+ if (!decision || !DECISIONS.includes(decision.action)) return 'bad_action';
1644
+ if (state.approval.artifact_sha256) {
1645
+ if (!decision.seen) return 'unseen';
1646
+ if (decision.seen !== state.approval.artifact_sha256) return 'stale';
1647
+ }
1648
+ if (state.approval.id && decision.approvalId !== state.approval.id) return 'stale';
1649
+ if (decision.action === 'revise' && state.approval.revisionsLeft === 0) return 'no_revisions';
1650
+ return '';
1651
+ }
1652
+
1653
+ // Records the person's decision and lets the run continue. Returns false, and
1654
+ // leaves the run waiting, when decisionProblem names a reason.
1655
+ export function resume(state, decision) {
1656
+ if (decisionProblem(state, decision)) return false;
1657
+ state.approval.decision = { action: decision.action, note: decision.note || '', seen: decision.seen || null, approvalId: decision.approvalId || null };
1658
+ state.status = 'running';
1659
+ return true;
1660
+ }
1661
+
1662
+ async function fail(state, host, step, message, detail = { code: 'execution_failed' }) {
1663
+ state.status = 'failed';
1664
+ state.error = message;
1665
+ state.failure = { ...detail, step };
1666
+ state.finished = host.now();
1667
+ await host.emit({ type: 'run_failed', step, error: message });
1668
+ return state;
1669
+ }
1670
+
1671
+ async function finish(state, host) {
1672
+ const last = finalStep(state.factory);
1673
+ state.deliverable = state.factory.outputs ? state.factory.outputs.map((output) => `# ${output.title}\n\n${state.artifacts[output.step]}`).join('\n\n---\n\n') : last ? state.artifacts[last.id] || '' : '';
1674
+ state.status = 'done';
1675
+ state.finished = host.now();
1676
+ state.receipt = await buildReceipt(state);
1677
+ await host.emit({ type: 'run_done', chars: state.deliverable.length });
1678
+ return state;
1679
+ }
1680
+
1681
+ export async function buildReceipt(state) {
1682
+ const deliveredHash = await sha256(state.deliverable);
1683
+ const current = latestRecords(state);
1684
+ const checks = current.filter((record) => record.kind === 'check' && record.review?.target === finalStep(state.factory)?.id && record.review.checked_sha256 === deliveredHash);
1685
+ let reviewStatus = !checks.length ? 'not_checked' : checks.some((record) => record.status === 'flagged') ? 'flagged' : checks.some((record) => record.status !== 'passed') ? 'unreviewed' : checks.some((record) => !record.review.complete) ? 'limited' : 'passed';
1686
+ const outputs = state.factory.outputs ? await Promise.all(state.factory.outputs.map(async (output) => {
1687
+ const hash = await sha256(state.artifacts[output.step]);
1688
+ const reviews = current.filter((record) => record.kind === 'check' && record.review?.target === output.step && record.review.checked_sha256 === hash);
1689
+ const status = !reviews.length ? 'not_checked' : reviews.some((record) => record.status === 'flagged') ? 'flagged' : reviews.some((record) => record.status !== 'passed') ? 'unreviewed' : reviews.some((record) => !record.review.complete) ? 'limited' : 'passed';
1690
+ return { ...output, sha256: hash, review: { status, checks: reviews.map((record) => ({ id: record.id, attempt: record.attempt })) } };
1691
+ })) : null;
1692
+ if (outputs) reviewStatus = ['flagged', 'unreviewed', 'not_checked', 'limited', 'passed'].find((status) => outputs.some((output) => output.review.status === status));
1693
+ const flags = current.filter((record) => record.status === 'flagged' || record.status === 'unreviewed').map((record) => `${record.title}: ${record.status === 'flagged' ? 'the check still failed after the allowed redo' : 'the reviewer returned no valid verdict'}`);
1694
+ flags.push(...new Set(current.flatMap((record) => [...(record.limitations || []), ...(record.review?.limitations || [])])));
1695
+ if (reviewStatus === 'not_checked') flags.push('The final deliverable was not checked.');
1696
+ return {
1697
+ v: 2,
1698
+ kind: 'factory-run-receipt',
1699
+ run: state.id,
1700
+ issuer: state.runner.mode === 'hosted' ? 'factory.mindburn.org' : 'self',
1701
+ factory: { name: state.factory.name, version: state.factory.version, sha256: await bundleHash(state.factory.files) },
1702
+ inputs_sha256: await sha256(canonical(state.inputs)),
1703
+ runner: { mode: state.runner.mode, provider: state.runner.provider || null, model: state.runner.model || null },
1704
+ started: new Date(state.started).toISOString(),
1705
+ finished: new Date(state.finished).toISOString(),
1706
+ steps: state.records.map((record) => ({
1707
+ id: record.id,
1708
+ title: record.title,
1709
+ agent: record.agent,
1710
+ kind: record.kind,
1711
+ attempt: record.attempt,
1712
+ status: record.status,
1713
+ tools: record.tools.map((tool) => ({ tool: tool.tool, target: tool.target })),
1714
+ note: record.verdict?.note,
1715
+ verdict: record.verdict,
1716
+ review: record.review,
1717
+ limitations: record.limitations,
1718
+ decision: record.decision,
1719
+ seen_sha256: record.seen_sha256,
1720
+ approval_id: record.approval_id,
1721
+ output_sha256: record.output_sha256,
1722
+ started: new Date(record.started).toISOString(),
1723
+ finished: new Date(record.finished).toISOString(),
1724
+ })),
1725
+ flags,
1726
+ ...(outputs ? { outputs } : {}),
1727
+ deliverable_review: { status: reviewStatus, sha256: deliveredHash, checks: outputs ? outputs.flatMap((output) => output.review.checks) : checks.map((record) => ({ id: record.id, attempt: record.attempt })) },
1728
+ deliverable_sha256: deliveredHash,
1729
+ usage: { ...state.usage },
1730
+ };
1731
+ }
1732
+
1733
+ // Runs a factory until it ends or needs a person. `onApproval(approval,
1734
+ // artifactText, problem)` resolves to { action: 'approve' | 'revise' |
1735
+ // 'reject', note?, seen, approvalId } where approvalId is approval.id and
1736
+ // `seen` is approval.artifact_sha256, the hash of
1737
+ // the text it was given. A decision never carries replacement text: what is
1738
+ // approved is what the line made. Nothing here decides for the person: with no
1739
+ // onApproval, or an answer that is not a decision, the run is returned still
1740
+ // waiting. A refused decision is asked again with the reason.
1741
+ export async function runToEnd(state, host, onApproval) {
1742
+ for (let guard = 0; guard < 120; guard += 1) {
1743
+ await advance(state, host);
1744
+ if (state.status === 'waiting') {
1745
+ await ensureApprovalIdentity(state);
1746
+ if (!onApproval) return state;
1747
+ let problem = '';
1748
+ for (let asked = 0; asked < 3 && state.status === 'waiting'; asked += 1) {
1749
+ const decision = await onApproval(state.approval, state.artifacts[state.approval.artifact], problem);
1750
+ if (!decision) return state;
1751
+ problem = decisionProblem(state, decision);
1752
+ if (!problem) resume(state, decision);
1753
+ }
1754
+ if (state.status === 'waiting') return state;
1755
+ } else if (state.status !== 'running') {
1756
+ return state;
1757
+ }
1758
+ }
1759
+ state.status = 'failed';
1760
+ state.error = 'The factory did not finish within its step budget.';
1761
+ return state;
1762
+ }