paper-motion 0.3.0 → 0.4.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 (59) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/bin/pamo.mjs +221 -20
  3. package/examples/breath/stills/contact-sheet.jpg +0 -0
  4. package/examples/breath/stills/t000.00.png +0 -0
  5. package/examples/breath/stills/t002.00.png +0 -0
  6. package/examples/breath/stills/t004.00.png +0 -0
  7. package/examples/breath/stills/t006.00.png +0 -0
  8. package/examples/breath/stills/t008.00.png +0 -0
  9. package/examples/breath/stills/t010.00.png +0 -0
  10. package/examples/chrome-endcard/stills/contact-sheet.jpg +0 -0
  11. package/examples/chrome-endcard/stills/t000.00.png +0 -0
  12. package/examples/chrome-endcard/stills/t001.33.png +0 -0
  13. package/examples/chrome-endcard/stills/t002.67.png +0 -0
  14. package/examples/chrome-endcard/stills/t004.00.png +0 -0
  15. package/examples/chrome-endcard/stills/t005.33.png +0 -0
  16. package/examples/chrome-endcard/stills/t006.67.png +0 -0
  17. package/examples/halo-explainer/stills/contact-sheet.jpg +0 -0
  18. package/examples/halo-explainer/stills/t000.00.png +0 -0
  19. package/examples/halo-explainer/stills/t006.00.png +0 -0
  20. package/examples/halo-explainer/stills/t012.00.png +0 -0
  21. package/examples/halo-explainer/stills/t018.00.png +0 -0
  22. package/examples/halo-explainer/stills/t024.00.png +0 -0
  23. package/examples/halo-explainer/stills/t030.00.png +0 -0
  24. package/examples/install-loop/stills/contact-sheet.jpg +0 -0
  25. package/examples/install-loop/stills/t000.00.png +0 -0
  26. package/examples/install-loop/stills/t001.00.png +0 -0
  27. package/examples/install-loop/stills/t002.00.png +0 -0
  28. package/examples/install-loop/stills/t003.00.png +0 -0
  29. package/examples/install-loop/stills/t004.00.png +0 -0
  30. package/examples/install-loop/stills/t005.00.png +0 -0
  31. package/examples/orbit-keynote/stills/contact-sheet.jpg +0 -0
  32. package/examples/orbit-keynote/stills/t000.00.png +0 -0
  33. package/examples/orbit-keynote/stills/t006.50.png +0 -0
  34. package/examples/orbit-keynote/stills/t013.00.png +0 -0
  35. package/examples/orbit-keynote/stills/t019.50.png +0 -0
  36. package/examples/orbit-keynote/stills/t026.00.png +0 -0
  37. package/examples/orbit-keynote/stills/t032.50.png +0 -0
  38. package/examples/signal/stills/contact-sheet.jpg +0 -0
  39. package/examples/signal/stills/t000.00.png +0 -0
  40. package/examples/signal/stills/t001.67.png +0 -0
  41. package/examples/signal/stills/t003.33.png +0 -0
  42. package/examples/signal/stills/t005.00.png +0 -0
  43. package/examples/signal/stills/t006.67.png +0 -0
  44. package/examples/signal/stills/t008.33.png +0 -0
  45. package/lib/chain.mjs +75 -7
  46. package/lib/customize.mjs +40 -2
  47. package/lib/edit.mjs +326 -0
  48. package/lib/gather.mjs +241 -0
  49. package/lib/host.mjs +25 -0
  50. package/lib/inventory.mjs +523 -0
  51. package/lib/piece.mjs +93 -17
  52. package/lib/registry.mjs +22 -6
  53. package/lib/studio.mjs +347 -33
  54. package/package.json +1 -1
  55. package/runtime/player.js +6 -3
  56. package/runtime/swatch.html +21 -0
  57. package/studio/fonts/GeistPixel-Square.woff2 +0 -0
  58. package/studio/fonts/GeistSerif-Regular.woff2 +0 -0
  59. package/studio/index.html +439 -327
package/CHANGELOG.md CHANGED
@@ -2,6 +2,114 @@
2
2
 
3
3
  All notable changes to the `paper-motion` npm package. Dates are release dates on npm.
4
4
 
5
+ ## 0.4.0 · 2026-11-12
6
+
7
+ A piece is built from what the user has to show, not from a mood.
8
+
9
+ **The studio in three steps, and the user edits the video themselves.**
10
+
11
+ - **What it says** (the brief and the inventory), **Your video** and **Export** replace the seven stages
12
+ (`STAGES` is now brief, inventory, video, final; a piece started before is moved on by itself: look, structure,
13
+ parts and assembly all become video).
14
+ - **Make my video** builds the first version from what the user kept, in the studio, with no agent turn.
15
+ - **Your video** plays the selected part live (its real scene, with their content, in their brand) and edits it in
16
+ place: Words (sentences, numbers, lists, the picture), Motion (how a sentence arrives, by watching each), Background
17
+ (the brand's, live on hover, or a new one from the library; on one part or every part), Tune (the block's own
18
+ settings and the length). Between parts, the transition, chosen by watching each (or the same everywhere). Parts
19
+ are reordered by dragging, added by intent (say something, show the product, prove it, how it works, end) with
20
+ something kept already in them when they can show it, shown another way (the same words, another block),
21
+ duplicated and removed.
22
+ - **Style** changes the whole video's colours (a palette, or each colour), type (a font pair) and pace, in `brand/`.
23
+ - **Undo** (and ⌘Z) for every change; **Ask your agent** for what the controls can't do.
24
+ - **Export** renders the final from the studio, full quality, and lists the files.
25
+ - `lib/edit.mjs`: the edits, readable by any agent too.
26
+ - **A project with no look yet gets one before its first video**, named after the piece (`graphite`, `geist-serif`
27
+ and `mesh-graphite`, changed in Style), when the studio builds it or `chain init --plan` fills it. It used to
28
+ render grey on grey and call itself "brand".
29
+ - `dither-dissolve` dissolves into the brand's name by default, not "paper-motion"; `focus-pull` shrinks a long
30
+ detail ("12,000") to fit its card instead of cutting it.
31
+ - `piece --help` and the steps after `chain init` name the new stages.
32
+
33
+ - **`inventory <piece> --from <url|file.md|folder|brand>`** gathers the inventory from what the user already has:
34
+ a page (its headline, description, figures and commands, plus screenshots on a desktop and a phone saved to
35
+ `brand/assets/`), a markdown file, a folder of files, or the brand (IDENTITY.md's Facts and its assets). What
36
+ it finds is written as proposals with their source; running it again never proposes twice what is there or
37
+ what the user removed. Figures that count up on load are read once they stop. Only sentences a screen can
38
+ hold and commands that run the product are proposed; `--plain` leaves commands and code out, for a brand whose
39
+ audience does not code.
40
+ - **`inventory <piece> --output <id>`** takes what a command really printed from stdin (`<the command> 2>&1 |
41
+ …`), tidied to a few lines; `--set id.field=value`, `--keep`, `--remove` and `--must` are the agent's edits.
42
+ `piece review <piece> inventory` refuses while an item is incomplete, and says how to finish each one.
43
+ - **Content** (the piece's inventory, the second half of What it says), written for people who do not code: items are sorted into what we say, what we show, what proves it and how it works, each named in
44
+ everyday words with its source and a sample of how it could look; no kind, field or block names. There the
45
+ user keeps or removes each proposal, marks what must appear, corrects a value or a list by hand and adds a
46
+ line or a figure of their own; it cannot be approved while a proposal waits or a kept item is incomplete.
47
+ Every edit reaches the agent. A piece started before gets it as already approved.
48
+ - **How each thing is shown is the user's to choose.** In Content, the samples of the blocks that can show an item
49
+ play when pointed at, and a sheet per item plays the chosen block live with the user's own content in their
50
+ brand (the scene itself, served by the studio, not a render). There they pick another block, tune its settings
51
+ (a direction, a ratio, how close the camera stops) and choose the background behind it, from the brand's set or
52
+ a new one from the library, each playing live on hover. The pick and the tuning are saved on the item (`show`,
53
+ `tune`); the plan uses that block for it and `chain init --plan` writes the settings into the part.
54
+ - **A new block, `line-in` (A sentence):** one sentence in the brand's display type, arriving in one of the
55
+ library's eight text entrances (rise, focus, scramble, type, track, flip, pop, bar), the marked words in the
56
+ accent. Its `repeat` field lets a piece use it several times, each with a different entrance: the planner gives
57
+ every sentence its own unless the user chose one, and spreads extra sentences through the piece instead of
58
+ stacking them. So three kept sentences are three different moments, not one.
59
+ - `plan` also fills the length asked with what the user kept and no slot took, while it fits.
60
+ - In Content, the ways a kind of thing can be shown appear once per group, not under every row; each row only
61
+ says how it is shown, and the page says how many moments the brief's length holds. A setting whose choices
62
+ have samples (a sentence's entrance) is chosen in the sheet by watching each one.
63
+ - Scenes opened with `?embed` play and loop without the player's controls, for showing one inside another page.
64
+ - Items carry a `status` (`proposed`, `removed`, or none for kept). `plan` uses only what was kept and says how
65
+ many proposals are still waiting.
66
+ - **`needs` on every block and shot**: the content it shows, by kind (a figure, a change, a screen, a command, a
67
+ line…), and how that content maps onto its CONFIG. Published in `registry.json` and each resource's file.
68
+ - **`inventory <piece>`** checks `pieces/<piece>/inventory.json` (the list of what the piece can really show, each
69
+ item with its kind and its source) and says which blocks show each item and what is missing: nothing shows the
70
+ product, nothing proves it, a kind no block shows yet. `inventory kinds` lists the kinds and their fields.
71
+ - **`plan <piece> [--seconds n]`** proposes up to three structures from the inventory, the same content in three
72
+ orders (show first, problem first, proof first). No item is shown twice, no block plays twice, every `must` is
73
+ in, no beat runs shorter than its land and none longer than its scene. It writes `plan.json` and prints the
74
+ structure board to share.
75
+ - **`chain init <piece> --plan <letter>`** installs the chosen structure with the inventory's content already in
76
+ each part's CONFIG, and records in `chain.json` which items each part shows.
77
+
78
+ The studio always says whose turn it is, and never plays a video that is out of date.
79
+
80
+ - **`piece ask <piece> "question"`**: run before asking the user something in the chat. The studio shows the
81
+ question in a bar across the top with a button back to the agent's app (found from the CLI's own process tree
82
+ on macOS; `--return <url>` or `PAPER_MOTION_RETURN` for an agent in a browser). Cleared by the next `piece
83
+ review` or `piece working`, or `--done`.
84
+ - **A turn bar in the studio**, also in the tab's title: your turn (and whether the agent is waiting for it), the
85
+ agent working and for how long, a board waiting, a join in progress, the agent not having read the last
86
+ changes, the studio itself stopped. Commands that wait (`piece review`, `board`) say so while they wait.
87
+ - **Stale videos are held.** Each joined render records what it was made from. When the piece changes (a text, a
88
+ length, the order, a join, a scene the agent rewrote), Your video or the final stops, dims and says
89
+ "Updating" with the part being rendered; Approve waits for the new one.
90
+ - **The studio joins the draft again by itself** after an edit by hand, with the sound the draft was made with.
91
+ The agent is only called when the join fails. An edit after the final render sends Final back to the agent.
92
+ - Fixed: editing a text while in Assembly left the old film in place and told no one; clearing a part's length
93
+ set it to 0.5 s; a part edited by hand in a piece past Parts had to be approved again.
94
+
95
+ From a test where an agent with no context made a video from `llms.txt` alone:
96
+
97
+ - `board --url`: option and look values may hold several keys and lists (`graphite:accent=#B8F03C,ramp=["#0A0A0B","#B8F03C"]`, `--look "acid-night:figure=#FFF,muted=#7E8079,geist"`); `--look brand` uses the palette, colors and font pair the project installed.
98
+ - `board --await <id> [--wait minutes]` waits (again) for the choice on a board already shared, for shells that cap how long a command runs.
99
+ - The studio lists the boards shared while a piece is open, in Your video, with a button to open each and the choice once made (`board --piece <name>` picks the piece when there are several).
100
+ - A chosen transition can be the join between two parts of a chain: `"join": "wipe"` (also crossfade, push, zoom-through, iris, slices, flash, scan-reveal, mask-reveal, glitch, cells, flip-3d).
101
+ - A tuned palette retunes its ramp (the steps between ground and accent follow the new accent); a background recolored into a palette takes the brand's ground behind everything.
102
+ - `chain --check` warns when the total drifts from the brief's length.
103
+
104
+ From a second test, an agent following the site's "For agents" page alone:
105
+
106
+ - `piece review` refuses while an earlier stage is not approved, and names the one to review first. It used to mark the earlier stages approved on its own. `--skip` moves on without them, for when the user agreed.
107
+ - `chain init --board <link>` looks the board up on the server of the link it is given (a bare id still uses `--site`, or the default site); the error says what to pass.
108
+ - After `chain init`, the next steps printed are the real ones in order: check each part's CONFIG, `--check`, `--drafts`, `--draft`, the full render, and `piece review <piece> video` when a piece is open.
109
+ - `chain.json` takes the open piece's title, not the board option's.
110
+ - `piece --help`, `chain --help` and `board --help` list every flag of their commands.
111
+ - `pamo --version`. Help says `--wait` is in minutes; the docs say `pamo` means `npx paper-motion`.
112
+
5
113
  ## 0.3.0 · 2026-11-11
6
114
 
7
115
  - **Studio**: `pamo studio` serves a local page where the user follows a piece stage by stage (brief, look, structure, parts, assembly, final), watches each stage's renders, approves or asks for changes, and edits the brief, the words on screen (a part's CONFIG texts, re-rendered on the spot), lengths, joins and order (drag and drop). `pieces/<piece>/piece.json` holds the stages and what the user did; `pamo piece init | review | working | status` is the agent's side (`review` hands a stage over and waits for the decision).
package/bin/pamo.mjs CHANGED
@@ -14,6 +14,7 @@ const HELP = `paper-motion — minimal, creative-directed video from code + Pape
14
14
  Usage: pamo <command> [options] (pamo = npx paper-motion, or node packages/engine/bin/pamo.mjs in the repo)
15
15
 
16
16
  setup install engine deps, check ffmpeg + browser
17
+ --version the engine's version
17
18
  doctor only check the environment
18
19
  new <name> [--dir works] [--format 16:9|9:16|1:1 | --size 1920x1080] [--fps 30] [--duration 10]
19
20
  [--from <example> | --piece (wired to brand/) | --still |
@@ -33,8 +34,9 @@ Usage: pamo <command> [options] (pamo = npx paper-motion, or node packages/eng
33
34
  (sound effects declared with s.sfx() are mixed in)
34
35
  sfx <scene.html> [--out file.wav] [--from s] [--to s]
35
36
  export only the synthesized sound effects
36
- board --url --title "…" [--ask "…"] [--reply name] [--look palette,font]
37
- --option id[:key=value,…] [--note "why"] … [--kit] [--site url]
37
+ board --url --title "…" [--ask "…"] [--reply name] [--look palette[:key=v,…],font | --look brand]
38
+ --option id[:key=value,…] [--note "why"] … [--kit] [--wait minutes | --no-wait] [--site url]
39
+ board --await <id> [--wait minutes] wait (again) for the choice on a board already shared
38
40
  a live board: a paper-motion.app link the user opens to see
39
41
  2–4 options in their look, tune one and choose; with --kit each
40
42
  option is a space-separated list of ids (a whole kit)
@@ -45,7 +47,14 @@ Usage: pamo <command> [options] (pamo = npx paper-motion, or node packages/eng
45
47
  --cols 1 stacks options full width (judge type at real size)
46
48
  --sequence plays the clips one after another, with their sound
47
49
  (for choices you hear: sound palettes, typing)
48
- chain init <piece> (--board <id|link> [--option D] | --parts id:sec,id:sec,…) [--format 1920x1080]
50
+ inventory <piece> [--from <url|file.md|folder|brand> …] | inventory kinds
51
+ what a piece can really show (pieces/<piece>/inventory.json: figures, screens,
52
+ commands, lines…), checked, with the blocks that show each item; --from reads
53
+ a site (and screenshots it), a README, a folder or the brand's Facts and assets
54
+ and proposes items the user keeps or removes in the studio
55
+ plan <piece> [--seconds n] structures proposed from the inventory: each beat a block that shows one of
56
+ its items, in three orders; prints the options for a structure board
57
+ chain init <piece> (--board <id|link> [--option D] [--site url] | --plan <letter> | --parts id:sec,id:sec,…) [--format 1920x1080]
49
58
  [--join whip|cut|dissolve|dip] [--title "…"] [--force]
50
59
  a piece made of blocks: installs each part as its own scene under
51
60
  pieces/<piece>/<nn>-<id>/ (edit each CONFIG) and writes chain.json;
@@ -60,11 +69,14 @@ Usage: pamo <command> [options] (pamo = npx paper-motion, or node packages/eng
60
69
  piece init <piece> [--title "…"] [--what "…"] [--who "…"] [--format "…"] [--tone "…"] [--action "…"]
61
70
  [--no-studio] start tracking a piece stage by stage (pieces/<piece>/piece.json)
62
71
  and start this project's studio in the background
63
- piece review <piece> <stage> [--wait 30] [--no-wait] [--no-open]
64
- hand a stage to the user in the studio (brief, look, structure,
65
- parts, assembly, final) and wait for their decision; prints it.
72
+ piece review <piece> <stage> [--wait 30 (minutes)] [--no-wait] [--no-open] [--skip]
73
+ hand a stage to the user in the studio (brief, inventory, video,
74
+ final) and wait for their decision; prints it.
66
75
  Starts the studio if needed; the first time, opens it for them
67
- piece working <piece> <stage> mark a stage as yours again (after changes were asked)
76
+ piece working <piece> <stage> mark a stage as yours (when you start it, or after changes were asked)
77
+ piece ask <piece> "question" | --done
78
+ run right before asking the user something in the chat: the studio
79
+ tells them you are waiting there, with a way back to you
68
80
  piece status <piece> where the piece is, and what the user did that you haven't read
69
81
  studio [--port 4317] [--open] [--stop]
70
82
  the local studio: the user follows each piece, approves, edits
@@ -181,6 +193,7 @@ const workDir = (file) => path.dirname(path.resolve(file));
181
193
  const fmtTime = (s) => (s < 60 ? `${s.toFixed(1)}s` : `${Math.floor(s / 60)}m${Math.round(s % 60)}s`);
182
194
 
183
195
  async function main() {
196
+ if (args.version || cmd === 'version') { console.log(JSON.parse(fs.readFileSync(path.join(ENGINE, 'package.json'), 'utf8')).version); return; }
184
197
  switch (cmd) {
185
198
  case 'setup': {
186
199
  await ensureDeps();
@@ -351,8 +364,25 @@ async function main() {
351
364
  const { parseSet, setArgs } = await import('../lib/customize.mjs');
352
365
  const reg = resolveRegistry(args.registry), { items } = await getIndex(reg), index = Object.fromEntries(items.map((i) => [i.id, i]));
353
366
  const opts = process.argv.flatMap((a, i, all) => (a === '--option' ? [all[i + 1]] : a.startsWith('--option=') ? [a.slice(9)] : [])).filter(Boolean);
354
- if (!opts.length || !args.title || args.help) { console.error('Usage: board --url --title "…" [--ask "…"] [--reply name] [--look palette,font] --option id[:key=value,…] [--note "why"] … [--kit] [--choose 1-3] [--wait minutes | --no-wait] [--long] [--site url]\n board --forget <id>\n On a recipe board an option may be a structure of your own: --option "@Title | 18 | Hook 0-2 [word-swap] one line; Proof 2-12 [browser-frame] three shots; Close 12-18 [svg-logo] the action" (a [block-id] makes the board play that clip for the beat).\n Prints a live board link: short (/b/<id>, stored on the site for 90 days) when the site answers, long (the board in the URL fragment) otherwise or with --long. Values after the colon are your adjustments; the reply line carries every non-default value.\n With a short link the command keeps running until the user chooses in the browser (10 min; --wait 3 changes it, --no-wait returns at once) and prints the reply line, the same one they could paste. --choose lets them pick several options (a count, or min-max). --forget deletes a stored board early.'); process.exit(args.help ? 0 : 1); }
367
+ if (!opts.length || !args.title || args.help) { console.error('Usage: board --url --title "…" [--ask "…"] [--reply name] [--look palette[:key=value,…],font | --look brand] --option id[:key=value,…] [--note "why"] … [--kit] [--choose 1-3] [--wait minutes | --no-wait] [--long] [--site url]\n board --await <id|link> [--wait minutes] (wait again for a board already shared)\n board --forget <id>\n On a recipe board an option may be a structure of your own: --option "@Title | 18 | Hook 0-2 [word-swap] one line; Proof 2-12 [browser-frame] three shots; Close 12-18 [svg-logo] the action" (a [block-id] makes the board play that clip for the beat).\n Prints a live board link: short (/b/<id>, stored on the site for 90 days) when the site answers, long (the board in the URL fragment) otherwise or with --long. Values after the colon are your adjustments; the reply line carries every non-default value.\n With a short link the command keeps running until the user chooses in the browser (10 min; --wait 3 changes it, in minutes; --no-wait returns at once) and prints the reply line, the same one they could paste. If your shell caps how long a command runs, share with --no-wait and then wait in short steps: board --await <id> --wait 2, again until it prints the line (exit code 3 means not yet). Values take lists and several keys: --option graphite:accent=#B8F03C,ramp=["#0A0A0B","#B8F03C"] (quote it for the shell); --look brand uses the palette, colors and font pair this project installed. --choose lets them pick several options (a count, or min-max). --forget deletes a stored board early.'); process.exit(args.help ? 0 : 1); }
355
368
  const LABELS = ['A', 'B', 'C', 'D'];
369
+ // commas only separate at the top level (not inside [..], {..} or quotes), and only before a new "key=":
370
+ // so ramp=["#000","#111"] and multi-value colors survive
371
+ const splitTop = (str, startsNext) => {
372
+ const out = []; let depth = 0, q = null, cur = '';
373
+ for (let i = 0; i < str.length; i++) {
374
+ const c = str[i];
375
+ if (q) { if (c === q && str[i - 1] !== '\\') q = null; cur += c; continue; }
376
+ if (c === '"' || c === "'") q = c; else if ('[{'.includes(c)) depth++; else if (']}'.includes(c)) depth--;
377
+ if (c === ',' && depth === 0 && startsNext(str.slice(i + 1))) { out.push(cur); cur = ''; continue; }
378
+ cur += c;
379
+ }
380
+ out.push(cur);
381
+ return out.map((x) => x.trim()).filter(Boolean);
382
+ };
383
+ const splitPairs = (kv) => splitTop(kv, (rest) => /^\s*[\w-]+=/.test(rest));
384
+ // --look "palette:key=v,key=v,font-pair": a new item starts at a comma followed by an id (no "=" before the next , or :)
385
+ const splitLook = (spec) => splitTop(spec, (rest) => /^\s*[\w-]+\s*(?:[,:]|$)/.test(rest) && !/^\s*[\w-]+=/.test(rest));
356
386
  const parseOne = async (spec) => {
357
387
  if (spec.startsWith('@')) {
358
388
  // a structure of the agent's own: "@Title | 18 | Hook 0-2 one line; Proof 2-12 three shots; Close 12-18 the action"
@@ -360,10 +390,10 @@ async function main() {
360
390
  const beats = (beatsSpec ?? '').split(';').map((x) => x.trim()).filter(Boolean).map((x) => { const m = x.match(/^(.+?)\s+(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)\s+(.+)$/); if (!m) throw new Error(`structure beat "${x}" must read "Name from-to [block-id] what"`); const sh = m[4].match(/^\[([\w-]+)\]\s*(.+)$/); return { name: m[1], from: Number(m[2]), to: Number(m[3]), what: sh ? sh[2] : m[4], ...(sh ? { id: sh[1] } : {}) }; });
361
391
  return { structure: { title, duration: Number(dur), beats } };
362
392
  }
363
- const [id, kv] = spec.split(':'), it = index[id];
393
+ const at = spec.indexOf(':'), id = (at < 0 ? spec : spec.slice(0, at)).trim(), kv = at < 0 ? '' : spec.slice(at + 1), it = index[id];
364
394
  if (!it) throw new Error(`"${id}" is not in the registry (try pamo search)`);
365
395
  if (!kv) return { id, values: {} };
366
- const full = await getItem(id, reg), values = parseSet(full, kv.split(',').map((x) => x.trim()).filter(Boolean));
396
+ const full = await getItem(id, reg), values = parseSet(full, splitPairs(kv));
367
397
  // keep only what differs from the defaults, so the link stays small and the reply line honest
368
398
  const keep = Object.fromEntries(setArgs(full, values).map((a) => { const m = a.match(/^--set '?(\w+)=(.*?)'?$/); return [m[1], values[m[1]]]; }));
369
399
  return { id, values: keep };
@@ -375,7 +405,19 @@ async function main() {
375
405
  else options.push({ label: LABELS[i], ...(await parseOne(spec)), ...(notes[i] ? { note: notes[i] } : {}) });
376
406
  }
377
407
  const look = {};
378
- if (typeof args.look === 'string') for (const spec of args.look.split(',')) { const o = await parseOne(spec.trim()); const t = index[o.id]?.type; if (t === 'palette') look.palette = o; else if (t === 'font-pair') look.font = o; else throw new Error(`--look takes a palette and a font pair, not "${o.id}"`); }
408
+ if (args.look === 'brand') {
409
+ // the look this project installed: its palette and font pair (paper-motion.json), with brand/tokens.json's colors
410
+ const lock = (() => { try { return JSON.parse(fs.readFileSync('paper-motion.json', 'utf8')).items ?? {}; } catch { return {}; } })();
411
+ const tokens = (() => { try { return JSON.parse(fs.readFileSync('brand/tokens.json', 'utf8')); } catch { return {}; } })();
412
+ const pal = Object.keys(lock).reverse().find((id) => index[id]?.type === 'palette'), font = Object.keys(lock).reverse().find((id) => index[id]?.type === 'font-pair');
413
+ if (!pal && !font) throw new Error('--look brand: no palette or font pair installed here yet (pamo add <palette> <font-pair>)');
414
+ if (pal) {
415
+ const full = await getItem(pal, reg), names = new Set((full.params ?? []).map((x) => x.name));
416
+ const pairs = Object.entries(tokens.colors ?? {}).filter(([k]) => names.has(k)).map(([k, v]) => `${k}=${v?.hex ?? v}`);
417
+ look.palette = await parseOne(pairs.length ? `${pal}:${pairs.join(',')}` : pal);
418
+ }
419
+ if (font) look.font = await parseOne(font);
420
+ } else if (typeof args.look === 'string') for (const spec of splitLook(args.look)) { const o = await parseOne(spec); const t = index[o.id]?.type; if (t === 'palette') look.palette = o; else if (t === 'font-pair') look.font = o; else throw new Error(`--look takes a palette and a font pair (or "brand"), not "${o.id}"`); }
379
421
  const choose = args.choose === undefined ? undefined : /^\d+-\d+$/.test(String(args.choose)) ? String(args.choose).split('-').map(Number) : Number(args.choose);
380
422
  const board = { v: 1, kind: args.kit ? 'kit' : 'resource', title: String(args.title), ...(args.ask ? { ask: String(args.ask) } : {}), ...(args.reply ? { reply: String(args.reply) } : {}), ...(Object.keys(look).length ? { look } : {}), ...(choose !== undefined ? { choose } : {}), options };
381
423
  const site = typeof args.site === 'string' ? args.site : SITE;
@@ -384,16 +426,37 @@ async function main() {
384
426
  // the short link when the site stores it; the long one (the board in the fragment) when it cannot
385
427
  if (!args.long) { try { short = await shortenBoard(site, board); } catch (e) { console.error(`(${e.message}; sharing the long link)`); } }
386
428
  console.log(short ? short.url : boardUrl(site, frag));
429
+ // the board also shows in the studio, in the stage it decides (--piece names the piece; else the one in progress)
430
+ const PC = await import('../lib/piece.mjs'), pdir = PC.activePiece(process.cwd(), typeof args.piece === 'string' ? args.piece : undefined);
431
+ if (pdir) PC.attachBoard(pdir, { url: short ? short.url : boardUrl(site, frag), id: short?.id, title: String(args.title), kind: board.kind });
387
432
  const wait = !args['no-wait']; // waiting is the default: the choice comes back without the user pasting anything
388
433
  if (short && wait) {
389
434
  const minutes = Number.isFinite(Number(args.wait)) && args.wait !== undefined && args.wait !== true ? Number(args.wait) : 10;
390
435
  console.error(`waiting up to ${minutes} min for the choice on ${short.id} (the user can still paste the line)…`);
391
- const choice = await awaitChoice(site, short.id, { timeout: minutes * 60 * 1000 });
392
- if (!choice) { console.error('no choice yet; ask the user to paste the line from the board'); process.exit(3); }
436
+ // while this waits, the studio shows the agent as listening
437
+ const pulse = pdir ? setInterval(() => PC.beat(pdir, { waiting: 'board' }), 3000) : null;
438
+ if (pdir) PC.beat(pdir, { waiting: 'board' });
439
+ const choice = await awaitChoice(site, short.id, { timeout: minutes * 60 * 1000 }).finally(() => { if (pulse) { clearInterval(pulse); PC.unbeat(pdir); } });
440
+ if (!choice) { console.error('no choice yet: wait again with board --await ' + short.id); process.exit(3); }
441
+ if (pdir) PC.markBoard(pdir, short.id, choice.line);
393
442
  console.log(choice.line);
394
443
  } else if (!short && wait) console.error('(no short link, so no waiting: the user will paste the line from the board)');
395
444
  break;
396
445
  }
446
+ if (args.await) {
447
+ // wait (again) for the choice on a board already shared: for shells that cap how long one command may run
448
+ const { awaitChoice } = await import('../lib/live-board.js');
449
+ const { SITE } = await import('../lib/registry.mjs');
450
+ const id = String(args.await).replace(/\/$/, '').split('/').pop(), site = typeof args.site === 'string' ? args.site : SITE;
451
+ const minutes = Number.isFinite(Number(args.wait)) && args.wait !== undefined && args.wait !== true ? Number(args.wait) : 10;
452
+ console.error(`waiting up to ${minutes} min for the choice on ${id}…`);
453
+ const choice = await awaitChoice(site, id, { timeout: minutes * 60 * 1000 });
454
+ if (!choice) { console.error('no choice yet: run it again'); process.exit(3); }
455
+ const PC = await import('../lib/piece.mjs'), pdir = PC.activePiece(process.cwd(), typeof args.piece === 'string' ? args.piece : undefined);
456
+ if (pdir) PC.markBoard(pdir, id, choice.line);
457
+ console.log(choice.line);
458
+ break;
459
+ }
397
460
  if (args.forget) {
398
461
  const { forgetBoard } = await import('../lib/live-board.js');
399
462
  const { SITE } = await import('../lib/registry.mjs');
@@ -404,7 +467,7 @@ async function main() {
404
467
  }
405
468
  await ensureDeps();
406
469
  const files = args._.slice(1);
407
- if (files.length < 2 || files.length > 6 || args.help) { console.error('Usage: board <a.html> <b.html> [... up to 6] [--t 2 | --clip 5] [--labels "A · x,B · y"] [--out board.png] (a rendered board)\n board --url --title "…" --option id[:key=value,…] … [--look palette,font] [--kit] (a live board link)'); process.exit(args.help ? 0 : 1); }
470
+ if (files.length < 2 || files.length > 6 || args.help) { console.error('Usage: board <a.html> <b.html> [... up to 6] [--t 2 | --clip 5] [--labels "A · x,B · y"] [--out board.png] (a rendered board)\n board --url --title "…" --option id[:key=value,…] … [--look palette,font | --look brand] [--kit] [--choose 1-3] [--wait minutes | --no-wait] [--long] [--site url] (a live board link)\n board --await <id|link> [--wait minutes] [--site url] (wait again for a board already shared)\n board --forget <id>\n board --url --help explains the live board in full.'); process.exit(args.help ? 0 : 1); }
408
471
  const { renderStills, renderVideo } = await import('../lib/render.mjs');
409
472
  const { composeBoard } = await import('../lib/board.mjs');
410
473
  const os = await import('node:os');
@@ -444,19 +507,44 @@ async function main() {
444
507
  case 'chain': {
445
508
  const sub = args._[1] === 'init' ? 'init' : 'render';
446
509
  const piece = sub === 'init' ? args._[2] : args._[1];
447
- if (!piece || args.help) { console.error('Usage: chain init <piece> [--board <id|link> [--option D] | --parts id:sec,…] [--format 1920x1080] [--join whip|cut|dissolve|dip] [--force]\n chain <piece> [--out file.mp4] [--motion-blur 5] [--audio music.wav] [--no-sfx] [--check]'); process.exit(args.help ? 0 : 1); }
510
+ if (!piece || args.help) { console.error('Usage: chain init <piece> [--board <id|link> [--option D] [--site url] | --plan <letter> | --parts id:sec,…] [--title "…"] [--format 1920x1080] [--join whip|cut|dissolve|dip] [--force]\n chain <piece> [--check] [--drafts [--only dir,…]] [--draft] [--out file.mp4] [--motion-blur 5] [--audio music.wav] [--no-sfx] [--dpr 1]\n --board takes the board\'s link (its id is the last part of a /b/<id> link) and the letter the user chose. In order: --check plans and warns without rendering; --drafts renders each part alone (parts stage); --draft joins a quick version (assembly stage); --motion-blur 5 is the full render (final stage).'); process.exit(args.help ? 0 : 1); }
448
511
  const chainLib = await import('../lib/chain.mjs');
449
512
  if (sub === 'init') {
450
513
  const { SITE } = await import('../lib/registry.mjs');
451
514
  let src;
452
515
  if (args.board) src = await chainLib.structureFromBoard(String(args.board), typeof args.option === 'string' ? args.option : undefined, { site: typeof args.site === 'string' ? args.site : SITE });
516
+ else if (typeof args.plan === 'string') src = (await import('../lib/inventory.mjs')).planParts(chainLib.pieceDir(piece), args.plan); // a structure proposed from the inventory
453
517
  else if (typeof args.parts === 'string') src = { parts: chainLib.partsFromList(args.parts) };
454
518
  else src = {}; // no source: sync an existing chain.json (install parts added since)
455
- const r = await chainLib.initChain({ piece, title: typeof args.title === 'string' ? args.title : src.title, parts: src.parts, format: typeof args.format === 'string' ? args.format : undefined, join: typeof args.join === 'string' ? args.join : undefined, registry: args.registry, force: !!args.force });
519
+ // an open piece already has its title; a board option's title ("Six blocks") names the structure, not the film
520
+ const open = (await import('../lib/piece.mjs')).readPiece(chainLib.pieceDir(piece));
521
+ if (src.fills) { // filled with the user's content: never render it grey on grey with no name
522
+ const look = await (await import('../lib/edit.mjs')).ensureLook(process.cwd(), open?.title ?? (typeof args.title === 'string' ? args.title : undefined));
523
+ if (look) console.log(`! the project had no look yet: installed ${look.join(', ')} (the user changes it in the studio's Style, or settle the brand on boards first)`);
524
+ }
525
+ const r = await chainLib.initChain({ piece, title: typeof args.title === 'string' ? args.title : open?.title ?? src.title, parts: src.parts, format: typeof args.format === 'string' ? args.format : undefined, join: typeof args.join === 'string' ? args.join : undefined, registry: args.registry, force: !!args.force });
456
526
  console.log(`✓ ${path.relative(process.cwd(), r.dir)}/chain.json · "${r.chain.title}" · ${r.chain.parts.length} parts, ${r.chain.format}`);
457
527
  for (const p of r.chain.parts) console.log(` ${p.dir ?? '(no block)'}${p.name ? ` · ${p.name}` : ''}${p.seconds ? ` · ${p.seconds} s` : ''}${p.what ? ` — ${p.what}` : ''}`);
458
528
  for (const n of r.notes) console.log(` ! ${n}`);
459
- console.log(`\nNext: write each part's text and data into its CONFIG (the "what" above is the brief), then:\n npx paper-motion chain ${piece} --motion-blur 5`);
529
+ if (src.fills) {
530
+ // the parts arrive with the inventory's content in their CONFIG: what is left is judgment, not typing
531
+ const { fillConfig } = await import('../lib/inventory.mjs');
532
+ r.chain.parts.forEach((part, i) => {
533
+ const fill = src.fills[i]; if (!part.dir || !fill || !Object.keys(fill).length) return;
534
+ const file = path.join(r.dir, part.dir, 'scene.html');
535
+ fs.writeFileSync(file, fillConfig(fs.readFileSync(file, 'utf8'), fill));
536
+ console.log(` ${part.dir} · filled ${Object.keys(fill).join(', ')}`);
537
+ });
538
+ }
539
+ console.log([
540
+ '', 'Next:',
541
+ ...(open ? [` npx paper-motion piece review ${piece} video (the user edits and approves the video in the studio)`] : []),
542
+ src.fills ? ` check each part's CONFIG: the inventory's content is in; adjust what needs judgment` : ` write each part's text and data into its CONFIG (the "what" above is the brief)`,
543
+ ` npx paper-motion chain ${piece} --check (plans and warns, renders nothing)`,
544
+ ` npx paper-motion chain ${piece} --drafts (each part alone, quick)`,
545
+ ` npx paper-motion chain ${piece} --draft (joined, quick)`,
546
+ ` npx paper-motion chain ${piece} --motion-blur 5 (the full render)`,
547
+ ].join('\n'));
460
548
  break;
461
549
  }
462
550
  await ensureDeps();
@@ -480,17 +568,107 @@ async function main() {
480
568
  break;
481
569
  }
482
570
 
571
+ case 'inventory': {
572
+ const inv = await import('../lib/inventory.mjs');
573
+ const { getIndex } = await import('../lib/registry.mjs');
574
+ const scenes = inv.scenesOf(await getIndex());
575
+ if (args._[1] === 'kinds') {
576
+ // what an inventory can hold, and what shows each kind
577
+ for (const [k, K] of Object.entries(inv.KINDS)) {
578
+ const shows = scenes.filter((b) => b.needs.some((n) => n.kind === k)).map((b) => b.id);
579
+ console.log(`${k.padEnd(9)} ${K.is}\n${' '.repeat(10)}fields: ${[...K.needs, ...(K.opt ?? []).map((f) => `${f}?`)].join(', ') || '—'}\n${' '.repeat(10)}shown by: ${shows.join(', ') || 'nothing yet'}`);
580
+ }
581
+ break;
582
+ }
583
+ const piece = args._[1];
584
+ if (!piece || args.help) { console.error('Usage: inventory <piece> [--from <url|file.md|folder|brand> … [--plain (no commands or code: the audience does not code)]] [--keep id,…] [--remove id,…] [--must id,…] [--set id.field=value] [--output id < what the command printed]\n check pieces/<piece>/inventory.json and say what can show each item;\n --from reads a site (and screenshots it), a README, a folder of files or the brand (its Facts and assets)\n and adds what it finds as proposals for the user to keep or remove in the studio\n inventory kinds the kinds of content an inventory holds, their fields, and the blocks that show them'); process.exit(piece ? 0 : 1); }
585
+ const dir = (await import('../lib/chain.mjs')).pieceDir(piece);
586
+ const froms = process.argv.flatMap((a, i, all) => (a === '--from' && all[i + 1] && !all[i + 1].startsWith('--') ? [all[i + 1]] : a.startsWith('--from=') ? [a.slice(7)] : []));
587
+ if (froms.length) {
588
+ if (froms.some((f) => /^https?:/i.test(f))) await ensureDeps();
589
+ const { gather } = await import('../lib/gather.mjs');
590
+ const existing = inv.loadRaw(dir);
591
+ const { fresh, notes } = await gather(froms, { existing, plain: !!args.plain, log: (m) => console.log(m) });
592
+ inv.saveRaw(dir, [...existing, ...fresh]);
593
+ for (const n of notes) console.log(` ${n}`);
594
+ console.log(`${fresh.length} new proposal${fresh.length === 1 ? '' : 's'}${existing.length ? ` (${existing.length} already there, untouched)` : ''}`);
595
+ }
596
+ // the agent's own edits to an item, the same ones the user makes in the studio
597
+ const ids = (v) => String(v).split(',').map((x) => x.trim()).filter(Boolean);
598
+ try {
599
+ if (typeof args.keep === 'string') for (const id of ids(args.keep)) inv.applyEdit(dir, { type: 'inv-status', id, value: 'kept' });
600
+ if (typeof args.remove === 'string') for (const id of ids(args.remove)) inv.applyEdit(dir, { type: 'inv-status', id, value: 'removed' });
601
+ if (typeof args.must === 'string') for (const id of ids(args.must)) inv.applyEdit(dir, { type: 'inv-must', id, value: true });
602
+ if (typeof args.set === 'string') {
603
+ const m = args.set.match(/^([^.=]+)\.([^=]+)=([\s\S]*)$/); if (!m) throw new Error('--set takes id.field=value');
604
+ inv.applyEdit(dir, { type: 'inv-field', id: m[1], key: m[2], value: m[3].replace(/\\n/g, '\n') });
605
+ }
606
+ if (typeof args.output === 'string') {
607
+ // what a command really printed, piped in: `<the command> 2>&1 | pamo inventory <piece> --output <id>`
608
+ if (process.stdin.isTTY) throw new Error('--output reads what the command printed from stdin: <the command> 2>&1 | pamo inventory <piece> --output <id>');
609
+ let piped = ''; for await (const c of process.stdin) piped += c; // read as it comes: the command may still be running
610
+ const lines = inv.tidyOutput(piped);
611
+ if (!lines.length) throw new Error('--output: nothing came in on stdin (did the command print anything?)');
612
+ inv.editInventory(dir, (list) => { const it = list.find((i) => i.id === args.output); if (!it) throw new Error(`no item "${args.output}"`); if (it.kind !== 'command') throw new Error(`${it.id} is a ${it.kind}, not a command`); it.output = lines; });
613
+ console.log(`${args.output}: output set, ${lines.length} line${lines.length === 1 ? '' : 's'}`);
614
+ }
615
+ } catch (e) { console.error(`✗ ${e.message}`); process.exit(1); }
616
+ const { items, waiting, problems } = inv.readInventory(dir);
617
+ const { rows, notes } = inv.report(items, scenes);
618
+ console.log(`${problems.length ? '✗' : '✓'} ${path.relative(process.cwd(), inv.inventoryFile(dir))} · ${items.length} in${waiting.length ? `, ${waiting.length} proposed` : ''}`);
619
+ const w = Math.max(...[...items, ...waiting].map((i) => i.id.length), 4);
620
+ for (const { it, blocks } of rows) console.log(` ${it.must ? '*' : ' '} ${it.id.padEnd(w)} ${it.kind.padEnd(8)} ${inv.describe(it)}\n ${' '.repeat(w)}→ ${blocks.join(', ') || (it.kind === 'music' ? 'the bed under the piece' : it.kind === 'logo' ? 'the close' : 'nothing yet')}`);
621
+ // proposals: gathered, not yet the user's; a plan does not use them
622
+ for (const it of waiting) console.log(` ? ${it.id.padEnd(w)} ${it.kind.padEnd(8)} ${inv.describe(it)}\n ${' '.repeat(w)} from ${it.source ?? '?'}${it.missing.length ? ` · still needs ${it.missing.join(', ')}` : ''}`);
623
+ for (const p of problems) console.log(` ✗ ${p}`);
624
+ if (items.length) for (const n of notes) console.log(` ! ${n}`);
625
+ const open = (await import('../lib/piece.mjs')).readPiece(dir);
626
+ if (waiting.length) console.log(`\n${waiting.length} proposal${waiting.length === 1 ? ' waits' : 's wait'} for the user: ${open ? `npx paper-motion piece review ${piece} inventory (they keep or remove each in the studio)` : `set "status" on each in the file (delete it to keep, "removed" to drop), or open a piece first (piece init) so they decide in the studio`}`);
627
+ if (problems.length) process.exit(1);
628
+ if (!waiting.length) console.log(`\nNext: npx paper-motion plan ${piece} [--seconds 20] (structures made from this content)`);
629
+ break;
630
+ }
631
+
632
+ case 'plan': {
633
+ const piece = args._[1];
634
+ if (!piece || args.help) { console.error('Usage: plan <piece> [--seconds n] proposes structures from pieces/<piece>/inventory.json: each beat a block that shows\n one of its items; writes plan.json and prints the options for a structure board'); process.exit(piece ? 0 : 1); }
635
+ const inv = await import('../lib/inventory.mjs');
636
+ const { getIndex } = await import('../lib/registry.mjs');
637
+ const dir = (await import('../lib/chain.mjs')).pieceDir(piece);
638
+ const open = (await import('../lib/piece.mjs')).readPiece(dir);
639
+ const { items, waiting, problems } = inv.readInventory(dir);
640
+ if (problems.length) { for (const p of problems) console.log(` ✗ ${p}`); console.error(`fix the inventory first (npx paper-motion inventory ${piece})`); process.exit(1); }
641
+ if (waiting.length) console.log(`! ${waiting.length} proposal${waiting.length === 1 ? ' is' : 's are'} not in this plan: the user has not kept ${waiting.length === 1 ? 'it' : 'them'} yet (npx paper-motion piece review ${piece} inventory)`);
642
+ const scenes = inv.scenesOf(await getIndex());
643
+ const seconds = args.seconds ? Number(args.seconds) : undefined;
644
+ const options = inv.propose(items, scenes, { seconds, brief: open?.brief ?? {} });
645
+ if (!options.length) { console.error('nothing in the inventory can be shown by a block yet (npx paper-motion inventory kinds)'); process.exit(1); }
646
+ inv.writePlan(dir, options, { seconds });
647
+ for (const o of options) {
648
+ console.log(`\n${o.letter} · ${o.title} · ${o.seconds} s — ${o.why}`);
649
+ for (const b of o.beats) console.log(` ${String(b.from).padStart(4)}–${String(b.to).padEnd(4)} ${b.name.padEnd(10)} ${`${b.id}${b.variant ? ` (${b.variant})` : ''}`.padEnd(22)} ${b.what}`);
650
+ if (o.left.length) console.log(` left out: ${o.left.join(', ')}`);
651
+ for (const n of o.notes) console.log(` ! ${n}`);
652
+ }
653
+ for (const n of inv.report(items, scenes).notes) console.log(`\n! ${n}`);
654
+ console.log(`\n✓ ${path.relative(process.cwd(), inv.planFile(dir))}\n\nShow them on a structure board (each plays its blocks in order):\n npx paper-motion board --url --title "Structure" --ask "Which order tells it best?" --reply structure-1 --look brand \\\n${options.map((o) => ` --option '${inv.boardOption(o).replace(/'/g, `'\\''`)}'`).join(' \\\n')}\nor install one as it is, with its content in every part:\n npx paper-motion chain init ${piece} --plan ${options[0].letter}`);
655
+ break;
656
+ }
657
+
483
658
  case 'piece': {
484
659
  const sub = args._[1], piece = args._[2];
485
- if (!['init', 'review', 'working', 'status'].includes(sub) || !piece || args.help) { console.error('Usage: piece init <piece> [--title "…"] [--what …] [--who …] [--format …] [--tone …] [--action …]\n piece review <piece> <stage> [--wait 30] [--no-wait]\n piece working <piece> <stage>\n piece status <piece>'); process.exit(args.help ? 0 : 1); }
660
+ if (!['init', 'review', 'working', 'status', 'ask'].includes(sub) || !piece || args.help) { console.error('Usage: piece init <piece> [--title "…"] [--what …] [--who …] [--format …] [--tone …] [--action …] [--no-studio] [--force]\n piece review <piece> <stage> [--wait 30 (minutes)] [--no-wait] [--no-open] [--no-studio] [--skip]\n piece working <piece> <stage>\n piece ask <piece> "the question you are asking in the chat" | --done\n piece status <piece>\n ask: run it right before you ask the user anything in the chat, so the studio sends them there (the next review or working clears it, or --done). --return <url> on any of these gives the studio a link back to you when you run in a browser.\n Stages: brief, inventory (what the video can show), video (they edit it in the studio), final. review hands a stage to the user in the studio and prints what they did, one line each; it refuses while an earlier stage is not approved (--skip moves on without it, only when the user agreed). --no-wait returns at once with what is unread; --no-open does not open their browser; --no-studio runs without the studio.'); process.exit(args.help ? 0 : 1); }
486
661
  const P = await import('../lib/piece.mjs');
487
662
  const { pieceDir } = await import('../lib/chain.mjs');
488
663
  const dir = pieceDir(piece), studioLib = await import('../lib/studio.mjs'), q = `?p=${encodeURIComponent(path.basename(dir))}`;
489
664
  const studio = async () => (args.studio === false || args['no-studio'] ? null : studioLib.ensureStudio(process.cwd(), { bin: fileURLToPath(import.meta.url) }));
665
+ // where this agent runs, so the studio can take the user back to it
666
+ const host = async () => { if (sub !== 'status') P.setHost(dir, (await import('../lib/host.mjs')).hostApp({ url: typeof args.return === 'string' ? args.return : undefined })); };
490
667
  if (sub === 'init') {
491
668
  const brief = Object.fromEntries(P.BRIEF_KEYS.filter((k) => typeof args[k] === 'string').map((k) => [k, args[k]]));
492
669
  const p = P.initPiece({ piece, title: typeof args.title === 'string' ? args.title : undefined, brief, force: !!args.force });
493
670
  console.log(`✓ ${path.relative(process.cwd(), dir)}/piece.json · "${p.title}" · stage ${p.stage}`);
671
+ await host();
494
672
  const missing = P.BRIEF_KEYS.filter((k) => !p.brief[k]);
495
673
  if (missing.length) console.log(` brief still empty: ${missing.join(', ')} (pass them, or write piece.json)`);
496
674
  const s = await studio().catch((e) => { console.log(` ! ${e.message}`); return null; });
@@ -506,11 +684,34 @@ async function main() {
506
684
  if (fresh.length) { console.log(`\n${fresh.length} unread:`); for (const e of fresh) console.log(P.eventLine(p, e)); }
507
685
  break;
508
686
  }
687
+ if (sub === 'ask') {
688
+ if (!P.readPiece(dir)) { console.error(`no piece.json in ${path.relative(process.cwd(), dir)}`); process.exit(1); }
689
+ await host();
690
+ if (args.done) { P.clearAsk(dir); console.log('✓ cleared: the studio no longer sends the user to the chat'); break; }
691
+ const question = typeof args._[3] === 'string' ? args._[3].trim() : '';
692
+ if (!question) { console.error('piece ask: give the question in one line (the one you are asking in the chat), or --done'); process.exit(1); }
693
+ P.setAsk(dir, question);
694
+ console.log(`✓ the studio now tells the user you asked this in the chat${P.readPiece(dir).agent ? ', with a way back to you' : ''}. Ask it now; the next piece review or piece working clears it (or piece ask ${piece} --done).`);
695
+ break;
696
+ }
509
697
  const stage = args._[3];
698
+ await host();
510
699
  if (!P.STAGES.includes(stage)) { console.error(`piece ${sub}: give a stage (${P.STAGES.join(', ')})`); process.exit(1); }
511
- if (sub === 'working') { P.updatePiece(dir, (p) => { p.stage = stage; p.stages[stage] = { ...p.stages[stage], status: 'working' }; }); console.log(`✓ ${stage}: working`); break; }
700
+ if (sub === 'working') { P.updatePiece(dir, (p) => { delete p.ask; p.stage = stage; p.stages[stage] = { ...p.stages[stage], status: 'working', since: new Date().toISOString() }; }); console.log(`✓ ${stage}: working`); break; }
512
701
  // review: hand the stage over and wait for the user's decision in the studio
513
- P.toReview(dir, stage);
702
+ if (stage === 'inventory') {
703
+ // the user is asked whether each thing is true and worth showing: they cannot judge half an item
704
+ const inv = await import('../lib/inventory.mjs');
705
+ let r; try { r = inv.readInventory(dir); } catch (e) { console.error(`✗ ${e.message}`); process.exit(1); }
706
+ const short = [...r.items, ...r.waiting].filter((i) => i.missing.length);
707
+ if (!r.items.length && !r.waiting.length) { console.error(`✗ the inventory is empty: gather it first (inventory ${piece} --from <url|README.md|brand>)`); process.exit(1); }
708
+ if (short.length) {
709
+ console.error(`✗ ${short.length} item${short.length === 1 ? ' is' : 's are'} not complete yet: finish or remove ${short.length === 1 ? 'it' : 'them'} before the user sees the inventory`);
710
+ for (const i of short) console.error(` ${i.id} (${i.kind}) needs ${i.missing.join(', ')}${i.kind === 'command' && i.missing.includes('output') ? ` → ${i.command} 2>&1 | npx paper-motion inventory ${piece} --output ${i.id} or --remove ${i.id}` : ` → --set ${i.id}.${i.missing[0]}=… or --remove ${i.id}`}`);
711
+ process.exit(1);
712
+ }
713
+ }
714
+ try { P.toReview(dir, stage, { skip: !!args.skip }); } catch (e) { console.error(`✗ ${e.message}`); process.exit(1); }
514
715
  const s = await studio();
515
716
  const url = s ? `${s.url}${q}` : null;
516
717
  // the first hand-over opens the studio in the user's browser; after that the open page follows the piece