@eventmodelers/cli 1.0.118 → 1.0.119
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/README.md +15 -0
- package/cli.js +36 -4
- package/package.json +1 -1
- package/shared/skills/learn-eventmodelers-api/SKILL.md +20 -0
- package/stacks/modeling-kit/templates/.claude/skills/analyze-code-base/SKILL.md +5 -3
- package/stacks/modeling-kit/templates/.claude/skills/detect-architecture-drift/SKILL.md +4 -2
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-orchestrating-event-modeling/SKILL.md +1 -1
- package/stacks/modeling-kit/templates/.claude/skills/summarize/SKILL.md +3 -3
package/README.md
CHANGED
|
@@ -436,6 +436,21 @@ different thing, and `anthropicBaseUrl` below is that path — but note that it
|
|
|
436
436
|
speaking Anthropic's own `/v1/messages`, which Ollama does not serve. Pointing it straight at
|
|
437
437
|
`localhost:11434` gets you a 404; a translating proxy has to sit in between.
|
|
438
438
|
|
|
439
|
+
### Asking for credentials again
|
|
440
|
+
|
|
441
|
+
A modeling kit installed in the project never asks on its own: `run --modeling` takes whatever
|
|
442
|
+
the `.eventmodelers/config.json` walk turns up. To replace those credentials (another account,
|
|
443
|
+
a rotated token, a different board) pass `--force`:
|
|
444
|
+
|
|
445
|
+
```bash
|
|
446
|
+
npx @eventmodelers/cli run --modeling --force
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
It re-runs the same credentials prompt as `init --modeling`, overwrites the project's
|
|
450
|
+
`.eventmodelers/config.json`, and then starts the loop with the new values. With the global
|
|
451
|
+
install, `--force` drops the "keep the stored credentials" choice so the board's credentials have
|
|
452
|
+
to be answered afresh. `init-config` (no flags) does the same re-ask without starting a loop.
|
|
453
|
+
|
|
439
454
|
### Running headless, in a container
|
|
440
455
|
|
|
441
456
|
`run` normally confirms which board it is about to drive and where that board's credentials
|
package/cli.js
CHANGED
|
@@ -1716,7 +1716,7 @@ async function fetchDefaultBoardId(baseUrl, token) {
|
|
|
1716
1716
|
// .eventmodelers/config.json up the tree beat ~/.eventmodelers/config.json. So
|
|
1717
1717
|
// `run --standalone --board-id <uuid>` is enough for a board used before, and any run can
|
|
1718
1718
|
// be pointed somewhere else entirely with --token/--organization-id.
|
|
1719
|
-
async function resolveModelingCredentials(cwd, flags, explicitConfigPath, print, nonInteractive = false) {
|
|
1719
|
+
async function resolveModelingCredentials(cwd, flags, explicitConfigPath, print, nonInteractive = false, force = false) {
|
|
1720
1720
|
const walked = loadEffectiveConfig(cwd, null, explicitConfigPath).config;
|
|
1721
1721
|
const explicit = Object.fromEntries(Object.entries(flags ?? {}).filter(([, v]) => v));
|
|
1722
1722
|
|
|
@@ -1795,7 +1795,8 @@ async function resolveModelingCredentials(cwd, flags, explicitConfigPath, print,
|
|
|
1795
1795
|
{ label: 'The account-wide credentials (~/.eventmodelers/config.json)', value: 'global' },
|
|
1796
1796
|
{ label: 'Credentials of its own — paste them now', value: 'board' },
|
|
1797
1797
|
];
|
|
1798
|
-
|
|
1798
|
+
// --force: the stored answer is exactly what the user wants to replace, so don't offer it.
|
|
1799
|
+
if (existing && !force) choices.unshift({ label: `Keep the credentials already stored for this board (${existing.from})`, value: 'keep' });
|
|
1799
1800
|
|
|
1800
1801
|
const configured = existing ? 'is already configured on this machine' : "hasn't been configured on this machine yet";
|
|
1801
1802
|
const choice = await selectPrompt(
|
|
@@ -1805,7 +1806,7 @@ async function resolveModelingCredentials(cwd, flags, explicitConfigPath, print,
|
|
|
1805
1806
|
choices,
|
|
1806
1807
|
// 'keep' when there is something to keep, else the pre-existing default: account-wide
|
|
1807
1808
|
// when it actually holds credentials, otherwise the paste.
|
|
1808
|
-
existing || hasAccountWide ? 0 : 1,
|
|
1809
|
+
(existing && !force) || hasAccountWide ? 0 : 1,
|
|
1809
1810
|
);
|
|
1810
1811
|
|
|
1811
1812
|
if (choice === 'keep') {
|
|
@@ -3639,6 +3640,7 @@ credentialFlags(program
|
|
|
3639
3640
|
.option('--exec [command]', 'Fallback for any harness --agent does not cover, or other flags than its preset. Hand each prompt to an external agent command instead of the default Claude runner — for agentic harnesses that bring their own tool loop, e.g. "codex exec --full-auto", "gemini --yolo -p" or "opencode run". One process per prompt; the prompt is appended as a quoted argument and also written to the file named by RALPH_PROMPT_FILE. Bare --exec uses localAi.exec from .eventmodelers/config.json. Claude remains the default when this flag is absent. A build kit runs it via ralph-exec.js; there, with agent tracing on, include the harness\'s JSON output flag (codex exec --json, opencode run --format json, gemini --output-format stream-json) — without it no slice cost is recorded. With --modeling/--standalone/--worker it is how any agent takes board prompts and chats: the loop claims each prompt or chat message and runs the command once for it. Keep the harness in its plain-text output mode there — stdout is the turn\'s answer — the eventmodelers MCP server is registered for it on every run (opencode.json, .gemini/settings.json, or -c flags for codex; any other command logs what to register by hand), authenticating with $EVENTMODELERS_TOKEN from the env the command inherits, and `init-agents --hosts <harness>` installs the skills for a harness that does not read .claude/skills. No subagents: self-directed work is done inline.')
|
|
3640
3641
|
.option('--bash', 'Use the bash-only ralph.sh loop (build-kit stacks only, no realtime)')
|
|
3641
3642
|
.option('--modeling', 'Connect a modeling agent to the board: it takes prompts and chat messages and hands each to an AI — by default one Claude process kept warm across prompts, for low-latency voice/live use; --agent (or --local-ai, or --exec as the manual fallback) picks another. Runs from a modeling-kit install in this directory, or from the global install (~/.eventmodelers/kit) when there is none. Built into the CLI, not a per-project file.')
|
|
3643
|
+
.option('-f, --force', 'Ask for credentials again before the modeling loop starts, even when a config already has everything required. With a modeling kit in this directory it re-runs the same prompt as `init --modeling`, overwriting the project\'s .eventmodelers/config.json; with the global install it drops the "keep the stored credentials" choice, so a board\'s credentials have to be answered afresh. Only affects the modeling loop (--modeling/--standalone/--worker/--global); cannot be combined with --non-interactive or --print.')
|
|
3642
3644
|
.option('--non-interactive', 'Never ask anything: take the board and credentials that resolve from flags, EVENTMODELERS_* env vars and the config files, and fail with the reason if they are incomplete instead of interviewing for them. A TTY was previously the only signal — right for CI or a supervisor, wrong for a loop started from a terminal, where stdin is a TTY nobody is watching and the run stops on a question. Only affects the modeling loop (--modeling/--standalone/--worker/--global).')
|
|
3643
3645
|
.option('--standalone', 'Let the modeling agent act on board changes on its own initiative, without asking: it subscribes to the board\'s change channel and, whenever the board goes quiet after an edit — or has simply been idle for a while — it takes a turn nobody asked for, judges the model as a whole and fans the work out over parallel subagents, one per changed area (examples on a new node, specs for a new command or read model, a missing attribute along a chain, a screen, a question). Filling that detail in while the human keeps modeling is the point. A plain --modeling agent only proposes, and only to the people chatting with it: after their edits it says in the chat what it would do and waits for a yes; everyone else\'s edits are ignored. Implies --modeling.')
|
|
3644
3646
|
.option('--max-agents <n>', 'Cap how many subagents a self-directed --standalone turn may dispatch at once, to bound what an unattended agent can spend per turn. The agent merges work that shares a slice or chain first, then takes the most valuable pieces up to this many and leaves the rest for a later turn. 1 makes it do the single most valuable piece itself, without spawning anything. Default 5. Ignored without --standalone — prompt turns are one piece of work by definition.', '5')
|
|
@@ -3685,6 +3687,9 @@ credentialFlags(program
|
|
|
3685
3687
|
}
|
|
3686
3688
|
// The build-kit runners claim their work from the same queue but have no addressee
|
|
3687
3689
|
// filter, so the flag would silently do nothing there rather than half of what it says.
|
|
3690
|
+
if (opts.force && !(opts.modeling || opts.standalone || opts.worker || opts.global)) {
|
|
3691
|
+
console.log('ℹ️ --force only applies to the modeling loop (--modeling/--standalone/--worker/--global); for a build kit use `re-init --force` or `init-config`. Ignoring it here.');
|
|
3692
|
+
}
|
|
3688
3693
|
if (opts.exclusive && !(opts.modeling || opts.standalone || opts.worker || opts.global)) {
|
|
3689
3694
|
console.log('ℹ️ --exclusive only applies to the modeling loop (--modeling/--standalone/--worker/--global); ignoring it here.');
|
|
3690
3695
|
}
|
|
@@ -3749,6 +3754,12 @@ credentialFlags(program
|
|
|
3749
3754
|
console.error('❌ --local-ai and --exec are mutually exclusive — pick one runner.');
|
|
3750
3755
|
process.exit(1);
|
|
3751
3756
|
}
|
|
3757
|
+
// --force means "ask me", --non-interactive/--print mean "never ask" — one has to lose,
|
|
3758
|
+
// and silently dropping either would surprise whoever passed it.
|
|
3759
|
+
if (opts.force && (opts.nonInteractive || globalOpts.print)) {
|
|
3760
|
+
console.error(`❌ --force asks for credentials again, which --non-interactive/--print rule out — pick one.`);
|
|
3761
|
+
process.exit(1);
|
|
3762
|
+
}
|
|
3752
3763
|
if (opts.local) {
|
|
3753
3764
|
console.error(`❌ ${picked} has no local-only mode — it is always driven by the org-wide realtime prompt queue, so --local has no use for it.`);
|
|
3754
3765
|
process.exit(1);
|
|
@@ -3777,10 +3788,31 @@ credentialFlags(program
|
|
|
3777
3788
|
...(opts.credentials ? parseCredentialsArg(opts.credentials) : {}),
|
|
3778
3789
|
...Object.fromEntries(Object.entries(credentialOverridesFromOpts(opts)).filter(([, v]) => v)),
|
|
3779
3790
|
};
|
|
3780
|
-
const config = await resolveModelingCredentials(cwd, flags, globalOpts.config, globalOpts.print, !!opts.nonInteractive);
|
|
3791
|
+
const config = await resolveModelingCredentials(cwd, flags, globalOpts.config, globalOpts.print, !!opts.nonInteractive, !!opts.force);
|
|
3781
3792
|
projectDir = await ensureGlobalKit(config.baseUrl);
|
|
3782
3793
|
kitDir = join(projectDir, MODELING_KIT.kitDirName);
|
|
3783
3794
|
overrides = config;
|
|
3795
|
+
} else if (opts.force) {
|
|
3796
|
+
// A project install never asks on its own — runModeling just walks the config files.
|
|
3797
|
+
// --force re-runs init's own credentials step against the same project-root config,
|
|
3798
|
+
// so the loop below picks up the fresh answer through that very walk.
|
|
3799
|
+
const configPath = globalOpts.config
|
|
3800
|
+
? resolve(projectDir, globalOpts.config)
|
|
3801
|
+
: join(projectDir, '.eventmodelers', 'config.json');
|
|
3802
|
+
const effective = loadEffectiveConfig(projectDir, kitDir, globalOpts.config);
|
|
3803
|
+
const cfg = await configureCredentials({
|
|
3804
|
+
config: effective.config,
|
|
3805
|
+
configPath,
|
|
3806
|
+
targetDir: projectDir,
|
|
3807
|
+
requiredFields: ['organizationId', 'token'],
|
|
3808
|
+
boardIdOptional: true,
|
|
3809
|
+
overrides: credentialOverridesFromOpts(opts),
|
|
3810
|
+
print: false,
|
|
3811
|
+
force: true,
|
|
3812
|
+
});
|
|
3813
|
+
ensureMcpRegistered(projectDir, cfg.baseUrl || DEFAULT_BASE_URL);
|
|
3814
|
+
ensureEnvToken(projectDir, cfg.token);
|
|
3815
|
+
ensureMcpEnabled(projectDir);
|
|
3784
3816
|
}
|
|
3785
3817
|
|
|
3786
3818
|
// Writes to a stdout pipe are asynchronous on POSIX — without waiting for this
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eventmodelers/cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.119",
|
|
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": {
|
|
@@ -377,6 +377,26 @@ Place a free-text markdown note in that lane the same way any other node is plac
|
|
|
377
377
|
|
|
378
378
|
The node's content lives in `meta.description` (a plain string of markdown source) — **not** `meta.content`; that field is silently accepted and stored but never rendered, producing a visibly empty note. There is no `fields[]` array on this element type, and no separate render/sketch call is needed (unlike SCREEN/HTML_SCREEN). `node.type` comes back as `"markdown"` (lowercase) on read.
|
|
379
379
|
|
|
380
|
+
### Linking to elements in markdown: `ref:<nodeId>`
|
|
381
|
+
|
|
382
|
+
Any markdown the app renders can point at an element on the board. Clicking the link zooms the canvas to that element. This works in MARKDOWN notes, element `meta.description`s (doc view and slice view) and timeline info notes.
|
|
383
|
+
|
|
384
|
+
| Write | Renders as |
|
|
385
|
+
|-------|------------|
|
|
386
|
+
| `ref:<nodeId>` | the element's **current** title, so it stays right after a rename. Use this one by default. |
|
|
387
|
+
| `[Visit Booked](ref:<nodeId>)` | your own label, for a different wording or a phrase in a sentence |
|
|
388
|
+
|
|
389
|
+
```markdown
|
|
390
|
+
| # | Decision | Element(s) |
|
|
391
|
+
|---|----------|------------|
|
|
392
|
+
| 1 | Insert into `visits` → event | ref:3f2b8c1e-1a2b-4c3d-8e9f-0123456789ab |
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
- `<nodeId>` is the full node uuid, taken from a read or from your own `node:created`. Never make one up: an id that isn't on the open board renders as a greyed-out `ref:3f2b8c1e…` link.
|
|
396
|
+
- A slice can be linked through its slice border's node id.
|
|
397
|
+
- Write it as a separate word (`see ref:<id>`, not `xref:<id>`). Inside backticks or code blocks it stays plain text.
|
|
398
|
+
- Use it only where the app renders markdown. Chat message `text` is plain text, so use a snippet's `nodeId` there (`link`, `tasks`, `changes`). Comments are plain text too.
|
|
399
|
+
|
|
380
400
|
---
|
|
381
401
|
|
|
382
402
|
## 3. Nodes
|
|
@@ -160,6 +160,8 @@ Create notes with `submit_node_events` (`node:created`, `meta.type: "MARKDOWN"`,
|
|
|
160
160
|
|
|
161
161
|
**Use tables, not prose.** Each note is mostly markdown tables. Prose is allowed only for a one-line intro, never for something that fits in a row.
|
|
162
162
|
|
|
163
|
+
**Link elements, don't just name them.** Wherever a note mentions an element on the board, write `ref:<nodeId>`. It renders as the element's current title, and a click zooms the canvas to it (see `/learn-eventmodelers-api` *Linking to elements in markdown*). Use `[label](ref:<nodeId>)` only when the wording must differ. Take the id from your `node:created` or from a read. An element that isn't placed yet keeps its plain *italic* name until it is, and then the row is updated.
|
|
164
|
+
|
|
163
165
|
**References must be precise and resolvable.** Paths are relative to the analysed code root, with a line number where one exists: `src/main/java/org/acme/visit/VisitController.java:57`, plus the class and method (`VisitController#processNewVisitForm`), table, endpoint (`POST /owners/{id}/pets/{petId}/visits/new`), test (`VisitControllerTests#testProcessNewVisitFormSuccess`) or template (`templates/pets/createOrUpdateVisitForm.html`). Use folders and packages for anything bigger than one class. Never "the visit code". A line number is valid for the commit of the *Analysis history* row that wrote it; when a later pass re-reads a changed file, it updates the references into that file.
|
|
164
166
|
|
|
165
167
|
### The analysis log — column 0 of every chapter
|
|
@@ -194,7 +196,7 @@ Code root `petclinic/` · running UI: http://localhost:8080 (test account `georg
|
|
|
194
196
|
## Chapter decisions
|
|
195
197
|
| # | Decision | Why | Source | Confirmed by |
|
|
196
198
|
|---|----------|-----|--------|--------------|
|
|
197
|
-
| 1 | Flow starts at
|
|
199
|
+
| 1 | Flow starts at ref:<petAddedId> | A visit needs a pet (`Visit.petId` not null) | `Visit.java:34` | person |
|
|
198
200
|
|
|
199
201
|
## Not modeled
|
|
200
202
|
| Code | Reason |
|
|
@@ -207,8 +209,8 @@ Code root `petclinic/` · running UI: http://localhost:8080 (test account `georg
|
|
|
207
209
|
```markdown
|
|
208
210
|
| # | Decision | Element(s) | Why | Source | Confirmed by |
|
|
209
211
|
|---|----------|------------|-----|--------|--------------|
|
|
210
|
-
| 1 | Insert into `visits` → event |
|
|
211
|
-
| 2 | Title *Book Visit*, not *New Visit Form* |
|
|
212
|
+
| 1 | Insert into `visits` → event | ref:<visitBookedId> | the only write of the booking | `VisitController.java:57` `#processNewVisitForm` | code |
|
|
213
|
+
| 2 | Title *Book Visit*, not *New Visit Form* | ref:<bookVisitId> | the business word | person, 2nd answer | person |
|
|
212
214
|
|
|
213
215
|
## Code vs. business terms
|
|
214
216
|
| Code | Board |
|
|
@@ -147,16 +147,18 @@ The answer comes as a `CHAT` turn (`Please do these:` + ticked titles or ids). R
|
|
|
147
147
|
|
|
148
148
|
Print the report below, with every finding as a `- [ ]` checkbox carrying its proposed fix, and ask which to apply. Wait for the answer.
|
|
149
149
|
|
|
150
|
+
Name each slice and element as `[<title>](ref:<nodeId>)` (the slice border's id for a slice). The terminal shows the title, and once the report is in a board note or an element description, a click zooms the canvas to it (see `/learn-eventmodelers-api` *Linking to elements in markdown*). A slice that exists only in code has no node, so it stays plain text.
|
|
151
|
+
|
|
150
152
|
```
|
|
151
153
|
## Architecture Drift — <scope: board | timeline "<title>" | context "<name>">
|
|
152
154
|
Compared: <n> slices on the board, <n> slice folders in code Analysed: <ISO timestamp>
|
|
153
155
|
|
|
154
156
|
### Slices
|
|
155
157
|
- [ ] <slice> — in code, not on the board → add it to the model
|
|
156
|
-
- [ ] <slice> — board says Done, no code → set back to Planned
|
|
158
|
+
- [ ] [<slice>](ref:<sliceBorderId>) — board says Done, no code → set back to Planned
|
|
157
159
|
|
|
158
160
|
### Fields
|
|
159
|
-
- [ ] <slice> / <element
|
|
161
|
+
- [ ] [<slice>](ref:<sliceBorderId>) / [<element>](ref:<nodeId>): model `customerId: String`, code `custoemrId` → fix model typo
|
|
160
162
|
|
|
161
163
|
### Specifications / Screens / Edges
|
|
162
164
|
- [ ] ...
|
|
@@ -448,7 +448,7 @@ Not delegated to a separate skill — performed directly by this orchestrating s
|
|
|
448
448
|
|
|
449
449
|
The note's body lives in **`meta.description`** as plain markdown source — headings, lists, bold, code fences, tables all render. **Not `meta.content`** — that field is accepted and stored without error but never rendered by the board UI, producing a visibly empty note; this was caught by comparing against a note authored directly in the UI, so treat it as confirmed, not a guess. There is no separate render/sketch call (unlike SCREEN/HTML_SCREEN) and no `fields[]` array on this element type.
|
|
450
450
|
|
|
451
|
-
**What the note should actually contain** — write for the next person (or next session) who opens this board cold, not for whoever just built it:
|
|
451
|
+
**What the note should actually contain** — write for the next person (or next session) who opens this board cold, not for whoever just built it. Wherever it names an element on the board, write `ref:<nodeId>`: it renders as the element's title and a click zooms the canvas to it (see `/learn-eventmodelers-api` *Linking to elements in markdown*):
|
|
452
452
|
- **Scope**: what business process this chapter covers, and its entities (identity keys).
|
|
453
453
|
- **Assumptions added beyond the literal brief** — anything invented to fill a gap the requirements left open, and why (e.g. adding a resolution event so a state isn't a one-way trap door).
|
|
454
454
|
- **Business rules deliberately encoded as scenarios, not new events** — so a reader doesn't mistake a missing event for an oversight.
|
|
@@ -171,7 +171,7 @@ The summary lives **on the board, not in a file**: one MARKDOWN note per chapter
|
|
|
171
171
|
2. Take the leftmost entry of `meta.timelineData.columns` as the first column (re-read the chapter after modeling — columns may have been added or reordered).
|
|
172
172
|
3. Create the note: `node:created`, `meta.type: "MARKDOWN"`, `cellId = "<feedbackLaneId>-<firstColumnId>"`, title `Summary — <Chapter Name>`, body as plain markdown in **`meta.description`** (not `meta.content` — it is stored but never rendered).
|
|
173
173
|
|
|
174
|
-
Exactly one summary note per chapter; on a re-run, update the existing one. Write it **after** the chapter's model, scenarios and review are complete, so it describes the finished shape. Use the source document's language (or `language`). Each note must read standalone for someone who has not seen the source, and carry enough detail to start specifying and building that chapter. Tables over prose, no filler.
|
|
174
|
+
Name every board element by link: `ref:<nodeId>` renders as the element's title and zooms the canvas to it on click (see `/learn-eventmodelers-api` *Linking to elements in markdown*). Exactly one summary note per chapter; on a re-run, update the existing one. Write it **after** the chapter's model, scenarios and review are complete, so it describes the finished shape. Use the source document's language (or `language`). Each note must read standalone for someone who has not seen the source, and carry enough detail to start specifying and building that chapter. Tables over prose, no filler.
|
|
175
175
|
|
|
176
176
|
**Every chapter's note:**
|
|
177
177
|
|
|
@@ -183,7 +183,7 @@ Exactly one summary note per chapter; on a re-run, update the existing one. Writ
|
|
|
183
183
|
| What | When | Source | Consequence of missing it |
|
|
184
184
|
|
|
185
185
|
## Business rules
|
|
186
|
-
| Rule (verbatim thresholds) | Modeled in (command / read model) | Scenario | Source |
|
|
186
|
+
| Rule (verbatim thresholds) | Modeled in (`ref:<nodeId>` of the command / read model) | Scenario | Source |
|
|
187
187
|
|
|
188
188
|
## Requirements & deliverables
|
|
189
189
|
Mandatory vs. optional (Muss/Kann), each with the board element that tracks it.
|
|
@@ -192,7 +192,7 @@ Mandatory vs. optional (Muss/Kann), each with the board element that tracks it.
|
|
|
192
192
|
Forms/documents exchanged and their fields, where known.
|
|
193
193
|
|
|
194
194
|
## Elements
|
|
195
|
-
Counts and names of the key events, commands, read models, automations, screens, and the slice list (state-change / state-view / automation)
|
|
195
|
+
Counts and names of the key events, commands, read models, automations, screens, and the slice list (state-change / state-view / automation), each as `ref:<nodeId>`. Note external parties and their translated events.
|
|
196
196
|
|
|
197
197
|
## Open questions & risks
|
|
198
198
|
| Question / risk | Source(s) | Why it matters |
|