@eventmodelers/cli 2.0.35 → 2.0.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/extensions/prototype/local-build.js +14 -0
- package/extensions/prototype/skills/local-prototype-build/SKILL.md +1 -1
- package/package.json +1 -1
- package/stacks/modeling-kit/templates/.claude/skills/prototype/SKILL.md +27 -18
- package/stacks/modeling-kit/templates/.claude/skills/prototype/assets/index.template.html +46 -0
- package/stacks/modeling-kit/templates/.claude/skills/prototype/assets/prototype.mjs +22 -2
- package/stacks/modeling-kit/templates/.claude/skills/prototype/assets/scaffold.mjs +192 -0
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { dirname, join, resolve } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { skeleton } from '../../stacks/modeling-kit/templates/.claude/skills/prototype/assets/scaffold.mjs';
|
|
7
8
|
import { loadScreens, loadSlices, paths, readJson, sliceHash, writeJson } from './state.js';
|
|
8
9
|
|
|
9
10
|
const PKG = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
@@ -61,12 +62,24 @@ export function status(cwd) {
|
|
|
61
62
|
const p = paths(cwd);
|
|
62
63
|
const { byId, ...report } = plan(cwd);
|
|
63
64
|
mkdirSync(p.blocks, { recursive: true });
|
|
65
|
+
const screens = loadScreens(p);
|
|
66
|
+
const sliceById = Object.fromEntries(loadSlices(p).map(({ slice }) => [slice.id, slice]));
|
|
64
67
|
// Every slice that already has a block keeps it in the work folder: unchanged ones as the page has them, changed ones as the previous
|
|
65
68
|
// version — to be patched in place (Edit) and given the new hash, not rewritten from nothing. Work already done on a changed
|
|
66
69
|
// slice's block is never overwritten by running `status` again. Only new slices start empty.
|
|
67
70
|
for (const s of report.slices) {
|
|
68
71
|
const file = join(p.blocks, `${s.id}.html`);
|
|
69
72
|
if (byId[s.id] && (s.state === 'unchanged' || !existsSync(file))) writeFileSync(file, byId[s.id].block + '\n');
|
|
73
|
+
// The deterministic part of a new or changed slice's block — specs from the scenarios, screens as JSX, a defineSlice stub — is generated
|
|
74
|
+
// (shared with the platform skill); the agent only fills the @@TODO. A block already being worked on is left alone.
|
|
75
|
+
if (s.state !== 'unchanged') {
|
|
76
|
+
const slice = sliceById[s.id];
|
|
77
|
+
const own = (slice.screens || []).map((x) => ({ title: x.title, lane: x.lane, ...(screens[x.linkedId || x.id] || {}) }));
|
|
78
|
+
if (byId[s.id]) { mkdirSync(join(p.work, 'previous'), { recursive: true }); writeFileSync(join(p.work, 'previous', `${s.id}.html`), byId[s.id].block + '\n'); }
|
|
79
|
+
if (!existsSync(file) || (byId[s.id] && readFileSync(file, 'utf8').trim() === byId[s.id].block.trim())) {
|
|
80
|
+
writeFileSync(file, skeleton({ slice, hash: s.hash, order: s.order, specSlice: slice, screens: own }).block);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
70
83
|
}
|
|
71
84
|
writeJson(join(p.work, 'status.json'), report);
|
|
72
85
|
return report;
|
|
@@ -88,6 +101,7 @@ export function assemble(cwd) {
|
|
|
88
101
|
const [found] = blocksIn(block);
|
|
89
102
|
if (!found || found.id !== s.id) problems.push(`${file}: not a slice block for ${s.id}`);
|
|
90
103
|
else if (found.hash !== s.hash) problems.push(`${file}: data-em-hash is ${found.hash}, the slice's hash is ${s.hash}`);
|
|
104
|
+
if (block.includes('@@TODO')) problems.push(`${file}: still has @@TODO — fill every one`);
|
|
91
105
|
if ((block.match(/<\/script/gi) || []).length !== 1) problems.push(`${file}: a closing script tag appears inside the block`);
|
|
92
106
|
return block;
|
|
93
107
|
});
|
|
@@ -14,7 +14,7 @@ The page `.prototype/index.html` is derived from the model: slice by slice, one
|
|
|
14
14
|
{{CLI}} prototype status
|
|
15
15
|
```
|
|
16
16
|
Lists every slice as `new`, `changed` or `unchanged`, and puts the blocks of unchanged slices into `.prototype/work/slices/`. The hash covers the slice's elements, scenarios and its screens — not its status or comments.
|
|
17
|
-
2. **
|
|
17
|
+
2. **Complete the block of every `new` and `changed` slice** in `.prototype/work/slices/<slice id>.html`. `status` has already generated its deterministic part — the script tag with the right hash, `GENERATED_SPECS` (every scenario and storyline), each HTML page of its screens as a JSX component, and a `defineSlice` stub with `@@TODO` — so only the judgment is left: `decide` / `evolve` / `process` and wiring the screen part to the hooks. `Edit` the skeleton in place (never rewrite it); `assemble` refuses a block that still has `@@TODO`. A `changed` slice's previous block is in `.prototype/work/previous/<slice id>.html` for reference. Read the slice from `.slices/<context>/<slice>/slice.json` and its screens from `.prototype/screens/<node id>/` (`screen.json` + `page-N.html`). Slices are independent: one after another works everywhere; if your environment can run sub-agents, you may give each slice's file to its own worker (about six at a time) and redo any that fail serially.
|
|
18
18
|
How to write a block — its logic from the scenarios, its part of the screen, its exact shape — is `references/block-rules.md`; follow its steps 4, 5 and 6, and the contract in `references/slices.example.html`. Ignore everything there about `connect`, MCP tools, reading from the board, `prototype.mjs` and uploading: the files above replace all of it.
|
|
19
19
|
3. **Assemble.**
|
|
20
20
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eventmodelers/cli",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.36",
|
|
4
4
|
"description": "Eventmodelers CLI — real-time Claude agent + skills for Claude Code, for any stack (Node, Supabase, Axon, OpenCQRS, UmaDB, Kurrent, or modeling-only)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -59,28 +59,30 @@ EM_TOKEN="$TOKEN" node <skill-dir>/assets/prototype.mjs status \
|
|
|
59
59
|
|
|
60
60
|
Per slice, the hash covers its elements with their fields and dependencies, its scenarios and storylines, and the content of its screens (HTML pages and marks, or the sketch) — not its status, assignee, ticket or comments, so moving a slice to Done rebuilds nothing. The hashes are compared with those on the blocks of the prototype the platform stores for this context — the page itself is the memory, no other state is kept — so a run in another chat session, folder or machine still rebuilds only what changed. `status` downloads it itself; the page never passes through you. Only when the platform has none yet does it fall back to a local `<out>/index.html` (an earlier run whose upload failed); `baseline` in `status.json` says which it was (`platform` / `local` / `none`).
|
|
61
61
|
|
|
62
|
-
`<out>/.work/status.json` carries the context's id (`contextId` — the MODEL_CONTEXT or timeline the name matched; the platform resolves a timeline to its effective context when storing) and lists every slice with `state` (`new` / `changed` / `unchanged`), its `hash`, `order` and `sliceType`, plus `removed` slices and the CSS framework the screens use. The blocks of unchanged slices are already copied to `<out>/.work/slices/<slice id>.html`.
|
|
62
|
+
`<out>/.work/status.json` carries the context's id (`contextId` — the MODEL_CONTEXT or timeline the name matched; the platform resolves a timeline to its effective context when storing) and lists every slice with `state` (`new` / `changed` / `unchanged`), its `hash`, `order` and `sliceType`, plus `removed` slices, and the CSS framework the screens use. The blocks of unchanged slices are already copied to `<out>/.work/slices/<slice id>.html`.
|
|
63
63
|
|
|
64
|
-
**
|
|
64
|
+
**For every new or changed slice `status` also writes the deterministic part of its block** — you don't write it:
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
| File | What it is |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `<out>/.work/slices/<slice id>.html` | the block **skeleton**: script tag with the right hash and `order`, `GENERATED_SPECS` (every scenario and storyline as a spec entry, example values typed), one component per HTML page of its screens (translated to JSX 1:1), and a `defineSlice` stub with `@@TODO` where judgment is needed |
|
|
69
|
+
| `<out>/.work/data/<slice id>.json` | the slice as the board has it: `slice` (elements with fields, descriptions, dependencies), `specs` (scenarios, storylines) and `screens` (pages, marks, or sketch) — **read this instead of calling the board again** |
|
|
70
|
+
| `<out>/.work/previous/<slice id>.html` | for a `changed` slice, the block as it was — reference only |
|
|
67
71
|
|
|
68
|
-
|
|
72
|
+
`assemble` refuses a block that still contains `@@TODO`. `// NEEDS:` lines in a skeleton name scenarios the generator could not turn into a spec; write those by hand.
|
|
69
73
|
|
|
70
|
-
|
|
74
|
+
**Nothing new or changed and nothing removed** → the local page is up to date, but that says nothing about the platform's copy (an earlier run may have stopped before uploading). Skip Steps 3–5 and 6's block work, but still write the questions (Step 5b), run `assemble` and **Upload** in Step 6 — `assemble` takes the template and runtime fresh from the skill, so a kit fix to the harness reaches the page even when the board didn't change — then report.
|
|
71
75
|
|
|
72
|
-
|
|
76
|
+
A change in one slice can matter to another (a renamed event field that a read model in another slice reads): that slice's own JSON changes with it, so it is flagged too. Build only what is flagged.
|
|
73
77
|
|
|
74
|
-
|
|
75
|
-
mcp__eventmodelers__get_slice_data { "boardId": "<BOARD_ID>", "contextName": "<CONTEXT>", "sliceIds": ["<id>", …], "format": "json" }
|
|
76
|
-
```
|
|
78
|
+
---
|
|
77
79
|
|
|
78
|
-
|
|
80
|
+
## Step 3 — Read the flagged slices
|
|
79
81
|
|
|
80
|
-
|
|
81
|
-
- its specs → `specifications[]` (`given` / `when` / `then` with example values; a rejection is a `then` step of type `SPEC_ERROR` whose `description` is the message; `expectEmptyList` on read-model steps) and `storylines[]`.
|
|
82
|
+
Read `<out>/.work/data/<slice id>.json` for each flagged slice. No board call is needed for them. (`mcp__eventmodelers__get_slice_data` / `get_nodes` only if a file is missing.)
|
|
82
83
|
|
|
83
|
-
|
|
84
|
+
- the slice's elements → `commands[]`, `events[]`, `readmodels[]`, `screens[]`, `processors[]` (automations), each element with `fields[]` (`type`, `example`, `generated`, `idAttribute`, `cardinality`, `subfields`, `mapping`), `description` and `dependencies[]` (`INBOUND`/`OUTBOUND`) — the wiring SCREEN → COMMAND → EVENT → READMODEL → SCREEN / AUTOMATION → COMMAND.
|
|
85
|
+
- `specs` → `specifications[]` and `storylines[]`; `screens` → `pages` and `marks` for an HTML screen, `sketch` `{ elements }` for a SCREEN (grid vocabulary in `storyboard-screen`), nothing for an uploaded screenshot (the screen's fields are all there is).
|
|
84
86
|
|
|
85
87
|
**Copies.** An element with `elementCopy: true` is a linked copy (its `id` is the original's). It is defined by the slice that owns the original, never twice. An EVENT whose original lives in another context, or that no COMMAND in this context produces, is an **external event**.
|
|
86
88
|
|
|
@@ -106,7 +108,7 @@ Write each flagged slice's logic before its UI. Names are the board's titles wit
|
|
|
106
108
|
|
|
107
109
|
**External events** — `externalEvents: [{ type, from, fields: [{ name, type, example }] }]` on the slice that consumes them; the simulate panel records them, which is how translation automations get triggered.
|
|
108
110
|
|
|
109
|
-
**Specs —
|
|
111
|
+
**Specs — already generated** into `GENERATED_SPECS` from the scenarios and storylines (the table below is the mapping the generator applies). Check it against the scenarios you analysed, fix what is off, and write any `// NEEDS:` entry by hand:
|
|
110
112
|
|
|
111
113
|
| Scenario | Spec entry |
|
|
112
114
|
|---|---|
|
|
@@ -122,6 +124,8 @@ Example values become typed data (`Int`/`Double` → numbers, `Boolean` → bool
|
|
|
122
124
|
|
|
123
125
|
## Step 5 — The slice's part of the screen
|
|
124
126
|
|
|
127
|
+
**The skeleton already holds each HTML page translated to JSX** — one component per page, markup 1:1, inline handlers dropped. What is left is judgment: cut the component to this slice's region, replace sample data with the hooks, wire the buttons. Do that by editing the skeleton in place (small edits), never by rewriting the page. A sketch screen has no generated component — redraw it as below.
|
|
128
|
+
|
|
125
129
|
**Screens are tabs, one per screen name.** Slices whose screens share a name contribute to the same tab, in timeline order — so a screen shown in three slices with three different marks is one tab with three parts. A slice declares its part as `screens: { '<Screen Title>': { actor: '<lane>', part: Component } }`.
|
|
126
130
|
|
|
127
131
|
**Read the screen HTML for what belongs to this slice:**
|
|
@@ -131,7 +135,7 @@ Example values become typed data (`Int`/`Double` → numbers, `Boolean` → bool
|
|
|
131
135
|
- a button inside a list row navigates with the row's ids (`go('<title>', { orderId })`) or issues that row's command with them.
|
|
132
136
|
- a button the model gives no meaning to: kept as designed, `onClick={() => notModeled('<label>')}` (`const notModeled = useNotModeled()`), and listed in the report.
|
|
133
137
|
|
|
134
|
-
**HTML screens — 1:1**:
|
|
138
|
+
**HTML screens — 1:1**: the generated component is the translation (several pages → a step-through with local `page` state, already generated); do not redesign it. **Sketch screens — redrawn**: a 1000×800 relative container, each element absolutely positioned at `gridX*20`/`gridY*20` (`gridWidth*20` × `gridHeight*20`) — `rectangle` → `div`, `headline`/`text` → text at its `fontSize`, `button` → `<button>`, `input` → `<input placeholder>`, `image` → a filled box, `line`/`circle` → inline SVG — then wired the same way.
|
|
135
139
|
|
|
136
140
|
Automation slices usually have no screen part — the Automations tab shows them.
|
|
137
141
|
|
|
@@ -141,7 +145,7 @@ Automation slices usually have no screen part — the Automations tab shows them
|
|
|
141
145
|
|
|
142
146
|
The prototype also **asks**: a guided tour through the screens, one `?` tag per question on the element it is about, a Next button leading from question to question — also onto another screen. This is how the people playing it tell us what the screens do (what is stored, what happens next, which rules apply). **What to ask, in which order and in which words: `eventmodeling-workshop-questions`** — read it, don't improvise a catalog.
|
|
143
147
|
|
|
144
|
-
**Regenerate the questions on every run, from the model as it is now** — also when every slice is `unchanged`, and never copied from the stored page: a question that was answered by the model since, or points at an element that is gone, must not come back. Write them to `<out>/.work/questions.json` (`assemble` embeds them; without the file the page asks nothing), an array in tour order:
|
|
148
|
+
**Regenerate the questions on every run, from the model as it is now** — also when every slice is `unchanged`, and never copied from the stored page: a question that was answered by the model since, or points at an element that is gone, must not come back, and a fresh look often finds questions the last run did not. Write them to `<out>/.work/questions.json` (`assemble` embeds them; without the file the page asks nothing), an array in tour order:
|
|
145
149
|
|
|
146
150
|
```json
|
|
147
151
|
[{ "id": "save-note-action", "screen": "Activity Composer", "ref": "text:Save note", "category": "action",
|
|
@@ -156,12 +160,17 @@ The prototype also **asks**: a guided tour through the screens, one `?` tag per
|
|
|
156
160
|
|
|
157
161
|
## Step 6 — Write the blocks, assemble, check
|
|
158
162
|
|
|
159
|
-
|
|
163
|
+
Complete each new or changed slice's skeleton in `<out>/.work/slices/<slice id>.html` (Steps 4–5): replace every `@@TODO`, keep the script tag and its hash as generated, keep the shape of `assets/slices.example.html`:
|
|
160
164
|
|
|
161
|
-
- one `<script type="text/plain" data-em-slice
|
|
165
|
+
- one `<script type="text/plain" data-em-slice=… data-em-hash=…>` element whose body ends with one `EMRuntime.defineSlice({ id, title, order, commands, readModels, automations, externalEvents, specs, screens })` call (already in the skeleton);
|
|
162
166
|
- JSX and modern JavaScript; React hooks as `React.useState`; `useCommand`, `useReadModel`, `useNav`, `useNotModeled` are globals;
|
|
163
167
|
- never the text of a closing script tag inside the block, not even in a string or comment.
|
|
164
168
|
|
|
169
|
+
**Slices are independent of each other — each one touches only its own files.** So the work can be split, but it never has to be:
|
|
170
|
+
|
|
171
|
+
- **Default, any agent: one slice after another**, in `order`. Nothing else is needed.
|
|
172
|
+
- **Only if your environment can run sub-agents / parallel workers** (a task or sub-agent tool; check what you actually have, don't assume): hand each flagged slice to one worker — at most about six at a time — with this brief: *"Complete `<out>/.work/slices/<id>.html` following Steps 3–5 and 6 of `<skill-dir>/SKILL.md` for this one slice; read `<out>/.work/data/<id>.json`; edit only that file; reply with the gaps you found (buttons not modeled and so on)."* Meanwhile you write the questions (Step 5b) — they need only `status.json` and the data files — and, when every worker is done, continue with `assemble`. A worker that fails or returns a block with `@@TODO` left is redone serially. With no such tool, ignore this paragraph.
|
|
173
|
+
|
|
165
174
|
Then:
|
|
166
175
|
|
|
167
176
|
```bash
|
|
@@ -85,6 +85,7 @@
|
|
|
85
85
|
.em-ev.em-out { border-left-color: #14b8a6; }
|
|
86
86
|
.em-panel input, .em-panel textarea { width: 100%; box-sizing: border-box; margin: 2px 0 6px; padding: 6px 9px; border-radius: 7px; border: 1px solid #2c3a55; background: #0e1522; color: #fff; font: inherit; }
|
|
87
87
|
.em-panel input:focus, .em-panel textarea:focus { outline: none; border-color: #f97316; }
|
|
88
|
+
.em-panel select { box-sizing: border-box; margin: 0; padding: 6px 9px; border-radius: 7px; border: 1px solid #2c3a55; background: #0e1522; color: #fff; font: inherit; } .em-panel .em-input { margin: 0; width: auto; }
|
|
88
89
|
.em-auto { max-width: 760px; }
|
|
89
90
|
.em-auto .em-ev { font-size: 13px; }
|
|
90
91
|
.em-auto h2 { color: #6b7280; }
|
|
@@ -455,6 +456,48 @@ function SpecView({ spec }) {
|
|
|
455
456
|
);
|
|
456
457
|
}
|
|
457
458
|
|
|
459
|
+
// Named snapshots of the event log. The page has no storage of its own, so the player keeps them: this asks it for the list, and a save / load /
|
|
460
|
+
// delete is a message to it (see the contract at emMount). Outside the player nobody answers: `list` stays null and nothing is shown.
|
|
461
|
+
function Snapshots() {
|
|
462
|
+
const [list, setList] = useState(null);
|
|
463
|
+
const [name, setName] = useState('');
|
|
464
|
+
const [error, setError] = useState('');
|
|
465
|
+
const [picked, setPicked] = useState('');
|
|
466
|
+
React.useEffect(() => {
|
|
467
|
+
const onMsg = (e) => {
|
|
468
|
+
const d = e.data;
|
|
469
|
+
if (e.source !== window.parent || !d || d.source !== 'proto-host' || d.type !== 'snapshots') return;
|
|
470
|
+
setList(Array.isArray(d.list) ? d.list : []);
|
|
471
|
+
setError(d.error || '');
|
|
472
|
+
setPicked((p) => (Array.isArray(d.list) && d.list.some((s) => s.id === p) ? p : ''));
|
|
473
|
+
};
|
|
474
|
+
window.addEventListener('message', onMsg);
|
|
475
|
+
window.parent.postMessage({ source: 'proto-kit', type: 'snapshots-request' }, '*');
|
|
476
|
+
return () => window.removeEventListener('message', onMsg);
|
|
477
|
+
}, []);
|
|
478
|
+
if (list === null) return null;
|
|
479
|
+
const send = (msg) => window.parent.postMessage({ source: 'proto-kit', ...msg }, '*');
|
|
480
|
+
const save = () => { if (!name.trim()) return; send({ type: 'snapshot-save', name: name.trim(), events: rt.events }); setName(''); };
|
|
481
|
+
return (
|
|
482
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, margin: '0 0 10px' }}>
|
|
483
|
+
<div style={{ display: 'flex', gap: 6 }}>
|
|
484
|
+
<select className="em-input" style={{ flex: 1, minWidth: 0 }} value={picked} onChange={(e) => setPicked(e.target.value)} aria-label="Snapshots">
|
|
485
|
+
<option value="">{list.length ? 'Snapshots…' : 'No snapshots yet'}</option>
|
|
486
|
+
{list.map((s) => <option key={s.id} value={s.id}>{s.name} · {s.count} event{s.count === 1 ? '' : 's'} · {new Date(s.at).toLocaleString()}</option>)}
|
|
487
|
+
</select>
|
|
488
|
+
<button className="em-btn" disabled={!picked} onClick={() => send({ type: 'snapshot-load', id: picked })} title="Replace the event log with this snapshot">Load</button>
|
|
489
|
+
<button className="em-btn" disabled={!picked} onClick={() => send({ type: 'snapshot-delete', id: picked })} title="Delete the selected snapshot" aria-label="Delete snapshot">Delete</button>
|
|
490
|
+
</div>
|
|
491
|
+
<div style={{ display: 'flex', gap: 6 }}>
|
|
492
|
+
<input className="em-input" style={{ flex: 1, minWidth: 0 }} placeholder="Snapshot name" maxLength={60} value={name}
|
|
493
|
+
onChange={(e) => { setName(e.target.value); setError(''); }} onKeyDown={(e) => { if (e.key === 'Enter') save(); }} />
|
|
494
|
+
<button className="em-btn" disabled={!name.trim()} onClick={save} title="Save the current event log under this name">Save</button>
|
|
495
|
+
</div>
|
|
496
|
+
{error && <div style={{ color: '#f87171', fontSize: 12 }}>{error}</div>}
|
|
497
|
+
</div>
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
458
501
|
function Panel({ slices, open: panelOpen, onToggle }) {
|
|
459
502
|
const events = useLog();
|
|
460
503
|
const [tab, setTab] = useState('events');
|
|
@@ -499,6 +542,7 @@ function Panel({ slices, open: panelOpen, onToggle }) {
|
|
|
499
542
|
{tab === 'events' && (
|
|
500
543
|
<>
|
|
501
544
|
<div className="em-sect"><h2>Event log</h2><button className="em-btn" onClick={() => rt.reset()}>Reset</button></div>
|
|
545
|
+
<Snapshots />
|
|
502
546
|
<EventBrowser events={events} />
|
|
503
547
|
</>
|
|
504
548
|
)}
|
|
@@ -648,6 +692,8 @@ function App() {
|
|
|
648
692
|
* The message contract with the board's player (feedback + replay). Only the embedding page may talk to it; everything else is ignored.
|
|
649
693
|
* player → page { source: 'proto-host', type: 'events-request', id } → { source: 'proto-kit', type: 'events', id, events, cursor }
|
|
650
694
|
* { source: 'proto-host', type: 'load', events } replace the log (a replay starts from a recorded one)
|
|
695
|
+
* snapshots page → player { source: 'proto-kit', type: 'snapshots-request' | 'snapshot-save' {name, events} | 'snapshot-load' {id} | 'snapshot-delete' {id} }
|
|
696
|
+
* player → page { source: 'proto-host', type: 'snapshots', list: [{id, name, at, count}], error? } — the Event log panel's list; a load arrives as `load`
|
|
651
697
|
* { source: 'proto-host', type: 'view', position } show the state as of event #position (null = now)
|
|
652
698
|
* { source: 'proto-host', type: 'screen', title } switch to that screen's tab
|
|
653
699
|
* page → player { source: 'proto-kit', type: 'ready' } once mounted
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
* <out>/index.html — so a fresh session or folder still rebuilds only what changed), copies the kept blocks to
|
|
12
12
|
* <out>/.work/slices/<id>.html and writes <out>/.work/status.json. Prints which slices are new,
|
|
13
13
|
* changed, unchanged or removed. Only new and changed slices need a new block.
|
|
14
|
+
* For every new/changed slice it also writes a block skeleton to <out>/.work/slices/<id>.html (generated specs from the
|
|
15
|
+
* scenarios and storylines, HTML screens converted to JSX, a defineSlice stub with @@TODO where judgment is needed),
|
|
16
|
+
* and the slice's data to <out>/.work/data/<id>.json.
|
|
14
17
|
* assemble writes <out>/index.html from the template, runtime.js and every block in .work/slices that
|
|
15
18
|
* belongs to a current slice, in timeline order.
|
|
16
19
|
* upload sends <out>/index.html to the platform (POST .../prototype/<context id>), which sanitizes, gzips
|
|
@@ -22,6 +25,7 @@ import { createHash } from 'node:crypto';
|
|
|
22
25
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
23
26
|
import { dirname, join } from 'node:path';
|
|
24
27
|
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import { skeleton } from './scaffold.mjs';
|
|
25
29
|
|
|
26
30
|
const ASSETS = dirname(fileURLToPath(import.meta.url));
|
|
27
31
|
const [command, ...rest] = process.argv.slice(2);
|
|
@@ -139,13 +143,25 @@ async function status() {
|
|
|
139
143
|
rmSync(BLOCKS, { recursive: true, force: true });
|
|
140
144
|
mkdirSync(BLOCKS, { recursive: true });
|
|
141
145
|
|
|
146
|
+
mkdirSync(join(WORK, 'data'), { recursive: true });
|
|
147
|
+
mkdirSync(join(WORK, 'previous'), { recursive: true });
|
|
142
148
|
const frameworkVotes = {};
|
|
149
|
+
const generated = {};
|
|
143
150
|
const slices = (fields.slices || []).map((slice, order) => {
|
|
144
151
|
const own = (slice.screens || []).map((x) => screens[x.linkedId || x.id] || null);
|
|
145
152
|
for (const s of own) if (s && s.pages) frameworkVotes[(s.cssFrameworks || [])[0] || 'bulma'] = (frameworkVotes[(s.cssFrameworks || [])[0] || 'bulma'] || 0) + 1;
|
|
146
153
|
const h = hash({ slice, specs: specsById[slice.id] || null, screens: own });
|
|
147
154
|
const before = byId[slice.id];
|
|
148
155
|
if (before && before.hash === h) writeFileSync(join(BLOCKS, `${slice.id}.html`), before.block + '\n');
|
|
156
|
+
const state = !before ? 'new' : before.hash === h ? 'unchanged' : 'changed';
|
|
157
|
+
if (state !== 'unchanged') {
|
|
158
|
+
const ownScreens = (slice.screens || []).map((x) => ({ title: x.title, lane: x.lane, ...(screens[x.linkedId || x.id] || {}) }));
|
|
159
|
+
writeFileSync(join(WORK, 'data', `${slice.id}.json`), JSON.stringify({ slice, specs: specsById[slice.id] || null, screens: ownScreens }, null, 2));
|
|
160
|
+
if (before) writeFileSync(join(WORK, 'previous', `${slice.id}.html`), before.block + '\n');
|
|
161
|
+
const sk = skeleton({ slice, hash: h, order: order + 1, specSlice: specsById[slice.id] || null, screens: ownScreens });
|
|
162
|
+
writeFileSync(join(BLOCKS, `${slice.id}.html`), sk.block);
|
|
163
|
+
generated[slice.id] = { specs: sk.specCount, needs: sk.needs };
|
|
164
|
+
}
|
|
149
165
|
return {
|
|
150
166
|
id: slice.id,
|
|
151
167
|
title: slice.title,
|
|
@@ -153,7 +169,7 @@ async function status() {
|
|
|
153
169
|
chapter: slice.chapter,
|
|
154
170
|
order: order + 1,
|
|
155
171
|
hash: h,
|
|
156
|
-
state
|
|
172
|
+
state,
|
|
157
173
|
};
|
|
158
174
|
});
|
|
159
175
|
const current = new Set(slices.map((s) => s.id));
|
|
@@ -169,13 +185,16 @@ async function status() {
|
|
|
169
185
|
}
|
|
170
186
|
if (!contextName && !contextIsId) contextName = args.context;
|
|
171
187
|
|
|
188
|
+
rmSync(join(WORK, 'questions.json'), { force: true }); // the questions are written afresh on every run (SKILL.md Step 5b), never carried over
|
|
189
|
+
|
|
172
190
|
const report = { context: contextName, contextId, baseline, cssFramework, mixedFrameworks: Object.keys(frameworkVotes).length > 1, slices, removed };
|
|
173
191
|
writeFileSync(join(WORK, 'status.json'), JSON.stringify(report, null, 2));
|
|
174
192
|
for (const s of slices) console.log(`${s.state.padEnd(9)} ${s.hash} ${s.id} ${s.title}`);
|
|
175
193
|
for (const r of removed) console.log(`removed ${''.padEnd(16)} ${r.id} ${r.title}`);
|
|
176
194
|
const todo = slices.filter((s) => s.state !== 'unchanged').length;
|
|
177
195
|
console.log(`\n${todo} of ${slices.length} slices to build, ${removed.length} removed (${{ platform: 'compared with the stored prototype', local: 'compared with the local page', none: 'no prototype yet' }[baseline]}) — see ${join(WORK, 'status.json')}`);
|
|
178
|
-
|
|
196
|
+
for (const s of slices.filter((x) => x.state !== 'unchanged')) console.log(`skeleton ${s.title}: ${generated[s.id].specs} spec(s) generated${generated[s.id].needs.length ? `, ${generated[s.id].needs.length} need(s) by hand` : ''} → ${join(BLOCKS, `${s.id}.html`)}`);
|
|
197
|
+
console.log(`Questions: this run writes ${join(WORK, 'questions.json')} afresh (SKILL.md Step 5b) and uploads — "nothing changed" does not skip the questions.`);
|
|
179
198
|
}
|
|
180
199
|
|
|
181
200
|
const CSS = {
|
|
@@ -198,6 +217,7 @@ function assemble() {
|
|
|
198
217
|
const [found] = blocksIn(block);
|
|
199
218
|
if (!found || found.id !== s.id) problems.push(`${file}: not a slice block for ${s.id}`);
|
|
200
219
|
else if (found.hash !== s.hash) problems.push(`${file}: data-em-hash is ${found.hash}, the slice's hash is ${s.hash}`);
|
|
220
|
+
if (block.includes('@@TODO')) problems.push(`${file}: still has @@TODO — fill every one`);
|
|
201
221
|
if ((block.match(/<\/script/gi) || []).length !== 1) problems.push(`${file}: a closing script tag appears inside the block`);
|
|
202
222
|
return block;
|
|
203
223
|
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Deterministic parts of a slice block — generated by `prototype.mjs status`, so the agent only writes what needs judgment
|
|
3
|
+
* (decide / evolve / process and wiring the screen to hooks). Shared by the platform skill and the local prototype.
|
|
4
|
+
*
|
|
5
|
+
* specsOf(slice, specs) the slice's `specs` array (every scenario and storyline, Step 4's table) as data
|
|
6
|
+
* htmlToJsx(html) one HTML page → JSX (class→className, style strings→objects, void elements closed, handlers dropped)
|
|
7
|
+
* skeleton(...) the block file: script tag with hash, GENERATED_SPECS, one component per HTML page, a defineSlice stub
|
|
8
|
+
*
|
|
9
|
+
* Anything the generator cannot decide is left as `@@TODO` — `assemble` refuses a block that still contains one.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr']);
|
|
13
|
+
const ATTR = {
|
|
14
|
+
class: 'className', for: 'htmlFor', tabindex: 'tabIndex', colspan: 'colSpan', rowspan: 'rowSpan', readonly: 'readOnly',
|
|
15
|
+
maxlength: 'maxLength', minlength: 'minLength', autocomplete: 'autoComplete', autofocus: 'autoFocus', contenteditable: 'contentEditable',
|
|
16
|
+
crossorigin: 'crossOrigin', enctype: 'encType', formaction: 'formAction', frameborder: 'frameBorder', srcset: 'srcSet',
|
|
17
|
+
cellpadding: 'cellPadding', cellspacing: 'cellSpacing', datetime: 'dateTime', novalidate: 'noValidate', spellcheck: 'spellCheck',
|
|
18
|
+
'accept-charset': 'acceptCharset', 'stroke-width': 'strokeWidth', 'stroke-linecap': 'strokeLinecap', 'stroke-linejoin': 'strokeLinejoin',
|
|
19
|
+
'fill-rule': 'fillRule', 'clip-rule': 'clipRule', 'clip-path': 'clipPath', viewbox: 'viewBox', 'stop-color': 'stopColor',
|
|
20
|
+
};
|
|
21
|
+
const BOOLEAN = new Set(['checked', 'disabled', 'selected', 'readonly', 'required', 'multiple', 'hidden', 'autofocus', 'open', 'novalidate']);
|
|
22
|
+
|
|
23
|
+
const camel = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
24
|
+
const slug = (s) => String(s || '').replace(/[^A-Za-z0-9]+(.)?/g, (_, c) => (c || '').toUpperCase()).replace(/^[^A-Za-z]+/, '') || 'Screen';
|
|
25
|
+
export const pascal = (s) => { const x = slug(s); return x.charAt(0).toUpperCase() + x.slice(1); };
|
|
26
|
+
|
|
27
|
+
function styleObject(css) {
|
|
28
|
+
const parts = css.split(';').map((d) => d.trim()).filter(Boolean).map((d) => {
|
|
29
|
+
const i = d.indexOf(':');
|
|
30
|
+
if (i < 0) return null;
|
|
31
|
+
const k = d.slice(0, i).trim();
|
|
32
|
+
const v = d.slice(i + 1).trim();
|
|
33
|
+
const key = k.startsWith('--') ? JSON.stringify(k) : camel(k.replace(/^-ms-/, 'ms-'));
|
|
34
|
+
return `${key}: ${JSON.stringify(v)}`;
|
|
35
|
+
}).filter(Boolean);
|
|
36
|
+
return `{{ ${parts.join(', ')} }}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function attrsToJsx(raw) {
|
|
40
|
+
const out = [];
|
|
41
|
+
const re = /([^\s=/"'<>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
|
|
42
|
+
for (let m; (m = re.exec(raw)); ) {
|
|
43
|
+
const name = m[1];
|
|
44
|
+
const val = m[2] ?? m[3] ?? m[4];
|
|
45
|
+
const lower = name.toLowerCase();
|
|
46
|
+
if (lower.startsWith('on')) continue; // inline handlers: the page sanitizer strips them, the block wires its own
|
|
47
|
+
if (lower === 'style') { if (val) out.push(`style=${styleObject(val)}`); continue; }
|
|
48
|
+
const key = ATTR[lower] || (lower.startsWith('data-') || lower.startsWith('aria-') ? lower : /^[a-z]+$/.test(lower) ? lower : camel(name));
|
|
49
|
+
if (val === undefined) out.push(BOOLEAN.has(lower) ? `${key}` : `${key}={true}`);
|
|
50
|
+
else if (lower === 'value' || lower === 'checked') out.push(`${lower === 'value' ? 'defaultValue' : 'defaultChecked'}=${JSON.stringify(val)}`);
|
|
51
|
+
else out.push(`${key}=${JSON.stringify(val.replace(/"/g, '"').replace(/&/g, '&'))}`);
|
|
52
|
+
}
|
|
53
|
+
return out.length ? ' ' + out.join(' ') : '';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** One HTML page (document or fragment) → a JSX expression body (single root). */
|
|
57
|
+
export function htmlToJsx(html) {
|
|
58
|
+
let src = String(html || '');
|
|
59
|
+
const body = src.match(/<body\b[^>]*>([\s\S]*?)<\/body>/i);
|
|
60
|
+
if (body) src = body[1];
|
|
61
|
+
src = src.replace(/<!--[\s\S]*?-->/g, '').replace(/<script\b[\s\S]*?<\/script>/gi, '').replace(/<style\b[\s\S]*?<\/style>/gi, '');
|
|
62
|
+
const out = [];
|
|
63
|
+
const re = /<(\/?)([a-zA-Z][\w:-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)(\/?)>|([^<]+)|(<)/g;
|
|
64
|
+
const stack = [];
|
|
65
|
+
for (let m; (m = re.exec(src)); ) {
|
|
66
|
+
if (m[5] !== undefined) {
|
|
67
|
+
out.push(m[5].replace(/[{}]/g, (c) => `{'${c}'}`).replace(/ /g, ' '));
|
|
68
|
+
} else if (m[6]) {
|
|
69
|
+
out.push('<');
|
|
70
|
+
} else if (m[1]) {
|
|
71
|
+
const tag = m[2].toLowerCase();
|
|
72
|
+
if (VOID.has(tag)) continue;
|
|
73
|
+
const at = stack.lastIndexOf(tag);
|
|
74
|
+
if (at < 0) continue;
|
|
75
|
+
while (stack.length > at) out.push(`</${stack.pop()}>`);
|
|
76
|
+
} else {
|
|
77
|
+
const tag = m[2].toLowerCase() === m[2] ? m[2] : m[2];
|
|
78
|
+
const lower = tag.toLowerCase();
|
|
79
|
+
const attrs = attrsToJsx(m[3] || '');
|
|
80
|
+
if (VOID.has(lower) || m[4]) out.push(`<${tag}${attrs} />`);
|
|
81
|
+
else { out.push(`<${tag}${attrs}>`); stack.push(lower === tag ? tag : tag); }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
while (stack.length) out.push(`</${stack.pop()}>`);
|
|
85
|
+
return out.join('').trim();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── specs ───────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
const kindOf = (step) => String(step.type || '').replace(/^SPEC_/, '').toUpperCase();
|
|
91
|
+
|
|
92
|
+
/** Typed value of a step field example: numbers/booleans stay, JSON-looking strings of lists/objects are parsed. */
|
|
93
|
+
function typed(v) {
|
|
94
|
+
if (typeof v !== 'string') return v;
|
|
95
|
+
const t = v.trim();
|
|
96
|
+
if (t === 'true' || t === 'false') return t === 'true';
|
|
97
|
+
if (t !== '' && /^-?\d+(\.\d+)?$/.test(t)) return Number(t);
|
|
98
|
+
if (/^[[{]/.test(t)) { try { return JSON.parse(t); } catch { /* keep the text */ } }
|
|
99
|
+
return v;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const dataOf = (step) => Object.fromEntries((step.fields || []).filter((f) => f.name && f.example !== undefined && f.example !== '').map((f) => [f.name, typed(f.example)]));
|
|
103
|
+
const nameOf = (step) => String(step.title || '').replace(/\s+/g, '');
|
|
104
|
+
const eventOf = (step) => ({ type: nameOf(step), data: dataOf(step) });
|
|
105
|
+
|
|
106
|
+
/** The slice's specs: every GWT scenario and storyline as a spec entry (SKILL.md Step 4 table). `needs` lists what only a person/agent can add. */
|
|
107
|
+
export function specsOf(slice, specSlice) {
|
|
108
|
+
const specs = [];
|
|
109
|
+
const needs = [];
|
|
110
|
+
const automation = (slice.processors || [])[0];
|
|
111
|
+
for (const s of (specSlice && specSlice.specifications) || []) {
|
|
112
|
+
const then = s.then || [];
|
|
113
|
+
const given = (s.given || []).filter((x) => kindOf(x) === 'EVENT').map(eventOf);
|
|
114
|
+
const err = then.find((x) => kindOf(x) === 'ERROR');
|
|
115
|
+
const when = (s.when || []).find((x) => kindOf(x) === 'COMMAND');
|
|
116
|
+
const rm = then.find((x) => kindOf(x) === 'READMODEL') || (s.when || []).find((x) => kindOf(x) === 'READMODEL');
|
|
117
|
+
const cmds = then.filter((x) => kindOf(x) === 'COMMAND');
|
|
118
|
+
if (when && err) specs.push({ title: s.title, kind: 'command', given, when: { type: nameOf(when), data: dataOf(when) }, error: err.description || err.title });
|
|
119
|
+
else if (when) specs.push({ title: s.title, kind: 'command', given, when: { type: nameOf(when), data: dataOf(when) }, then: then.filter((x) => kindOf(x) === 'EVENT').map(eventOf) });
|
|
120
|
+
else if (rm) specs.push({ title: s.title, kind: 'readmodel', readModel: nameOf(rm), given, expect: s.expectEmptyList ? [] : Array.isArray(s.examples) ? s.examples.map((r) => (r && typeof r === 'object' ? Object.fromEntries(Object.entries(r).map(([k, v]) => [k, typed(v)])) : r)) : [] });
|
|
121
|
+
else if (cmds.length && automation) specs.push({ title: s.title, kind: 'automation', automation: String(automation.title).replace(/\s+/g, ''), given, expectCommands: cmds.map((c) => ({ type: nameOf(c), data: dataOf(c) })) });
|
|
122
|
+
else needs.push(`scenario "${s.title}" has no shape the generator knows — write its spec by hand`);
|
|
123
|
+
}
|
|
124
|
+
for (const sl of (specSlice && specSlice.storylines) || []) {
|
|
125
|
+
const given = [];
|
|
126
|
+
for (const beat of sl.elements || []) {
|
|
127
|
+
const k = kindOf(beat);
|
|
128
|
+
if (k === 'EVENT') given.push(eventOf(beat));
|
|
129
|
+
else if (k === 'COMMAND') specs.push({ title: `${sl.title}: ${beat.title}`, kind: 'command', given: [...given], when: { type: nameOf(beat), data: dataOf(beat) }, then: [] });
|
|
130
|
+
else if (k === 'READMODEL' && Array.isArray(beat.examples) && beat.examples.length) specs.push({ title: `${sl.title}: ${beat.title}`, kind: 'readmodel', readModel: nameOf(beat), given: [...given], expect: beat.examples });
|
|
131
|
+
}
|
|
132
|
+
if (!(sl.elements || []).length) needs.push(`storyline "${sl.title}" is empty`);
|
|
133
|
+
}
|
|
134
|
+
return { specs, needs };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── the block skeleton ──────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
const lit = (v) => JSON.stringify(v, null, 2).replace(/\n/g, '\n');
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The block file of one flagged slice. `screens` = the slice's screen nodes ({ title, node }) from status; `keep` = the previous block's text
|
|
143
|
+
* for a changed slice is NOT used here — a changed slice's old block is kept by status and patched by the agent, skeletons are for new slices.
|
|
144
|
+
*/
|
|
145
|
+
export function skeleton({ slice, hash, order, specSlice, screens }) {
|
|
146
|
+
const { specs, needs } = specsOf(slice, specSlice);
|
|
147
|
+
const parts = [];
|
|
148
|
+
const todos = [];
|
|
149
|
+
const screenEntries = [];
|
|
150
|
+
(screens || []).forEach((s, i) => {
|
|
151
|
+
const comp = `Screen${pascal(s.title)}${i + 1}`;
|
|
152
|
+
if (s.pages && s.pages.length) {
|
|
153
|
+
const pageNames = s.pages.map((p, n) => {
|
|
154
|
+
const name = s.pages.length > 1 ? `${comp}Page${n + 1}` : comp;
|
|
155
|
+
const html = typeof p === 'string' ? p : p.html || p.content || '';
|
|
156
|
+
parts.push(`function ${name}() {\n return (\n <>\n${htmlToJsx(html)}\n </>\n );\n}`);
|
|
157
|
+
return name;
|
|
158
|
+
});
|
|
159
|
+
if (pageNames.length > 1) {
|
|
160
|
+
parts.push(`function ${comp}() {\n const pages = [${pageNames.join(', ')}];\n const [page, setPage] = React.useState(0);\n const Page = pages[page];\n return <Page next={() => setPage(Math.min(page + 1, pages.length - 1))} />;\n}`);
|
|
161
|
+
}
|
|
162
|
+
todos.push(`${comp}: markup is the whole page translated 1:1 — cut it to this slice's region (marks decide when the screen is shared), replace sample data with useReadModel / useCommand, wire the buttons`);
|
|
163
|
+
screenEntries.push(` ${JSON.stringify(s.title)}: { actor: ${JSON.stringify(s.lane || 'User')}, part: ${comp} },`);
|
|
164
|
+
} else {
|
|
165
|
+
todos.push(`screen "${s.title}" is a sketch or has no pages — write its part by hand (sketch elements are in .work/data/${slice.id}.json)`);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
const cmdStubs = (slice.commands || []).filter((c) => !c.elementCopy).map((c) => ` ${String(c.title).replace(/\s+/g, '')}: { decide(history, cmd, ctx) { /* @@TODO */ } },`);
|
|
169
|
+
const rmStubs = (slice.readmodels || []).filter((c) => !c.elementCopy).map((c) => ` ${String(c.title).replace(/\s+/g, '')}: { initial: () => null, evolve(state, e) { /* @@TODO */ return state; } },`);
|
|
170
|
+
const autoStubs = (slice.processors || []).map((a) => ` ${String(a.title).replace(/\s+/g, '')}: { readModel: '@@TODO', trigger: 'auto', todo: (items) => items, process: (item) => ({ command: { type: '@@TODO', data: {} } }) },`);
|
|
171
|
+
const head = `<script type="text/plain" data-em-slice="${slice.id}" data-em-title="${String(slice.title).replace(/"/g, '"')}" data-em-hash="${hash}">`;
|
|
172
|
+
const body = [
|
|
173
|
+
`// GENERATED by prototype.mjs status — specs and screen markup. Edit in place and fill every open marker (assemble refuses a block that still has one).`,
|
|
174
|
+
...(needs.length ? needs.map((n) => `// NEEDS: ${n}`) : []),
|
|
175
|
+
...todos.map((t) => `// TODO: ${t}`),
|
|
176
|
+
`const GENERATED_SPECS = ${lit(specs)};`,
|
|
177
|
+
...parts,
|
|
178
|
+
[
|
|
179
|
+
`EMRuntime.defineSlice({`,
|
|
180
|
+
` id: ${JSON.stringify(slice.id)},`,
|
|
181
|
+
` title: ${JSON.stringify(slice.title)},`,
|
|
182
|
+
` order: ${order},`,
|
|
183
|
+
cmdStubs.length ? ` commands: {\n${cmdStubs.join('\n')}\n },` : '',
|
|
184
|
+
rmStubs.length ? ` readModels: {\n${rmStubs.join('\n')}\n },` : '',
|
|
185
|
+
autoStubs.length ? ` automations: {\n${autoStubs.join('\n')}\n },` : '',
|
|
186
|
+
` specs: GENERATED_SPECS,`,
|
|
187
|
+
screenEntries.length ? ` screens: {\n${screenEntries.join('\n')}\n },` : '',
|
|
188
|
+
`});`,
|
|
189
|
+
].filter(Boolean).join('\n'),
|
|
190
|
+
].filter(Boolean).join('\n\n');
|
|
191
|
+
return { block: `${head}\n${body}\n</script>\n`, specCount: specs.length, needs, todos };
|
|
192
|
+
}
|