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,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
|
+
}
|