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.
- package/LICENSE +202 -0
- package/NOTICE +16 -0
- package/README.md +189 -2
- package/factories/README.md +17 -0
- package/factories/client-file-readiness/README.md +3 -0
- package/factories/client-file-readiness/agents/analyst/agent.md +10 -0
- package/factories/client-file-readiness/agents/reviewer/agent.md +10 -0
- package/factories/client-file-readiness/agents/writer/agent.md +11 -0
- package/factories/client-file-readiness/factory.yaml +12 -0
- package/factories/client-file-readiness/flow.yaml +56 -0
- package/factories/competitor-teardown/README.md +3 -0
- package/factories/competitor-teardown/agents/analyst/agent.md +10 -0
- package/factories/competitor-teardown/agents/researcher/agent.md +11 -0
- package/factories/competitor-teardown/agents/reviewer/agent.md +10 -0
- package/factories/competitor-teardown/agents/writer/agent.md +11 -0
- package/factories/competitor-teardown/factory.yaml +11 -0
- package/factories/competitor-teardown/flow.yaml +75 -0
- package/factories/homepage-audit/README.md +3 -0
- package/factories/homepage-audit/agents/analyst/agent.md +10 -0
- package/factories/homepage-audit/agents/researcher/agent.md +11 -0
- package/factories/homepage-audit/agents/reviewer/agent.md +10 -0
- package/factories/homepage-audit/agents/writer/agent.md +11 -0
- package/factories/homepage-audit/factory.yaml +11 -0
- package/factories/homepage-audit/flow.yaml +77 -0
- package/factories/inbox-triage/README.md +3 -0
- package/factories/inbox-triage/agents/analyst/agent.md +10 -0
- package/factories/inbox-triage/agents/reviewer/agent.md +10 -0
- package/factories/inbox-triage/agents/writer/agent.md +11 -0
- package/factories/inbox-triage/factory.yaml +11 -0
- package/factories/inbox-triage/flow.yaml +57 -0
- package/factories/index.json +356 -0
- package/factories/launch-kit/README.md +3 -0
- package/factories/launch-kit/agents/reviewer/agent.md +10 -0
- package/factories/launch-kit/agents/strategist/agent.md +10 -0
- package/factories/launch-kit/agents/writer/agent.md +11 -0
- package/factories/launch-kit/factory.yaml +12 -0
- package/factories/launch-kit/flow.yaml +75 -0
- package/factories/meeting-to-actions/CHANGELOG.md +7 -0
- package/factories/meeting-to-actions/README.md +15 -0
- package/factories/meeting-to-actions/agents/analyst/agent.md +10 -0
- package/factories/meeting-to-actions/agents/reviewer/agent.md +10 -0
- package/factories/meeting-to-actions/agents/writer/agent.md +11 -0
- package/factories/meeting-to-actions/factory.yaml +11 -0
- package/factories/meeting-to-actions/flow.yaml +53 -0
- package/factories/meeting-to-actions/mindburn.requirements.yaml +8 -0
- package/factories/meeting-to-actions/mindburn.tests.yaml +16 -0
- package/factories/outreach-pack/README.md +3 -0
- package/factories/outreach-pack/agents/reviewer/agent.md +5 -0
- package/factories/outreach-pack/agents/writer/agent.md +5 -0
- package/factories/outreach-pack/factory.yaml +9 -0
- package/factories/outreach-pack/flow.yaml +42 -0
- package/factories/prospect-brief/README.md +3 -0
- package/factories/prospect-brief/agents/researcher/agent.md +11 -0
- package/factories/prospect-brief/agents/reviewer/agent.md +10 -0
- package/factories/prospect-brief/agents/strategist/agent.md +10 -0
- package/factories/prospect-brief/agents/writer/agent.md +11 -0
- package/factories/prospect-brief/factory.yaml +11 -0
- package/factories/prospect-brief/flow.yaml +79 -0
- package/package.json +79 -4
- package/packages/cli/bin/factoryhub.mjs +8 -0
- package/packages/cli/package.json +29 -0
- package/packages/cli/src/args.mjs +47 -0
- package/packages/cli/src/commands/export.mjs +55 -0
- package/packages/cli/src/commands/get.mjs +38 -0
- package/packages/cli/src/commands/inspect.mjs +66 -0
- package/packages/cli/src/commands/list.mjs +29 -0
- package/packages/cli/src/commands/mcp.mjs +26 -0
- package/packages/cli/src/commands/new.mjs +148 -0
- package/packages/cli/src/commands/pack.mjs +97 -0
- package/packages/cli/src/commands/resume.mjs +67 -0
- package/packages/cli/src/commands/run.mjs +190 -0
- package/packages/cli/src/commands/search.mjs +31 -0
- package/packages/cli/src/commands/validate.mjs +81 -0
- package/packages/cli/src/main.mjs +68 -0
- package/packages/cli/src/util.mjs +21 -0
- package/packages/engine/lib/catalog.mjs +50 -0
- package/packages/engine/lib/hub.mjs +71 -0
- package/packages/engine/lib/node.mjs +114 -0
- package/packages/engine/lib/providers.mjs +52 -0
- package/packages/engine/lib/run.mjs +131 -0
- package/packages/engine/lib/skill.mjs +229 -0
- package/packages/engine/package.json +39 -0
- package/packages/engine/src/author-tests.mjs +78 -0
- package/packages/engine/src/compose.mjs +150 -0
- package/packages/engine/src/cost.mjs +41 -0
- package/packages/engine/src/diagnostics.mjs +21 -0
- package/packages/engine/src/factory-engine.mjs +1762 -0
- package/packages/engine/src/index.mjs +9 -0
- package/packages/engine/src/markdown.mjs +97 -0
- package/packages/engine/src/package.mjs +116 -0
- package/packages/engine/src/run.mjs +232 -0
- package/packages/mcp/bin/factoryhub-mcp.mjs +6 -0
- package/packages/mcp/package.json +36 -0
- package/packages/mcp/src/server.mjs +280 -0
- package/spec/README.md +36 -0
- package/spec/factory-format.md +621 -0
- package/spec/schema/factory.schema.json +43 -0
- package/spec/schema/flow.schema.json +169 -0
- package/spec/skill-export.md +126 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Exports a factory as an Agent Skill folder, following spec/skill-export.md.
|
|
2
|
+
// The factoryhub site implements the same mapping; the golden files in
|
|
3
|
+
// spec/skill-export/, one per house factory, keep the two identical.
|
|
4
|
+
//
|
|
5
|
+
// <id>/SKILL.md frontmatter + instructions written from the flow
|
|
6
|
+
// <id>/factory/... the canonical bundle, byte for byte
|
|
7
|
+
import { parseBundle, parseYaml, bundleHash, finalStep, isGeneratedForeman } from '../src/factory-engine.mjs';
|
|
8
|
+
import { canonicalFiles } from './catalog.mjs';
|
|
9
|
+
import { DEFAULT_HUB } from './hub.mjs';
|
|
10
|
+
|
|
11
|
+
export const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
12
|
+
export const MAX_NAME = 64;
|
|
13
|
+
export const MAX_DESCRIPTION = 1024;
|
|
14
|
+
export const SKILL_SECTIONS = ['## What it produces', '## Inputs to collect', '## Steps', '## Run it with the factoryhub CLI', '## Follow it by hand'];
|
|
15
|
+
export const DECISION_LINE = 'Stop and ask the person to approve';
|
|
16
|
+
// The Agent Skills spec makes `metadata` a map from strings to strings, so the
|
|
17
|
+
// factoryhub fields are flat keys with a `factoryhub-` prefix, in this order.
|
|
18
|
+
export const METADATA_KEYS = ['factoryhub-id', 'factoryhub-version', 'factoryhub-source', 'factoryhub-sha256', 'factoryhub-author'];
|
|
19
|
+
|
|
20
|
+
const q = (text) => JSON.stringify(String(text));
|
|
21
|
+
const indent = (text, pad = ' ') => String(text).trim().split('\n').map((line) => (line.trim() ? `${pad}${line}` : '')).join('\n');
|
|
22
|
+
const times = (n) => (n === 1 ? 'once' : `up to ${n} times`);
|
|
23
|
+
|
|
24
|
+
/** The skill description: the factory's own, plus a "Use when" sentence when it has none. */
|
|
25
|
+
export function skillDescription(factory) {
|
|
26
|
+
const base = String(factory.description || '').replace(/\s+/g, ' ').trim();
|
|
27
|
+
const tail = /\buse when\b/i.test(base) ? '' : ` Use when the person wants to run the “${factory.title}” factory.`;
|
|
28
|
+
if (base.length + tail.length <= MAX_DESCRIPTION) return `${base}${tail}`;
|
|
29
|
+
const room = MAX_DESCRIPTION - tail.length - 1;
|
|
30
|
+
const cut = base.slice(0, room).replace(/\s+\S*$/, '');
|
|
31
|
+
return `${cut}…${tail}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function frontmatter({ id, factory, sha256, source }) {
|
|
35
|
+
// Every value is quoted, so a name such as `no` or `2024` stays text in any YAML reader.
|
|
36
|
+
const lines = ['---', `name: ${q(id)}`, `description: ${q(skillDescription(factory))}`];
|
|
37
|
+
if (factory.license) lines.push(`license: ${q(factory.license)}`);
|
|
38
|
+
lines.push('metadata:', ` factoryhub-id: ${q(id)}`, ` factoryhub-version: ${q(factory.version)}`, ` factoryhub-source: ${q(source)}`, ` factoryhub-sha256: ${q(sha256)}`);
|
|
39
|
+
if (factory.author) lines.push(` factoryhub-author: ${q(factory.author)}`);
|
|
40
|
+
lines.push('---');
|
|
41
|
+
return lines.join('\n');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function produces(factory) {
|
|
45
|
+
const last = finalStep(factory);
|
|
46
|
+
const lines = [];
|
|
47
|
+
if (factory.outputs) {
|
|
48
|
+
lines.push('The deliverable joins these results, in this order, without rewriting them:', '', ...factory.outputs.map((output) => `- **${output.title}**, from the step "${titleOf(factory, output.step)}"`));
|
|
49
|
+
} else if (last) {
|
|
50
|
+
lines.push(`The deliverable is the result of the step **${last.title}**, written by the \`${last.agent}\` agent.`);
|
|
51
|
+
} else {
|
|
52
|
+
lines.push('This factory declares no step that produces a result.');
|
|
53
|
+
}
|
|
54
|
+
if (factory.done.length) lines.push('', 'It is done when:', '', ...factory.done.map((line) => `- ${line}`));
|
|
55
|
+
return lines.join('\n');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function inputs(factory) {
|
|
59
|
+
if (!factory.inputs.length) return 'This factory takes no inputs.';
|
|
60
|
+
const required = factory.inputs.filter((input) => input.required).length;
|
|
61
|
+
return [
|
|
62
|
+
`Ask the person for these before the first step. ${required ? `${required} ${required === 1 ? 'is' : 'are'} required.` : 'All are optional.'}`,
|
|
63
|
+
'',
|
|
64
|
+
...factory.inputs.map((input) => `- **${input.key}** (${input.type}, ${input.required ? 'required' : 'optional'})${input.help ? `: ${input.help}` : ''}`),
|
|
65
|
+
'',
|
|
66
|
+
'A `text` or `url` input holds up to 2,000 characters and a `longtext` input up to 40,000; all inputs together hold up to 80,000. Never invent a missing input: ask for it, or leave an optional one empty.',
|
|
67
|
+
].join('\n');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const titleOf = (factory, id) => factory.steps.find((step) => step.id === id)?.title || id;
|
|
71
|
+
|
|
72
|
+
function steps(factory) {
|
|
73
|
+
if (!factory.steps.length) return 'This factory has no flow, so it declares no steps. Follow `factory/agents/foreman/agent.md` if it has one.';
|
|
74
|
+
const parallel = factory.steps.some((step) => factory.steps.some((other) => other.id !== step.id && other.needs.length && other.needs.join() === step.needs.join()));
|
|
75
|
+
const head = `Do the steps in this order. A step starts only when the steps it needs are finished.${parallel ? ' Steps that need the same earlier steps can run at the same time.' : ''}`;
|
|
76
|
+
const items = factory.steps.map((step, index) => {
|
|
77
|
+
const needs = step.needs.length ? step.needs.map((id) => `"${titleOf(factory, id)}"`).join(', ') : 'nothing earlier';
|
|
78
|
+
const agent = factory.agents[step.agent];
|
|
79
|
+
const who = `the \`${step.agent}\` agent${agent?.description ? ` (${agent.description.replace(/\.$/, '')})` : ''}`;
|
|
80
|
+
if (step.kind === 'approve') {
|
|
81
|
+
return [
|
|
82
|
+
`${index + 1}. **${step.title}** (a decision). Who: the person. Needs: ${needs}.`,
|
|
83
|
+
'',
|
|
84
|
+
indent(`${DECISION_LINE} the result of "${titleOf(factory, step.target)}". Show it to them in full.${step.task ? ` ${step.task.replace(/\s+/g, ' ').trim()}` : ''}`),
|
|
85
|
+
'',
|
|
86
|
+
indent(`If they approve, continue. ${step.rounds ? `If they ask for changes, redo "${titleOf(factory, step.target)}" with their note, ${times(step.rounds)}, and redo every step that used it. ` : 'This decision allows no changes: they approve or reject. '}If they reject it, stop: the run has no result. Never decide for them.`),
|
|
87
|
+
].join('\n');
|
|
88
|
+
}
|
|
89
|
+
if (step.kind === 'check') {
|
|
90
|
+
return [
|
|
91
|
+
`${index + 1}. **${step.title}** (a check). Who: ${who}. Needs: ${needs}.`,
|
|
92
|
+
'',
|
|
93
|
+
indent(`Check the result of "${titleOf(factory, step.target)}" against these criteria:`),
|
|
94
|
+
'',
|
|
95
|
+
indent(step.task || 'The result does what its step was asked to do, with no invented facts.', ' > '),
|
|
96
|
+
'',
|
|
97
|
+
indent(step.rounds ? `If it fails, send it back to be redone ${times(step.rounds)} with the reasons, and redo every step that used it. If it still fails, carry on and tell the person the check failed.` : 'If it fails, do not redo it: carry on and tell the person the check failed.'),
|
|
98
|
+
].join('\n');
|
|
99
|
+
}
|
|
100
|
+
const tools = [...step.tools, ...step.missing];
|
|
101
|
+
return [
|
|
102
|
+
`${index + 1}. **${step.title}** (${step.kind === 'deliver' ? 'writes the deliverable' : 'work'}). Who: ${who}. Needs: ${needs}.${tools.length ? ` Tools: ${tools.map((tool) => `\`${tool}\``).join(', ')}.` : ''}`,
|
|
103
|
+
'',
|
|
104
|
+
indent(step.task),
|
|
105
|
+
].join('\n');
|
|
106
|
+
});
|
|
107
|
+
return [head, '', items.join('\n\n')].join('\n');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function cliRun(factory) {
|
|
111
|
+
const example = factory.inputs.filter((input) => input.required).map((input) => ` --input ${input.key}="..."`).join('');
|
|
112
|
+
return [
|
|
113
|
+
'The factoryhub CLI runs these steps exactly as written, stops at every decision to ask the person, and writes the deliverable with a receipt of every step. It calls the model with your own key.',
|
|
114
|
+
'',
|
|
115
|
+
'```sh',
|
|
116
|
+
'git clone https://github.com/Mindburn-Labs/factoryhub',
|
|
117
|
+
'cd factoryhub && npm ci',
|
|
118
|
+
'export ANTHROPIC_API_KEY=... # or OPENAI_API_KEY or OPENROUTER_API_KEY',
|
|
119
|
+
`npx factoryhub run /path/to/this/skill/factory${example}`,
|
|
120
|
+
'```',
|
|
121
|
+
'',
|
|
122
|
+
'A model on your own machine works too: add `--provider ollama --model <name>`, or `--base-url <url> --model <name>` for any OpenAI-compatible server.',
|
|
123
|
+
].join('\n');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function byHand(factory) {
|
|
127
|
+
const agents = [...new Set(factory.steps.map((step) => step.agent).filter((name) => name && name !== 'owner'))];
|
|
128
|
+
const last = finalStep(factory);
|
|
129
|
+
return [
|
|
130
|
+
'Without the CLI, follow the factory from its files:',
|
|
131
|
+
'',
|
|
132
|
+
'1. Read `factory/agents/foreman/agent.md`. It describes the whole workflow in prose.',
|
|
133
|
+
`2. For each step above, take on the role in its agent file (${agents.map((name) => `\`factory/agents/${name}/agent.md\``).join(', ') || 'none'}) and do the task. Use only the inputs, the results of earlier steps and pages you actually read. Say plainly when something could not be found.`,
|
|
134
|
+
`3. At every decision, ${DECISION_LINE.charAt(0).toLowerCase()}${DECISION_LINE.slice(1)}. Wait for their answer.`,
|
|
135
|
+
`4. ${factory.outputs ? 'Give the person the declared outputs, in order and unchanged.' : last ? `Give the person the result of "${last.title}", unchanged, as the deliverable.` : 'There is no deliverable step.'}`,
|
|
136
|
+
'',
|
|
137
|
+
'Following the files by hand does not reproduce the runner\'s enforced checks or its receipt. Use the CLI when those matter.',
|
|
138
|
+
].join('\n');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Builds the skill folder for a factory. Returns { id, sha256, files } where
|
|
143
|
+
* `files` maps paths inside the `<id>/` folder to their text.
|
|
144
|
+
*/
|
|
145
|
+
export async function exportSkill(sourceFiles, { id, source, hub = DEFAULT_HUB } = {}) {
|
|
146
|
+
const bundle = canonicalFiles(sourceFiles);
|
|
147
|
+
const { ok, errors, factory } = parseBundle(bundle);
|
|
148
|
+
if (!ok) throw new Error(`This factory is not valid, so it cannot be exported:\n${errors.map((error) => ` - ${error}`).join('\n')}`);
|
|
149
|
+
const name = id || factory.name;
|
|
150
|
+
if (!SKILL_NAME.test(name) || name.length > MAX_NAME) throw new Error(`"${name}" cannot be a skill name: use lowercase letters, digits and single hyphens, at most ${MAX_NAME} characters.`);
|
|
151
|
+
const sha256 = await bundleHash(bundle);
|
|
152
|
+
const url = source || `${hub.replace(/\/+$/, '')}/f/${name}`;
|
|
153
|
+
const body = [
|
|
154
|
+
`# ${factory.title}`,
|
|
155
|
+
'',
|
|
156
|
+
factory.description,
|
|
157
|
+
'',
|
|
158
|
+
// Read from the canonical bundle alone, so a bundle that already carries the
|
|
159
|
+
// foreman the engine writes exports to the same bytes as one without it.
|
|
160
|
+
`This skill was exported from the factory \`${name}\`, version ${factory.version}. \`factory/\` holds the factory's own files, unchanged, ${isGeneratedForeman(bundle['agents/foreman/agent.md']) ? 'plus a foreman written from the flow' : 'with nothing added'}. Source: ${url}`,
|
|
161
|
+
'',
|
|
162
|
+
'## What it produces', '', produces(factory), '',
|
|
163
|
+
'## Inputs to collect', '', inputs(factory), '',
|
|
164
|
+
'## Steps', '', steps(factory), '',
|
|
165
|
+
'## Run it with the factoryhub CLI', '', cliRun(factory), '',
|
|
166
|
+
'## Follow it by hand', '', byHand(factory), '',
|
|
167
|
+
].join('\n');
|
|
168
|
+
const files = { 'SKILL.md': `${frontmatter({ id: name, factory, sha256, source: url })}\n\n${body}` };
|
|
169
|
+
for (const [path, content] of Object.entries(bundle)) files[`factory/${path}`] = content;
|
|
170
|
+
return { id: name, sha256, files };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Splits a SKILL.md into its frontmatter text and body. */
|
|
174
|
+
export function splitSkill(text) {
|
|
175
|
+
const match = String(text).match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
176
|
+
return match ? { head: match[1], body: match[2] } : null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Checks an exported skill folder (paths relative to the `<id>/` folder).
|
|
181
|
+
* Returns a list of problems; empty means it passes.
|
|
182
|
+
*/
|
|
183
|
+
export async function checkSkill(files, { folder, hub = DEFAULT_HUB } = {}) {
|
|
184
|
+
const problems = [];
|
|
185
|
+
const text = files['SKILL.md'];
|
|
186
|
+
if (typeof text !== 'string') return ['SKILL.md is missing.'];
|
|
187
|
+
const parts = splitSkill(text);
|
|
188
|
+
if (!parts) return ['SKILL.md needs YAML frontmatter between --- lines at the top.'];
|
|
189
|
+
let meta;
|
|
190
|
+
try { meta = parseYaml(parts.head); } catch (error) { return [`The frontmatter is not valid YAML: ${error.message}`]; }
|
|
191
|
+
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return ['The frontmatter must be a mapping.'];
|
|
192
|
+
const known = ['name', 'description', 'license', 'compatibility', 'metadata', 'allowed-tools'];
|
|
193
|
+
for (const key of Object.keys(meta)) if (!known.includes(key)) problems.push(`Unknown frontmatter field "${key}".`);
|
|
194
|
+
const name = meta.name;
|
|
195
|
+
if (typeof name !== 'string' || !SKILL_NAME.test(name) || name.length > MAX_NAME) problems.push('name must be lowercase letters, digits and single hyphens, at most 64 characters.');
|
|
196
|
+
if (folder && name !== folder) problems.push(`name "${name}" must match the folder name "${folder}".`);
|
|
197
|
+
if (typeof meta.description !== 'string' || !meta.description.trim() || meta.description.length > MAX_DESCRIPTION) problems.push(`description must be 1 to ${MAX_DESCRIPTION} characters.`);
|
|
198
|
+
else if (!/\buse when\b/i.test(meta.description)) problems.push('description must say when to use the skill ("Use when ...").');
|
|
199
|
+
if (meta.license !== undefined && (typeof meta.license !== 'string' || !meta.license.trim())) problems.push('license must be text.');
|
|
200
|
+
const metadata = meta.metadata;
|
|
201
|
+
let info = null;
|
|
202
|
+
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) problems.push('metadata is missing: it must be a map with the factoryhub-* fields.');
|
|
203
|
+
else {
|
|
204
|
+
for (const [key, value] of Object.entries(metadata)) if (typeof value !== 'string') problems.push(`metadata.${key} must be text: the Agent Skills spec allows only string values.`);
|
|
205
|
+
info = { id: metadata['factoryhub-id'], version: metadata['factoryhub-version'], source: metadata['factoryhub-source'], sha256: metadata['factoryhub-sha256'], author: metadata['factoryhub-author'] };
|
|
206
|
+
if (info.id !== name) problems.push('metadata.factoryhub-id must equal name.');
|
|
207
|
+
if (typeof info.version !== 'string' || !info.version) problems.push('metadata.factoryhub-version must be text.');
|
|
208
|
+
if (typeof info.source !== 'string' || !/^https:\/\/\S+\/f\/[a-z0-9-]+$/.test(info.source)) problems.push('metadata.factoryhub-source must be the factory\'s https address.');
|
|
209
|
+
else if (info.source !== `${hub.replace(/\/+$/, '')}/f/${name}`) problems.push(`metadata.factoryhub-source must be ${hub}/f/${name}.`);
|
|
210
|
+
if (typeof info.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(info.sha256)) problems.push('metadata.factoryhub-sha256 must be a SHA-256 hex digest.');
|
|
211
|
+
}
|
|
212
|
+
for (const heading of SKILL_SECTIONS) if (!parts.body.includes(`\n${heading}\n`)) problems.push(`The body is missing the section "${heading}".`);
|
|
213
|
+
const bundle = {};
|
|
214
|
+
for (const [path, content] of Object.entries(files)) {
|
|
215
|
+
if (path === 'SKILL.md') continue;
|
|
216
|
+
if (!path.startsWith('factory/')) { problems.push(`Unexpected file ${path}: a skill export holds SKILL.md and factory/ only.`); continue; }
|
|
217
|
+
bundle[path.slice('factory/'.length)] = content;
|
|
218
|
+
}
|
|
219
|
+
const parsed = parseBundle(bundle);
|
|
220
|
+
if (!parsed.ok) problems.push(`factory/ is not a valid factory: ${parsed.errors.join(' ')}`);
|
|
221
|
+
else {
|
|
222
|
+
if (parsed.factory.name !== name && !info?.source) problems.push('factory/ holds a different factory.');
|
|
223
|
+
if (info && (await bundleHash(bundle)) !== info.sha256) problems.push('factory/ does not match metadata.factoryhub-sha256.');
|
|
224
|
+
const decisions = parsed.factory.steps.filter((step) => step.kind === 'approve').length;
|
|
225
|
+
const asked = parts.body.split(`${DECISION_LINE} the result of`).length - 1;
|
|
226
|
+
if (asked !== decisions) problems.push(`The flow has ${decisions} decision steps but the body asks the person ${asked} times.`);
|
|
227
|
+
}
|
|
228
|
+
return problems;
|
|
229
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@factoryhub/engine",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The factory engine: parses factory bundles, runs their flows with your model, and writes and verifies receipts. No dependencies.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"private": true,
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=22"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/Mindburn-Labs/factoryhub.git",
|
|
14
|
+
"directory": "packages/engine"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./src/index.mjs",
|
|
18
|
+
"./factory-engine": "./src/factory-engine.mjs",
|
|
19
|
+
"./author-tests": "./src/author-tests.mjs",
|
|
20
|
+
"./compose": "./src/compose.mjs",
|
|
21
|
+
"./cost": "./src/cost.mjs",
|
|
22
|
+
"./diagnostics": "./src/diagnostics.mjs",
|
|
23
|
+
"./markdown": "./src/markdown.mjs",
|
|
24
|
+
"./package": "./src/package.mjs",
|
|
25
|
+
"./node": "./lib/node.mjs",
|
|
26
|
+
"./skill": "./lib/skill.mjs",
|
|
27
|
+
"./hub": "./lib/hub.mjs",
|
|
28
|
+
"./catalog": "./lib/catalog.mjs",
|
|
29
|
+
"./run": "./lib/run.mjs",
|
|
30
|
+
"./providers": "./lib/providers.mjs"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"src",
|
|
34
|
+
"lib"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"test": "node --test \"test/*.test.mjs\""
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Portable, declarative examples. No code, regular expressions or extra grader.
|
|
2
|
+
import { parseBundle, parseYaml, checkInputs, sha256 } from './factory-engine.mjs';
|
|
3
|
+
|
|
4
|
+
export const AUTHOR_TEST_VERSION = 1;
|
|
5
|
+
export const AUTHOR_TEST_FILE = 'mindburn.tests.yaml';
|
|
6
|
+
const object = value => value && typeof value === 'object' && !Array.isArray(value);
|
|
7
|
+
const unknown = (value, allowed) => Object.keys(value).filter(key => !allowed.includes(key));
|
|
8
|
+
|
|
9
|
+
export function readAuthorTests(files) {
|
|
10
|
+
if (!Object.hasOwn(files, AUTHOR_TEST_FILE)) return { declared: false, ok: true, cases: [], errors: [] };
|
|
11
|
+
const errors = [], cases = [];
|
|
12
|
+
try {
|
|
13
|
+
const spec = parseYaml(files[AUTHOR_TEST_FILE]);
|
|
14
|
+
if (!object(spec) || spec.version !== 1 || unknown(spec, ['version', 'cases']).length
|
|
15
|
+
|| !object(spec.cases) || !Object.keys(spec.cases).length || Object.keys(spec.cases).length > 8) {
|
|
16
|
+
throw new Error('Use version: 1 and a cases mapping with 1–8 examples.');
|
|
17
|
+
}
|
|
18
|
+
const { factory } = parseBundle(files);
|
|
19
|
+
for (const [id, entry] of Object.entries(spec.cases)) {
|
|
20
|
+
if (!/^[a-z][a-z0-9_-]{0,63}$/.test(id) || !object(entry) || unknown(entry, ['name', 'inputs', 'expect']).length) throw new Error(`Invalid example: ${id.slice(0, 64)}.`);
|
|
21
|
+
const name = entry.name === undefined ? id.replaceAll('_', ' ') : entry.name;
|
|
22
|
+
if (typeof name !== 'string' || !name.trim() || name.length > 100) throw new Error(`Give ${id} a name of at most 100 characters.`);
|
|
23
|
+
if (!object(entry.inputs)) throw new Error(`${id} needs an inputs mapping.`);
|
|
24
|
+
const inputs = checkInputs(factory, entry.inputs);
|
|
25
|
+
if (!inputs.ok) throw new Error(`${id}: ${inputs.problems.join(' ')}`);
|
|
26
|
+
const expect = entry.expect;
|
|
27
|
+
if (!object(expect) || unknown(expect, ['contains', 'excludes', 'min_length', 'max_length']).length) throw new Error(`${id} needs supported expected-result checks.`);
|
|
28
|
+
let checks = 0;
|
|
29
|
+
for (const key of ['contains', 'excludes']) {
|
|
30
|
+
if (expect[key] === undefined) continue;
|
|
31
|
+
if (!Array.isArray(expect[key]) || !expect[key].length || expect[key].length > 50
|
|
32
|
+
|| expect[key].some(value => typeof value !== 'string' || !value.trim() || value.length > 500)) throw new Error(`${id}.${key} needs 1–50 nonempty text values of at most 500 characters.`);
|
|
33
|
+
checks += expect[key].length;
|
|
34
|
+
}
|
|
35
|
+
for (const key of ['min_length', 'max_length']) {
|
|
36
|
+
if (expect[key] === undefined) continue;
|
|
37
|
+
if (!Number.isInteger(expect[key]) || expect[key] < 0 || expect[key] > 100000) throw new Error(`${id}.${key} must be an integer from 0 to 100000.`);
|
|
38
|
+
checks += 1;
|
|
39
|
+
}
|
|
40
|
+
if (!checks || (expect.min_length !== undefined && expect.max_length !== undefined && expect.min_length > expect.max_length)) throw new Error(`${id} needs at least one consistent expected-result check.`);
|
|
41
|
+
cases.push({ id, name: name.trim(), inputs: inputs.inputs, expect, checks });
|
|
42
|
+
}
|
|
43
|
+
} catch (error) { errors.push(String(error.message).slice(0, 1200)); }
|
|
44
|
+
return { declared: true, ok: errors.length === 0, cases: errors.length ? [] : cases, errors };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function evaluateAuthorTest(example, run) {
|
|
48
|
+
const total = example.checks;
|
|
49
|
+
if (run.status !== 'done') return { status: 'failed', total, passed: 0, failures: ['The run did not finish with a result.'], models: [] };
|
|
50
|
+
const output = String(run.deliverable || '');
|
|
51
|
+
const normalize = text => text.normalize('NFKC').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
52
|
+
const folded = normalize(output), checks = [];
|
|
53
|
+
for (const value of example.expect.contains || []) checks.push([`Contains “${value}”`, folded.includes(normalize(value))]);
|
|
54
|
+
for (const value of example.expect.excludes || []) checks.push([`Excludes “${value}”`, !folded.includes(normalize(value))]);
|
|
55
|
+
const length = [...output].length;
|
|
56
|
+
if (example.expect.min_length !== undefined) checks.push([`At least ${example.expect.min_length} characters`, length >= example.expect.min_length]);
|
|
57
|
+
if (example.expect.max_length !== undefined) checks.push([`At most ${example.expect.max_length} characters`, length <= example.expect.max_length]);
|
|
58
|
+
const failures = checks.filter(([, passed]) => !passed).map(([name]) => name);
|
|
59
|
+
return { status: failures.length ? 'failed' : 'passed', total, passed: total - failures.length, failures,
|
|
60
|
+
outputSha256: await sha256(output), models: [...new Set((run.receipt?.usage?.metering?.calls || []).map(call => call.model).filter(Boolean))] };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function authorTestSummary(definition, records = [], includeRuns = false) {
|
|
64
|
+
if (!definition.declared) return { status: 'not-verified', declared: false, total: 0, passed: 0, cases: [] };
|
|
65
|
+
if (!definition.ok) return { status: 'invalid', declared: true, total: 0, passed: 0, cases: [], errors: definition.errors };
|
|
66
|
+
const cases = definition.cases.map(example => {
|
|
67
|
+
const record = records.find(item => item?.caseId === example.id);
|
|
68
|
+
return { id: example.id, name: example.name, total: example.checks, passed: record?.passed || 0,
|
|
69
|
+
status: record?.status || 'not-run', failures: record?.failures || [],
|
|
70
|
+
...(includeRuns && record?.runId ? { runId: record.runId } : {}) };
|
|
71
|
+
});
|
|
72
|
+
const status = cases.some(item => item.status === 'running') ? 'running'
|
|
73
|
+
: cases.some(item => item.status === 'failed') ? 'failed'
|
|
74
|
+
: cases.every(item => item.status === 'passed') ? 'passed' : 'not-run';
|
|
75
|
+
return { version: AUTHOR_TEST_VERSION, declared: true, status, total: cases.reduce((sum, item) => sum + item.total, 0),
|
|
76
|
+
passed: cases.reduce((sum, item) => sum + item.passed, 0), cases,
|
|
77
|
+
models: [...new Set(records.flatMap(record => record?.models || []))], provenance: 'hub-run-tests' };
|
|
78
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Deterministic composition. The resulting ordinary bundle is execution truth;
|
|
2
|
+
// no member definition or registry is consulted when it runs.
|
|
3
|
+
import { parseBundle, parseYaml, toYaml, normalizeFiles, ancestorsOf, finalStep, bundleHash, canonical, withForeman, isGeneratedForeman, parseFrontmatter, INPUT_TYPES } from './factory-engine.mjs';
|
|
4
|
+
|
|
5
|
+
export const COMPILER_VERSION = 'mindburn.compose/v1';
|
|
6
|
+
const fail = (message) => { throw new Error(message); };
|
|
7
|
+
const map = (value) => value && typeof value === 'object' && !Array.isArray(value);
|
|
8
|
+
const keys = (value, allowed, label) => {
|
|
9
|
+
if (!map(value)) fail(`${label} must be a map.`);
|
|
10
|
+
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
11
|
+
if (unknown.length) fail(`${label} has unsupported semantics: ${unknown.join(', ')}.`);
|
|
12
|
+
};
|
|
13
|
+
const namespace = (member, id) => {
|
|
14
|
+
const result = `${member}_${id}`;
|
|
15
|
+
if (!/^[a-z][a-z0-9_]{0,40}$/.test(result)) fail(`The namespaced name "${result}" exceeds the portable name limit.`);
|
|
16
|
+
return result;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const agentName = (member, name) => namespace(member, name.replaceAll('-', '_')).replaceAll('_', '-');
|
|
20
|
+
|
|
21
|
+
export async function composeFactories(spec) {
|
|
22
|
+
keys(spec, ['name', 'title', 'description', 'intent', 'inputs', 'members', 'outputs', 'unmetCapabilities'], 'Composition');
|
|
23
|
+
if (!/^[a-z][a-z0-9-]{0,63}$/.test(spec.name || '') || typeof spec.title !== 'string' || !spec.title.trim() || typeof spec.intent !== 'string' || !spec.intent.trim() || spec.intent.length > 2000) fail('Composition needs a name, title and original intent.');
|
|
24
|
+
if (!Array.isArray(spec.members) || !spec.members.length || spec.members.length > 8) fail('Compose between 1 and 8 pinned factories.');
|
|
25
|
+
if (!map(spec.inputs) || Object.keys(spec.inputs).length > 8) fail('Composition needs at most 8 shared inputs.');
|
|
26
|
+
for (const [id, input] of Object.entries(spec.inputs)) {
|
|
27
|
+
if (!/^[a-z][a-z0-9_]{0,63}$/.test(id)) fail(`Invalid shared input "${id}".`);
|
|
28
|
+
keys(input, ['type', 'label', 'help', 'optional', 'required', 'visibility'], `Input ${id}`);
|
|
29
|
+
if (input.type !== undefined && !INPUT_TYPES.includes(input.type)) fail(`Unknown input type for ${id}.`);
|
|
30
|
+
}
|
|
31
|
+
const unmet = spec.unmetCapabilities === undefined ? [] : Array.isArray(spec.unmetCapabilities) ? [...spec.unmetCapabilities] : spec.unmetCapabilities;
|
|
32
|
+
if (!Array.isArray(unmet) || unmet.some((item) => typeof item !== 'string' || !item.trim())) fail('Unmet capabilities must be explicit text requirements.');
|
|
33
|
+
const members = new Map();
|
|
34
|
+
let defaults;
|
|
35
|
+
for (const member of spec.members) {
|
|
36
|
+
keys(member, ['id', 'files', 'sha256', 'inputs', 'public'], 'Member');
|
|
37
|
+
if (!/^[a-z][a-z0-9_]{0,15}$/.test(member.id || '') || members.has(member.id)) fail('Each member needs a unique short name.');
|
|
38
|
+
if (!/^[a-f0-9]{64}$/.test(member.sha256 || '') || await bundleHash(member.files) !== member.sha256) fail(`Pinned factory "${member.id}" does not match its bundle hash.`);
|
|
39
|
+
const parsed = parseBundle(member.files);
|
|
40
|
+
if (!parsed.ok || !parsed.factory.runnable) fail(`Factory "${member.id}" cannot run: ${[...parsed.errors, ...parsed.factory.blockers].join(' ')}`);
|
|
41
|
+
if (member.public !== undefined && typeof member.public !== 'boolean') fail('Member public scope must be boolean.');
|
|
42
|
+
const flow = parseYaml(member.files['flow.yaml']);
|
|
43
|
+
for (const input of Object.values(flow.inputs || {})) {
|
|
44
|
+
keys(input, ['type', 'label', 'help', 'optional', 'required', 'visibility'], 'Member input');
|
|
45
|
+
if (input.type !== undefined && !INPUT_TYPES.includes(input.type)) fail('Unsupported member input type.');
|
|
46
|
+
}
|
|
47
|
+
keys(flow, ['flow', 'requires', 'title', 'author', 'version', 'license', 'tags', 'time', 'inputs', 'steps', 'done', 'outputs', 'composition'], `Factory ${member.id}`);
|
|
48
|
+
if (flow.flow && flow.flow !== 'mindburn.flow/v1') fail('Unsupported member flow version.');
|
|
49
|
+
const config = parseYaml(member.files['factory.yaml']);
|
|
50
|
+
keys(config, ['schemaVersion', 'name', 'description', 'repositories', 'agentDefaults'], `Configuration ${member.id}`);
|
|
51
|
+
if (config.schemaVersion !== 'v1alpha1' || (config.repositories && (!Array.isArray(config.repositories) || config.repositories.length))) fail('Repository execution is not supported by this compiler.');
|
|
52
|
+
if (config.agentDefaults) {
|
|
53
|
+
keys(config.agentDefaults, ['harness'], 'Agent defaults');
|
|
54
|
+
if (config.agentDefaults.harness) keys(config.agentDefaults.harness, ['type', 'model'], 'Harness');
|
|
55
|
+
}
|
|
56
|
+
const memberDefaults = config.agentDefaults || null;
|
|
57
|
+
if (!members.size) defaults = memberDefaults;
|
|
58
|
+
else if (canonical(defaults) !== canonical(memberDefaults)) fail('Member harness defaults differ. Choose compatible source revisions before connecting them.');
|
|
59
|
+
for (const [path, text] of Object.entries(member.files)) {
|
|
60
|
+
if (!['factory.yaml', 'flow.yaml', 'README.md'].includes(path) && !/^agents\/[a-z0-9][a-z0-9_-]{0,40}\/agent\.md$/.test(path)) fail(`Cannot compile required file "${path}".`);
|
|
61
|
+
if (path.startsWith('agents/')) keys(parseFrontmatter(text).meta, ['description', 'agentType'], 'Agent metadata');
|
|
62
|
+
if (path === 'agents/foreman/agent.md' && !isGeneratedForeman(text)) fail('An authored foreman needs an explicit flow translation before composition.');
|
|
63
|
+
}
|
|
64
|
+
if (!map(member.inputs) || canonical(Object.keys(member.inputs).sort()) !== canonical(parsed.factory.inputs.map((input) => input.key).sort())) fail(`Map every input of "${member.id}" exactly once.`);
|
|
65
|
+
for (const raw of Object.values(flow.steps)) {
|
|
66
|
+
keys(raw, ['title', 'agent', 'kind', 'needs', 'checks', 'shows', 'tools', 'task', 'rounds', 'context', 'criteria'], `Step in ${member.id}`);
|
|
67
|
+
if ((raw.checks !== undefined && raw.kind !== 'check') || (raw.shows !== undefined && raw.kind !== 'approve')) fail('checks and shows must belong to their matching step kinds.');
|
|
68
|
+
if (raw.rounds !== undefined && (!Number.isInteger(raw.rounds) || raw.rounds < 0 || raw.rounds > 5)) fail('Revision rounds exceed this runner’s limits.');
|
|
69
|
+
}
|
|
70
|
+
members.set(member.id, { ...member, factory: parsed.factory, flow });
|
|
71
|
+
}
|
|
72
|
+
const steps = {};
|
|
73
|
+
const files = {};
|
|
74
|
+
const provenance = [];
|
|
75
|
+
const sourceRef = (ref) => {
|
|
76
|
+
keys(ref, ['member', 'step'], 'Artifact');
|
|
77
|
+
const member = members.get(ref.member);
|
|
78
|
+
const step = member?.factory.steps.find((item) => item.id === ref.step && ['work', 'deliver'].includes(item.kind));
|
|
79
|
+
if (!step) fail('An artifact binding must name member work.');
|
|
80
|
+
return { id: namespace(ref.member, ref.step), member };
|
|
81
|
+
};
|
|
82
|
+
for (const member of members.values()) {
|
|
83
|
+
const scoped = {};
|
|
84
|
+
for (const [alias, binding] of Object.entries(member.inputs)) {
|
|
85
|
+
keys(binding, ['input', 'artifact'], `Binding ${alias}`);
|
|
86
|
+
if (Object.keys(binding).length !== 1) fail('A binding selects exactly one shared input or artifact.');
|
|
87
|
+
if (binding.input !== undefined) {
|
|
88
|
+
if (!Object.hasOwn(spec.inputs, binding.input)) fail(`Unknown shared input "${binding.input}".`);
|
|
89
|
+
const sourceInput = member.factory.inputs.find((input) => input.key === alias);
|
|
90
|
+
const shared = spec.inputs[binding.input];
|
|
91
|
+
if ((shared.type || 'text') !== sourceInput.type || (sourceInput.required && (shared.optional === true || shared.required === false))) fail(`Shared input "${binding.input}" weakens the required type or presence of "${alias}".`);
|
|
92
|
+
if (sourceInput.visibility === 'private' && shared.visibility === 'public') fail('A private source input cannot become public.');
|
|
93
|
+
scoped[alias] = { input: binding.input };
|
|
94
|
+
} else {
|
|
95
|
+
if (member.factory.inputs.find((input) => input.key === alias).type !== 'longtext') fail('An artifact binding requires a longtext input.');
|
|
96
|
+
scoped[alias] = { artifact: sourceRef(binding.artifact) };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
for (const name of Object.keys(member.factory.agents)) {
|
|
100
|
+
if (name === 'foreman') continue;
|
|
101
|
+
const path = `agents/${agentName(member.id, name)}/agent.md`;
|
|
102
|
+
if (Object.hasOwn(files, path)) fail(`Generated agent name collides: ${path}. Choose different member names.`);
|
|
103
|
+
files[path] = member.files[`agents/${name}/agent.md`];
|
|
104
|
+
}
|
|
105
|
+
for (const step of member.factory.steps) {
|
|
106
|
+
const raw = member.flow.steps[step.id];
|
|
107
|
+
if (!raw) fail('Member step names must already use canonical identifiers.');
|
|
108
|
+
const context = { inputs: {}, artifacts: {}, ...(member.public === true || step.context?.public ? { public: true } : {}) };
|
|
109
|
+
const needs = new Set(step.needs.map((id) => namespace(member.id, id)));
|
|
110
|
+
for (const [alias, input] of Object.entries(step.context?.inputs || Object.fromEntries(member.factory.inputs.map((item) => [item.key, item.key])))) {
|
|
111
|
+
const binding = scoped[input];
|
|
112
|
+
if (binding.input !== undefined) context.inputs[alias] = binding.input;
|
|
113
|
+
else {
|
|
114
|
+
if (binding.artifact.member.id === member.id) fail('Member inputs cannot bind their own output.');
|
|
115
|
+
context.artifacts[alias] = binding.artifact.id;
|
|
116
|
+
// Wait for the upstream factory's checks and approvals as well as
|
|
117
|
+
// its text. Revision invalidation then reaches every consumer.
|
|
118
|
+
for (const terminal of binding.artifact.member.factory.steps.filter((item) => !binding.artifact.member.factory.steps.some((other) => other.needs.includes(item.id)))) needs.add(namespace(binding.artifact.member.id, terminal.id));
|
|
119
|
+
needs.add(binding.artifact.id);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
for (const [alias, id] of Object.entries(step.context?.artifacts || Object.fromEntries(ancestorsOf(member.factory, step.id).filter((id) => ['work', 'deliver'].includes(member.factory.steps.find((item) => item.id === id).kind)).map((id) => [id, id])))) {
|
|
123
|
+
if (Object.hasOwn(context.inputs, alias) || Object.hasOwn(context.artifacts, alias)) fail(`Context binding "${alias}" collides in ${member.id}.`);
|
|
124
|
+
context.artifacts[alias] = namespace(member.id, id);
|
|
125
|
+
}
|
|
126
|
+
const compiled = { ...raw, agent: step.kind === 'approve' ? undefined : agentName(member.id, step.agent), needs: [...needs], context };
|
|
127
|
+
if (step.kind === 'check') compiled.checks = namespace(member.id, step.target);
|
|
128
|
+
if (step.kind === 'approve') compiled.shows = namespace(member.id, step.target);
|
|
129
|
+
if (step.id === finalStep(member.factory)?.id && !member.factory.outputs) compiled.criteria = step.criteria || member.factory.done;
|
|
130
|
+
const id = namespace(member.id, step.id);
|
|
131
|
+
if (Object.hasOwn(steps, id)) fail(`Generated step name collides: ${id}. Choose different member names.`);
|
|
132
|
+
steps[id] = compiled;
|
|
133
|
+
}
|
|
134
|
+
for (const requirement of member.factory.composition?.unmetCapabilities || []) if (!unmet.includes(requirement)) unmet.push(requirement);
|
|
135
|
+
provenance.push({ id: member.id, name: member.factory.name, version: member.factory.version, sha256: member.sha256 });
|
|
136
|
+
}
|
|
137
|
+
if (Object.keys(steps).length > 24 || Object.keys(files).length > 39) fail('Connected work exceeds the portable step or agent limit. Split it explicitly.');
|
|
138
|
+
if (!Array.isArray(spec.outputs) || !spec.outputs.length) fail('Declare the exact output artifacts.');
|
|
139
|
+
const outputs = spec.outputs.map((output) => {
|
|
140
|
+
keys(output, ['member', 'step', 'title'], 'Output');
|
|
141
|
+
return { step: sourceRef({ member: output.member, step: output.step }).id, title: output.title };
|
|
142
|
+
});
|
|
143
|
+
files['factory.yaml'] = toYaml({ schemaVersion: 'v1alpha1', name: spec.name, description: spec.description || 'Connected factories produce checked documents with an owner decision.', repositories: [], ...(defaults ? { agentDefaults: defaults } : {}) });
|
|
144
|
+
files['flow.yaml'] = toYaml({ flow: 'mindburn.flow/v1', requires: ['scoped-context', 'multiple-outputs'], title: spec.title, version: '1.0.0', inputs: spec.inputs, steps, outputs, composition: { compiler: COMPILER_VERSION, intent: spec.intent, sources: provenance, unmetCapabilities: unmet } });
|
|
145
|
+
const result = withForeman(normalizeFiles(files));
|
|
146
|
+
if (Object.values(result).reduce((sum, value) => sum + value.length, 0) > 400000) fail('Connected bundle exceeds the portable size limit.');
|
|
147
|
+
const parsed = parseBundle(result);
|
|
148
|
+
if (!parsed.ok || !parsed.factory.runnable) fail([...parsed.errors, ...parsed.factory.blockers].join(' '));
|
|
149
|
+
return { files: result, factory: parsed.factory, sha256: await bundleHash(result) };
|
|
150
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Published direct-API list prices, not an invoice or a spending allowance.
|
|
2
|
+
// Source: https://platform.claude.com/docs/en/about-claude/pricing
|
|
3
|
+
// Keep unknown models/tiers/usage unpriced rather than guessing a zero cost.
|
|
4
|
+
const count = (value) => Number.isSafeInteger(value) && value >= 0;
|
|
5
|
+
const round = (value) => Math.round(value * 1e9) / 1e9;
|
|
6
|
+
|
|
7
|
+
function price(call) {
|
|
8
|
+
if (call?.provider !== 'anthropic' || call.billing !== 'anthropic-direct' || call.measured !== true
|
|
9
|
+
|| call.serviceTier !== 'standard' || ![null, 'standard'].includes(call.speed)
|
|
10
|
+
|| ![null, 'global', 'us'].includes(call.inferenceGeo)) return null;
|
|
11
|
+
const fields = ['input', 'output', 'uncachedInput', 'cacheReadInput', 'cacheWriteInput', 'cacheWrite5mInput', 'cacheWrite1hInput', 'searchRequests'];
|
|
12
|
+
if (!fields.every((key) => count(call[key])) || call.input !== call.uncachedInput + call.cacheReadInput + call.cacheWriteInput
|
|
13
|
+
|| call.cacheWriteInput !== call.cacheWrite5mInput + call.cacheWrite1hInput) return null;
|
|
14
|
+
let input, output, cacheRead;
|
|
15
|
+
if (call.model === 'claude-haiku-5-5') {
|
|
16
|
+
[input, output, cacheRead] = call.input > 100000 ? [0.5, 2.5, 0.05] : [0.1, 0.5, 0.01];
|
|
17
|
+
} else if (call.model === 'claude-sonnet-5-5') [input, output, cacheRead] = [2, 10, 0.1];
|
|
18
|
+
else return null;
|
|
19
|
+
const tokens = (call.uncachedInput * input + call.output * output + call.cacheReadInput * cacheRead
|
|
20
|
+
+ call.cacheWrite5mInput * input * 1.25 + call.cacheWrite1hInput * input * 2) / 1e6;
|
|
21
|
+
return tokens * (call.inferenceGeo === 'us' ? 1.1 : 1) + call.searchRequests * 0.01;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function estimateRunCost(usage) {
|
|
25
|
+
const ledger = usage?.metering;
|
|
26
|
+
const unavailable = { currency: 'USD', estimatedUsd: null, recordedUsd: 0, pricedCalls: 0, unpricedCalls: count(usage?.llmCalls) ? usage.llmCalls : null, countersMatch: false };
|
|
27
|
+
if (ledger?.version !== 1 || !Array.isArray(ledger.calls) || !count(ledger.unrecorded)
|
|
28
|
+
|| !count(usage.llmCalls) || ledger.calls.length + ledger.unrecorded !== usage.llmCalls) return unavailable;
|
|
29
|
+
let recorded = 0, pricedCalls = 0, unpricedCalls = ledger.unrecorded;
|
|
30
|
+
for (const call of ledger.calls) {
|
|
31
|
+
const cost = price(call);
|
|
32
|
+
if (cost === null) unpricedCalls += 1;
|
|
33
|
+
else { recorded += cost; pricedCalls += 1; }
|
|
34
|
+
}
|
|
35
|
+
// If the per-call ledger and run counters disagree, its subtotal is still
|
|
36
|
+
// useful but must not be presented as a price for the complete run.
|
|
37
|
+
const matches = ['input', 'output'].every((key) => count(usage[key]) && ledger.calls.every((call) => count(call?.[key]))
|
|
38
|
+
&& ledger.calls.reduce((sum, call) => sum + call[key], 0) === usage[key]);
|
|
39
|
+
return { currency: 'USD', estimatedUsd: !unpricedCalls && matches ? round(recorded) : null,
|
|
40
|
+
recordedUsd: round(recorded), pricedCalls, unpricedCalls, countersMatch: matches };
|
|
41
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Safe operational evidence. Never include provider messages, user text,
|
|
2
|
+
// artifact names, credentials or request headers in logs and CI output.
|
|
3
|
+
import { failureDetails } from './factory-engine.mjs';
|
|
4
|
+
import { estimateRunCost } from './cost.mjs';
|
|
5
|
+
|
|
6
|
+
export function runDiagnostic(run) {
|
|
7
|
+
const status = ['running', 'waiting', 'done', 'failed', 'stopped'].includes(run?.status) ? run.status : 'unavailable';
|
|
8
|
+
const usage = Object.fromEntries(['input', 'output', 'llmCalls', 'fetches', 'searches'].map((key) => [key, Number.isSafeInteger(run?.usage?.[key]) && run.usage[key] >= 0 ? run.usage[key] : null]));
|
|
9
|
+
const failure = run?.failure;
|
|
10
|
+
const kind = run?.factory?.steps?.find((step) => step.id === failure?.step)?.kind;
|
|
11
|
+
return {
|
|
12
|
+
id: /^[A-Za-z0-9_-]{10,64}$/.test(run?.id || '') ? run.id : null,
|
|
13
|
+
status,
|
|
14
|
+
...(status === 'failed' ? { failure: {
|
|
15
|
+
...failureDetails({ code: failure?.code, status: failure?.httpStatus }),
|
|
16
|
+
stage: ['work', 'check', 'deliver', 'approve'].includes(kind) ? kind : null,
|
|
17
|
+
} } : {}),
|
|
18
|
+
usage,
|
|
19
|
+
...(run?.usage?.metering ? { costEstimate: estimateRunCost(run.usage) } : {}),
|
|
20
|
+
};
|
|
21
|
+
}
|