factoryhub 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +16 -0
  3. package/README.md +189 -2
  4. package/factories/README.md +17 -0
  5. package/factories/client-file-readiness/README.md +3 -0
  6. package/factories/client-file-readiness/agents/analyst/agent.md +10 -0
  7. package/factories/client-file-readiness/agents/reviewer/agent.md +10 -0
  8. package/factories/client-file-readiness/agents/writer/agent.md +11 -0
  9. package/factories/client-file-readiness/factory.yaml +12 -0
  10. package/factories/client-file-readiness/flow.yaml +56 -0
  11. package/factories/competitor-teardown/README.md +3 -0
  12. package/factories/competitor-teardown/agents/analyst/agent.md +10 -0
  13. package/factories/competitor-teardown/agents/researcher/agent.md +11 -0
  14. package/factories/competitor-teardown/agents/reviewer/agent.md +10 -0
  15. package/factories/competitor-teardown/agents/writer/agent.md +11 -0
  16. package/factories/competitor-teardown/factory.yaml +11 -0
  17. package/factories/competitor-teardown/flow.yaml +75 -0
  18. package/factories/homepage-audit/README.md +3 -0
  19. package/factories/homepage-audit/agents/analyst/agent.md +10 -0
  20. package/factories/homepage-audit/agents/researcher/agent.md +11 -0
  21. package/factories/homepage-audit/agents/reviewer/agent.md +10 -0
  22. package/factories/homepage-audit/agents/writer/agent.md +11 -0
  23. package/factories/homepage-audit/factory.yaml +11 -0
  24. package/factories/homepage-audit/flow.yaml +77 -0
  25. package/factories/inbox-triage/README.md +3 -0
  26. package/factories/inbox-triage/agents/analyst/agent.md +10 -0
  27. package/factories/inbox-triage/agents/reviewer/agent.md +10 -0
  28. package/factories/inbox-triage/agents/writer/agent.md +11 -0
  29. package/factories/inbox-triage/factory.yaml +11 -0
  30. package/factories/inbox-triage/flow.yaml +57 -0
  31. package/factories/index.json +356 -0
  32. package/factories/launch-kit/README.md +3 -0
  33. package/factories/launch-kit/agents/reviewer/agent.md +10 -0
  34. package/factories/launch-kit/agents/strategist/agent.md +10 -0
  35. package/factories/launch-kit/agents/writer/agent.md +11 -0
  36. package/factories/launch-kit/factory.yaml +12 -0
  37. package/factories/launch-kit/flow.yaml +75 -0
  38. package/factories/meeting-to-actions/CHANGELOG.md +7 -0
  39. package/factories/meeting-to-actions/README.md +15 -0
  40. package/factories/meeting-to-actions/agents/analyst/agent.md +10 -0
  41. package/factories/meeting-to-actions/agents/reviewer/agent.md +10 -0
  42. package/factories/meeting-to-actions/agents/writer/agent.md +11 -0
  43. package/factories/meeting-to-actions/factory.yaml +11 -0
  44. package/factories/meeting-to-actions/flow.yaml +53 -0
  45. package/factories/meeting-to-actions/mindburn.requirements.yaml +8 -0
  46. package/factories/meeting-to-actions/mindburn.tests.yaml +16 -0
  47. package/factories/outreach-pack/README.md +3 -0
  48. package/factories/outreach-pack/agents/reviewer/agent.md +5 -0
  49. package/factories/outreach-pack/agents/writer/agent.md +5 -0
  50. package/factories/outreach-pack/factory.yaml +9 -0
  51. package/factories/outreach-pack/flow.yaml +42 -0
  52. package/factories/prospect-brief/README.md +3 -0
  53. package/factories/prospect-brief/agents/researcher/agent.md +11 -0
  54. package/factories/prospect-brief/agents/reviewer/agent.md +10 -0
  55. package/factories/prospect-brief/agents/strategist/agent.md +10 -0
  56. package/factories/prospect-brief/agents/writer/agent.md +11 -0
  57. package/factories/prospect-brief/factory.yaml +11 -0
  58. package/factories/prospect-brief/flow.yaml +79 -0
  59. package/package.json +79 -4
  60. package/packages/cli/bin/factoryhub.mjs +8 -0
  61. package/packages/cli/package.json +29 -0
  62. package/packages/cli/src/args.mjs +47 -0
  63. package/packages/cli/src/commands/export.mjs +55 -0
  64. package/packages/cli/src/commands/get.mjs +38 -0
  65. package/packages/cli/src/commands/inspect.mjs +66 -0
  66. package/packages/cli/src/commands/list.mjs +29 -0
  67. package/packages/cli/src/commands/mcp.mjs +26 -0
  68. package/packages/cli/src/commands/new.mjs +148 -0
  69. package/packages/cli/src/commands/pack.mjs +97 -0
  70. package/packages/cli/src/commands/resume.mjs +67 -0
  71. package/packages/cli/src/commands/run.mjs +190 -0
  72. package/packages/cli/src/commands/search.mjs +31 -0
  73. package/packages/cli/src/commands/validate.mjs +81 -0
  74. package/packages/cli/src/main.mjs +68 -0
  75. package/packages/cli/src/util.mjs +21 -0
  76. package/packages/engine/lib/catalog.mjs +50 -0
  77. package/packages/engine/lib/hub.mjs +71 -0
  78. package/packages/engine/lib/node.mjs +114 -0
  79. package/packages/engine/lib/providers.mjs +52 -0
  80. package/packages/engine/lib/run.mjs +131 -0
  81. package/packages/engine/lib/skill.mjs +229 -0
  82. package/packages/engine/package.json +39 -0
  83. package/packages/engine/src/author-tests.mjs +78 -0
  84. package/packages/engine/src/compose.mjs +150 -0
  85. package/packages/engine/src/cost.mjs +41 -0
  86. package/packages/engine/src/diagnostics.mjs +21 -0
  87. package/packages/engine/src/factory-engine.mjs +1762 -0
  88. package/packages/engine/src/index.mjs +9 -0
  89. package/packages/engine/src/markdown.mjs +97 -0
  90. package/packages/engine/src/package.mjs +116 -0
  91. package/packages/engine/src/run.mjs +232 -0
  92. package/packages/mcp/bin/factoryhub-mcp.mjs +6 -0
  93. package/packages/mcp/package.json +36 -0
  94. package/packages/mcp/src/server.mjs +280 -0
  95. package/spec/README.md +36 -0
  96. package/spec/factory-format.md +621 -0
  97. package/spec/schema/factory.schema.json +43 -0
  98. package/spec/schema/flow.schema.json +169 -0
  99. package/spec/skill-export.md +126 -0
@@ -0,0 +1,148 @@
1
+ import { existsSync, readdirSync } from 'node:fs';
2
+ import { resolve, relative } from 'node:path';
3
+ import { engine, writeFiles, out, styles, usage } from '../util.mjs';
4
+
5
+ /** The files of a new factory: small, valid, and using every kind of step. */
6
+ export function scaffold({ name, title, description }) {
7
+ const heading = title || name.replace(/-/g, ' ').replace(/^./, (c) => c.toUpperCase());
8
+ const about = (description || `Turns a brief into a checked first draft, with your approval of the outline first. Use when you need a first draft that keeps to the brief.`).replace(/\s+/g, ' ').trim();
9
+ const wrapped = about.match(/.{1,76}(\s|$)/g).map((line) => ` ${line.trim()}`).join('\n');
10
+ return {
11
+ 'factory.yaml': `schemaVersion: v1alpha1
12
+ name: ${name}
13
+ description: |
14
+ ${wrapped}
15
+ repositories: []
16
+ agentDefaults:
17
+ harness:
18
+ type: claude
19
+ model: sonnet
20
+ `,
21
+ 'flow.yaml': `flow: mindburn.flow/v1
22
+ title: ${JSON.stringify(heading)}
23
+ version: "0.1.0"
24
+ license: Apache-2.0
25
+ tags: [writing]
26
+ time: about 2 minutes
27
+ inputs:
28
+ brief:
29
+ type: longtext
30
+ help: "What the draft is for, who reads it, and the facts it must use."
31
+ example:
32
+ type: url
33
+ optional: true
34
+ help: "A page whose tone the draft should match, for example example.com/about."
35
+ steps:
36
+ outline:
37
+ title: Outline the draft
38
+ agent: writer
39
+ kind: work
40
+ tools: [web_fetch]
41
+ task: |
42
+ If an example page was given, read it to learn its tone. Then write an
43
+ outline: a working title, the reader in one sentence, and four to six
44
+ sections with one line each on what the section says. Use only facts
45
+ from the brief; mark anything missing as "[ADD: what is missing]".
46
+ approve_outline:
47
+ title: Agree the outline
48
+ kind: approve
49
+ needs: [outline]
50
+ shows: outline
51
+ task: |
52
+ Everything else is written from this outline. Approve it, or say what
53
+ to change.
54
+ draft:
55
+ title: Write the draft
56
+ agent: writer
57
+ kind: deliver
58
+ needs: [approve_outline]
59
+ task: |
60
+ Write the full draft from the approved outline, section by section, in
61
+ the tone of the example page if one was read. Keep every "[ADD: ...]"
62
+ marker. Do not add facts that are not in the brief.
63
+ check_draft:
64
+ title: Check the draft
65
+ agent: reviewer
66
+ kind: check
67
+ needs: [draft]
68
+ checks: draft
69
+ task: |
70
+ Every section of the approved outline is present. Every fact in the
71
+ draft appears in the brief. Missing facts are marked "[ADD: ...]", not
72
+ invented.
73
+ done:
74
+ - "The draft follows the approved outline."
75
+ - "Nothing in the draft is missing from the brief or invented."
76
+ `,
77
+ 'agents/writer/agent.md': `---
78
+ description: Writes clear drafts from a brief and the steps before it.
79
+ agentType: CUSTOM
80
+ ---
81
+
82
+ You are the writer. You turn what the person gave and what earlier steps
83
+ produced into plain, specific text for a busy reader.
84
+
85
+ - You use only facts from the inputs, earlier results and pages you read.
86
+ - Where a fact is missing, you leave a marker such as "[ADD: the price]".
87
+ - You keep to the shape, sections and length the step asks for.
88
+ `,
89
+ 'agents/reviewer/agent.md': `---
90
+ description: Checks a result against its criteria and sends weak work back.
91
+ agentType: REVIEW
92
+ ---
93
+
94
+ You are the reviewer. You compare a result with its task, the inputs and the
95
+ earlier evidence, and you pass it only when every criterion is met.
96
+
97
+ - You fail work only for a clear breach you can point to, and you say where.
98
+ - You never fix the work yourself; you say what must change.
99
+ `,
100
+ 'README.md': `# ${heading}
101
+
102
+ ${about}
103
+
104
+ Edit the files to make this factory yours:
105
+
106
+ - \`factory.yaml\`: the name and what the factory is for.
107
+ - \`flow.yaml\`: the inputs and the steps, who does each and what each needs.
108
+ - \`agents/<name>/agent.md\`: one file per agent, its role in prose.
109
+
110
+ Check it with \`factoryhub validate\`, read it back with \`factoryhub inspect .\`,
111
+ and run it with your own model key with \`factoryhub run .\`.
112
+ `,
113
+ };
114
+ }
115
+
116
+ export default {
117
+ name: 'new',
118
+ summary: 'Create a new factory folder that validates and runs.',
119
+ usage: `Usage: factoryhub new <name> [--dir parent] [--title text] [--description text]
120
+
121
+ Creates <parent>/<name>/ with factory.yaml, flow.yaml, two agents and a README.
122
+ The starting factory turns a brief into a checked draft and uses every kind
123
+ of step: work, approve, deliver and check. Edit it to make it yours.
124
+
125
+ <name> is lowercase letters, digits and hyphens.
126
+
127
+ Options:
128
+ --dir where to create the folder (default: the current folder)
129
+ --title the factory's title
130
+ --description what it produces and when to use it`,
131
+ options: { string: ['dir', 'title', 'description'] },
132
+ async run({ positional, flags }, io) {
133
+ const [name] = positional;
134
+ if (!name) throw usage('Name the new factory: factoryhub new my-factory');
135
+ if (engine.slugify(name) !== name) throw usage(`"${name}" is not a factory name. Use lowercase letters, digits and hyphens, such as ${engine.slugify(name) || 'my-factory'}.`);
136
+ const folder = resolve(io.cwd, flags.dir || '.', name);
137
+ if (existsSync(folder) && readdirSync(folder).length) throw usage(`${folder} already exists and is not empty.`);
138
+ const files = scaffold({ name, title: flags.title, description: flags.description });
139
+ const parsed = engine.parseBundle(files);
140
+ if (!parsed.ok || !parsed.factory.runnable) throw new Error(`The new factory did not validate:\n${[...parsed.errors, ...parsed.factory.blockers].map((item) => ` - ${item}`).join('\n')}`);
141
+ await writeFiles(folder, files);
142
+ const s = styles(io);
143
+ const shown = relative(io.cwd, folder) || '.';
144
+ out(io, `${s.green('created')} ${shown}/ with ${Object.keys(files).sort().join(', ')}`);
145
+ out(io, `It validates and runs. Next:\n factoryhub inspect ${shown}\n factoryhub run ${shown}`);
146
+ return 0;
147
+ },
148
+ };
@@ -0,0 +1,97 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { resolve, relative } from 'node:path';
4
+ import { engine, readFactory, canonicalFiles, out, styles, usage } from '../util.mjs';
5
+ import { packageManifest, zip } from '../../../engine/src/package.mjs';
6
+
7
+ const RUNNER = ['run.mjs', 'factory-engine.mjs', 'author-tests.mjs'];
8
+ const ENGINE_DIR = new URL('../../../engine/src/', import.meta.url);
9
+
10
+ function running(factory, withRunner) {
11
+ const example = factory.inputs.filter((input) => input.required).map((input) => ` --input ${input.key}="..."`).join('');
12
+ return `# ${factory.title}
13
+
14
+ ${factory.description}
15
+
16
+ This folder holds the factory's files${withRunner ? ' and a portable runner with no dependencies' : ''}. Running it calls the model you choose, with your own key; your provider bills you for it.
17
+
18
+ - \`factory.yaml\`, \`flow.yaml\` and \`agents/\`: the factory.
19
+ ${withRunner ? '- `run.mjs`, `factory-engine.mjs` and `author-tests.mjs`: the runner (Apache-2.0).\n' : ''}- \`INPUTS.schema.json\`: the JSON Schema of the inputs.
20
+ - \`FACTORY-HUB.json\`: the source hash, declared model and cost, and the validation result. It is generated, not source.
21
+ ${withRunner ? `
22
+ ## Run it
23
+
24
+ Needs Node.js 22 or newer.
25
+
26
+ \`\`\`sh
27
+ export ANTHROPIC_API_KEY=... # or OPENAI_API_KEY=...
28
+ node run.mjs${example}
29
+ \`\`\`
30
+
31
+ A model on your own machine works through any OpenAI-compatible server:
32
+
33
+ \`\`\`sh
34
+ node run.mjs --base-url http://localhost:11434/v1 --model <name>
35
+ \`\`\`
36
+
37
+ The runner stops at every approval step and asks you. With nobody at the keyboard it stops with exit code 3 and approves nothing; \`--yes\` approves every decision and the receipt records it. \`node run.mjs --validate\` checks the files without a key and without running anything. \`node run.mjs --help\` lists every option.
38
+ ` : ''}`;
39
+ }
40
+
41
+ export default {
42
+ name: 'pack',
43
+ summary: 'Pack a factory into a zip that runs anywhere with Node, or a bundle .json.',
44
+ usage: `Usage: factoryhub pack <factory> [--out file] [--format zip|json] [--no-runner]
45
+
46
+ zip (the default) writes <name>/ with the factory's files, the generated
47
+ foreman, INPUTS.schema.json, FACTORY-HUB.json, RUNNING.md and the portable
48
+ runner (run.mjs with the engine), so "node run.mjs" works after unzipping.
49
+
50
+ json writes the transfer bundle (mindburn.factory.bundle/v1): every file with
51
+ its SHA-256, the form "factoryhub run" and "factoryhub validate" read back.
52
+
53
+ Options:
54
+ --out output file (default: <name>-<version>.zip or <name>.bundle.json)
55
+ --format zip or json
56
+ --no-runner leave the runner out of the zip
57
+ --force replace an existing output file`,
58
+ options: { boolean: ['runner', 'force'], string: ['out', 'format'] },
59
+ async run({ positional, flags }, io) {
60
+ const [target] = positional;
61
+ if (!target) throw usage('Name a factory to pack.');
62
+ const format = flags.format || 'zip';
63
+ if (!['zip', 'json'].includes(format)) throw usage('--format is zip or json.');
64
+ const read = await readFactory(target, { cwd: io.cwd, fetchImpl: io.fetch });
65
+ const files = canonicalFiles(read.files);
66
+ const parsed = engine.parseBundle(files);
67
+ if (!parsed.ok) throw new Error(`This factory is not valid, so it was not packed:\n${parsed.errors.map((error) => ` - ${error}`).join('\n')}`);
68
+ const { factory } = parsed;
69
+ const output = resolve(io.cwd, flags.out || (format === 'zip' ? `${factory.name}-${factory.version}.zip` : `${factory.name}.bundle.json`));
70
+ if (existsSync(output) && !flags.force) throw usage(`${output} already exists. Pass --force to replace it.`);
71
+ let bytes;
72
+ if (format === 'json') {
73
+ const envelope = engine.toEnvelope(files, { project: factory.name });
74
+ for (const entry of envelope.files) {
75
+ entry.sha256 = await engine.sha256(entry.content);
76
+ entry.bytes = new TextEncoder().encode(entry.content).length;
77
+ }
78
+ envelope.sha256 = await engine.bundleHash(files);
79
+ bytes = `${JSON.stringify(envelope, null, 2)}\n`;
80
+ } else {
81
+ const withRunner = flags.runner !== false;
82
+ const manifest = await packageManifest(files);
83
+ const dir = factory.name;
84
+ const entries = {};
85
+ for (const [path, content] of Object.entries(files)) entries[`${dir}/${path}`] = content;
86
+ entries[`${dir}/RUNNING.md`] = running(factory, withRunner);
87
+ entries[`${dir}/INPUTS.schema.json`] = `${JSON.stringify(manifest.inputs, null, 2)}\n`;
88
+ entries[`${dir}/FACTORY-HUB.json`] = `${JSON.stringify(manifest, null, 2)}\n`;
89
+ if (withRunner) for (const name of RUNNER) entries[`${dir}/${name}`] = await readFile(new URL(name, ENGINE_DIR), 'utf8');
90
+ bytes = Buffer.from(await zip(entries).arrayBuffer());
91
+ }
92
+ await writeFile(output, bytes);
93
+ const s = styles(io);
94
+ out(io, `${s.green('packed')} ${relative(io.cwd, output) || output} ${factory.title} v${factory.version}, sha256 ${await engine.bundleHash(files)}`);
95
+ return 0;
96
+ },
97
+ };
@@ -0,0 +1,67 @@
1
+ import { readFile, rm } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ import { usage } from '../util.mjs';
4
+ import { restoreRun, decide, pendingDecision } from '../../../engine/lib/run.mjs';
5
+ import { drive, lineReader, providerFor } from './run.mjs';
6
+
7
+ const REASONS = {
8
+ not_waiting: 'This run is not waiting for a decision.',
9
+ bad_action: 'The decision must be approve, revise or reject.',
10
+ unseen: 'Pass --seen with the SHA-256 printed with the decision text.',
11
+ stale: '--seen does not match the text this run is waiting on. Show the person the current text (factoryhub resume <file> --show) and use its hash.',
12
+ no_revisions: 'This step has been sent back as often as the factory allows: approve or reject it.',
13
+ };
14
+
15
+ export default {
16
+ name: 'resume',
17
+ summary: 'Continue a saved run with the person\'s decision.',
18
+ usage: `Usage: factoryhub resume <run.json> --decision approve|revise|reject --seen <sha256> [--note text]
19
+ factoryhub resume <run.json> --show
20
+
21
+ Continues a run that "factoryhub run --save <run.json>" saved at a decision.
22
+ The decision must be the person's own. --seen is the SHA-256 of the exact
23
+ text they were shown, printed with it, so a decision cannot land on other
24
+ text. The run continues with the same provider and model, using the key in
25
+ this environment, until it is done or reaches the next decision, where it is
26
+ saved to the same file again.
27
+
28
+ Exit codes: as for "factoryhub run".
29
+
30
+ Options:
31
+ --decision approve, revise (with --note saying what to change) or reject
32
+ --seen the SHA-256 of the text the person saw
33
+ --note what to change, for revise
34
+ --show print the waiting decision and its hash, change nothing
35
+ --out where to save the deliverable
36
+ --yes, -y approve every later decision without asking (recorded as
37
+ runner mode "cli --yes" in the receipt)
38
+ --json print the outcome as JSON`,
39
+ options: { boolean: ['show', 'yes', 'json'], string: ['decision', 'seen', 'note', 'out'], alias: { y: 'yes' } },
40
+ async run({ positional, flags }, io) {
41
+ const [file] = positional;
42
+ if (!file) throw usage('Name the saved run: factoryhub resume run.json --decision approve --seen <sha256>');
43
+ const path = resolve(io.cwd, file);
44
+ const state = restoreRun(JSON.parse(await readFile(path, 'utf8')));
45
+ const pending = await pendingDecision(state);
46
+ if (flags.show) {
47
+ if (!pending) throw usage('This run is not waiting for a decision.');
48
+ io.stdout.write(flags.json ? `${JSON.stringify({ step: pending.step, title: pending.title, ask: pending.ask, text: pending.text, seen_sha256: pending.seen_sha256 }, null, 2)}\n` : `${pending.title}\n\n${pending.text}\n\n${pending.ask}\n\nseen: ${pending.seen_sha256}\n`);
49
+ return 0;
50
+ }
51
+ if (!['approve', 'revise', 'reject'].includes(flags.decision)) throw usage('Give the person\'s decision: --decision approve, revise or reject.');
52
+ if (flags.decision === 'revise' && !String(flags.note || '').trim()) throw usage('A revise decision needs --note saying what to change.');
53
+ const why = decide(state, { action: flags.decision, note: flags.note || '', seen: flags.seen, approvalId: state.approval?.id });
54
+ if (why) throw usage(REASONS[why] || why);
55
+ if (flags.yes) state.runner = { ...state.runner, mode: 'cli --yes' };
56
+ const provider = providerFor(state.cli || {}, io);
57
+ const reader = lineReader(io);
58
+ try {
59
+ const code = await drive(state, { provider, flags, io, reader, saveTo: path });
60
+ // A finished or stopped run has nothing left to resume.
61
+ if (code !== 3) await rm(path, { force: true });
62
+ return code;
63
+ } finally {
64
+ reader.close();
65
+ }
66
+ },
67
+ };
@@ -0,0 +1,190 @@
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
2
+ import { resolve, relative, dirname } from 'node:path';
3
+ import { createInterface } from 'node:readline';
4
+ import { engine, readFactory, canonicalFiles, out, json, styles, usage } from '../util.mjs';
5
+ import { chooseProvider } from '../../../engine/lib/providers.mjs';
6
+ import { nodeHost, prepareRun, continueRun, pendingDecision } from '../../../engine/lib/run.mjs';
7
+
8
+ /** Reads typed lines one at a time, keeping answers typed ahead. */
9
+ export function lineReader(io) {
10
+ let reader = null;
11
+ return {
12
+ async ask(prompt) {
13
+ if (!io.interactive) return null;
14
+ reader ||= createInterface({ input: io.stdin, terminal: false });
15
+ reader.iterator ||= reader[Symbol.asyncIterator]();
16
+ io.stderr.write(prompt);
17
+ const { value, done } = await reader.iterator.next();
18
+ return done ? null : value;
19
+ },
20
+ close() { reader?.close(); },
21
+ };
22
+ }
23
+
24
+ /** The provider a saved run continues with: the same settings, the key from this environment. */
25
+ export function providerFor(settings, io) {
26
+ return chooseProvider({ provider: settings.provider || undefined, model: settings.model, baseUrl: settings.baseUrl || undefined, apiKeyEnv: settings.apiKeyEnv || undefined, env: io.env, fetchImpl: io.fetch });
27
+ }
28
+
29
+ /**
30
+ * Runs a prepared or restored run to its end or to a decision nobody answered,
31
+ * then reports it. Shared by `run` and `resume`. Returns the exit code.
32
+ */
33
+ export async function drive(state, { provider, flags, io, reader, saveTo }) {
34
+ const s = styles(io);
35
+ const say = (text = '') => io.stderr.write(`${text}\n`);
36
+ const { factory } = state;
37
+ const titleOf = (id) => factory.steps.find((step) => step.id === id)?.title || id;
38
+ const host = nodeHost({
39
+ provider,
40
+ fetchImpl: io.fetch,
41
+ emit(event) {
42
+ if (event.type === 'step_started') say(`\n${s.bold(event.title)} ${s.dim(event.agent)}${event.redo ? s.dim(' (redoing)') : ''}`);
43
+ else if (event.type === 'tool') say(s.dim(` ${titleOf(event.step)}: ${event.tool === 'web_search' ? 'searching' : 'reading'} ${event.target}`));
44
+ else if (event.type === 'step_done') say(s.dim(` done, ${event.chars} characters`));
45
+ else if (event.type === 'check') say(` ${event.pass ? s.green('passed') : event.status === 'sent_back' ? s.yellow('sent back') : s.red(event.status === 'unreviewed' ? 'unreviewed' : 'flagged')} ${s.dim(event.note)}${event.pass ? '' : event.issues.map((issue) => `\n - ${issue}`).join('')}`);
46
+ else if (event.type === 'run_failed') say(`\n${s.red('Stopped')}: ${event.error}`);
47
+ },
48
+ });
49
+
50
+ await continueRun(state, host, async (approval, text, problem) => {
51
+ const seen = await engine.sha256(text);
52
+ if (problem === 'no_revisions') say(s.dim('This step has been sent back as often as the factory allows: approve it or reject it.'));
53
+ else say(`\n${s.bold(approval.title)} ${s.yellow('needs your decision')}\n\n${text}\n\n${s.dim(approval.ask)}`);
54
+ if (flags.yes) { say(s.dim('Approved by --yes.')); return { action: 'approve', seen, approvalId: approval.id }; }
55
+ if (!io.interactive) return null;
56
+ let answer = '';
57
+ // An empty line is not a decision. Closing the input leaves the run waiting.
58
+ while (!answer) {
59
+ const line = await reader.ask(`\nType ${s.bold('approve')} to continue, ${s.bold('reject')} to stop the run, or what to change:\n> `);
60
+ if (line === null) return null;
61
+ answer = line.trim();
62
+ }
63
+ if (/^(approve|a|yes|y)$/i.test(answer)) return { action: 'approve', seen, approvalId: approval.id };
64
+ if (/^(reject|stop|no|n)$/i.test(answer)) return { action: 'reject', seen, approvalId: approval.id };
65
+ return { action: 'revise', note: answer, seen, approvalId: approval.id };
66
+ });
67
+
68
+ if (state.status === 'waiting') {
69
+ const decision = await pendingDecision(state);
70
+ let saved = null;
71
+ if (saveTo) {
72
+ await mkdir(dirname(saveTo), { recursive: true });
73
+ await writeFile(saveTo, `${JSON.stringify(state)}\n`);
74
+ saved = relative(io.cwd, saveTo) || saveTo;
75
+ }
76
+ if (flags.json) json(io, { status: 'waiting', step: decision.step, title: decision.title, ask: decision.ask, text: decision.text, seen_sha256: decision.seen_sha256, state: saved });
77
+ if (saved) {
78
+ if (!flags.json) say(`\n${s.bold(decision.title)} ${s.yellow('needs a decision')}\n\n${decision.text}\n\n${s.dim(decision.ask)}`);
79
+ say(`\nWaiting at "${decision.title}". Nothing was approved. The run is saved in ${saved}.\nShow the person the text above, then continue with their decision:\n factoryhub resume ${saved} --decision approve --seen ${decision.seen_sha256}\n factoryhub resume ${saved} --decision revise --note "what to change" --seen ${decision.seen_sha256}\n factoryhub resume ${saved} --decision reject --seen ${decision.seen_sha256}`);
80
+ } else say(`\nWaiting at "${decision.title}": nobody answered, so nothing was approved and no result was written.\nRun it at a terminal to decide, pass --save <file> to continue later with "factoryhub resume", or pass --yes to approve every decision.`);
81
+ return 3;
82
+ }
83
+ if (state.status === 'stopped') {
84
+ if (flags.json) json(io, { status: 'stopped', step: state.stopped.step });
85
+ say(`\nStopped at "${state.stopped.title}" by your decision. No result was made.`);
86
+ return 1;
87
+ }
88
+ if (state.status !== 'done') {
89
+ if (flags.json) json(io, { status: state.status, error: state.error, failure: state.failure });
90
+ return 1;
91
+ }
92
+ const stamp = new Date(state.finished).toISOString().slice(0, 16).replace(/[-:T]/g, '');
93
+ const file = resolve(io.cwd, flags.out || `${factory.name}-${stamp}.md`);
94
+ const receiptFile = `${file.replace(/\.md$/, '')}.receipt.json`;
95
+ await mkdir(dirname(file), { recursive: true });
96
+ await writeFile(file, state.deliverable);
97
+ await writeFile(receiptFile, `${JSON.stringify(state.receipt, null, 2)}\n`);
98
+ const review = state.receipt.deliverable_review.status;
99
+ if (flags.json) json(io, { status: 'done', deliverable: relative(io.cwd, file), receipt: relative(io.cwd, receiptFile), review, flags: state.receipt.flags, usage: { calls: state.usage.llmCalls, input: state.usage.input, output: state.usage.output } });
100
+ else out(io, state.deliverable);
101
+ say(s.dim(`\nSaved ${relative(io.cwd, file)} and its receipt. Final check: ${review}. ${state.usage.llmCalls} model calls, ${state.usage.input + state.usage.output} tokens.`));
102
+ if (state.receipt.flags.length) say(s.dim(`Note: ${state.receipt.flags.join('; ')}`));
103
+ return 0;
104
+ }
105
+
106
+ export default {
107
+ name: 'run',
108
+ summary: 'Run a factory with your own model key or a local model. Stops at every decision.',
109
+ usage: `Usage: factoryhub run <factory> [--input name=value ...] [options]
110
+
111
+ Runs the factory's steps in order on this machine. Steps that do not depend
112
+ on each other run at the same time. At every approval step the run stops,
113
+ shows you the text, and asks: approve, reject, or say what to change.
114
+
115
+ The model is yours. The first of these that is set decides:
116
+ ANTHROPIC_API_KEY Anthropic (default model claude-sonnet-5-5)
117
+ OPENAI_API_KEY OpenAI (default model gpt-5)
118
+ OPENROUTER_API_KEY OpenRouter (default model openrouter/auto)
119
+ or a server on your machine:
120
+ --provider ollama --model <name> Ollama at http://localhost:11434/v1
121
+ --base-url <url> --model <name> any OpenAI-compatible server
122
+
123
+ Web search needs an Anthropic model; with other models the agents read the
124
+ pages they are given. Agents read public web pages only.
125
+
126
+ When it finishes, the deliverable is printed and saved with a receipt of
127
+ every step. Inputs are asked for when missing and you are at a terminal.
128
+
129
+ With nobody at the terminal (a script, or an agent's shell) a decision is
130
+ never taken for you: the run stops there. With --save <file> it is saved, and
131
+ "factoryhub resume" continues it with the person's decision.
132
+
133
+ Exit codes: 0 done, 1 failed or stopped by your decision, 2 usage,
134
+ 3 waiting for a decision (nothing was approved).
135
+
136
+ Options:
137
+ --input, -i name=value; name=@file reads the value from a file (repeatable)
138
+ --provider anthropic, openai, openrouter, ollama or openai-compatible
139
+ --model the model name (or set FACTORYHUB_MODEL)
140
+ --base-url an OpenAI-compatible server address
141
+ --api-key-env the environment variable holding the key for --base-url
142
+ --save where to save the run when it waits for a decision
143
+ --yes, -y approve every decision without asking; the receipt's runner
144
+ mode then reads "cli --yes"
145
+ --out where to save the deliverable (default: <name>-<time>.md)
146
+ --json print the outcome as JSON instead of the deliverable`,
147
+ options: { boolean: ['yes', 'json'], string: ['provider', 'model', 'base-url', 'api-key-env', 'out', 'save'], repeat: ['input'], alias: { i: 'input', y: 'yes' } },
148
+ async run({ positional, flags }, io) {
149
+ const [target] = positional;
150
+ if (!target) throw usage('Name the factory to run.');
151
+ const s = styles(io);
152
+ const read = await readFactory(target, { cwd: io.cwd, fetchImpl: io.fetch });
153
+ const parsed = engine.parseBundle(canonicalFiles(read.files));
154
+ if (!parsed.ok) throw new Error(`This is not a valid factory:\n${parsed.errors.map((error) => ` - ${error}`).join('\n')}`);
155
+ if (!parsed.factory.runnable) throw new Error(parsed.factory.blockers.join('\n') || 'This factory has no flow.yaml, so there are no steps to run.');
156
+ const { factory } = parsed;
157
+ const settings = { provider: flags.provider || null, model: flags.model || null, baseUrl: flags['base-url'] || null, apiKeyEnv: flags['api-key-env'] || null };
158
+ const provider = providerFor(settings, io);
159
+ settings.model = provider.model;
160
+
161
+ const inputs = {};
162
+ for (const pair of flags.input) {
163
+ const at = pair.indexOf('=');
164
+ if (at < 1) throw usage(`--input needs name=value, not "${pair}".`);
165
+ const key = pair.slice(0, at);
166
+ const value = pair.slice(at + 1);
167
+ inputs[key] = value.startsWith('@') ? await readFile(resolve(io.cwd, value.slice(1)), 'utf8') : value;
168
+ }
169
+
170
+ const reader = lineReader(io);
171
+ const say = (text = '') => io.stderr.write(`${text}\n`);
172
+ try {
173
+ say(`\n${s.bold(factory.title)} ${s.dim(`v${factory.version}, ${provider.name}, ${provider.model}`)}`);
174
+ say(factory.description);
175
+ for (const input of factory.inputs) {
176
+ if (inputs[input.key] !== undefined || !io.interactive) continue;
177
+ const answer = await reader.ask(`\n${s.bold(input.label)}${input.required ? '' : s.dim(' (optional, Enter to skip)')} ${s.dim(`${input.type}${input.type === 'longtext' ? ', or @file' : ''}`)}\n${s.dim(input.help)}\n> `);
178
+ if (answer === null) break;
179
+ inputs[input.key] = answer.startsWith('@') ? await readFile(resolve(io.cwd, answer.slice(1).trim()), 'utf8') : answer;
180
+ }
181
+ // An unattended approval is recorded in the receipt's runner mode.
182
+ const prepared = prepareRun({ files: read.files, inputs, runner: { mode: flags.yes ? 'cli --yes' : 'cli', provider: provider.name, model: provider.model } });
183
+ if (!prepared.ok) throw usage(prepared.problems.join('\n'));
184
+ prepared.state.cli = settings;
185
+ return await drive(prepared.state, { provider, flags, io, reader, saveTo: flags.save ? resolve(io.cwd, flags.save) : null });
186
+ } finally {
187
+ reader.close();
188
+ }
189
+ },
190
+ };
@@ -0,0 +1,31 @@
1
+ import { out, json, table } from '../util.mjs';
2
+ import { hubClient, DEFAULT_HUB } from '../../../engine/lib/hub.mjs';
3
+
4
+ export const HUB_NOTE = `The factoryhub site is in private preview. Until it opens, it answers
5
+ only requests that carry the preview access key: set FACTORYHUB_ACCESS_KEY
6
+ and it is sent as the x-hub-access-key header.`;
7
+
8
+ export default {
9
+ name: 'search',
10
+ summary: 'Search the published factories on factoryhub (needs FACTORYHUB_ACCESS_KEY in preview).',
11
+ usage: `Usage: factoryhub search [words ...] [--base-url url] [--json]
12
+
13
+ Lists the published factories whose id, title, description or tags contain
14
+ every word. With no words, lists them all.
15
+
16
+ ${HUB_NOTE}
17
+
18
+ Options:
19
+ --base-url the factoryhub address (default: FACTORYHUB_BASE_URL or ${DEFAULT_HUB})
20
+ --json print the catalogue entries as JSON`,
21
+ options: { boolean: ['json'], string: ['base-url'] },
22
+ async run({ positional, flags }, io) {
23
+ const hub = hubClient({ baseUrl: flags['base-url'], env: io.env, fetchImpl: io.fetch });
24
+ const found = await hub.search(positional.join(' '));
25
+ if (flags.json) { json(io, found); return 0; }
26
+ if (!found.length) { out(io, 'No published factory matches.'); return 0; }
27
+ out(io, table([['ID', 'VERSION', 'TITLE'], ...found.map((entry) => [entry.id, entry.version || '', entry.title || entry.name])]));
28
+ out(io, `\nGet one with: factoryhub get <id>`);
29
+ return 0;
30
+ },
31
+ };
@@ -0,0 +1,81 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { engine, readFactory, out, json, plural, styles, usage, canonicalFiles } from '../util.mjs';
4
+
5
+ export default {
6
+ name: 'validate',
7
+ summary: 'Check factories the way the engine reads them. No key, no model.',
8
+ usage: `Usage: factoryhub validate [factory ...] [--json]
9
+
10
+ Reads each factory with the engine's parser and lists every problem, every
11
+ tool this runner does not have and every file it did not read. A factory is a
12
+ folder, a bundle .json file, an https address of a bundle, or a house factory
13
+ id. With no factory, validates the folder you are in.
14
+
15
+ Exit codes: 0 every factory is valid and runs here, 1 one or more does not.
16
+
17
+ Options:
18
+ --json print the results as JSON`,
19
+ options: { boolean: ['json'] },
20
+ async run({ positional, flags }, io) {
21
+ const targets = positional.length ? positional : existsSync(join(io.cwd, 'factory.yaml')) ? ['.'] : [];
22
+ if (!targets.length) throw usage('Name a factory to validate, or run this inside a factory folder.');
23
+ const results = [];
24
+ for (const target of targets) results.push(await check(target, io));
25
+ if (flags.json) json(io, results);
26
+ else results.forEach((result, index) => { if (index) out(io); print(result, io); });
27
+ return results.every((result) => result.runnable) ? 0 : 1;
28
+ },
29
+ };
30
+
31
+ export async function check(target, io) {
32
+ let read;
33
+ try {
34
+ read = await readFactory(target, { cwd: io.cwd, fetchImpl: io.fetch });
35
+ } catch (error) {
36
+ return { target, valid: false, runnable: false, errors: [error.message], warnings: [], blockers: [], leftOut: [] };
37
+ }
38
+ const { ok, errors, warnings, factory } = engine.parseBundle(read.files);
39
+ // Blockers and the missing flow are reported on their own lines.
40
+ const notes = warnings.filter((warning) => !factory.blockers.includes(warning) && !warning.startsWith('This bundle has no flow.yaml'));
41
+ return {
42
+ target,
43
+ name: factory.name,
44
+ title: factory.title,
45
+ version: factory.version,
46
+ valid: ok,
47
+ hasFlow: factory.flowValid,
48
+ runnable: ok && factory.runnable,
49
+ sha256: ok ? await engine.bundleHash(canonicalFiles(read.files)) : null,
50
+ steps: factory.steps.length,
51
+ agents: Object.values(factory.agents).filter((agent) => agent.type !== 'FOREMAN').length,
52
+ inputs: factory.inputs.length,
53
+ tools: [...new Set(factory.steps.flatMap((step) => step.tools))].sort(),
54
+ errors,
55
+ blockers: factory.blockers,
56
+ warnings: notes,
57
+ leftOut: read.leftOut,
58
+ hasFlowFile: read.files['flow.yaml'] !== undefined,
59
+ };
60
+ }
61
+
62
+ function print(result, io) {
63
+ const s = styles(io);
64
+ const name = result.title ? `${result.title} v${result.version}` : result.target;
65
+ if (!result.valid) {
66
+ out(io, `${s.red('invalid')} ${name} (${result.target}): ${plural(result.errors.length, 'problem')}`);
67
+ for (const error of result.errors) out(io, ` - ${error}`);
68
+ } else if (!result.hasFlow) {
69
+ out(io, `${s.yellow('no flow')} ${name}: readable, but ${result.hasFlowFile ? 'its flow.yaml has no steps' : 'it has no flow.yaml'}, so there is nothing to run. An agent can follow agents/foreman/agent.md.`);
70
+ } else if (result.blockers.length) {
71
+ out(io, `${s.yellow('blocked')} ${name}: valid, but it cannot run here:`);
72
+ for (const blocker of result.blockers) out(io, ` - ${blocker}`);
73
+ } else {
74
+ out(io, `${s.green('valid')} ${name}: runs here. ${plural(result.steps, 'step')}, ${plural(result.agents, 'agent')}, ${plural(result.inputs, 'input')}; tools: ${result.tools.join(', ') || 'none'}.`);
75
+ }
76
+ if (result.warnings.length) {
77
+ out(io, ' Worth a look:');
78
+ for (const warning of result.warnings) out(io, ` - ${warning}`);
79
+ }
80
+ if (result.leftOut.length) out(io, ` Not part of the factory, so not read: ${result.leftOut.join(', ')}`);
81
+ }