factoryhub 0.1.0 → 0.1.1

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 (87) hide show
  1. package/README.md +9 -3
  2. package/factories/index.json +231 -0
  3. package/factories/newsletter-note/README.md +11 -0
  4. package/factories/newsletter-note/agents/reviewer/agent.md +10 -0
  5. package/factories/newsletter-note/agents/writer/agent.md +11 -0
  6. package/factories/newsletter-note/factory.yaml +12 -0
  7. package/factories/newsletter-note/flow.yaml +45 -0
  8. package/factories/newsletter-note/mindburn.tests.yaml +80 -0
  9. package/factories/release-desk/README.md +13 -0
  10. package/factories/release-desk/agents/editor/agent.md +10 -0
  11. package/factories/release-desk/agents/reviewer/agent.md +10 -0
  12. package/factories/release-desk/factory.yaml +13 -0
  13. package/factories/release-desk/flow.yaml +67 -0
  14. package/factories/release-desk/mindburn.tests.yaml +163 -0
  15. package/factories/short-video-cut/README.md +11 -0
  16. package/factories/short-video-cut/agents/reviewer/agent.md +10 -0
  17. package/factories/short-video-cut/agents/writer/agent.md +11 -0
  18. package/factories/short-video-cut/factory.yaml +13 -0
  19. package/factories/short-video-cut/flow.yaml +46 -0
  20. package/factories/short-video-cut/mindburn.tests.yaml +80 -0
  21. package/factories/video-facts/README.md +11 -0
  22. package/factories/video-facts/agents/analyst/agent.md +10 -0
  23. package/factories/video-facts/agents/reviewer/agent.md +10 -0
  24. package/factories/video-facts/factory.yaml +13 -0
  25. package/factories/video-facts/flow.yaml +39 -0
  26. package/factories/video-facts/mindburn.tests.yaml +34 -0
  27. package/factories/video-to-posts/README.md +35 -0
  28. package/factories/video-to-posts/agents/analyst/agent.md +10 -0
  29. package/factories/video-to-posts/agents/reviewer/agent.md +10 -0
  30. package/factories/video-to-posts/agents/writer/agent.md +11 -0
  31. package/factories/video-to-posts/factory.yaml +12 -0
  32. package/factories/video-to-posts/flow.yaml +62 -0
  33. package/factories/video-to-posts/mindburn.tests.yaml +34 -0
  34. package/factories/week-of-posts/README.md +11 -0
  35. package/factories/week-of-posts/agents/reviewer/agent.md +10 -0
  36. package/factories/week-of-posts/agents/writer/agent.md +11 -0
  37. package/factories/week-of-posts/factory.yaml +12 -0
  38. package/factories/week-of-posts/flow.yaml +46 -0
  39. package/factories/week-of-posts/mindburn.tests.yaml +80 -0
  40. package/package.json +15 -2
  41. package/packages/cli/package.json +1 -1
  42. package/packages/cli/src/commands/export.mjs +0 -1
  43. package/packages/cli/src/commands/get.mjs +0 -1
  44. package/packages/cli/src/commands/inspect.mjs +0 -1
  45. package/packages/cli/src/commands/list.mjs +1 -6
  46. package/packages/cli/src/commands/login.mjs +161 -0
  47. package/packages/cli/src/commands/logout.mjs +43 -0
  48. package/packages/cli/src/commands/mcp.mjs +0 -1
  49. package/packages/cli/src/commands/new.mjs +0 -1
  50. package/packages/cli/src/commands/pack.mjs +0 -1
  51. package/packages/cli/src/commands/publish.mjs +62 -0
  52. package/packages/cli/src/commands/pull.mjs +59 -0
  53. package/packages/cli/src/commands/push.mjs +115 -0
  54. package/packages/cli/src/commands/resume.mjs +0 -1
  55. package/packages/cli/src/commands/run.mjs +182 -20
  56. package/packages/cli/src/commands/runs.mjs +52 -0
  57. package/packages/cli/src/commands/search.mjs +0 -1
  58. package/packages/cli/src/commands/validate.mjs +1 -3
  59. package/packages/cli/src/commands/whoami.mjs +34 -0
  60. package/packages/cli/src/hosted.mjs +159 -0
  61. package/packages/cli/src/main.mjs +80 -14
  62. package/packages/cli/src/tool-sources.mjs +272 -0
  63. package/packages/cli/src/util.mjs +37 -0
  64. package/packages/engine/lib/credentials.mjs +121 -0
  65. package/packages/engine/lib/hosted.mjs +119 -0
  66. package/packages/engine/lib/hub.mjs +376 -24
  67. package/packages/engine/lib/nango.mjs +157 -0
  68. package/packages/engine/lib/node.mjs +161 -33
  69. package/packages/engine/lib/providers.mjs +8 -3
  70. package/packages/engine/lib/run.mjs +70 -9
  71. package/packages/engine/lib/text.mjs +5 -0
  72. package/packages/engine/package.json +8 -2
  73. package/packages/engine/src/factory-engine.mjs +415 -68
  74. package/packages/engine/src/flow-sources.mjs +135 -0
  75. package/packages/engine/src/mcp-client.mjs +429 -0
  76. package/packages/engine/src/run.mjs +81 -5
  77. package/packages/engine/src/tool-sources.mjs +566 -0
  78. package/packages/mcp/package.json +1 -1
  79. package/packages/mcp/src/server.mjs +47 -17
  80. package/packages/sdk/package.json +28 -0
  81. package/packages/sdk/src/index.d.ts +163 -0
  82. package/packages/sdk/src/index.mjs +266 -0
  83. package/spec/README.md +7 -1
  84. package/spec/factory-format.md +264 -19
  85. package/spec/schema/factory.yaml.json +815 -0
  86. package/spec/schema/flow.schema.json +24 -2
  87. package/spec/skill-export.md +3 -0
@@ -0,0 +1,62 @@
1
+ flow: mindburn.flow/v1
2
+ title: One video, a week of posts
3
+ author: Mindburn Labs
4
+ version: "1.0.0"
5
+ license: MIT
6
+ tags: [social, marketing]
7
+ time: about 3 minutes
8
+ inputs:
9
+ transcript:
10
+ type: longtext
11
+ help: "The transcript of your video, podcast or talk, pasted as it is. Keep the timestamps if it has them: each post then points back to the moment it came from."
12
+ about_you:
13
+ type: text
14
+ optional: true
15
+ help: "Who you are and who watches, for example \"I make short videos about building small apps; most of my viewers are students\". It sets the voice, not the facts."
16
+ steps:
17
+ moments:
18
+ title: Find the moments worth posting
19
+ agent: analyst
20
+ kind: work
21
+ task: |
22
+ Read the whole transcript. First write a fact sheet: every number, name, date, result and claim the speaker states, each with the speaker's exact words and its timestamp, if the transcript has timestamps. Keep every qualifier the speaker attaches to a fact, such as "maybe", "about" or "mostly". Where the speaker corrects themselves, the corrected figure is the fact; note the correction. Where the transcript is garbled, or a figure is said two ways without a correction, mark it "unclear" instead of choosing.
23
+
24
+ Then pick the five moments most worth posting: a surprising number or result, a short story with a turn, a strong opinion, a how-to someone could copy, a mistake and its lesson. For each give a one-line title, the exact quote (under 40 words), its timestamp, and why a stranger would stop scrolling for it. Rank them, strongest first. Do not pick a moment whose point the transcript does not make. If the transcript supports fewer than five honest moments, give fewer.
25
+ posts:
26
+ title: Write the week of posts
27
+ agent: writer
28
+ kind: deliver
29
+ needs: [moments]
30
+ task: |
31
+ Write a week of posts from the moments, in the speaker's voice and, wherever you can, in the speaker's own words. Use about_you only for the voice and the audience, never as a source of facts. Sections, in this order:
32
+
33
+ 1. **The week at a glance**: a table with columns Day (1 to 7), Channel, Moment, First line.
34
+ 2. **X**: one thread of 4 to 6 posts built on the strongest moment, and two single posts on other moments. No post is longer than 280 characters.
35
+ 3. **LinkedIn**: one post under 200 words whose first line works on its own.
36
+ 4. **Short video**: a 30-second script for TikTok, Reels or Shorts: the hook to say in the first two seconds, three beats and a closing line. For each beat, give the timestamp of the original clip to cut in or, if the transcript has no timestamps, the clip's first words.
37
+ 5. **Newsletter**: a note under 120 words that points readers to the full video, with "[ADD: link to the video]" where the link goes.
38
+ 6. **Before you post**: every factual claim the posts make, each with the quote and timestamp it rests on; every person or company the posts name other than the speaker, so you can check they are happy to be named; and anything the speaker should double-check.
39
+
40
+ Every fact comes from the transcript, as the fact sheet records it. Never round a number, shorten a time span, make a result bigger or more certain, drop a qualifier that keeps a claim modest, name a person or quote words the speaker did not say. Where a post would be stronger with a real asset, such as a screenshot or a link, write "[ADD: what belongs here]" instead of inventing it. Add no hype the speaker did not use, and no exclamation marks or emojis unless the transcript has them. Use at most two hashtags a post.
41
+ check_posts:
42
+ title: Check every line against the transcript
43
+ agent: reviewer
44
+ kind: check
45
+ needs: [posts]
46
+ checks: posts
47
+ task: |
48
+ Compare every line of every post with the transcript and the fact sheet. Each number, time span, name, result and quote appears in the transcript with the same meaning. Nothing is rounded, shortened, made bigger or more certain, or moved to a different context, and quotes are word for word. A qualifier the speaker attached stays wherever leaving it out would make the claim sound bigger or more certain. A self-corrected figure uses the corrected value; an unclear one is not stated as fact. Send back any line the transcript does not support.
49
+
50
+ Then check the shape: all six sections are present and in order, no X post is longer than 280 characters, the LinkedIn post and the newsletter note keep to their word limits, each beat of the short-video script points to a timestamp or to the first words of a clip, and every factual claim appears in "Before you post". When you send work back, quote the post's words and the transcript's words side by side so the writer can correct them.
51
+ approve_posts:
52
+ title: Approve the posts
53
+ kind: approve
54
+ needs: [check_posts]
55
+ shows: posts
56
+ task: |
57
+ These go out under your name. Approve them, or say what to change: a moment to drop, a line that does not sound like you, a fact that is wrong. Nothing is posted for you: you copy what you approve.
58
+
59
+ done:
60
+ - "Every number, time span, name and quote in the posts is in the transcript, with the same meaning."
61
+ - "Every post can be copied as it stands, or marks plainly what to add."
62
+ - "Every factual claim is listed under \"Before you post\" with the words it rests on."
@@ -0,0 +1,34 @@
1
+ version: 1
2
+ cases:
3
+ frying_pan_app:
4
+ name: Keeps every figure exactly as the speaker said it
5
+ inputs:
6
+ transcript: |
7
+ [0:00] So this is the story of how a frying pan made me build an app.
8
+ [0:06] Our flat group chat had 212 messages in one month. I counted. Most of them were about one frying pan that nobody would wash.
9
+ [0:18] There are five of us in the flat. Everyone thought they did the most dishes. Everyone was wrong, including me.
10
+ [0:31] I'm not a programmer. I did one coding course at school and forgot most of it. But I'd been using an AI coding assistant for small things, and I thought, okay, how hard can a dishes app be?
11
+ [0:47] It took me two weekends. Maybe three, if you count the weekend I spent on the logo.
12
+ [0:55] And that's my first lesson. I spent a whole Saturday on a logo, and not one person has ever mentioned the logo. Not one.
13
+ [1:06] Here's how it works, and honestly the trick is how little it does. When something needs washing, you tap one button. The app looks at who has done the fewest dishes this week and sends that one person a message. That's it. No points, no leaderboard, no badges.
14
+ [1:27] I tried points first. Points made it worse. One flatmate started washing single teaspoons to collect points.
15
+ [1:38] So here's my hot take: most chore apps fail because they turn chores into a game. Nobody in my flat wanted a game. We wanted the arguing to stop.
16
+ [1:50] Did it work? The group chat went from 212 messages to, I checked last night, 9 messages last month. And three of those were about pizza.
17
+ [2:04] Then a friend posted about it, and people from other flats started asking for it. 38 people signed up in the first week. Mostly friends of friends, to be fair. 11 of them used it again the next day, which for an app about dishes I'll take.
18
+ [2:22] It costs me about four, no, three pounds a month to run. Three.
19
+ [2:28] What I'd do differently: skip the logo, and ask my flatmates what annoyed them before writing a single line. I guessed it was fairness. It was the frying pan. It's always one frying pan.
20
+ [2:42] If you build something like this, start with the one message the app sends. Write that message first. Everything else is decoration.
21
+ [2:51] Anyway. The pan is clean. For now.
22
+ # Invented sample: Maya, her flat and this video do not exist. The
23
+ # disclosure stays out of the inputs so it does not leak into the posts;
24
+ # the README carries it.
25
+ about_you: "I'm Maya. I make short videos about building small apps; most of my viewers are students and first-time builders."
26
+ # Contains: the sections the flow asks for and the subject of the video.
27
+ # Excludes: the easy roundings of what Maya said (two weekends, maybe
28
+ # three; one coding course; 38 sign-ups; three pounds after a correction)
29
+ # and a result she never claims.
30
+ expect:
31
+ contains: ["LinkedIn", "Short video", "Newsletter", "Before you post", "frying pan"]
32
+ excludes: ["went viral", "in one weekend", "in a weekend", "40 people", "nearly 40", "almost 40", "never coded", "£4", "four pounds a month"]
33
+ min_length: 1500
34
+ max_length: 16000
@@ -0,0 +1,11 @@
1
+ # X and LinkedIn posts from the facts
2
+
3
+ Paste the facts sheet of a video, podcast or talk, and the transcript if you have it. You get an X thread of 4 to 6 posts, two single posts and a LinkedIn post, in your voice and wherever possible in your own words, with a list of every fact they use and the words it rests on. A checker holds every line against the facts sheet and sends back anything stronger than what you said: a rounded number, a dropped "maybe", a result made bigger. Nothing is posted for you.
4
+
5
+ The facts sheet can come from "Facts from a video", or you can write your own: one fact a line, each with its timestamp or the first words of the line. "Release desk" puts these posts together with the other pieces for one approval.
6
+
7
+ ## Try it on the example
8
+
9
+ The example is invented: Maya, her flat and her video do not exist. Its facts sheet is the one "Facts from a video" should write from her transcript. On the factory page, "Fill in the example" pastes it for you.
10
+
11
+ The example is in `mindburn.tests.yaml`. With the downloaded folder, `node run.mjs --test` runs it and checks that the posts keep the sections and none of the easy roundings. Passing checks are a repeatable regression signal, not proof that every facts sheet will be handled correctly. Read the posts against your facts before you copy them.
@@ -0,0 +1,10 @@
1
+ ---
2
+ description: Checks work against stated criteria and sends weak work back.
3
+ agentType: REVIEW
4
+ ---
5
+
6
+ You are the reviewer. You hold one piece of work against the criteria you are given and decide whether it passes.
7
+
8
+ - You judge only what is on the page and only against the stated criteria.
9
+ - You fail work for a clear, specific breach you can point to. You never fail it for taste, or for something nobody asked for.
10
+ - When you send work back, each reason is one concrete sentence the author can act on.
@@ -0,0 +1,11 @@
1
+ ---
2
+ description: Writes the finished words the person will use or send.
3
+ agentType: CUSTOM
4
+ ---
5
+
6
+ You are the writer. You write the finished thing the person will use: the brief, the message, the page, the report.
7
+
8
+ - Plain words, short sentences, the reader's own vocabulary. No filler and no hype.
9
+ - You keep to the sections, word limits and order the step asks for.
10
+ - You add no fact that is not in the inputs or earlier results. Where a real asset or decision is missing, you mark the gap clearly instead of inventing it.
11
+ - What you write should be usable as it stands.
@@ -0,0 +1,12 @@
1
+ schemaVersion: v1alpha1
2
+ name: week-of-posts
3
+ description: |
4
+ Writes an X thread, two single posts and a LinkedIn post from the facts
5
+ sheet of one video, podcast or talk, in the speaker's voice, saying nothing
6
+ the facts sheet does not say. Use after "Facts from a video", or with your
7
+ own list of what you said, when you want posts you can put your name to.
8
+ repositories: []
9
+ agentDefaults:
10
+ harness:
11
+ type: claude
12
+ model: sonnet
@@ -0,0 +1,46 @@
1
+ flow: mindburn.flow/v1
2
+ title: X and LinkedIn posts from the facts
3
+ author: Mindburn Labs
4
+ version: "1.0.0"
5
+ license: MIT
6
+ tags: [social, marketing]
7
+ time: about 2 minutes
8
+ inputs:
9
+ facts:
10
+ type: longtext
11
+ help: "The facts sheet from \"Facts from a video\", or your own list of what you said: one fact a line, each with its timestamp or the first words of the line."
12
+ transcript:
13
+ type: longtext
14
+ optional: true
15
+ help: "The transcript the facts came from. With it, the posts can use your exact wording and the checker can quote it when it sends a line back."
16
+ about_you:
17
+ type: text
18
+ optional: true
19
+ help: "Who you are and who watches, for example \"I make short videos about building small apps; most of my viewers are students\". It sets the voice, not the facts."
20
+ steps:
21
+ posts:
22
+ title: Write the X thread and the LinkedIn post
23
+ agent: writer
24
+ kind: deliver
25
+ task: |
26
+ Write posts from the facts sheet, in the speaker's voice and, wherever you can, in the speaker's own words as the sheet quotes them. Use about_you only for the voice and the audience, never as a source of facts. Sections, in this order:
27
+
28
+ 1. **X**: one thread of 4 to 6 numbered posts built on the strongest fact or story on the sheet, then two single posts on other facts. No post is longer than 280 characters.
29
+ 2. **LinkedIn**: one post under 200 words whose first line works on its own.
30
+ 3. **Facts used**: every factual claim the posts make, each with the facts-sheet ID if the sheet has IDs, its timestamp or first words, and the exact words it rests on.
31
+
32
+ Every fact comes from the facts sheet. If the transcript is given, you may take the speaker's exact wording from it, but only for facts the sheet has. Never round a number, shorten or lengthen a time span, make a result bigger or more certain, drop a qualifier that keeps a claim modest, state an opinion as a fact, work out a figure the sheet does not state, such as a percentage, or name a person the sheet does not name. A fact the sheet marks "unclear" is not stated as settled. Where a post would be stronger with a real asset, such as a screenshot or a link, write "[ADD: what belongs here]" instead of inventing it. Add no hype the speaker did not use, and no exclamation marks or emojis unless the sheet quotes them. Use at most two hashtags a post.
33
+ check_posts:
34
+ title: Check every line against the facts sheet
35
+ agent: reviewer
36
+ kind: check
37
+ needs: [posts]
38
+ checks: posts
39
+ task: |
40
+ Compare every line of every post with the facts sheet, and with the transcript if it is given. Each number, time span, name, result and quote is on the facts sheet with the same meaning. Send back any line that says more than the source: a rounded or worked-out figure, a shorter or longer time span, a result made bigger or more certain, a dropped qualifier such as "maybe", "about" or "mostly", an opinion stated as fact, a fact marked "unclear" stated as settled, or a quote that is not word for word. Send back any fact the sheet does not have, even one that sounds harmless.
41
+
42
+ Then check the shape: the three sections are present and in order, the thread has 4 to 6 numbered posts and there are two single posts, no X post is longer than 280 characters, the LinkedIn post is under 200 words, and every factual claim appears under "Facts used". When you send work back, quote the post's words and the source's words side by side so the writer can correct them.
43
+ done:
44
+ - "Every number, time span, name and quote in the posts is on the facts sheet, with the same meaning."
45
+ - "No post says more than the source: nothing rounded, worked out or made more certain, and no qualifier dropped."
46
+ - "Every factual claim is listed under \"Facts used\" with the words it rests on."
@@ -0,0 +1,80 @@
1
+ version: 1
2
+ cases:
3
+ frying_pan_posts:
4
+ name: Writes posts that say no more than the facts sheet
5
+ inputs:
6
+ facts: |
7
+ ## Facts
8
+
9
+ | ID | Where | Fact | Exact words |
10
+ | --- | --- | --- | --- |
11
+ | F1 | [0:00] | The video is the story of how a frying pan made the speaker build an app. | "So this is the story of how a frying pan made me build an app." |
12
+ | F2 | [0:06] | The flat's group chat had 212 messages in one month; the speaker counted them. | "Our flat group chat had 212 messages in one month. I counted." |
13
+ | F3 | [0:06] | Most of those messages were about one frying pan that nobody would wash. | "Most of them were about one frying pan that nobody would wash." |
14
+ | F4 | [0:18] | Five people live in the flat. | "There are five of us in the flat." |
15
+ | F5 | [0:18] | Everyone thought they did the most dishes, and everyone was wrong, the speaker included. | "Everyone thought they did the most dishes. Everyone was wrong, including me." |
16
+ | F6 | [0:31] | The speaker is not a programmer: one coding course at school, most of it forgotten. | "I'm not a programmer. I did one coding course at school and forgot most of it." |
17
+ | F7 | [0:31] | The speaker had been using an AI coding assistant for small things. | "But I'd been using an AI coding assistant for small things" |
18
+ | F8 | [0:47] | The app took two weekends, maybe three counting the weekend spent on the logo. | "It took me two weekends. Maybe three, if you count the weekend I spent on the logo." |
19
+ | F9 | [0:55] | The speaker's first lesson: a whole Saturday went on a logo, and nobody has ever mentioned it. | "I spent a whole Saturday on a logo, and not one person has ever mentioned the logo. Not one." |
20
+ | F10 | [1:06] | When something needs washing, you tap one button, and the app messages the one person who has done the fewest dishes this week. | "When something needs washing, you tap one button. The app looks at who has done the fewest dishes this week and sends that one person a message." |
21
+ | F11 | [1:06] | The app has no points, no leaderboard and no badges. | "No points, no leaderboard, no badges." |
22
+ | F12 | [1:27] | Points came first and made it worse: one flatmate washed single teaspoons to collect points. | "I tried points first. Points made it worse. One flatmate started washing single teaspoons to collect points." |
23
+ | F13 | [1:38] | The speaker's opinion, called a hot take: most chore apps fail because they turn chores into a game. | "So here's my hot take: most chore apps fail because they turn chores into a game." |
24
+ | F14 | [1:38] | Nobody in the flat wanted a game; they wanted the arguing to stop. | "Nobody in my flat wanted a game. We wanted the arguing to stop." |
25
+ | F15 | [1:50] | The group chat went from 212 messages to 9 messages last month, and three of those were about pizza. | "The group chat went from 212 messages to, I checked last night, 9 messages last month. And three of those were about pizza." |
26
+ | F16 | [2:04] | After a friend posted about it, people from other flats started asking for it. | "Then a friend posted about it, and people from other flats started asking for it." |
27
+ | F17 | [2:04] | 38 people signed up in the first week, mostly friends of friends. | "38 people signed up in the first week. Mostly friends of friends, to be fair." |
28
+ | F18 | [2:04] | 11 of them used it again the next day. | "11 of them used it again the next day, which for an app about dishes I'll take." |
29
+ | F19 | [2:22] | Running it costs three pounds a month, after a correction. | "It costs me about four, no, three pounds a month to run. Three." |
30
+ | F20 | [2:28] | What the speaker would do differently: skip the logo, and ask the flatmates what annoyed them before writing a single line. They guessed fairness; it was the frying pan. | "What I'd do differently: skip the logo, and ask my flatmates what annoyed them before writing a single line. I guessed it was fairness. It was the frying pan." |
31
+ | F21 | [2:42] | The speaker's advice: start with the one message the app sends, and write that message first. | "If you build something like this, start with the one message the app sends. Write that message first. Everything else is decoration." |
32
+ | F22 | [2:51] | The pan is clean, for now. | "The pan is clean. For now." |
33
+
34
+ ## Quotes worth using
35
+
36
+ - [0:06] "Our flat group chat had 212 messages in one month. I counted." (F2)
37
+ - [0:55] "not one person has ever mentioned the logo. Not one." (F9)
38
+ - [1:06] "No points, no leaderboard, no badges." (F11)
39
+ - [1:27] "One flatmate started washing single teaspoons to collect points." (F12)
40
+ - [1:38] "We wanted the arguing to stop." (F14)
41
+ - [2:28] "I guessed it was fairness. It was the frying pan." (F20)
42
+ - [2:42] "Write that message first. Everything else is decoration." (F21)
43
+ - [2:51] "The pan is clean. For now." (F22)
44
+
45
+ ## Corrections and unclear
46
+
47
+ - [2:22] The running cost: the speaker says "about four, no, three pounds a month", then "Three." The corrected figure, three pounds a month, is the fact (F19).
48
+ - [0:47] and [0:55] The time on the logo is said two ways: "the weekend I spent on the logo" and "a whole Saturday on a logo". Unclear: quote each where it is said, and do not state how long the logo took.
49
+
50
+ ## Names
51
+
52
+ No one other than the speaker is named.
53
+ transcript: |
54
+ [0:00] So this is the story of how a frying pan made me build an app.
55
+ [0:06] Our flat group chat had 212 messages in one month. I counted. Most of them were about one frying pan that nobody would wash.
56
+ [0:18] There are five of us in the flat. Everyone thought they did the most dishes. Everyone was wrong, including me.
57
+ [0:31] I'm not a programmer. I did one coding course at school and forgot most of it. But I'd been using an AI coding assistant for small things, and I thought, okay, how hard can a dishes app be?
58
+ [0:47] It took me two weekends. Maybe three, if you count the weekend I spent on the logo.
59
+ [0:55] And that's my first lesson. I spent a whole Saturday on a logo, and not one person has ever mentioned the logo. Not one.
60
+ [1:06] Here's how it works, and honestly the trick is how little it does. When something needs washing, you tap one button. The app looks at who has done the fewest dishes this week and sends that one person a message. That's it. No points, no leaderboard, no badges.
61
+ [1:27] I tried points first. Points made it worse. One flatmate started washing single teaspoons to collect points.
62
+ [1:38] So here's my hot take: most chore apps fail because they turn chores into a game. Nobody in my flat wanted a game. We wanted the arguing to stop.
63
+ [1:50] Did it work? The group chat went from 212 messages to, I checked last night, 9 messages last month. And three of those were about pizza.
64
+ [2:04] Then a friend posted about it, and people from other flats started asking for it. 38 people signed up in the first week. Mostly friends of friends, to be fair. 11 of them used it again the next day, which for an app about dishes I'll take.
65
+ [2:22] It costs me about four, no, three pounds a month to run. Three.
66
+ [2:28] What I'd do differently: skip the logo, and ask my flatmates what annoyed them before writing a single line. I guessed it was fairness. It was the frying pan. It's always one frying pan.
67
+ [2:42] If you build something like this, start with the one message the app sends. Write that message first. Everything else is decoration.
68
+ [2:51] Anyway. The pan is clean. For now.
69
+ # Invented sample: Maya, her flat and this video do not exist. The
70
+ # disclosure stays out of the inputs so it does not leak into the result;
71
+ # the README carries it.
72
+ about_you: "I'm Maya. I make short videos about building small apps; most of my viewers are students and first-time builders."
73
+ # The facts sheet is the reference result of "Facts from a video" on the
74
+ # same transcript. Excludes: the roundings it invites, the running cost before
75
+ # the correction, a result she never claims and percentages she never said.
76
+ expect:
77
+ contains: ["LinkedIn", "Facts used", "frying pan"]
78
+ excludes: ["went viral", "in one weekend", "in a weekend", "40 people", "nearly 40", "almost 40", "never coded", "£4", "four pounds a month", "96%", "29%"]
79
+ min_length: 900
80
+ max_length: 9000
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "factoryhub",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "AI factories, shared: the open factory format, its engine, a CLI and a local MCP server, with the house factories.",
5
5
  "keywords": [
6
6
  "factoryhub",
@@ -27,6 +27,16 @@
27
27
  "url": "git+https://github.com/Mindburn-Labs/factoryhub.git"
28
28
  },
29
29
  "mcpName": "io.github.Mindburn-Labs/factoryhub",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./packages/sdk/src/index.d.ts",
33
+ "default": "./packages/sdk/src/index.mjs"
34
+ },
35
+ "./engine": "./packages/engine/src/index.mjs",
36
+ "./package.json": "./package.json",
37
+ "./*": "./*"
38
+ },
39
+ "types": "./packages/sdk/src/index.d.ts",
30
40
  "engines": {
31
41
  "node": ">=22"
32
42
  },
@@ -49,6 +59,8 @@
49
59
  "packages/mcp/package.json",
50
60
  "packages/mcp/bin",
51
61
  "packages/mcp/src",
62
+ "packages/sdk/package.json",
63
+ "packages/sdk/src",
52
64
  "factories",
53
65
  "NOTICE",
54
66
  "spec/*.md",
@@ -59,7 +71,8 @@
59
71
  "lint": "eslint . && node scripts/check-links.mjs",
60
72
  "validate": "node packages/cli/bin/factoryhub.mjs validate factories/*/",
61
73
  "generate": "node scripts/generate.mjs",
62
- "check": "npm run lint && npm test && npm run validate && node scripts/generate.mjs --check"
74
+ "check": "npm run lint && npm test && npm run validate && node scripts/generate.mjs --check && npm run check:rust",
75
+ "check:rust": "cargo fmt --all --check && cargo clippy --all-targets --locked -- -D warnings && cargo test --locked && cargo build --locked --target wasm32-unknown-unknown -p factoryhub-core"
63
76
  },
64
77
  "dependencies": {
65
78
  "@modelcontextprotocol/server": "2.4.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@factoryhub/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "The factoryhub command line: validate, inspect, scaffold, pack, run and export AI factories, and find them on factoryhub.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -7,7 +7,6 @@ import { DEFAULT_HUB } from '../../../engine/lib/hub.mjs';
7
7
 
8
8
  export default {
9
9
  name: 'export',
10
- summary: 'Export a factory as an Agent Skill folder (SKILL.md plus the factory).',
11
10
  usage: `Usage: factoryhub export <factory> --as skill [--out dir] [--id id] [--force]
12
11
 
13
12
  Writes <dir>/<id>/SKILL.md and <dir>/<id>/factory/, following
@@ -6,7 +6,6 @@ import { HUB_NOTE } from './search.mjs';
6
6
 
7
7
  export default {
8
8
  name: 'get',
9
- summary: 'Download a published factory from factoryhub into a folder, checking every file.',
10
9
  usage: `Usage: factoryhub get <id> [--out dir] [--base-url url] [--force]
11
10
 
12
11
  Downloads the factory's transfer bundle, checks every file against its
@@ -2,7 +2,6 @@ import { engine, readFactory, summarize, out, json, table, plural, styles, usage
2
2
 
3
3
  export default {
4
4
  name: 'inspect',
5
- summary: 'Show what a factory does: inputs, steps in order, agents, tools, checks.',
6
5
  usage: `Usage: factoryhub inspect <factory> [--json]
7
6
 
8
7
  Prints a factory's inputs, its steps stage by stage (steps in one stage can
@@ -3,7 +3,6 @@ import { readFolder, listFactoryDirs, summarize, matches, out, json, table, HOUS
3
3
 
4
4
  export default {
5
5
  name: 'list',
6
- summary: 'List the house factories, or the factory folders in a directory.',
7
6
  usage: `Usage: factoryhub list [directory] [--query words] [--json]
8
7
 
9
8
  Lists the factory folders directly inside a directory. With no directory,
@@ -15,11 +14,7 @@ Options:
15
14
  options: { boolean: ['json'], string: ['query'] },
16
15
  async run({ positional, flags }, io) {
17
16
  const dir = positional[0] ? resolve(io.cwd, positional[0]) : HOUSE_FACTORIES;
18
- const entries = [];
19
- for (const id of await listFactoryDirs(dir)) {
20
- const { files } = await readFolder(resolve(dir, id));
21
- entries.push(await summarize(files, { id }));
22
- }
17
+ const entries = await Promise.all((await listFactoryDirs(dir)).map(async (id) => summarize((await readFolder(resolve(dir, id))).files, { id })));
23
18
  const kept = entries.filter((entry) => matches(entry, flags.query));
24
19
  if (flags.json) { json(io, kept); return 0; }
25
20
  if (!kept.length) { out(io, entries.length ? 'No factory matches.' : `No factory folders in ${dir}.`); return 0; }
@@ -0,0 +1,161 @@
1
+ import { hostname } from 'node:os';
2
+ import { createInterface } from 'node:readline';
3
+ import { Writable } from 'node:stream';
4
+ import { out, err, json, styles, usage, connect, day, BASE_URL_HELP } from '../util.mjs';
5
+ import { hubClient, deviceLogin, SCOPES, OPT_IN_SCOPES, TOKEN_PATTERN } from '../../../engine/lib/hub.mjs';
6
+ import { saveCredential, storedCredential, credentialsPath, TOKEN_ENV, PRIVACY } from '../../../engine/lib/credentials.mjs';
7
+
8
+ const MAX_DAYS = 366;
9
+
10
+ /** Scopes from --scopes "a,b" or "a b": each one known, none repeated. */
11
+ export function parseScopes(value) {
12
+ if (value === undefined) return null;
13
+ const scopes = [...new Set(String(value).split(/[\s,]+/).filter(Boolean))];
14
+ if (!scopes.length) throw usage(`--scopes needs at least one of: ${SCOPES.join(', ')}.`);
15
+ const unknown = scopes.filter((scope) => !SCOPES.includes(scope));
16
+ if (unknown.length) throw usage(`Unknown scope ${unknown.join(', ')}. Choose from: ${SCOPES.join(', ')}.`);
17
+ return SCOPES.filter((scope) => scopes.includes(scope));
18
+ }
19
+
20
+ function parseDays(value) {
21
+ if (value === undefined) return null;
22
+ const days = Number(value);
23
+ if (!Number.isInteger(days) || days < 1 || days > MAX_DAYS) throw usage(`--days needs a whole number from 1 to ${MAX_DAYS}, not "${value}".`);
24
+ return days;
25
+ }
26
+
27
+ /** Reads a token without showing it: hidden typing at a terminal, else all of stdin. */
28
+ export async function readSecret(io, prompt) {
29
+ if (io.stdin.isTTY) {
30
+ io.stderr.write(prompt);
31
+ const muted = new Writable({ write(_chunk, _encoding, done) { done(); } });
32
+ const reader = createInterface({ input: io.stdin, output: muted, terminal: true });
33
+ try {
34
+ const line = await new Promise((resolve) => { reader.once('line', resolve); reader.once('close', () => resolve('')); });
35
+ io.stderr.write('\n');
36
+ return line.trim();
37
+ } finally { reader.close(); }
38
+ }
39
+ let text = '';
40
+ for await (const chunk of io.stdin) text += chunk;
41
+ return text.trim();
42
+ }
43
+
44
+ const shortName = () => `factoryhub CLI on ${hostname() || 'this computer'}`.slice(0, 60);
45
+ const prefixOf = (token) => (token?.prefix ? `${token.prefix}…` : 'fhpat_…');
46
+
47
+ /** What the person sees once a token is stored. */
48
+ function report(io, { base, me, path, flags, replaced, origin }) {
49
+ const s = styles(io);
50
+ if (flags.json) {
51
+ json(io, { base, account: { handle: me.account.handle, name: me.account.name }, token: { id: me.token.id, name: me.token.name, prefix: me.token.prefix, scopes: me.token.scopes, expires: me.token.expires, origin }, saved: path, replaced });
52
+ return;
53
+ }
54
+ out(io, `${s.green('Logged in')} to ${base} as ${s.bold(`@${me.account.handle}`)}.`);
55
+ out(io, ` Token ${me.token.name} (${prefixOf(me.token)}), expires ${day(me.token.expires)}`);
56
+ out(io, ` Scopes ${me.token.scopes.join(', ')}`);
57
+ out(io, ` Saved in ${path}, ${PRIVACY}.`);
58
+ if (replaced === 'revoked') out(io, s.dim(' The token this computer received by its previous login was revoked.'));
59
+ else if (replaced === 'kept') out(io, s.dim(' The token saved before was one you passed in, so it was not revoked: revoke it in your account settings if nothing else uses it.'));
60
+ else if (replaced === 'not_revoked') out(io, s.yellow(' The token this computer received by its previous login could not be revoked: revoke it in your account settings.'));
61
+ out(io, `\nNext: ${s.bold('factoryhub push <folder>')} saves a factory to your account, privately.`);
62
+ if (!me.token.scopes.includes('factories:publish')) out(io, s.dim('To publish factories from here, log in with --scopes including factories:publish.'));
63
+ if (io.env[TOKEN_ENV]) err(io, s.yellow(`\nFACTORYHUB_TOKEN is set, so it is used instead of this login until you unset it.`));
64
+ }
65
+
66
+ // After a new token is stored, the one it replaced is revoked only if this
67
+ // CLI received it by device login: it was never printed, so nothing else
68
+ // should be using it. A token the person passed in with --token may also live
69
+ // in CI or another machine, so it is left alone. A record without an origin
70
+ // predates this rule and is treated as passed in.
71
+ // Returns 'revoked', 'not_revoked', 'kept' or null when nothing was replaced.
72
+ async function revokeReplaced(base, previous, io) {
73
+ if (!previous?.token) return null;
74
+ if (previous.origin !== 'device') return 'kept';
75
+ try {
76
+ await hubClient({ baseUrl: base, env: io.env, fetchImpl: io.fetch, token: previous.token }).revoke();
77
+ return 'revoked';
78
+ } catch {
79
+ return 'not_revoked';
80
+ }
81
+ }
82
+
83
+ export default {
84
+ name: 'login',
85
+ usage: `Usage: factoryhub login [--scopes a,b] [--name text] [--days n] [--no-browser]
86
+ factoryhub login --token < token.txt
87
+
88
+ Shows a short code and opens the factoryhub device page. Sign in there,
89
+ check the code matches, choose what this computer may do, and approve.
90
+ The token it receives is saved in ${'${XDG_CONFIG_HOME:-~/.config}'}/factoryhub/credentials.json,
91
+ readable only by you on macOS and Linux (mode 0600; on Windows, as private
92
+ as your user folder). It is never printed. A later login revokes a token an
93
+ earlier device login saved, but never one you passed in with --token.
94
+
95
+ Scopes (default: every scope that is not opt-in):
96
+ factories:read open your private factories
97
+ factories:write save factories to your account (push)
98
+ runs:start start hosted runs
99
+ runs:read follow your runs and read their results
100
+ factories:publish publish factories (opt-in: tick it on the page)
101
+ runs:decide decide a waiting step for you (opt-in: tick it on the page)
102
+
103
+ For CI or a machine without a browser, make a token in your account
104
+ settings and pass it on standard input with --token, or set FACTORYHUB_TOKEN
105
+ (which always wins over a saved login).
106
+
107
+ Options:
108
+ --scopes the scopes to ask for, comma-separated
109
+ --name what the token is called on your account page
110
+ (default: "factoryhub CLI on <this computer>")
111
+ --days how long the token lives, 1 to ${MAX_DAYS} days (default: the hub's, 90)
112
+ --no-browser print the address instead of opening it
113
+ --token read a token from standard input instead
114
+ --base-url ${BASE_URL_HELP}
115
+ --json print the result as JSON`,
116
+ options: { boolean: ['token', 'browser', 'json'], string: ['scopes', 'name', 'days', 'base-url'] },
117
+ async run({ positional, flags }, io) {
118
+ if (positional.length) throw usage(`login takes no words, only options. To pass a token, use: factoryhub login --token < token.txt`);
119
+ const { base, hub } = await connect(flags, io);
120
+ const s = styles(io);
121
+ const scopes = parseScopes(flags.scopes);
122
+ const days = parseDays(flags.days);
123
+ const name = flags.name === undefined ? shortName() : String(flags.name).trim().slice(0, 60);
124
+ if (!name) throw usage('--name cannot be empty.');
125
+ const previous = await storedCredential(base, io.env);
126
+
127
+ let token;
128
+ let origin;
129
+ if (flags.token) {
130
+ if (flags.scopes || flags.days || flags.name) throw usage('--token uses the token as it was made; --scopes, --days and --name apply to a new login only.');
131
+ token = await readSecret(io, 'Paste your factoryhub token (it is not shown) and press Enter: ');
132
+ if (!token) throw usage('No token on standard input. Pass it like this: factoryhub login --token < token.txt');
133
+ if (!TOKEN_PATTERN.test(token)) throw usage('That is not a factoryhub personal access token: they start with fhpat_ and are 49 characters long.');
134
+ origin = 'pasted';
135
+ } else {
136
+ const asked = scopes || [];
137
+ const optIn = asked.filter((scope) => OPT_IN_SCOPES.includes(scope));
138
+ const answer = await deviceLogin(hub, {
139
+ name, scopes, days,
140
+ sleep: io.sleep,
141
+ onCode: async ({ userCode, verificationUri, verificationUriComplete, expiresIn }) => {
142
+ err(io, `To log in to ${base}, open this page in a browser where you are signed in:\n\n ${s.bold(verificationUri)}\n\nand enter the code ${s.bold(userCode)}\n`);
143
+ err(io, s.dim(`Check that the page shows the same code before you approve.${optIn.length ? ` Tick ${optIn.join(' and ')} there: opt-in scopes are granted only when you tick them.` : ''}`));
144
+ const page = verificationUriComplete || verificationUri;
145
+ if (flags.browser !== false && io.openUrl && new URL(page, base).origin === new URL(base).origin) {
146
+ if (await io.openUrl(new URL(page, base).href)) err(io, s.dim('Opened the page in your browser.'));
147
+ }
148
+ err(io, `Waiting for you to approve (the code works for ${Math.round(expiresIn / 60)} minutes; Ctrl+C to stop)...`);
149
+ },
150
+ });
151
+ token = answer.access_token;
152
+ origin = 'device';
153
+ }
154
+
155
+ const me = await hubClient({ baseUrl: base, env: io.env, fetchImpl: io.fetch, token }).me();
156
+ const path = await saveCredential(base, { token, origin, id: me.token.id, name: me.token.name, prefix: me.token.prefix, scopes: me.token.scopes, expires: me.token.expires, account: me.account.handle }, io.env);
157
+ const replaced = previous && previous.token !== token ? await revokeReplaced(base, previous, io) : null;
158
+ report(io, { base, me, path: credentialsPath(io.env) || path, flags, replaced, origin });
159
+ return 0;
160
+ },
161
+ };
@@ -0,0 +1,43 @@
1
+ import { out, styles, usage, hubBase, BASE_URL_HELP } from '../util.mjs';
2
+ import { hubClient } from '../../../engine/lib/hub.mjs';
3
+ import { storedCredential, deleteCredential, credentialsPath, TOKEN_ENV } from '../../../engine/lib/credentials.mjs';
4
+
5
+ export default {
6
+ name: 'logout',
7
+ usage: `Usage: factoryhub logout [--base-url url]
8
+
9
+ Revokes the token "factoryhub login" saved, on the factoryhub server first,
10
+ so it stops working everywhere, and then deletes it from this computer. If
11
+ the server cannot be reached, nothing is deleted: run it again later, or
12
+ revoke the token in your account settings.
13
+
14
+ A token in FACTORYHUB_TOKEN is not touched: unset it yourself, and revoke it
15
+ in your account settings if nobody needs it.
16
+
17
+ Options:
18
+ --base-url ${BASE_URL_HELP}`,
19
+ options: { string: ['base-url'] },
20
+ async run({ positional, flags }, io) {
21
+ if (positional.length) throw usage('logout takes no words, only options.');
22
+ const s = styles(io);
23
+ const base = hubBase(flags, io);
24
+ const stored = await storedCredential(base, io.env);
25
+ const envNote = io.env[TOKEN_ENV] ? s.yellow(`\nFACTORYHUB_TOKEN is still set, so commands still use that token. Unset it, and revoke it in your account settings if nobody needs it.`) : '';
26
+ if (!stored) {
27
+ out(io, `Not logged in to ${base} on this computer: nothing to do.${envNote}`);
28
+ return 0;
29
+ }
30
+ let note;
31
+ try {
32
+ await hubClient({ baseUrl: base, env: io.env, fetchImpl: io.fetch, token: stored.token, tokenSource: 'file' }).revoke();
33
+ note = 'revoked on the server';
34
+ } catch (error) {
35
+ // A token the server no longer accepts is already as good as revoked.
36
+ if (error.status === 401) note = 'the server no longer accepted it';
37
+ else throw new Error(`${error.message}\nThe token was not revoked, so it is still saved in ${credentialsPath(io.env)}. Try again, or revoke "${stored.name || 'it'}" in your account settings.`, { cause: error });
38
+ }
39
+ await deleteCredential(base, io.env);
40
+ out(io, `${s.green('Logged out')} of ${base}: ${stored.name ? `"${stored.name}" ` : ''}${note}, and deleted from this computer.${envNote}`);
41
+ return 0;
42
+ },
43
+ };
@@ -6,7 +6,6 @@
6
6
 
7
7
  export default {
8
8
  name: 'mcp',
9
- summary: 'Start the local MCP server on stdio, for MCP clients.',
10
9
  usage: `Usage: factoryhub mcp
11
10
 
12
11
  Starts the factoryhub MCP server on stdin and stdout, the same server as the
@@ -115,7 +115,6 @@ and run it with your own model key with \`factoryhub run .\`.
115
115
 
116
116
  export default {
117
117
  name: 'new',
118
- summary: 'Create a new factory folder that validates and runs.',
119
118
  usage: `Usage: factoryhub new <name> [--dir parent] [--title text] [--description text]
120
119
 
121
120
  Creates <parent>/<name>/ with factory.yaml, flow.yaml, two agents and a README.
@@ -40,7 +40,6 @@ The runner stops at every approval step and asks you. With nobody at the keyboar
40
40
 
41
41
  export default {
42
42
  name: 'pack',
43
- summary: 'Pack a factory into a zip that runs anywhere with Node, or a bundle .json.',
44
43
  usage: `Usage: factoryhub pack <factory> [--out file] [--format zip|json] [--no-runner]
45
44
 
46
45
  zip (the default) writes <name>/ with the factory's files, the generated