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,280 @@
|
|
|
1
|
+
// The factoryhub MCP server: find, read, validate, export and run AI
|
|
2
|
+
// factories from any MCP client. It runs locally over stdio. Runs use the
|
|
3
|
+
// model key in this process's environment, never one of ours, and stop at
|
|
4
|
+
// every human decision: the client must ask the person and call resume_run.
|
|
5
|
+
import { readFile, writeFile, mkdir, rm } from 'node:fs/promises';
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { join, resolve, delimiter } from 'node:path';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { randomBytes } from 'node:crypto';
|
|
10
|
+
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/server';
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
import { parseBundle, toEnvelope, sha256 } from '../../engine/src/factory-engine.mjs';
|
|
13
|
+
import { readFactory, readFolder, listFactoryDirs, writeFiles, HOUSE_FACTORIES } from '../../engine/lib/node.mjs';
|
|
14
|
+
import { summarize, matches, canonicalFiles } from '../../engine/lib/catalog.mjs';
|
|
15
|
+
import { exportSkill, checkSkill } from '../../engine/lib/skill.mjs';
|
|
16
|
+
import { hubClient } from '../../engine/lib/hub.mjs';
|
|
17
|
+
import { chooseProvider } from '../../engine/lib/providers.mjs';
|
|
18
|
+
import { nodeHost, prepareRun, continueRun, pendingDecision, decide, restoreRun } from '../../engine/lib/run.mjs';
|
|
19
|
+
|
|
20
|
+
export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
21
|
+
export const SPEC_PATH = new URL('../../../spec/factory-format.md', import.meta.url);
|
|
22
|
+
export const TOOL_NAMES = ['list_factories', 'get_factory', 'validate_factory', 'export_skill', 'run_factory', 'resume_run'];
|
|
23
|
+
|
|
24
|
+
const INSTRUCTIONS = `factoryhub serves AI factories: small teams of AI agents written as plain files (factory.yaml, flow.yaml, agents/<name>/agent.md).
|
|
25
|
+
- list_factories and get_factory find and read factories; validate_factory checks one; export_skill turns one into an Agent Skill folder.
|
|
26
|
+
- run_factory runs one with the model key configured for this server. It returns when the run finishes or reaches a human decision.
|
|
27
|
+
- At a decision, show the person the full text, ask them to approve, reject, or say what to change, and call resume_run with their answer. Never decide for them.
|
|
28
|
+
- The prompt "factory_format" holds the format specification for writing a new factory.`;
|
|
29
|
+
|
|
30
|
+
const ID = z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).max(64);
|
|
31
|
+
|
|
32
|
+
function text(value) {
|
|
33
|
+
return typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function result(value, summary) {
|
|
37
|
+
return { structuredContent: value, content: [{ type: 'text', text: summary ? `${summary}\n\n${text(value)}` : text(value) }] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function failure(error) {
|
|
41
|
+
return { isError: true, content: [{ type: 'text', text: String(error?.message || error) }] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Creates the server. Options exist for tests and embedding:
|
|
46
|
+
* env (keys, FACTORYHUB_FACTORIES, FACTORYHUB_RUNS_DIR), fetchImpl, factoryDirs, runsDir.
|
|
47
|
+
*/
|
|
48
|
+
export function createServer({ env = process.env, fetchImpl = fetch, factoryDirs, runsDir } = {}) {
|
|
49
|
+
const dirs = factoryDirs || [HOUSE_FACTORIES, ...String(env.FACTORYHUB_FACTORIES || '').split(delimiter).filter(Boolean).map((dir) => resolve(dir))];
|
|
50
|
+
const runs = new Map();
|
|
51
|
+
const runFolder = runsDir || env.FACTORYHUB_RUNS_DIR || join(homedir(), '.factoryhub', 'runs');
|
|
52
|
+
|
|
53
|
+
async function localIds() {
|
|
54
|
+
const seen = new Map();
|
|
55
|
+
for (const dir of dirs) for (const id of await listFactoryDirs(dir)) if (!seen.has(id)) seen.set(id, join(dir, id));
|
|
56
|
+
return seen;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function resolveFactory({ id, path, source = 'local' }) {
|
|
60
|
+
if (path) {
|
|
61
|
+
const read = await readFactory(path, { fetchImpl });
|
|
62
|
+
return { id: read.source.id, files: read.files, location: read.source.location, leftOut: read.leftOut };
|
|
63
|
+
}
|
|
64
|
+
if (!id) throw new Error('Give a factory id, or a path to a factory folder or bundle.');
|
|
65
|
+
if (source === 'hub') {
|
|
66
|
+
const got = await hubClient({ env, fetchImpl }).get(id);
|
|
67
|
+
return { id, files: got.files, location: got.source, leftOut: got.leftOut };
|
|
68
|
+
}
|
|
69
|
+
const location = (await localIds()).get(id);
|
|
70
|
+
if (!location) throw new Error(`No local factory "${id}". Call list_factories to see the ids, or pass source "hub" or a path.`);
|
|
71
|
+
const { files, skipped } = await readFolder(location);
|
|
72
|
+
return { id, files, location, leftOut: skipped };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function saveRun(state) {
|
|
76
|
+
await mkdir(runFolder, { recursive: true });
|
|
77
|
+
const file = join(runFolder, `${state.id}.json`);
|
|
78
|
+
await writeFile(file, `${JSON.stringify(state)}\n`);
|
|
79
|
+
return file;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function loadRun(runId) {
|
|
83
|
+
if (runs.has(runId)) return runs.get(runId);
|
|
84
|
+
const file = join(runFolder, `${runId}.json`);
|
|
85
|
+
if (!/^[A-Za-z0-9_-]{1,80}$/.test(runId) || !existsSync(file)) throw new Error(`No saved run "${runId}".`);
|
|
86
|
+
const state = restoreRun(JSON.parse(await readFile(file, 'utf8')));
|
|
87
|
+
runs.set(runId, state);
|
|
88
|
+
return state;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function progress(ctx) {
|
|
92
|
+
const token = ctx?.mcpReq?._meta?.progressToken;
|
|
93
|
+
let count = 0;
|
|
94
|
+
return (event) => {
|
|
95
|
+
if (token === undefined || !['step_started', 'step_done', 'check', 'approval_needed', 'run_failed'].includes(event.type)) return;
|
|
96
|
+
count += 1;
|
|
97
|
+
const message = event.type === 'step_started' ? `Started: ${event.title}` : event.type === 'step_done' ? `Finished step ${event.step}` : event.type === 'check' ? `Check ${event.step}: ${event.status}` : event.type === 'approval_needed' ? `Waiting for a decision at ${event.step}` : `Stopped: ${event.error}`;
|
|
98
|
+
ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: token, progress: count, message } }).catch(() => {});
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function report(state) {
|
|
103
|
+
runs.set(state.id, state);
|
|
104
|
+
const stateFile = await saveRun(state);
|
|
105
|
+
if (state.status === 'waiting') {
|
|
106
|
+
const decision = await pendingDecision(state);
|
|
107
|
+
return result({
|
|
108
|
+
run_id: state.id, status: 'waiting', decision, state_file: stateFile,
|
|
109
|
+
next: 'Show the person the full decision text and ask them to approve, reject, or say what to change. Then call resume_run with run_id, their decision, seen_sha256 and approval_id. Never decide for them.',
|
|
110
|
+
}, `The run is waiting for the person to decide at "${decision.title}".`);
|
|
111
|
+
}
|
|
112
|
+
if (state.status === 'done') {
|
|
113
|
+
const deliverableFile = join(runFolder, `${state.id}.md`);
|
|
114
|
+
await writeFile(deliverableFile, state.deliverable);
|
|
115
|
+
await writeFile(join(runFolder, `${state.id}.receipt.json`), `${JSON.stringify(state.receipt, null, 2)}\n`);
|
|
116
|
+
return result({ run_id: state.id, status: 'done', deliverable: state.deliverable, review: state.receipt.deliverable_review.status, flags: state.receipt.flags, deliverable_file: deliverableFile, receipt_file: join(runFolder, `${state.id}.receipt.json`), usage: { calls: state.usage.llmCalls, input_tokens: state.usage.input, output_tokens: state.usage.output } }, 'The run is done.');
|
|
117
|
+
}
|
|
118
|
+
if (state.status === 'stopped') return result({ run_id: state.id, status: 'stopped', step: state.stopped.step, note: state.stopped.note || '' }, 'The person rejected the result, so the run stopped with no deliverable.');
|
|
119
|
+
return result({ run_id: state.id, status: state.status, error: state.error, failure: state.failure || null }, 'The run did not finish.');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const server = new McpServer({ name: 'factoryhub', version: VERSION }, { capabilities: { tools: {}, resources: {}, prompts: {} }, instructions: INSTRUCTIONS });
|
|
123
|
+
|
|
124
|
+
server.registerTool('list_factories', {
|
|
125
|
+
title: 'List factories',
|
|
126
|
+
description: 'Lists AI factories: the house factories and folders named in FACTORYHUB_FACTORIES (source "local"), or the published catalogue on factoryhub (source "hub", which needs FACTORYHUB_ACCESS_KEY while the site is in private preview). Filters by words in the id, title, description or tags.',
|
|
127
|
+
inputSchema: z.object({ query: z.string().max(200).optional(), source: z.enum(['local', 'hub']).optional() }),
|
|
128
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
129
|
+
}, async ({ query, source = 'local' }) => {
|
|
130
|
+
try {
|
|
131
|
+
let entries;
|
|
132
|
+
if (source === 'hub') entries = (await hubClient({ env, fetchImpl }).search(query || '')).map((entry) => ({ id: entry.id, title: entry.title, description: entry.description, version: entry.version, tags: entry.tags || [], runnable: entry.runnable, source: 'hub' }));
|
|
133
|
+
else {
|
|
134
|
+
entries = [];
|
|
135
|
+
for (const [id, location] of await localIds()) {
|
|
136
|
+
const summary = await summarize((await readFolder(location)).files, { id });
|
|
137
|
+
if (matches(summary, query)) entries.push({ id, title: summary.title, description: summary.description, version: summary.version, tags: summary.tags, runnable: summary.runnable, inputs: summary.inputs.map((input) => input.key), steps: summary.steps.length, source: 'local', path: location });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return result({ factories: entries }, `${entries.length} ${entries.length === 1 ? 'factory' : 'factories'}.`);
|
|
141
|
+
} catch (error) { return failure(error); }
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
server.registerTool('get_factory', {
|
|
145
|
+
title: 'Get a factory',
|
|
146
|
+
description: 'Reads one factory: its summary (inputs, steps in order, agents, tools, checks), its bundle SHA-256, and the full text of every file. Give an id (local or, with source "hub", published) or a path to a folder or bundle .json.',
|
|
147
|
+
inputSchema: z.object({ id: ID.optional(), path: z.string().max(1000).optional(), source: z.enum(['local', 'hub']).optional() }),
|
|
148
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
149
|
+
}, async (args) => {
|
|
150
|
+
try {
|
|
151
|
+
const found = await resolveFactory(args);
|
|
152
|
+
const summary = await summarize(found.files, { id: found.id });
|
|
153
|
+
return result({ summary, files: canonicalFiles(found.files), location: found.location, left_out: found.leftOut }, `${summary.title} v${summary.version}.`);
|
|
154
|
+
} catch (error) { return failure(error); }
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
server.registerTool('validate_factory', {
|
|
158
|
+
title: 'Validate a factory',
|
|
159
|
+
description: 'Checks a factory with the engine\'s parser, without a key or a model: every problem, every tool this runner lacks, and whether it runs here. Give a path to a folder or bundle .json, an id, or the files themselves as a map of path to text.',
|
|
160
|
+
inputSchema: z.object({ path: z.string().max(1000).optional(), id: ID.optional(), files: z.record(z.string().max(200), z.string().max(400000)).optional() }),
|
|
161
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
162
|
+
}, async ({ path, id, files }) => {
|
|
163
|
+
try {
|
|
164
|
+
const source = files || (await resolveFactory({ id, path })).files;
|
|
165
|
+
const { ok, errors, warnings, factory } = parseBundle(source);
|
|
166
|
+
const value = { valid: ok, runnable: ok && factory.runnable, name: factory.name, title: factory.title, version: factory.version, errors, blockers: factory.blockers, warnings: warnings.filter((warning) => !factory.blockers.includes(warning)) };
|
|
167
|
+
return result(value, !ok ? `Not valid: ${errors.length} problem${errors.length === 1 ? '' : 's'}.` : factory.runnable ? 'Valid, and it runs here.' : 'Valid, but it cannot run here.');
|
|
168
|
+
} catch (error) { return failure(error); }
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
server.registerTool('export_skill', {
|
|
172
|
+
title: 'Export as an Agent Skill',
|
|
173
|
+
description: 'Turns a factory into an Agent Skill folder: <id>/SKILL.md (what it produces, inputs to collect, every step and who does it, where to stop and ask the person to approve) and <id>/factory/ (the files unchanged, plus a foreman written from the flow when the author wrote none). With out_dir, writes the folder there; without, returns SKILL.md and the file list.',
|
|
174
|
+
inputSchema: z.object({ id: ID.optional(), path: z.string().max(1000).optional(), source: z.enum(['local', 'hub']).optional(), out_dir: z.string().max(1000).optional(), overwrite: z.boolean().optional() }),
|
|
175
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
176
|
+
}, async ({ out_dir: outDir, overwrite = false, ...args }) => {
|
|
177
|
+
try {
|
|
178
|
+
const found = await resolveFactory(args);
|
|
179
|
+
const skill = await exportSkill(found.files, { id: found.id });
|
|
180
|
+
const problems = await checkSkill(skill.files, { folder: skill.id });
|
|
181
|
+
let folder = null;
|
|
182
|
+
if (outDir) {
|
|
183
|
+
folder = resolve(outDir, skill.id);
|
|
184
|
+
if (existsSync(folder)) {
|
|
185
|
+
if (!overwrite) throw new Error(`${folder} already exists. Pass overwrite: true to replace it.`);
|
|
186
|
+
if (!existsSync(join(folder, 'SKILL.md'))) throw new Error(`${folder} is not an earlier skill export, so it was left alone.`);
|
|
187
|
+
await rm(folder, { recursive: true, force: true });
|
|
188
|
+
}
|
|
189
|
+
await writeFiles(folder, skill.files, { allow: (file) => file === 'SKILL.md' || file.startsWith('factory/') });
|
|
190
|
+
}
|
|
191
|
+
return result({ id: skill.id, sha256: skill.sha256, folder, files: Object.keys(skill.files), problems, skill_md: skill.files['SKILL.md'] }, folder ? `Wrote ${folder}.` : 'SKILL.md is below; pass out_dir to write the folder.');
|
|
192
|
+
} catch (error) { return failure(error); }
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
server.registerTool('run_factory', {
|
|
196
|
+
title: 'Run a factory',
|
|
197
|
+
description: 'Runs a factory on this machine with the model key set in this server\'s environment (ANTHROPIC_API_KEY, OPENAI_API_KEY or OPENROUTER_API_KEY), or a local OpenAI-compatible server (provider "ollama" or base_url, with model). It can take minutes. It returns when the run is done, fails, or reaches a human decision: then it returns run_id, the text to show, seen_sha256 and approval_id. Show the person the text, get their decision, and call resume_run. Never decide for them.',
|
|
198
|
+
inputSchema: z.object({
|
|
199
|
+
id: ID.optional(), path: z.string().max(1000).optional(), source: z.enum(['local', 'hub']).optional(),
|
|
200
|
+
inputs: z.record(z.string().max(64), z.string().max(40000)).optional(),
|
|
201
|
+
provider: z.enum(['anthropic', 'openai', 'openrouter', 'ollama', 'openai-compatible']).optional(),
|
|
202
|
+
model: z.string().max(160).optional(), base_url: z.string().max(2000).optional(),
|
|
203
|
+
}),
|
|
204
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
205
|
+
}, async ({ inputs = {}, provider: kind, model, base_url: baseUrl, ...args }, ctx) => {
|
|
206
|
+
try {
|
|
207
|
+
const found = await resolveFactory(args);
|
|
208
|
+
const provider = chooseProvider({ provider: kind, model, baseUrl, env, fetchImpl });
|
|
209
|
+
const id = `mcp-${Date.now().toString(36)}-${randomBytes(4).toString('hex')}`;
|
|
210
|
+
const prepared = prepareRun({ files: found.files, inputs, id, runner: { mode: 'mcp', provider: provider.name, model: provider.model } });
|
|
211
|
+
if (!prepared.ok) throw new Error(prepared.problems.join('\n'));
|
|
212
|
+
const state = prepared.state;
|
|
213
|
+
state.mcp = { provider: kind || null, model: provider.model, baseUrl: baseUrl || null };
|
|
214
|
+
await continueRun(state, nodeHost({ provider, fetchImpl, emit: progress(ctx) }));
|
|
215
|
+
return await report(state);
|
|
216
|
+
} catch (error) { return failure(error); }
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
server.registerTool('resume_run', {
|
|
220
|
+
title: 'Resume a run with the person\'s decision',
|
|
221
|
+
description: 'Continues a run that is waiting at a human decision. Call it only with the person\'s own answer: approve, reject (ends the run with no result), or revise with a note saying what to change. Pass the seen_sha256 and approval_id from the waiting result, so the decision applies to the exact text the person saw. Returns at the next decision or when the run ends.',
|
|
222
|
+
inputSchema: z.object({
|
|
223
|
+
run_id: z.string().regex(/^[A-Za-z0-9_-]{1,80}$/),
|
|
224
|
+
decision: z.enum(['approve', 'revise', 'reject']),
|
|
225
|
+
note: z.string().max(4000).optional(),
|
|
226
|
+
seen_sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
227
|
+
approval_id: z.string().regex(/^[a-f0-9]{64}$/),
|
|
228
|
+
}),
|
|
229
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
230
|
+
}, async ({ run_id: runId, decision, note, seen_sha256: seen, approval_id: approvalId }, ctx) => {
|
|
231
|
+
try {
|
|
232
|
+
const state = await loadRun(runId);
|
|
233
|
+
if (decision === 'revise' && !String(note || '').trim()) throw new Error('A revise decision needs a note saying what to change.');
|
|
234
|
+
const why = decide(state, { action: decision, note, seen, approvalId });
|
|
235
|
+
if (why) {
|
|
236
|
+
const reasons = { not_waiting: 'This run is not waiting for a decision.', bad_action: 'The decision must be approve, revise or reject.', unseen: 'Pass seen_sha256 from the waiting result.', stale: 'seen_sha256 or approval_id does not match the text the run is waiting on. Read the current decision again and show it to the person.', no_revisions: 'This step has been sent back as often as the factory allows: approve or reject it.' };
|
|
237
|
+
throw new Error(reasons[why] || why);
|
|
238
|
+
}
|
|
239
|
+
const settings = state.mcp || {};
|
|
240
|
+
const provider = chooseProvider({ provider: settings.provider || undefined, model: settings.model, baseUrl: settings.baseUrl || undefined, env, fetchImpl });
|
|
241
|
+
await continueRun(state, nodeHost({ provider, fetchImpl, emit: progress(ctx) }));
|
|
242
|
+
return await report(state);
|
|
243
|
+
} catch (error) { return failure(error); }
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
server.registerResource('factory', new ResourceTemplate('factoryhub://factories/{id}', {
|
|
247
|
+
list: async () => {
|
|
248
|
+
const resources = [];
|
|
249
|
+
for (const [id, location] of await localIds()) {
|
|
250
|
+
const summary = await summarize((await readFolder(location)).files, { id });
|
|
251
|
+
resources.push({ uri: `factoryhub://factories/${id}`, name: id, title: summary.title, description: summary.description, mimeType: 'application/json' });
|
|
252
|
+
}
|
|
253
|
+
return { resources };
|
|
254
|
+
},
|
|
255
|
+
}), { title: 'Factory bundle', description: 'A local factory as a transfer bundle: every file with its SHA-256.', mimeType: 'application/json' }, async (uri, { id }) => {
|
|
256
|
+
const found = await resolveFactory({ id: String(id) });
|
|
257
|
+
const files = canonicalFiles(found.files);
|
|
258
|
+
const envelope = toEnvelope(files, { project: found.id });
|
|
259
|
+
for (const entry of envelope.files) entry.sha256 = await sha256(entry.content);
|
|
260
|
+
return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(envelope, null, 2) }] };
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
server.registerResource('factory-format', 'factoryhub://spec/factory-format', { title: 'The factory format', description: 'The specification of the factory format, v1alpha1.', mimeType: 'text/markdown' }, async (uri) => ({
|
|
264
|
+
contents: [{ uri: uri.href, mimeType: 'text/markdown', text: await readFile(SPEC_PATH, 'utf8') }],
|
|
265
|
+
}));
|
|
266
|
+
|
|
267
|
+
server.registerPrompt('factory_format', {
|
|
268
|
+
title: 'Write a factory',
|
|
269
|
+
description: 'The factory format specification, to read or to write a new factory from. With a goal, asks for a factory that reaches it.',
|
|
270
|
+
argsSchema: z.object({ goal: z.string().max(2000).optional() }),
|
|
271
|
+
}, async ({ goal }) => {
|
|
272
|
+
const spec = await readFile(SPEC_PATH, 'utf8');
|
|
273
|
+
const ask = goal
|
|
274
|
+
? `Write a factory for this goal, in the format above: ${goal}\n\nOutput every file in full: factory.yaml, flow.yaml and agents/<name>/agent.md for every agent a step names. Then check the files with the validate_factory tool and fix every problem it reports.`
|
|
275
|
+
: 'Use this specification to read, explain or write factories. Check any factory you write with the validate_factory tool.';
|
|
276
|
+
return { messages: [{ role: 'user', content: { type: 'text', text: `${spec}\n\n---\n\n${ask}` } }] };
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
return server;
|
|
280
|
+
}
|
package/spec/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# The factory format
|
|
2
|
+
|
|
3
|
+
| File | What it is |
|
|
4
|
+
|---|---|
|
|
5
|
+
| [`factory-format.md`](factory-format.md) | the specification, v1alpha1 |
|
|
6
|
+
| [`schema/factory.schema.json`](schema/factory.schema.json) | JSON Schema (2020-12) for `factory.yaml` |
|
|
7
|
+
| [`schema/flow.schema.json`](schema/flow.schema.json) | JSON Schema (2020-12) for `flow.yaml` |
|
|
8
|
+
| [`skill-export.md`](skill-export.md) | how a factory becomes an Agent Skill folder |
|
|
9
|
+
| [`skill-export/`](skill-export) | the expected `SKILL.md` of every house factory |
|
|
10
|
+
| [`fixtures/`](fixtures) | good, bad, semantic and lenient factories that test the schemas against the engine |
|
|
11
|
+
| [`test/`](test) | those tests |
|
|
12
|
+
| [`rfcs/`](rfcs) | proposals to change the format |
|
|
13
|
+
|
|
14
|
+
The reference implementation is the engine in
|
|
15
|
+
[`packages/engine`](../packages/engine). `npm test` checks the schemas, the
|
|
16
|
+
fixtures, the house factories and the skill export against it.
|
|
17
|
+
|
|
18
|
+
## Using the schemas in an editor
|
|
19
|
+
|
|
20
|
+
Editors that read the `yaml-language-server` comment validate a file as you
|
|
21
|
+
type:
|
|
22
|
+
|
|
23
|
+
```yaml
|
|
24
|
+
# yaml-language-server: $schema=https://raw.githubusercontent.com/Mindburn-Labs/factoryhub/main/spec/schema/flow.schema.json
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The schemas check one file at a time. Rules that span steps and files, such as
|
|
28
|
+
an agent file existing for every step, are in
|
|
29
|
+
[section 6 of the spec](factory-format.md#6-validity-and-runnability);
|
|
30
|
+
`factoryhub validate` checks those too.
|
|
31
|
+
|
|
32
|
+
## Changing the format
|
|
33
|
+
|
|
34
|
+
Read [`rfcs/README.md`](rfcs/README.md). Small fixes to wording go straight to
|
|
35
|
+
a pull request. A change to what a valid factory is, or to how a run behaves,
|
|
36
|
+
starts as an RFC.
|