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,9 @@
|
|
|
1
|
+
// The engine's public surface in one import. Each module can also be imported
|
|
2
|
+
// on its own: see "exports" in package.json.
|
|
3
|
+
export * from './factory-engine.mjs';
|
|
4
|
+
export * from './author-tests.mjs';
|
|
5
|
+
export * from './compose.mjs';
|
|
6
|
+
export * from './cost.mjs';
|
|
7
|
+
export * from './diagnostics.mjs';
|
|
8
|
+
export * from './markdown.mjs';
|
|
9
|
+
export * from './package.mjs';
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// A small, safe Markdown renderer for deliverables: escapes everything first,
|
|
2
|
+
// then builds headings, lists, tables, quotes, code, links and emphasis.
|
|
3
|
+
|
|
4
|
+
const escapeHtml = (text) => String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
5
|
+
|
|
6
|
+
function inline(text) {
|
|
7
|
+
let out = escapeHtml(String(text).replace(/\u0000/g, ''));
|
|
8
|
+
const codes = [];
|
|
9
|
+
out = out.replace(/`([^`]+)`/g, (_, code) => { codes.push(code); return `\u0000${codes.length - 1}\u0000`; });
|
|
10
|
+
// Finished links are set aside like code spans, so the bare-address pass
|
|
11
|
+
// can never match inside an href that was already written.
|
|
12
|
+
const stash = (html) => { codes.push({ html }); return `\u0000${codes.length - 1}\u0000`; };
|
|
13
|
+
out = out.replace(/\[([^\]]+)\]\((https?:\/\/[^\s()<>"]+)\)/g, (_, label, href) => stash(`<a href="${href}" target="_blank" rel="noopener nofollow">${label}</a>`));
|
|
14
|
+
out = out.replace(/(^|[\s(])(https?:\/\/[^\s<>()"]+[^\s<>()".,;:!?])/g, (_, lead, href) => `${lead}${stash(`<a href="${href}" target="_blank" rel="noopener nofollow">${href.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '')}</a>`)}`);
|
|
15
|
+
out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>').replace(/(^|[^*\w])\*([^*\s][^*]*)\*(?!\w)/g, '$1<em>$2</em>').replace(/(^|[^\w])_([^_\s][^_]*)_(?!\w)/g, '$1<em>$2</em>');
|
|
16
|
+
out = out.replace(/\[(ADD|DECISION NEEDED):([^\]]*)\]/g, '<mark>[$1:$2]</mark>');
|
|
17
|
+
const restore = (text) => text.replace(/\u0000(\d+)\u0000/g, (_, index) => { const item = codes[Number(index)]; return typeof item === 'string' ? `<code>${item}</code>` : restore(item.html); });
|
|
18
|
+
return restore(out);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A plain message whose field and tool names sit in backticks, such as a
|
|
22
|
+
// factory's problems, as parts to show as text: the names as code, without the
|
|
23
|
+
// backticks, and nothing else read as Markdown.
|
|
24
|
+
export function codeParts(text) {
|
|
25
|
+
return String(text ?? '').split(/`([^`\n]+)`/).map((part, index) => ({ code: index % 2 === 1, text: part })).filter((part) => part.text);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const cells = (row) => row.trim().replace(/^\||\|$/g, '').split('|').map((cell) => cell.trim());
|
|
29
|
+
|
|
30
|
+
export function renderMarkdown(source) {
|
|
31
|
+
const lines = String(source || '').replace(/\r\n/g, '\n').split('\n');
|
|
32
|
+
const html = [];
|
|
33
|
+
let i = 0;
|
|
34
|
+
const listAt = (line) => line.match(/^(\s*)([-*•]|\d+[.)])\s+(.*)$/);
|
|
35
|
+
while (i < lines.length) {
|
|
36
|
+
const line = lines[i];
|
|
37
|
+
if (!line.trim()) { i += 1; continue; }
|
|
38
|
+
if (/^```/.test(line)) {
|
|
39
|
+
const buffer = [];
|
|
40
|
+
i += 1;
|
|
41
|
+
while (i < lines.length && !/^```/.test(lines[i])) { buffer.push(lines[i]); i += 1; }
|
|
42
|
+
i += 1;
|
|
43
|
+
html.push(`<pre><code>${escapeHtml(buffer.join('\n'))}</code></pre>`);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const heading = line.match(/^(#{1,6})\s+(.*)$/);
|
|
47
|
+
if (heading) {
|
|
48
|
+
const level = Math.min(heading[1].length + 1, 5);
|
|
49
|
+
html.push(`<h${level}>${inline(heading[2].replace(/#+\s*$/, ''))}</h${level}>`);
|
|
50
|
+
i += 1;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (/^\s*([-*_])\s*\1\s*\1[\s\-*_]*$/.test(line)) { html.push('<hr>'); i += 1; continue; }
|
|
54
|
+
if (line.includes('|') && i + 1 < lines.length && /^\s*\|?\s*:?-{2,}/.test(lines[i + 1]) && lines[i + 1].includes('-')) {
|
|
55
|
+
const head = cells(line);
|
|
56
|
+
i += 2;
|
|
57
|
+
const rows = [];
|
|
58
|
+
while (i < lines.length && lines[i].includes('|') && lines[i].trim()) { rows.push(cells(lines[i])); i += 1; }
|
|
59
|
+
html.push(`<div class="table-wrap"><table><thead><tr>${head.map((cell) => `<th>${inline(cell)}</th>`).join('')}</tr></thead><tbody>${rows.map((row) => `<tr>${head.map((_, index) => `<td>${inline(row[index] || '')}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (/^\s*>/.test(line)) {
|
|
63
|
+
const buffer = [];
|
|
64
|
+
while (i < lines.length && /^\s*>/.test(lines[i])) { buffer.push(lines[i].replace(/^\s*>\s?/, '')); i += 1; }
|
|
65
|
+
html.push(`<blockquote>${renderMarkdown(buffer.join('\n'))}</blockquote>`);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (listAt(line)) {
|
|
69
|
+
const base = listAt(line)[1].length;
|
|
70
|
+
const ordered = /\d/.test(listAt(line)[2]);
|
|
71
|
+
const items = [];
|
|
72
|
+
while (i < lines.length) {
|
|
73
|
+
const item = listAt(lines[i]);
|
|
74
|
+
if (item && item[1].length <= base + 1) {
|
|
75
|
+
items.push([item[3]]);
|
|
76
|
+
i += 1;
|
|
77
|
+
} else if (items.length && lines[i].trim() && (/^\s{2,}/.test(lines[i]) || (item && item[1].length > base + 1))) {
|
|
78
|
+
items[items.length - 1].push(lines[i].slice(Math.min(base + 2, lines[i].match(/^\s*/)[0].length)));
|
|
79
|
+
i += 1;
|
|
80
|
+
} else if (!lines[i].trim() && i + 1 < lines.length && (listAt(lines[i + 1]) || /^\s{2,}\S/.test(lines[i + 1]))) {
|
|
81
|
+
i += 1;
|
|
82
|
+
} else break;
|
|
83
|
+
}
|
|
84
|
+
const tag = ordered ? 'ol' : 'ul';
|
|
85
|
+
html.push(`<${tag}>${items.map(([first, ...rest]) => ((box) => `<li${box ? ' class="task"' : ''}>${box ? `<input type="checkbox" disabled${box[1] === ' ' ? '' : ' checked'} aria-label="${box[1] === ' ' ? 'Not done' : 'Done'}">` : ''}<span>${inline(box ? first.slice(box[0].length) : first)}${rest.length ? renderMarkdown(rest.join('\n')) : ''}</span></li>`)(first.match(/^\[([ xX])\]\s+/))).join('')}</${tag}>`);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const buffer = [];
|
|
89
|
+
while (i < lines.length && lines[i].trim() && !/^(#{1,6}\s|```|\s*>)/.test(lines[i]) && !listAt(lines[i]) && !(lines[i].includes('|') && i + 1 < lines.length && /^\s*\|?\s*:?-{2,}/.test(lines[i + 1]))) {
|
|
90
|
+
buffer.push(lines[i]);
|
|
91
|
+
i += 1;
|
|
92
|
+
}
|
|
93
|
+
if (buffer.length) html.push(`<p>${buffer.map(inline).join('<br>')}</p>`);
|
|
94
|
+
else { html.push(`<p>${inline(lines[i])}</p>`); i += 1; }
|
|
95
|
+
}
|
|
96
|
+
return html.join('\n');
|
|
97
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Portable package metadata and ZIP downloads, derived from the executable source.
|
|
2
|
+
import { parseBundle, parseYaml, inputSchema, bundleHash } from './factory-engine.mjs';
|
|
3
|
+
import { readAuthorTests, authorTestSummary, AUTHOR_TEST_VERSION } from './author-tests.mjs';
|
|
4
|
+
|
|
5
|
+
// Requirements are author declarations, never proof of compatibility or cost.
|
|
6
|
+
// An invalid companion remains in the source archive, with visible diagnostics.
|
|
7
|
+
export function packageDetails(files) {
|
|
8
|
+
const parsed = parseBundle(files);
|
|
9
|
+
const { factory } = parsed;
|
|
10
|
+
const warnings = [];
|
|
11
|
+
let declared = {};
|
|
12
|
+
if (files['mindburn.requirements.yaml']) {
|
|
13
|
+
try {
|
|
14
|
+
declared = parseYaml(files['mindburn.requirements.yaml']);
|
|
15
|
+
if (!declared || typeof declared !== 'object' || Array.isArray(declared)) throw new Error('Use a mapping.');
|
|
16
|
+
} catch {
|
|
17
|
+
warnings.push('Model and cost requirements could not be read. Check mindburn.requirements.yaml.');
|
|
18
|
+
declared = {};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const model = (value, label) => {
|
|
22
|
+
if (value === undefined) return null;
|
|
23
|
+
if (typeof value !== 'string' || !value.trim() || value.length > 160) {
|
|
24
|
+
warnings.push(`${label} must name a model in at most 160 characters.`);
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
return value.trim();
|
|
28
|
+
};
|
|
29
|
+
const minimum = model(declared.models?.minimum, 'Minimum model');
|
|
30
|
+
if (declared.models !== undefined && (!declared.models || typeof declared.models !== 'object' || Array.isArray(declared.models))) warnings.push('Model requirements must contain named minimum and recommended fields.');
|
|
31
|
+
const recommended = model(declared.models?.recommended, 'Recommended model') || model(factory.harness?.model, 'Default model');
|
|
32
|
+
let expectedCost = null;
|
|
33
|
+
const cost = declared.expected_cost;
|
|
34
|
+
if (cost !== undefined) {
|
|
35
|
+
if (cost && typeof cost === 'object' && cost.currency === 'USD'
|
|
36
|
+
&& Number.isFinite(cost.min) && Number.isFinite(cost.max) && cost.min >= 0 && cost.max >= cost.min
|
|
37
|
+
&& typeof cost.model === 'string' && cost.model.trim() && cost.model.length <= 160
|
|
38
|
+
&& typeof cost.basis === 'string' && cost.basis.trim() && cost.basis.length <= 1000) {
|
|
39
|
+
expectedCost = { currency: 'USD', min: cost.min, max: cost.max, model: cost.model.trim(), basis: cost.basis.trim(), provenance: 'author-estimate' };
|
|
40
|
+
} else warnings.push('Expected cost needs USD, non-negative min/max, a model and the basis of the estimate.');
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
format: 'mindburn-factory-package/v1',
|
|
44
|
+
name: factory.name, title: factory.title, version: factory.version,
|
|
45
|
+
inputs: inputSchema(factory),
|
|
46
|
+
requirements: {
|
|
47
|
+
runtime: 'Node.js 20 or newer for the included runner',
|
|
48
|
+
models: { minimum, recommended, provenance: 'author-declared', verified: false },
|
|
49
|
+
tools: [...new Set(factory.steps.flatMap((step) => [...step.tools, ...step.missing]))].sort(),
|
|
50
|
+
unsupported: factory.unsupportedRequirements,
|
|
51
|
+
expectedCost,
|
|
52
|
+
},
|
|
53
|
+
validation: { runnable: parsed.ok && factory.runnable, errors: parsed.errors, blockers: factory.blockers, warnings: [...parsed.warnings, ...warnings] },
|
|
54
|
+
// Structural validation is deliberately separate from effectiveness tests.
|
|
55
|
+
authorTests: authorTestSummary(readAuthorTests(files)),
|
|
56
|
+
securityScan: { status: 'not-scanned' },
|
|
57
|
+
changelog: Object.keys(files).find((path) => /^CHANGELOG(?:\.(?:md|txt))?$/.test(path)) || null,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function packageManifest(files, scan = null, tests = null) {
|
|
62
|
+
const sourceSha256 = await bundleHash(files);
|
|
63
|
+
const manifest = { ...packageDetails(files), sourceSha256 };
|
|
64
|
+
if (scan?.sourceSha256 === sourceSha256 && scan.version === 1) manifest.securityScan = scan;
|
|
65
|
+
if (tests?.sourceSha256 === sourceSha256 && tests.version === AUTHOR_TEST_VERSION) manifest.authorTests = tests;
|
|
66
|
+
return manifest;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------- bundle ----
|
|
70
|
+
|
|
71
|
+
const CRC_TABLE = (() => {
|
|
72
|
+
const table = new Uint32Array(256);
|
|
73
|
+
for (let n = 0; n < 256; n += 1) {
|
|
74
|
+
let c = n;
|
|
75
|
+
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
76
|
+
table[n] = c >>> 0;
|
|
77
|
+
}
|
|
78
|
+
return table;
|
|
79
|
+
})();
|
|
80
|
+
|
|
81
|
+
function crc32(bytes) {
|
|
82
|
+
let crc = 0xffffffff;
|
|
83
|
+
for (const byte of bytes) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
84
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// A plain (stored, uncompressed) zip: enough for a handful of text files.
|
|
88
|
+
export function zip(files) {
|
|
89
|
+
const encoder = new TextEncoder();
|
|
90
|
+
const parts = [];
|
|
91
|
+
const central = [];
|
|
92
|
+
let offset = 0;
|
|
93
|
+
for (const [name, text] of Object.entries(files)) {
|
|
94
|
+
const nameBytes = encoder.encode(name);
|
|
95
|
+
const data = encoder.encode(text);
|
|
96
|
+
const crc = crc32(data);
|
|
97
|
+
const local = new DataView(new ArrayBuffer(30));
|
|
98
|
+
local.setUint32(0, 0x04034b50, true); local.setUint16(4, 20, true); local.setUint16(6, 0x0800, true);
|
|
99
|
+
local.setUint16(8, 0, true); local.setUint16(10, 0, true); local.setUint16(12, 0x21, true);
|
|
100
|
+
local.setUint32(14, crc, true); local.setUint32(18, data.length, true); local.setUint32(22, data.length, true);
|
|
101
|
+
local.setUint16(26, nameBytes.length, true); local.setUint16(28, 0, true);
|
|
102
|
+
parts.push(new Uint8Array(local.buffer), nameBytes, data);
|
|
103
|
+
const entry = new DataView(new ArrayBuffer(46));
|
|
104
|
+
entry.setUint32(0, 0x02014b50, true); entry.setUint16(4, 20, true); entry.setUint16(6, 20, true); entry.setUint16(8, 0x0800, true);
|
|
105
|
+
entry.setUint16(10, 0, true); entry.setUint16(12, 0, true); entry.setUint16(14, 0x21, true);
|
|
106
|
+
entry.setUint32(16, crc, true); entry.setUint32(20, data.length, true); entry.setUint32(24, data.length, true);
|
|
107
|
+
entry.setUint16(28, nameBytes.length, true); entry.setUint32(42, offset, true);
|
|
108
|
+
central.push(new Uint8Array(entry.buffer), nameBytes);
|
|
109
|
+
offset += 30 + nameBytes.length + data.length;
|
|
110
|
+
}
|
|
111
|
+
const centralSize = central.reduce((sum, part) => sum + part.length, 0);
|
|
112
|
+
const end = new DataView(new ArrayBuffer(22));
|
|
113
|
+
end.setUint32(0, 0x06054b50, true); end.setUint16(8, Object.keys(files).length, true); end.setUint16(10, Object.keys(files).length, true);
|
|
114
|
+
end.setUint32(12, centralSize, true); end.setUint32(16, offset, true);
|
|
115
|
+
return new Blob([...parts, ...central, new Uint8Array(end.buffer)], { type: 'application/zip' });
|
|
116
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Runs a factory on your own machine with your own model.
|
|
3
|
+
//
|
|
4
|
+
// node run.mjs run the factory in this folder
|
|
5
|
+
// node run.mjs path/to/factory run a factory folder
|
|
6
|
+
// node run.mjs https://factory.mindburn.org/f/launch-kit.json
|
|
7
|
+
// node run.mjs --validate [folder, bundle.json or address] check it, run nothing
|
|
8
|
+
//
|
|
9
|
+
// Model: set ANTHROPIC_API_KEY, or OPENAI_API_KEY, or point at any
|
|
10
|
+
// OpenAI-compatible server (Ollama, LM Studio, OpenRouter, vLLM):
|
|
11
|
+
// node run.mjs --base-url http://localhost:11434/v1 --model llama3.3
|
|
12
|
+
//
|
|
13
|
+
// Options: --input name=value (repeatable) --model NAME --provider anthropic|openai
|
|
14
|
+
// --base-url URL --out FILE
|
|
15
|
+
// --yes approve every decision step without asking. This is the
|
|
16
|
+
// only way a run is approved with nobody at the keyboard.
|
|
17
|
+
// --validate read the factory as the Hub does and list what is wrong
|
|
18
|
+
// or missing. No key, no model, no questions, nothing written.
|
|
19
|
+
// --test run declared examples; write author-test-results.json.
|
|
20
|
+
// --input-schema print the current JSON input schema and exit.
|
|
21
|
+
//
|
|
22
|
+
// Exit codes: 0 done, 1 failed or stopped at your decision, 3 waiting for a
|
|
23
|
+
// decision nobody was there to make (run it in a terminal, or pass --yes).
|
|
24
|
+
// With --validate: 0 it would run, 1 it would not.
|
|
25
|
+
//
|
|
26
|
+
// Needs Node 20 or newer and factory-engine.mjs next to this file.
|
|
27
|
+
// --test also needs author-tests.mjs; all three ship in a packed factory. Apache-2.0.
|
|
28
|
+
import { readFile, writeFile, readdir } from 'node:fs/promises';
|
|
29
|
+
import { existsSync } from 'node:fs';
|
|
30
|
+
import { join, relative, dirname } from 'node:path';
|
|
31
|
+
import { fileURLToPath } from 'node:url';
|
|
32
|
+
import { createInterface } from 'node:readline/promises';
|
|
33
|
+
import process from 'node:process';
|
|
34
|
+
|
|
35
|
+
const enginePath = new URL('./factory-engine.mjs', import.meta.url);
|
|
36
|
+
const engine = await import(existsSync(enginePath) ? enginePath.href : new URL('./engine/factory-engine.mjs', import.meta.url).href);
|
|
37
|
+
|
|
38
|
+
const args = process.argv.slice(2);
|
|
39
|
+
const options = { inputs: {}, yes: false, validate: false };
|
|
40
|
+
let target = null;
|
|
41
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
42
|
+
const arg = args[i];
|
|
43
|
+
if (arg === '--yes' || arg === '-y') options.yes = true;
|
|
44
|
+
else if (arg === '--test') options.test = true;
|
|
45
|
+
else if (arg === '--validate') options.validate = true;
|
|
46
|
+
else if (arg === '--input-schema') options.inputSchema = true;
|
|
47
|
+
// The help is this file's opening comment, all of it.
|
|
48
|
+
else if (arg === '--help' || arg === '-h') { const lines = (await readFile(new URL(import.meta.url), 'utf8')).split('\n').slice(1); console.log(lines.slice(0, lines.findIndex((line) => !line.startsWith('//'))).map((line) => line.replace(/^\/\/ ?/, '')).join('\n')); process.exit(0); }
|
|
49
|
+
else if (arg === '--input' || arg === '-i') { const pair = args[i += 1] || ''; const at = pair.indexOf('='); options.inputs[pair.slice(0, at)] = pair.slice(at + 1); }
|
|
50
|
+
else if (arg.startsWith('--')) options[arg.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = args[i += 1];
|
|
51
|
+
else target = arg;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const dim = (text) => (process.stdout.isTTY ? `\x1b[2m${text}\x1b[0m` : text);
|
|
55
|
+
const bold = (text) => (process.stdout.isTTY ? `\x1b[1m${text}\x1b[0m` : text);
|
|
56
|
+
const die = (message, code = 1) => { console.error(`\n${message}\n`); process.exit(code); };
|
|
57
|
+
|
|
58
|
+
// The files of a factory, as the Hub would hold them. `skipped` collects what
|
|
59
|
+
// looks meant for the factory but is not part of it: a misnamed flow.yml, a
|
|
60
|
+
// note next to an agent. The runner and engine beside it are not listed.
|
|
61
|
+
// Walk the source directories admitted by ALLOWED_PATH, never unrelated project
|
|
62
|
+
// trees such as node_modules. File admission remains with the shared engine.
|
|
63
|
+
const SOURCE_FOLDERS = new Set(['agents', 'automations', 'scorers', 'runners', 'routers', 'webhooks', 'benchmarks', 'skills', 'self-improvement',
|
|
64
|
+
'profiles', 'flows', 'schemas', 'prompts', 'assets', 'examples', 'checks', 'fixtures', 'capabilities', 'policies', 'adapters', 'ui']);
|
|
65
|
+
async function readFolder(root, skipped = []) {
|
|
66
|
+
const files = {};
|
|
67
|
+
const walk = async (dir) => {
|
|
68
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
69
|
+
const { name } = entry;
|
|
70
|
+
if (name.startsWith('.')) continue;
|
|
71
|
+
const path = join(dir, name);
|
|
72
|
+
const rel = relative(root, path).split('\\').join('/');
|
|
73
|
+
if (entry.isSymbolicLink()) {
|
|
74
|
+
if (dir !== root || SOURCE_FOLDERS.has(name) || engine.ALLOWED_PATH.test(rel)) throw new Error(`Factory source cannot contain a symbolic link: ${rel}`);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (entry.isDirectory()) { if (SOURCE_FOLDERS.has(name) || dir !== root) await walk(path); continue; }
|
|
78
|
+
if (engine.ALLOWED_PATH.test(rel)) files[rel] = await readFile(path, 'utf8');
|
|
79
|
+
else if (dir !== root || (/\.(md|ya?ml)$/i.test(name) && name !== 'RUNNING.md')) skipped.push(rel);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
await walk(root);
|
|
83
|
+
return files;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// --validate: what the Hub would make of these files, and whether this runner
|
|
87
|
+
// would run them. Prints to stdout and exits 0 when it would run, 1 when not.
|
|
88
|
+
function validate(files, leftOut) {
|
|
89
|
+
const { ok, errors, warnings, factory } = engine.parseBundle(files);
|
|
90
|
+
const name = `${factory.title || 'This factory'} v${factory.version}`;
|
|
91
|
+
const lines = [];
|
|
92
|
+
if (!ok) lines.push(`${name}: not a valid factory. ${errors.length} ${errors.length === 1 ? 'problem' : 'problems'}:`, ...errors.map((error) => ` - ${error}`));
|
|
93
|
+
else if (!factory.flowValid) lines.push(`${name}: readable, but ${files['flow.yaml'] === undefined ? 'it has no flow.yaml' : 'its flow.yaml has no steps'}, so there are no steps to run. It can be read, shared, and run in an agent from agents/foreman/agent.md.`);
|
|
94
|
+
else if (factory.blockers.length) lines.push(`${name}: readable, but it cannot run here:`, ...factory.blockers.map((blocker) => ` - ${blocker}`));
|
|
95
|
+
else {
|
|
96
|
+
const tools = [...new Set(factory.steps.flatMap((step) => step.tools))].sort();
|
|
97
|
+
const count = (n, one) => `${n} ${one}${n === 1 ? '' : 's'}`;
|
|
98
|
+
lines.push(`${name}: valid, and it runs here. ${count(factory.steps.length, 'step')}, ${count(Object.values(factory.agents).filter((agent) => agent.type !== 'FOREMAN').length, 'agent')}, ${count(factory.inputs.length, 'input')}; tools: ${tools.join(', ') || 'none'}.`);
|
|
99
|
+
}
|
|
100
|
+
// Blockers and the missing flow are said above; the rest is worth reading.
|
|
101
|
+
const notes = warnings.filter((warning) => !factory.blockers.includes(warning) && !warning.startsWith('This bundle has no flow.yaml'));
|
|
102
|
+
if (notes.length) lines.push('', 'Worth a look:', ...notes.map((note) => ` - ${note}`));
|
|
103
|
+
if (leftOut.length) lines.push('', `Not part of the factory, so not read: ${leftOut.join(', ')}`);
|
|
104
|
+
console.log(lines.join('\n'));
|
|
105
|
+
process.exit(ok && factory.runnable ? 0 : 1);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
target ||= existsSync('factory.yaml') ? '.' : dirname(fileURLToPath(import.meta.url));
|
|
109
|
+
let files;
|
|
110
|
+
let leftOut = [];
|
|
111
|
+
if (/^https?:\/\//.test(target)) {
|
|
112
|
+
const read = await engine.fromEnvelope(await (await fetch(target)).json());
|
|
113
|
+
if (read.problems.length) die(`That bundle could not be read:\n${read.problems.map((problem) => ` - ${problem}`).join('\n')}`);
|
|
114
|
+
({ files, leftOut } = read);
|
|
115
|
+
} else if (existsSync(target) && target.endsWith('.json')) {
|
|
116
|
+
const read = await engine.fromEnvelope(JSON.parse(await readFile(target, 'utf8')));
|
|
117
|
+
if (read.problems.length) die(`That bundle could not be read:\n${read.problems.map((problem) => ` - ${problem}`).join('\n')}`);
|
|
118
|
+
({ files, leftOut } = read);
|
|
119
|
+
} else {
|
|
120
|
+
if (!existsSync(join(target, 'factory.yaml'))) die(`No factory.yaml in ${target}. Run: node run.mjs path/to/factory`);
|
|
121
|
+
files = await readFolder(target, leftOut);
|
|
122
|
+
}
|
|
123
|
+
if (options.validate) validate(files, leftOut);
|
|
124
|
+
const parsed = engine.parseBundle(files);
|
|
125
|
+
if (!parsed.ok) die(`This is not a valid factory:\n${parsed.errors.map((error) => ` - ${error}`).join('\n')}`);
|
|
126
|
+
if (options.inputSchema) { console.log(JSON.stringify(engine.inputSchema(parsed.factory), null, 2)); process.exit(0); }
|
|
127
|
+
if (!parsed.factory.runnable) die(parsed.factory.blockers.length ? `${parsed.factory.blockers.join('\n')}\n\nOr run it in an agent that has the tool, from agents/foreman/agent.md.` : 'This factory has no flow.yaml, so there are no steps to run. Add a flow, or run it in an agent from agents/foreman/agent.md.');
|
|
128
|
+
const { factory } = parsed;
|
|
129
|
+
|
|
130
|
+
const kind = options.provider || (options.baseUrl || process.env.OPENAI_BASE_URL ? 'openai' : process.env.ANTHROPIC_API_KEY ? 'anthropic' : process.env.OPENAI_API_KEY ? 'openai' : null);
|
|
131
|
+
if (!kind) die('No model configured. Set ANTHROPIC_API_KEY or OPENAI_API_KEY, or pass --base-url and --model for a local model.');
|
|
132
|
+
const provider = kind === 'anthropic'
|
|
133
|
+
? engine.anthropicProvider({ apiKey: process.env.ANTHROPIC_API_KEY, model: options.model || process.env.FACTORY_MODEL || 'claude-sonnet-5-5' })
|
|
134
|
+
: engine.openaiProvider({ apiKey: process.env.OPENAI_API_KEY || process.env.OPENROUTER_API_KEY || '', baseUrl: options.baseUrl || process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1', model: options.model || process.env.FACTORY_MODEL || 'gpt-5' });
|
|
135
|
+
|
|
136
|
+
const terminal = createInterface({ input: process.stdin, output: process.stdout });
|
|
137
|
+
console.log(`\n${bold(factory.title)} ${dim(`v${factory.version}, ${provider.name}, ${provider.model}`)}\n${factory.description}\n`);
|
|
138
|
+
for (const input of options.test ? [] : factory.inputs) {
|
|
139
|
+
if (options.inputs[input.key] !== undefined) continue;
|
|
140
|
+
if (!process.stdin.isTTY) continue;
|
|
141
|
+
const hint = input.type === 'longtext' ? ' (a file path also works)' : '';
|
|
142
|
+
options.inputs[input.key] = await terminal.question(`${bold(input.label)}${input.required ? '' : dim(' (optional)')}${hint}\n${dim(input.help)}\n> `);
|
|
143
|
+
console.log('');
|
|
144
|
+
}
|
|
145
|
+
for (const input of options.test ? [] : factory.inputs) {
|
|
146
|
+
const value = options.inputs[input.key];
|
|
147
|
+
if (input.type === 'longtext' && value && value.length < 300 && existsSync(value.trim())) options.inputs[input.key] = await readFile(value.trim(), 'utf8');
|
|
148
|
+
}
|
|
149
|
+
const checked = engine.checkInputs(factory, options.inputs);
|
|
150
|
+
if (!options.test && !checked.ok) die(checked.problems.join('\n'));
|
|
151
|
+
const testsPath = new URL('./author-tests.mjs', import.meta.url);
|
|
152
|
+
const tests = options.test ? await import(existsSync(testsPath) ? testsPath.href : new URL('./engine/author-tests.mjs', import.meta.url).href) : null;
|
|
153
|
+
const definition = tests?.readAuthorTests(files);
|
|
154
|
+
if (options.test && (!definition.declared || !definition.ok)) die(definition.errors.join('\n') || 'This factory has no mindburn.tests.yaml examples.');
|
|
155
|
+
|
|
156
|
+
const titleOf = (id) => factory.steps.find((step) => step.id === id)?.title || id;
|
|
157
|
+
const host = {
|
|
158
|
+
provider,
|
|
159
|
+
limits: engine.DEFAULT_LIMITS,
|
|
160
|
+
now: () => Date.now(),
|
|
161
|
+
// Agents read public web pages only: never this machine or its network,
|
|
162
|
+
// whatever a factory or a page asks for. Each redirect is checked too.
|
|
163
|
+
async fetchPage(address) {
|
|
164
|
+
let url = address;
|
|
165
|
+
for (let hop = 0; hop < 6; hop += 1) {
|
|
166
|
+
if (!engine.isPublicAddress(url)) throw new Error('that is not a public web address');
|
|
167
|
+
const response = await fetch(url, { redirect: 'manual', headers: { 'user-agent': 'Mozilla/5.0 (compatible; FactoryRunner/1.0)', accept: 'text/html,*/*;q=0.5' }, signal: AbortSignal.timeout(20000) });
|
|
168
|
+
if (response.status >= 300 && response.status < 400) { url = new URL(response.headers.get('location') || '', url).href; continue; }
|
|
169
|
+
if (!response.ok) throw new Error(`the site answered with status ${response.status}`);
|
|
170
|
+
return `Address: ${url}\n\n${engine.htmlToText(await response.text(), url)}`;
|
|
171
|
+
}
|
|
172
|
+
throw new Error('the page redirects too many times');
|
|
173
|
+
},
|
|
174
|
+
emit(event) {
|
|
175
|
+
if (event.type === 'step_started') console.log(`${bold(event.title)} ${dim(event.agent)}${event.redo ? dim(' (redoing)') : ''}`);
|
|
176
|
+
else if (event.type === 'tool') console.log(dim(` ${titleOf(event.step)}: ${event.tool === 'web_search' ? 'searching' : 'reading'} ${event.target}`));
|
|
177
|
+
else if (event.type === 'step_done') console.log(dim(` ${titleOf(event.step)}: done, ${event.chars} characters`));
|
|
178
|
+
else if (event.type === 'check') console.log(` ${event.pass ? 'passed' : event.status === 'sent_back' ? 'sent back' : 'flagged'} ${dim(event.note)}${event.pass ? '' : `\n${event.issues.map((issue) => dim(` - ${issue}`)).join('\n')}`}`);
|
|
179
|
+
else if (event.type === 'run_failed') console.log(`\nStopped: ${event.error}`);
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
async function execute(inputs, suffix = '') {
|
|
184
|
+
const state = engine.newRun({ id: `local-${Date.now().toString(36)}${suffix}`, factory, inputs, runner: { mode: 'cli', provider: provider.name, model: provider.model } });
|
|
185
|
+
await engine.runToEnd(state, host, async (approval, text, problem) => {
|
|
186
|
+
const approvalId = approval.id;
|
|
187
|
+
// The decision names the text printed here, so it cannot land on any other.
|
|
188
|
+
const seen = await engine.sha256(text);
|
|
189
|
+
if (problem === 'no_revisions') console.log(dim('This step has been sent back as often as the factory allows: approve it or reject it.'));
|
|
190
|
+
else console.log(`\n${bold(approval.title)} ${dim('needs your decision')}\n\n${text}\n\n${dim(approval.ask)}\n`);
|
|
191
|
+
if (options.yes) return { action: 'approve', seen, approvalId };
|
|
192
|
+
// Nobody is at the keyboard: the run waits. It is never approved by default.
|
|
193
|
+
if (!process.stdin.isTTY) return null;
|
|
194
|
+
// An empty line is not a decision: ask again. Closing the input (Ctrl-D)
|
|
195
|
+
// leaves the run waiting.
|
|
196
|
+
let answer = '';
|
|
197
|
+
while (!answer) {
|
|
198
|
+
try { answer = (await terminal.question(`Type ${bold('approve')} to continue, ${bold('reject')} to stop the run, or what to change:\n> `)).trim(); } catch { return null; }
|
|
199
|
+
}
|
|
200
|
+
console.log('');
|
|
201
|
+
if (/^(approve|a|yes|y)$/i.test(answer)) return { action: 'approve', seen, approvalId };
|
|
202
|
+
if (/^(reject|stop|no|n)$/i.test(answer)) return { action: 'reject', seen, approvalId };
|
|
203
|
+
return { action: 'revise', note: answer, seen, approvalId };
|
|
204
|
+
});
|
|
205
|
+
return state;
|
|
206
|
+
}
|
|
207
|
+
if (options.test) {
|
|
208
|
+
const cases = [];
|
|
209
|
+
try {
|
|
210
|
+
for (const example of definition.cases) {
|
|
211
|
+
console.log(`\nExample: ${example.name}`);
|
|
212
|
+
const state = await execute(example.inputs, `-${example.id}`);
|
|
213
|
+
const report = await tests.evaluateAuthorTest(example, state);
|
|
214
|
+
cases.push({ caseId: example.id, ...report });
|
|
215
|
+
console.log(`${report.status}: ${report.passed}/${report.total} checks${report.failures.length ? `\n${report.failures.join('\n')}` : ''}`);
|
|
216
|
+
}
|
|
217
|
+
} finally { terminal.close(); }
|
|
218
|
+
const report = { ...tests.authorTestSummary(definition, cases), sourceSha256: await engine.bundleHash(files), provenance: 'local-run-tests', signed: false };
|
|
219
|
+
await writeFile(options.out || 'author-test-results.json', `${JSON.stringify(report, null, 2)}\n`);
|
|
220
|
+
process.exit(report.status === 'passed' ? 0 : 1);
|
|
221
|
+
}
|
|
222
|
+
const state = await execute(checked.inputs);
|
|
223
|
+
terminal.close();
|
|
224
|
+
if (state.status === 'waiting') die(`Waiting at "${state.approval.title}": no decision was made, so nothing was approved and no result was written.\nRun this in a terminal to decide, or pass --yes to approve every decision step.`, 3);
|
|
225
|
+
if (state.status === 'stopped') die(`Stopped at "${state.stopped.title}" by your decision. No result was made.`);
|
|
226
|
+
if (state.status !== 'done') process.exit(1);
|
|
227
|
+
|
|
228
|
+
const out = options.out || `${factory.name}-${new Date().toISOString().slice(0, 16).replace(/[:T]/g, '')}.md`;
|
|
229
|
+
await writeFile(out, state.deliverable);
|
|
230
|
+
await writeFile(out.replace(/\.md$/, '') + '.receipt.json', `${JSON.stringify(state.receipt, null, 2)}\n`);
|
|
231
|
+
console.log(`\n${state.deliverable}\n\n${dim(`Saved ${out} and its receipt. ${state.usage.llmCalls} model calls, ${state.usage.input + state.usage.output} tokens.`)}`);
|
|
232
|
+
if (state.receipt.flags.length) console.log(dim(`Note: ${state.receipt.flags.join('; ')}`));
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@factoryhub/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A local stdio MCP server for AI factories: list, read, validate, export as a skill, and run with your own model, stopping at every human decision.",
|
|
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/mcp"
|
|
15
|
+
},
|
|
16
|
+
"bin": {
|
|
17
|
+
"factoryhub-mcp": "bin/factoryhub-mcp.mjs"
|
|
18
|
+
},
|
|
19
|
+
"exports": {
|
|
20
|
+
".": "./src/server.mjs"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"bin",
|
|
24
|
+
"src"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "node --test \"test/*.test.mjs\""
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@modelcontextprotocol/server": "2.4.0",
|
|
31
|
+
"zod": "4.6.7"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@modelcontextprotocol/client": "2.4.0"
|
|
35
|
+
}
|
|
36
|
+
}
|