@eventmodelers/cli 1.0.87 → 1.0.89
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 +2 -0
- package/package.json +1 -1
- package/shared/build-kit/lib/backoff.js +12 -0
- package/shared/build-kit/lib/ralph.js +65 -32
- package/shared/build-kit/lib/tracing.js +3 -4
- package/shared/build-kit/lib/turn.js +57 -0
- package/shared/build-kit/ralph-claude.js +18 -4
- package/shared/build-kit/ralph-exec.js +7 -2
- package/shared/skills/connect/SKILL.md +2 -0
- package/shared/skills/learn-eventmodelers-api/SKILL.md +22 -15
- package/stacks/modeling-kit/templates/.claude/skills/discover-storyboard/SKILL.md +2 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-storyboarding-events/SKILL.md +1 -1
- package/stacks/modeling-kit/templates/.claude/skills/examples/SKILL.md +7 -17
- package/stacks/modeling-kit/templates/.claude/skills/examples/references/api-fallback.md +4 -3
- package/stacks/modeling-kit/templates/.claude/skills/html-screen/SKILL.md +2 -1
- package/stacks/modeling-kit/templates/.claude/skills/html-screen/references/api-fallback.md +2 -0
- package/stacks/modeling-kit/templates/.claude/skills/storyboard/SKILL.md +1 -1
- package/stacks/modeling-kit/templates/kit/CLAUDE-STANDALONE.md +1 -4
package/README.md
CHANGED
|
@@ -55,6 +55,8 @@ npx @eventmodelers/cli run
|
|
|
55
55
|
|
|
56
56
|
`run` doesn't re-configure anything. It finds whatever kit dir `init` already created in this project and starts its agent loop (`ralph-claude.js`) against your existing `.eventmodelers/config.json`.
|
|
57
57
|
|
|
58
|
+
Each agent turn is capped at 60 minutes by default so a hung agent can't stall the loop: it gets SIGTERM, then SIGKILL 10s later, and a slice that keeps timing out is Blocked by the stuck-slice guard. Change the cap with `"turnTimeoutMinutes"` in `.eventmodelers/config.json` or `RALPH_TURN_TIMEOUT_MIN` for one run; `0` turns it off.
|
|
59
|
+
|
|
58
60
|
**Pulling the latest board state without starting an agent** — e.g. to inspect a context's slices, or in a script:
|
|
59
61
|
|
|
60
62
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eventmodelers/cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.89",
|
|
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": {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Exponential backoff with jitter: baseMs, 2×, 4×, … capped at capMs. Half the delay is
|
|
2
|
+
// fixed, half random, so agents that failed together (a platform restart takes every agent
|
|
3
|
+
// on it down at once) don't all come back in the same second.
|
|
4
|
+
//
|
|
5
|
+
// attempt is 1-based — the delay before the first retry is between baseMs/2 and baseMs.
|
|
6
|
+
|
|
7
|
+
export function backoffMs(attempt, { baseMs, capMs }) {
|
|
8
|
+
const ceiling = Math.min(capMs, baseMs * 2 ** Math.max(0, attempt - 1));
|
|
9
|
+
return Math.round(ceiling / 2 + Math.random() * (ceiling / 2));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -11,6 +11,7 @@ import { join, dirname } from 'path';
|
|
|
11
11
|
import { homedir } from 'os';
|
|
12
12
|
import { randomUUID } from 'crypto';
|
|
13
13
|
import { createRealtimeAdapter } from './adapters/realtime-adapter.js';
|
|
14
|
+
import { backoffMs, sleep } from './backoff.js';
|
|
14
15
|
|
|
15
16
|
// ── HTTP helpers ──────────────────────────────────────────────────────────────
|
|
16
17
|
|
|
@@ -275,6 +276,15 @@ async function handleSliceChanged(payload, cfg, kitDir, queueAllStatuses) {
|
|
|
275
276
|
await retryOn401('fetchAndPersistSlices', () => fetchAndPersistSlices(cfg, kitDir)).catch((err) =>
|
|
276
277
|
console.error('[agent] Slice persist error:', err),
|
|
277
278
|
);
|
|
279
|
+
// This agent's own status writes (claim → InProgress, → Done, → Blocked) come straight back
|
|
280
|
+
// as slice:changed. Queuing them only buys a turn in which the agent reads its own echo and
|
|
281
|
+
// skips it. Attribution decides, never a guess: only an agentId equal to ours is dropped —
|
|
282
|
+
// a person's edit or another agent's (different id), or an event with no attribution at all,
|
|
283
|
+
// is still queued, so a missing field degrades to the old behaviour rather than losing work.
|
|
284
|
+
if (payload?.agentId && cfg.agentId && payload.agentId === cfg.agentId) {
|
|
285
|
+
console.log(`[agent] Own write — not queued (slice="${payload.sliceTitle}" status="${payload.sliceStatus}")`);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
278
288
|
// Planned slices are handled by onPlannedSlice directly — no task needed.
|
|
279
289
|
// queueAllStatuses opts out of that split entirely (e.g. bridge has no
|
|
280
290
|
// onPlannedSlice consumer, so a lingering Planned slice would otherwise
|
|
@@ -321,11 +331,33 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllSt
|
|
|
321
331
|
|
|
322
332
|
// A subscribe that errors on a stale token needs a fresh token AND a new join
|
|
323
333
|
// attempt — setAuth alone doesn't re-join a channel that already errored out.
|
|
324
|
-
//
|
|
325
|
-
//
|
|
334
|
+
// Retried with exponential backoff (2s → 5min) rather than a fixed delay and a cap:
|
|
335
|
+
// the cap used to leave the channel dead for good — the scheduled refresh only calls
|
|
336
|
+
// setAuth, it never re-joins — while the backoff alone keeps a genuinely revoked
|
|
337
|
+
// access from hammering the platform. A revoked token ends the process anyway, via
|
|
338
|
+
// retryOn401 inside refreshToken.
|
|
326
339
|
let channelErrorStreak = 0;
|
|
340
|
+
let resubscribeTimer = null;
|
|
341
|
+
const onChannelFailure = (reason) => {
|
|
342
|
+
// One failure can be reported more than once (a channel erroring while its retry is
|
|
343
|
+
// already scheduled); a second resubscribe on top would join the channel twice.
|
|
344
|
+
if (resubscribeTimer) return;
|
|
345
|
+
channelErrorStreak += 1;
|
|
346
|
+
const delay = backoffMs(channelErrorStreak, { baseMs: 2_000, capMs: 5 * 60_000 });
|
|
347
|
+
console.warn(`[agent] ${ts()} Channel "${channelName}": ${reason} (attempt ${channelErrorStreak}) — resubscribing in ${Math.round(delay / 1000)}s`);
|
|
348
|
+
resubscribeTimer = setTimeout(async () => {
|
|
349
|
+
try {
|
|
350
|
+
await refreshToken(`channel ${reason}`);
|
|
351
|
+
} catch {
|
|
352
|
+
// Logged by refreshToken. Rejoining on the old token is still worth a try —
|
|
353
|
+
// if it fails too, that's just the next failure, with a longer delay.
|
|
354
|
+
}
|
|
355
|
+
resubscribeTimer = null;
|
|
356
|
+
subscribeChannel();
|
|
357
|
+
}, delay);
|
|
358
|
+
};
|
|
327
359
|
const subscribeChannel = () => {
|
|
328
|
-
realtime.subscribe(
|
|
360
|
+
Promise.resolve(realtime.subscribe(
|
|
329
361
|
channelName,
|
|
330
362
|
{
|
|
331
363
|
// A kill names exactly one agent: {type: 'kill', id: '<agentId>', instruction: 'exit'}.
|
|
@@ -340,32 +372,22 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllSt
|
|
|
340
372
|
},
|
|
341
373
|
'slice:changed': (payload) => handleSliceChanged(payload, cfg, kitDir, queueAllStatuses),
|
|
342
374
|
},
|
|
343
|
-
|
|
344
|
-
if (status
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
} else {
|
|
348
|
-
console.log(`[agent] ${ts()} Channel "${channelName}": ${status}`);
|
|
349
|
-
}
|
|
350
|
-
channelErrorStreak = 0;
|
|
351
|
-
return;
|
|
352
|
-
}
|
|
353
|
-
channelErrorStreak += 1;
|
|
354
|
-
console.warn(`[agent] ${ts()} Channel "${channelName}": ${status} (attempt ${channelErrorStreak}/5)`);
|
|
355
|
-
if (channelErrorStreak > 5) {
|
|
356
|
-
console.error(`[agent] ${ts()} Channel "${channelName}" failed ${channelErrorStreak} times in a row — giving up until the next scheduled token refresh (every 10min)`);
|
|
375
|
+
(status) => {
|
|
376
|
+
if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') return onChannelFailure(status);
|
|
377
|
+
if (status !== 'SUBSCRIBED' || channelErrorStreak === 0) {
|
|
378
|
+
console.log(`[agent] ${ts()} Channel "${channelName}": ${status}`);
|
|
357
379
|
return;
|
|
358
380
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
console.error(`[agent] ${ts()}
|
|
366
|
-
|
|
381
|
+
console.log(`[agent] ${ts()} Channel "${channelName}": ${status} — recovered after ${channelErrorStreak} failed attempt(s)`);
|
|
382
|
+
channelErrorStreak = 0;
|
|
383
|
+
// Broadcasts sent while the channel was down are gone. Re-read the slices once so a
|
|
384
|
+
// slice set to Planned during the gap still gets built — .slices/ is what ralphLoop
|
|
385
|
+
// picks Planned work from.
|
|
386
|
+
retryOn401('fetchAndPersistSlices (after reconnect)', () => fetchAndPersistSlices(cfg, kitDir)).catch((err) =>
|
|
387
|
+
console.error(`[agent] ${ts()} Slice re-fetch after reconnect failed:`, err),
|
|
388
|
+
);
|
|
367
389
|
},
|
|
368
|
-
);
|
|
390
|
+
)).catch((err) => onChannelFailure(`subscribe failed: ${err.message}`));
|
|
369
391
|
};
|
|
370
392
|
subscribeChannel();
|
|
371
393
|
|
|
@@ -531,15 +553,26 @@ async function blockStuckSlice(kitDir, cfg, credentialed, planned, attempts) {
|
|
|
531
553
|
console.error(`[ralph] ${reason} Marked "${planned.title}" (id=${planned.id}) as Blocked — moving on.`);
|
|
532
554
|
}
|
|
533
555
|
|
|
556
|
+
// Returns true once fn succeeds; false for a turn that timed out (see lib/turn.js). A timeout
|
|
557
|
+
// is not retried in place: re-running the same hung turn every 60s would spin forever without
|
|
558
|
+
// the stuck-slice guard ever seeing it, so it goes back to ralphLoop, which recounts the slice.
|
|
559
|
+
// Any other failure is retried in place with exponential backoff (30s → 10min): a harness
|
|
560
|
+
// that fails fast (API outage, rate limit, broken install) used to be relaunched every 60s
|
|
561
|
+
// for as long as it stayed broken.
|
|
534
562
|
async function runWithRetry(label, fn) {
|
|
535
|
-
|
|
563
|
+
for (let attempt = 1; ; attempt++) {
|
|
536
564
|
try {
|
|
537
565
|
console.log(`[ralph] ${label}`);
|
|
538
566
|
await fn();
|
|
539
|
-
return;
|
|
567
|
+
return true;
|
|
540
568
|
} catch (err) {
|
|
541
|
-
|
|
542
|
-
|
|
569
|
+
if (err?.timedOut) {
|
|
570
|
+
console.error(`[ralph] ${err.message} — back to the loop`);
|
|
571
|
+
return false;
|
|
572
|
+
}
|
|
573
|
+
const delay = backoffMs(attempt, { baseMs: 30_000, capMs: 10 * 60_000 });
|
|
574
|
+
console.error(`[ralph] Error (attempt ${attempt}) — retrying in ${Math.round(delay / 1000)}s:`, err.message);
|
|
575
|
+
await sleep(delay);
|
|
543
576
|
}
|
|
544
577
|
}
|
|
545
578
|
}
|
|
@@ -580,7 +613,7 @@ async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice, localOnly = false)
|
|
|
580
613
|
}
|
|
581
614
|
|
|
582
615
|
const prompt = readFileSync(backendPromptFile, 'utf-8');
|
|
583
|
-
await runWithRetry(`onPlannedSlice: building slice "${planned.title}"...`, () => onPlannedSlice(prompt, {
|
|
616
|
+
const built = await runWithRetry(`onPlannedSlice: building slice "${planned.title}"...`, () => onPlannedSlice(prompt, {
|
|
584
617
|
sliceId: planned.id,
|
|
585
618
|
sliceTitle: planned.title,
|
|
586
619
|
context: planned.ctx,
|
|
@@ -588,7 +621,7 @@ async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice, localOnly = false)
|
|
|
588
621
|
boardId: cfg.boardId ?? null,
|
|
589
622
|
attempt: stuckSlice.count,
|
|
590
623
|
}));
|
|
591
|
-
console.log(`[ralph] Slice build complete — waiting for next slice`);
|
|
624
|
+
if (built) console.log(`[ralph] Slice build complete — waiting for next slice`);
|
|
592
625
|
if (credentialed) await fetchAndPersistSlices(cfg, kitDir).catch(() => {});
|
|
593
626
|
didWork = true;
|
|
594
627
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// What a slice costs: one trace per build turn against one slice, posted to the platform's
|
|
2
|
-
// /api/org/:orgId/
|
|
2
|
+
// /api/org/:orgId/agent-traces. Only the runner can see what a turn cost, so it reports it.
|
|
3
3
|
//
|
|
4
4
|
// Best effort: each trace is appended to a local JSONL file, then POSTed once. No retries, no
|
|
5
5
|
// buffering. A failed upload is logged and forgotten; the file is the record.
|
|
@@ -114,10 +114,9 @@ export function createSliceTracer({ baseUrl, token, organizationId, agentId, tra
|
|
|
114
114
|
const canUpload = !!(enabled && baseUrl && token && organizationId && agentId);
|
|
115
115
|
|
|
116
116
|
async function post(trace) {
|
|
117
|
-
|
|
118
|
-
if (!canUpload || !trace.boardId) return;
|
|
117
|
+
if (!canUpload) return;
|
|
119
118
|
try {
|
|
120
|
-
const res = await fetch(`${baseUrl}/api/org/${organizationId}/
|
|
119
|
+
const res = await fetch(`${baseUrl}/api/org/${organizationId}/agent-traces`, {
|
|
121
120
|
method: 'POST',
|
|
122
121
|
headers: { 'Content-Type': 'application/json', 'x-token': token, 'x-agent-id': agentId },
|
|
123
122
|
body: JSON.stringify({ traces: [trace] }),
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Wall-clock cap on one agent turn. Without it a hung harness (a stuck tool call, a
|
|
2
|
+
// network stall, an interactive prompt nobody answers) blocks the loop forever — no
|
|
3
|
+
// further tasks, no further slices, and the stuck-slice guard never gets a look in.
|
|
4
|
+
//
|
|
5
|
+
// RALPH_TURN_TIMEOUT_MIN (env) or turnTimeoutMinutes (.eventmodelers/config.json);
|
|
6
|
+
// default 60, 0 turns the cap off.
|
|
7
|
+
|
|
8
|
+
import { execFile } from 'child_process';
|
|
9
|
+
|
|
10
|
+
const KILL_GRACE_MS = 10_000;
|
|
11
|
+
|
|
12
|
+
export function turnTimeoutMs(cfg) {
|
|
13
|
+
const raw = process.env.RALPH_TURN_TIMEOUT_MIN ?? cfg?.turnTimeoutMinutes ?? 60;
|
|
14
|
+
const minutes = Number(raw);
|
|
15
|
+
return Number.isFinite(minutes) && minutes > 0 ? minutes * 60_000 : 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Thrown (rejected) by a runner whose turn hit the cap. ralph.js's runWithRetry lets it
|
|
19
|
+
// through to the outer loop instead of re-running the same turn in place, so a slice
|
|
20
|
+
// that times out again is counted — and eventually Blocked — by the stuck-slice guard.
|
|
21
|
+
export class TurnTimeoutError extends Error {
|
|
22
|
+
constructor(label, timeoutMs) {
|
|
23
|
+
super(`${label} timed out after ${Math.round(timeoutMs / 60_000)}min`);
|
|
24
|
+
this.timedOut = true;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// SIGTERM, then SIGKILL after a grace period. `shell: true` means proc is only the /bin/sh
|
|
29
|
+
// wrapping the real command, so its children are signalled first — killing just the shell
|
|
30
|
+
// would orphan the harness and leave it running. (A separate process group would be
|
|
31
|
+
// cleaner, but a background group reading the terminal gets stopped by SIGTTIN, and
|
|
32
|
+
// Ctrl-C would no longer reach it.)
|
|
33
|
+
// The shell is only signalled once pkill has returned: killed any earlier, its children are
|
|
34
|
+
// re-parented to init before pkill -P gets to look for them, and survive.
|
|
35
|
+
function signal(proc, sig, viaShell) {
|
|
36
|
+
const killSelf = () => { try { proc.kill(`SIG${sig}`); } catch { /* already gone */ } };
|
|
37
|
+
if (viaShell && proc.pid) execFile('pkill', [`-${sig}`, '-P', String(proc.pid)], killSelf);
|
|
38
|
+
else killSelf();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function superviseTurn(proc, { timeoutMs, label, log, viaShell = false }) {
|
|
42
|
+
let timedOut = false;
|
|
43
|
+
let killTimer;
|
|
44
|
+
const timer = timeoutMs
|
|
45
|
+
? setTimeout(() => {
|
|
46
|
+
timedOut = true;
|
|
47
|
+
log(`${label} exceeded ${Math.round(timeoutMs / 60_000)}min — stopping it`);
|
|
48
|
+
signal(proc, 'TERM', viaShell);
|
|
49
|
+
killTimer = setTimeout(() => signal(proc, 'KILL', viaShell), KILL_GRACE_MS);
|
|
50
|
+
}, timeoutMs)
|
|
51
|
+
: null;
|
|
52
|
+
proc.on('close', () => {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
clearTimeout(killTimer);
|
|
55
|
+
});
|
|
56
|
+
return { timedOut: () => timedOut };
|
|
57
|
+
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { startRalph, loadLocalConfig, resolveAgentIdentity } from './lib/ralph.js';
|
|
6
6
|
import { createSliceTracer, usageFromClaudeResult } from './lib/tracing.js';
|
|
7
|
+
import { turnTimeoutMs, superviseTurn, TurnTimeoutError } from './lib/turn.js';
|
|
7
8
|
import { spawn } from 'child_process';
|
|
8
9
|
import { dirname, join, resolve } from 'path';
|
|
9
10
|
import { fileURLToPath } from 'url';
|
|
@@ -33,9 +34,17 @@ const tracer = createSliceTracer({
|
|
|
33
34
|
// --local must mean zero board contact — never hand Claude live board
|
|
34
35
|
// credentials via the inline header, even if config.json has them, or it'll
|
|
35
36
|
// treat them as already-connected and skip straight to board sync.
|
|
37
|
+
// The token itself stays out of the prompt: `-p` is argv, visible to every local
|
|
38
|
+
// user in `ps`, and the prompt lands in session transcripts. The header names the
|
|
39
|
+
// env var instead (set in claudeEnv below) — connect treats it as the inline token,
|
|
40
|
+
// and a curl header written as "x-token: $EVENTMODELERS_TOKEN" expands in the shell.
|
|
36
41
|
const inlineHeader = !localOnly && cfg.boardId
|
|
37
|
-
? `board=${cfg.boardId} token=$
|
|
42
|
+
? `board=${cfg.boardId} token=$EVENTMODELERS_TOKEN org=${cfg.organizationId} baseUrl=${cfg.baseUrl}\n\n`
|
|
38
43
|
: '';
|
|
44
|
+
const timeoutMs = turnTimeoutMs(cfg);
|
|
45
|
+
// The model can still surface the token (e.g. a Bash command it expanded by hand), and
|
|
46
|
+
// --verbose logs tool input and assistant text as is — mask it before it hits the log.
|
|
47
|
+
const redact = (s) => (cfg.token ? String(s).split(cfg.token).join('***') : s);
|
|
39
48
|
|
|
40
49
|
// --verbose here (set via `eventmodelers run --verbose`, passed down as RALPH_VERBOSE)
|
|
41
50
|
// logs full tool input and assistant reasoning text; the default (condensed) mode logs
|
|
@@ -86,6 +95,7 @@ function runClaude(prompt, slice = null) {
|
|
|
86
95
|
stdio: ['inherit', 'pipe', 'inherit'],
|
|
87
96
|
env: claudeEnv,
|
|
88
97
|
});
|
|
98
|
+
const turn = superviseTurn(proc, { timeoutMs, label: 'Claude turn', log: (line) => console.error(`[ralph] ${line}`) });
|
|
89
99
|
|
|
90
100
|
let buffer = '';
|
|
91
101
|
proc.stdout.on('data', (chunk) => {
|
|
@@ -99,9 +109,9 @@ function runClaude(prompt, slice = null) {
|
|
|
99
109
|
|
|
100
110
|
if (msg.type === 'assistant') {
|
|
101
111
|
for (const block of msg.message?.content ?? []) {
|
|
102
|
-
if (block.type === 'text' && block.text && verbose) console.log(block.text);
|
|
112
|
+
if (block.type === 'text' && block.text && verbose) console.log(redact(block.text));
|
|
103
113
|
if (block.type === 'tool_use') {
|
|
104
|
-
if (verbose) console.log(`→ ${describeToolUse(block)}`);
|
|
114
|
+
if (verbose) console.log(`→ ${redact(describeToolUse(block))}`);
|
|
105
115
|
else if (block.name === 'Skill') console.log(`→ Skill: ${block.input?.skill ?? ''}`);
|
|
106
116
|
else console.log(`→ ${block.name}`);
|
|
107
117
|
}
|
|
@@ -113,7 +123,11 @@ function runClaude(prompt, slice = null) {
|
|
|
113
123
|
}
|
|
114
124
|
});
|
|
115
125
|
|
|
116
|
-
proc.on('close', (code) =>
|
|
126
|
+
proc.on('close', (code) => {
|
|
127
|
+
if (turn.timedOut()) reject(new TurnTimeoutError('Claude turn', timeoutMs));
|
|
128
|
+
else if (code === 0) resolve();
|
|
129
|
+
else reject(new Error(`Claude exited ${code}`));
|
|
130
|
+
});
|
|
117
131
|
proc.on('error', reject);
|
|
118
132
|
});
|
|
119
133
|
}
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import { startRalph, loadLocalConfig, resolveAgentIdentity } from './lib/ralph.js';
|
|
21
21
|
import { createSliceTracer, usageFromHarnessEvent, sumUsage } from './lib/tracing.js';
|
|
22
|
+
import { turnTimeoutMs, superviseTurn, TurnTimeoutError } from './lib/turn.js';
|
|
22
23
|
import { spawn } from 'child_process';
|
|
23
24
|
import { writeFileSync, mkdtempSync } from 'fs';
|
|
24
25
|
import { tmpdir } from 'os';
|
|
@@ -42,10 +43,12 @@ if (!execCmd) {
|
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
// Same rule as ralph-claude.js: --local must mean zero board contact, so credentials
|
|
45
|
-
// never reach the child even when config.json has them.
|
|
46
|
+
// never reach the child even when config.json has them. Also as there: the token is
|
|
47
|
+
// passed by env var name, never by value — the prompt goes on the command line.
|
|
46
48
|
const inlineHeader = !localOnly && cfg.boardId
|
|
47
|
-
? `board=${cfg.boardId} token=$
|
|
49
|
+
? `board=${cfg.boardId} token=$EVENTMODELERS_TOKEN org=${cfg.organizationId} baseUrl=${cfg.baseUrl}\n\n`
|
|
48
50
|
: '';
|
|
51
|
+
const timeoutMs = turnTimeoutMs(cfg);
|
|
49
52
|
|
|
50
53
|
const childEnv = {
|
|
51
54
|
...process.env,
|
|
@@ -122,8 +125,10 @@ function runExec(prompt, slice = null) {
|
|
|
122
125
|
shell: true,
|
|
123
126
|
env: { ...childEnv, RALPH_PROMPT_FILE: promptFile },
|
|
124
127
|
});
|
|
128
|
+
const turn = superviseTurn(proc, { timeoutMs, label: 'exec turn', viaShell: true, log: (line) => console.error(`[ralph-exec] ${line}`) });
|
|
125
129
|
const usage = traced ? collectUsage(proc.stdout) : null;
|
|
126
130
|
proc.on('close', (code) => {
|
|
131
|
+
if (turn.timedOut()) return reject(new TurnTimeoutError('exec turn', timeoutMs));
|
|
127
132
|
if (traced) {
|
|
128
133
|
const u = usage();
|
|
129
134
|
if (u) tracer.record({ ...slice, status: code === 0 ? 'ok' : 'error' }, { ...u, durationMs: u.durationMs ?? Date.now() - startedAt });
|
|
@@ -61,6 +61,8 @@ If an inline `board=<uuid>` is found, use it as `BOARD_ID` — **it takes priori
|
|
|
61
61
|
|
|
62
62
|
(`agent=<uuid>` is not one of the four required values — it is optional, and its absence never makes this skill ask anything.)
|
|
63
63
|
|
|
64
|
+
**`token=$EVENTMODELERS_TOKEN` is an inline token too.** The build loop passes the env var's *name*, not its value, so the secret never sits in the prompt. Treat it exactly like a literal inline token: `TOKEN` is `$EVENTMODELERS_TOKEN` (it counts toward "all four inline" below). Keep using the reference, never its value — write curl headers as `-H "x-token: $EVENTMODELERS_TOKEN"` (double quotes, so the shell expands it), don't `echo` or print it, and hand it to a subagent as `token=$EVENTMODELERS_TOKEN` (it inherits the same environment). MCP calls need nothing extra — `.mcp.json` already reads the same variable.
|
|
65
|
+
|
|
64
66
|
**All four inline means this skill is already finished — stop here.** `token=`, `board=`, `org=` and `baseUrl=` arriving together is the shape a parent agent hands a subagent, and it resolves every required value in this one step. Do not walk the config file (Step 1), do not ask anything (Step 2), do not persist (Step 3), and do not make the verify call (Step 4): the parent resolved these values against this board and verified them there, so a subagent verifying them again learns nothing it wasn't just told and pays a round trip for it. Step 3.5 is a no-op too whenever `.mcp.json` already carries an `eventmodelers` entry — read the file, don't rewrite it, and don't re-register a server the session is already connected to. Print `Connected — board <BOARD_ID>` and return to the skill that invoked you.
|
|
65
67
|
|
|
66
68
|
---
|
|
@@ -27,8 +27,7 @@ Write tools take their items as an array (`nodeIds[]`, `elements[]`, `connection
|
|
|
27
27
|
| `get_nodes` | `boardId`, `type?`, `name?`, `chapterId?`, `nodeIds?`, `projection?` (`"line"`) | List nodes, optionally by type and/or a partial case-insensitive title match. `chapterId` scopes to one timeline — prefer this over an unscoped board-wide call whenever the step is working within one chapter (the common case); `nodeIds` fetches a known, scattered subset in one call (e.g. re-verifying exactly the nodes just touched by a batch write) instead of a full `type` refetch; `projection: "line"` maps each match to `{id, type, title}` only. Every node carries `cellName`, its cell address in the chapter it is placed in (absent when unplaced) — read the cell names the cell-addressed tools need from here, never derive or invent one | §3 `GET .../nodes` |
|
|
28
28
|
| `get_node` | `boardId`, `nodeId`, `projection?` (`"cells"` \| `"edges"`) | Get one node — the full response carries `cellName`, its cell address in the chapter it is placed in. `projection: "cells"` (CHAPTER nodes only) returns just `{rows, columns, cells}` instead of the full `timelineData` — use whenever only the grid/occupancy is needed, not the whole chapter; `projection: "edges"` returns just that node's inbound/outbound connections instead of `findNodeById`'s full record. Both are opt-in — omitting `projection` is the unchanged full response | §3 `GET .../nodes/:nodeId` |
|
|
29
29
|
| `get_node_comments` | `boardId`, `nodeId` | List comments on a node | §1 `GET .../nodes/:nodeId/comments` |
|
|
30
|
-
| `get_board_events` | `boardId` |
|
|
31
|
-
| `search_board_events` | `boardId`, `name` | Search events by node name | §1 `GET .../events/search` |
|
|
30
|
+
| `get_board_events` | `boardId`, `since?`, `seq?` | A page of at most 300 board events, oldest first, as `{events, pageSize, hasMore}`. Not the whole log: while `hasMore` is true, page on by passing the last event's `seq` as `since`. `seq` returns only the event of exactly that seq | §1 `GET .../events` |
|
|
32
31
|
| `submit_node_events` | `boardId`, `events[]`, `autoConnect?`, `compact?` | Create/update nodes (raw `NodeChangeEvent`/edge events). Every event property is described on the tool's own `events[]` schema — read that rather than this skill when all you need is the event shape. An event needs its own `id` (required — a fresh uuid per event, never the node id) and its `eventType`, plus the keys of its kind (`nodeId`/`meta`/`node`, or `edgeId`/`source`/`target`). The `id` keys the returned `hashes` map and makes a resubmit idempotent — the same event sent twice under one id writes a single board event. Don't send `boardId` or `timestamp` per event — the server takes the board from the call and stamps the time itself, and ignores both if sent. `autoConnect: false` places freshly-created nodes without wiring them to their own/previous-column neighbors (avoids a stray nearest-left edge); `compact: true` returns `{persisted: <count>}` instead of the per-node hash map | §3 `POST .../nodes/events` |
|
|
33
32
|
| `delete_node` | `boardId`, `nodeIds[]` | Delete nodes, applied in order — one failure doesn't stop the rest; answers `{results}`. Deleting a chapter (timeline) cascades — every node placed in one of its cells, plus any node parented to it (e.g. SLICE_BORDER), is deleted too, along with all their edges | (via `node:deleted` event, §3) |
|
|
34
33
|
| `create_drawing` | `boardId`, `drawings[]` (each `kind`, `x`, `y`, `width`, `height`, ...) | Freehand canvas annotation (path/rect/text/sticky) — never placed in a cell. An annotation is rarely one stroke: a loop plus its arrows and label is one call with several `drawings` entries, applied in order, not three calls | — (REST `POST .../drawing/draw` accepts a single drawing or an array) |
|
|
@@ -59,9 +58,8 @@ Write tools take their items as an array (`nodeIds[]`, `elements[]`, `connection
|
|
|
59
58
|
| `link_element` | `boardId`, `nodeId`, plus either `targetNodeId` or `timelineId` (+ `columnIndex?`, `lane?`) | Turn a node into a linked copy of `nodeId` — it receives a full copy of that node's meta plus `meta.linkedTo`. Name an existing `targetNodeId`, or pass `timelineId` to have the copy placed and linked in this one call (inheriting the original's type and title), which is what a translation or automation chain wants | §3 `POST .../nodes/:nodeId/link` |
|
|
60
59
|
| `add_comment` | `boardId`, `comments[]` (each `nodeId`, `text`, `type?` `'COMMENT'\|'TASK'\|'QUESTION'`, `author?`) | Add comments on any nodes of one board — `QUESTION` is the type for a gap/edge case raised during review, and a review posts all its questions in one call; answers `{results}` in request order | §1 `POST .../boards/:boardId/comments` (batch) |
|
|
61
60
|
| `update_comment` | `boardId`, `nodeId`, `commentId`, `action` (`'resolve'\|'delete'`) | Resolve or delete a comment | — (via comment events) |
|
|
62
|
-
| `create_screen` | `boardId`, `screens[]` (each `contentType` `'image'\|'sketch'\|'html'`, `chapterId`, `cellId?`/`cellName?`, `nodeId?`, content fields `imageBase64`/`mimeType`, `elements[]`, or `pages[]`/`backgroundColor`, `title?`, `description?`, `fields?`), `autoConnect?` | Create + place new screen nodes (SCREEN or HTML_SCREEN) with their content, in one call; entries applied in order, one failure doesn't stop the rest; answers `{results}`. `title` names the node (`meta.title`) in the same call — no follow-up `node:changed` just to label the screen. `autoConnect: false` places without wiring to timeline neighbors | §4 `POST .../images/:id/sketch` + `image-nodes` |
|
|
63
|
-
| `render_screen` | `boardId`, `nodeId`, `elements[]?` (SCREEN) or `pages[]?`+`backgroundColor?` (HTML_SCREEN), `description?` | Update an existing screen's content — exactly one of `elements`/`pages` | §4 `POST .../images/:id/sketch` + `image-nodes` |
|
|
64
|
-
| `add_field_examples` | `boardId`, `nodeId?`, `name?`, `cellName?`, `timelineId?` | Fill empty field examples using linked-node context | — (MCP-only convenience) |
|
|
61
|
+
| `create_screen` | `boardId`, `screens[]` (each `contentType` `'image'\|'sketch'\|'html'`, `chapterId`, `cellId?`/`cellName?`, `nodeId?`, content fields `imageBase64`/`mimeType`, `elements[]`, or `pages[]`/`backgroundColor`, `title?`, `description?`, `fields?`), `autoConnect?` | Create + place new screen nodes (SCREEN or HTML_SCREEN) with their content, in one call; each HTML page at most 6000 characters (`HTML_SCREEN_PAGE_TOO_LONG` otherwise); entries applied in order, one failure doesn't stop the rest; answers `{results}`. `title` names the node (`meta.title`) in the same call — no follow-up `node:changed` just to label the screen. `autoConnect: false` places without wiring to timeline neighbors | §4 `POST .../images/:id/sketch` + `image-nodes` |
|
|
62
|
+
| `render_screen` | `boardId`, `nodeId`, `elements[]?` (SCREEN) or `pages[]?`+`backgroundColor?` (HTML_SCREEN), `description?` | Update an existing screen's content — exactly one of `elements`/`pages`; each HTML page at most 6000 characters (`HTML_SCREEN_PAGE_TOO_LONG` otherwise) | §4 `POST .../images/:id/sketch` + `image-nodes` |
|
|
65
63
|
| `get_attribute_chain` | `boardId`, `timelineId`, `targetCellName`, `sourceCellName` | Resolve every node between two cells, ordered target→source. Both cell names must come from a node's reported `cellName` (`get_board_outline`, `get_nodes`, `get_node`) — a guessed address that happens to exist silently resolves the wrong chain | — (MCP-only convenience) |
|
|
66
64
|
| `get_image_snapshot_description` | `boardId`, `nodeId` | Load the `{elements:[...]}` sketch description from storage | — (reads what §4 sketch endpoints write) |
|
|
67
65
|
| `validate_slice_data` | `sliceData` | Offline validation of a `SliceDataOutput` payload — no board access | — (MCP-only, pure function) |
|
|
@@ -188,18 +186,27 @@ Delete a board.
|
|
|
188
186
|
|
|
189
187
|
---
|
|
190
188
|
|
|
191
|
-
### GET `/api/org/:orgId/boards/:boardId/events/search`
|
|
192
|
-
Search events by node name.
|
|
193
|
-
|
|
194
|
-
**Query params**: `name` (string)
|
|
195
|
-
**Response**: `200` — matching event array
|
|
196
|
-
|
|
197
|
-
---
|
|
198
|
-
|
|
199
189
|
### GET `/api/org/:orgId/boards/:boardId/events`
|
|
200
|
-
Get
|
|
190
|
+
Get a page of board events in sequence order — **at most 300 per call**, oldest first. One call is not the whole log: to read it all, pass the last event's `seq` as `since` and repeat while the `X-Has-More` response header is `true`. `X-Page-Size` carries the page size that applied.
|
|
201
191
|
|
|
202
|
-
**
|
|
192
|
+
**Query params**:
|
|
193
|
+
- `since` (integer, optional) — only events with a `seq` greater than this
|
|
194
|
+
- `skipSilent` (boolean, default `true`) — `false` also includes events marked silent
|
|
195
|
+
- `seq` (integer, optional) — only the event of exactly this seq (silent or not); `since` and `skipSilent` are ignored
|
|
196
|
+
|
|
197
|
+
**Response**: `200` — event array (at most 300; at most one with `seq`, empty if there is none — `X-Has-More` is then `false`)
|
|
198
|
+
**Errors**: `400` `EVENT_SEQ_INVALID` — `seq` is not a non-negative integer
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
since=0
|
|
202
|
+
while :; do
|
|
203
|
+
more=$(curl -s -D - -o page.json "$BASE_URL/api/org/$ORG_ID/boards/$BOARD_ID/events?since=$since" -H "x-token: $TOKEN" \
|
|
204
|
+
| tr -d '\r' | awk -F': ' 'tolower($1)=="x-has-more"{print $2}')
|
|
205
|
+
# ...use page.json...
|
|
206
|
+
[ "$more" = "true" ] || break
|
|
207
|
+
since=$(jq '.[-1].seq' page.json)
|
|
208
|
+
done
|
|
209
|
+
```
|
|
203
210
|
|
|
204
211
|
---
|
|
205
212
|
|
|
@@ -317,6 +317,8 @@ mcp__eventmodelers__create_screen {
|
|
|
317
317
|
|
|
318
318
|
**Never pass an empty `pages` array** — an empty array produces a blank placeholder, same as in `html-screen`/`eventmodeling-storyboarding-events`.
|
|
319
319
|
|
|
320
|
+
**Keep every reconstructed page at most 6000 characters per page (the server rejects a longer page with `400 HTML_SCREEN_PAGE_TOO_LONG` — split the screen into more pages or trim the markup instead).** A reconstructed real-world page easily runs longer — keep only the structure and content the screen is about, not every nav link, footer, or repeated list item.
|
|
321
|
+
|
|
320
322
|
**Image path — `renderMode == "image"` only, when the user explicitly asked for screenshots:**
|
|
321
323
|
|
|
322
324
|
```
|
package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-storyboarding-events/SKILL.md
CHANGED
|
@@ -245,7 +245,7 @@ The MCP `create_screen` call above already sets the node's `title` and `meta.fie
|
|
|
245
245
|
|
|
246
246
|
A storyboard screen is placed at a *provisional* position — Steps 4 and 5 wire it to its COMMAND / READMODEL once those exist, and may move it first. Pass `autoConnect: false` on `create_screen` here so the placement doesn't pre-wire the screen to whatever happens to sit in the adjacent column; the real `SCREEN → COMMAND` and `READMODEL → SCREEN` edges are created deliberately in Steps 4 and 5. When creating several screens whose HTML is already authored, put them all in one `create_screen` call's `screens` array with `autoConnect: false`.
|
|
247
247
|
|
|
248
|
-
Design the page(s) as real HTML/CSS, following the `html-screen` skill's guidance: write full-size markup (16px body text, generous padding — the canvas scales it down, don't shrink it yourself), one complete self-contained fragment per page (no `<html>`/`<head>`/`<body>` wrapper — the canvas adds those), no `<script>`/inline handlers (stripped server-side), and Bulma CSS classes (`title`, `button`, `is-primary`, `field`/`control`/`input`, etc. — remember heading size modifiers like `class="title is-1"`) since Bulma 0.9.4 is loaded by default. Every page MUST include real field labels matching the actual event/command fields this screen captures or displays, and at least one primary action (submit/confirm button) for command screens.
|
|
248
|
+
Design the page(s) as real HTML/CSS, following the `html-screen` skill's guidance: write full-size markup (16px body text, generous padding — the canvas scales it down, don't shrink it yourself), one complete self-contained fragment per page (no `<html>`/`<head>`/`<body>` wrapper — the canvas adds those), no `<script>`/inline handlers (stripped server-side), and Bulma CSS classes (`title`, `button`, `is-primary`, `field`/`control`/`input`, etc. — remember heading size modifiers like `class="title is-1"`) since Bulma 0.9.4 is loaded by default. Keep every page at most 6000 characters per page (the server rejects a longer page with `400 HTML_SCREEN_PAGE_TOO_LONG` — split the screen into more pages or trim the markup instead). Every page MUST include real field labels matching the actual event/command fields this screen captures or displays, and at least one primary action (submit/confirm button) for command screens.
|
|
249
249
|
|
|
250
250
|
> **CRITICAL: NEVER pass an empty `pages` array.** An empty pages array produces a blank placeholder and is always wrong. You MUST design and include actual page markup before calling the render API.
|
|
251
251
|
|
|
@@ -31,31 +31,21 @@ From `$ARGUMENTS`, extract:
|
|
|
31
31
|
|
|
32
32
|
1. Read the chapter **once**: `mcp__eventmodelers__get_nodes { "boardId": "$BOARD_ID", "chapterId": "$CHAPTER_ID" }`. That single response carries every element's `meta.fields` *including the `example` values already filled in* — which is exactly the canonical-value pool Step 3c asks for, for the whole chapter, in one call.
|
|
33
33
|
2. Pick the canonical value per field name from that pool (e.g. `customerId: "cust-123"`, `email: "jane@example.com"`) before writing anything, so every element ends up consistent.
|
|
34
|
-
3. Then
|
|
34
|
+
3. Then generate every target's values and write them all in a single `submit_node_events { events: [...] }` call — one `node:changed` event per element, never one call per element.
|
|
35
35
|
|
|
36
36
|
A run that opens each element with its own `get_node` to "read existing examples first" is doing step 1 N times over.
|
|
37
37
|
|
|
38
38
|
---
|
|
39
39
|
|
|
40
|
-
## Step 2 — Resolve
|
|
40
|
+
## Step 2 — Resolve the element (prefer MCP)
|
|
41
41
|
|
|
42
|
-
`
|
|
42
|
+
Resolve `target` to a node, with whichever identifier it is:
|
|
43
43
|
|
|
44
|
-
- `target` is a UUID →
|
|
45
|
-
- `target` is a name →
|
|
46
|
-
- `target` is a cell name (e.g. `B3`) →
|
|
44
|
+
- `target` is a UUID → `mcp__eventmodelers__get_node { "boardId": "$BOARD_ID", "nodeId": "<target>" }`
|
|
45
|
+
- `target` is a name → `mcp__eventmodelers__get_nodes { "boardId": "$BOARD_ID", "name": "<target>" }` — a partial, case-insensitive title match; prefer an exact title, and if several elements still match, list them and ask the user to pick one
|
|
46
|
+
- `target` is a cell name (e.g. `B3`) → `mcp__eventmodelers__get_nodes { "boardId": "$BOARD_ID", "chapterId": "$CHAPTER_ID" }` and take the node whose `cellName` is `target` (if multiple chapters exist, find the chapter first with `get_nodes { "type": "CHAPTER" }` and ask the user if ambiguous). Never construct a cell address yourself — only use one you were given or read back as a node's `cellName`.
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
mcp__eventmodelers__add_field_examples { "boardId": "$BOARD_ID", "nodeId": "<target, if a UUID>" }
|
|
50
|
-
```
|
|
51
|
-
```
|
|
52
|
-
mcp__eventmodelers__add_field_examples { "boardId": "$BOARD_ID", "name": "<target, if a name>" }
|
|
53
|
-
```
|
|
54
|
-
```
|
|
55
|
-
mcp__eventmodelers__add_field_examples { "boardId": "$BOARD_ID", "cellName": "<target, if a cell name>", "timelineId": "$CHAPTER_ID" }
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
If this succeeds, skip straight to Step 6 (report back), describing the fields the tool reports as changed. Use the manual fallback flow below (Steps 2–5) only if MCP isn't connected.
|
|
48
|
+
Then continue with Step 3 (linked elements) — `mcp__eventmodelers__get_connected_nodes { "boardId": "$BOARD_ID", "nodeId": "<resolved id>", "includeFields": true }` returns the neighbours and their fields in one call.
|
|
59
49
|
|
|
60
50
|
**Fallback (no MCP):** see `references/api-fallback.md` — "Step 2 — Resolve the element".
|
|
61
51
|
|
|
@@ -15,11 +15,12 @@ curl -s "$BASE_URL/api/org/$ORG_ID/boards/$BOARD_ID/nodes/$target" \
|
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
### 2b — Name search
|
|
18
|
-
If `target` is not a UUID,
|
|
18
|
+
If `target` is not a UUID, list the board's nodes and match on `meta.title` (the REST nodes endpoint has no name filter):
|
|
19
19
|
|
|
20
20
|
```bash
|
|
21
|
-
curl -s "$BASE_URL/api/org/$ORG_ID/boards/$BOARD_ID/
|
|
22
|
-
-H "x-user-id: examples-skill"
|
|
21
|
+
curl -s "$BASE_URL/api/org/$ORG_ID/boards/$BOARD_ID/nodes" \
|
|
22
|
+
-H "x-user-id: examples-skill" \
|
|
23
|
+
| jq --arg t "$target" '[.[] | select((.meta.title // "") | ascii_downcase | contains($t | ascii_downcase))]'
|
|
23
24
|
```
|
|
24
25
|
|
|
25
26
|
Pick the best match (exact title match preferred; case-insensitive). If multiple matches exist, list them and ask the user to pick one.
|
|
@@ -54,6 +54,7 @@ Write normal, full-size HTML/CSS for each page — as if designing a real webpag
|
|
|
54
54
|
Guidelines:
|
|
55
55
|
- **Keep it simple — this is a view-only mockup, not a working app.** No JavaScript, no interactivity beyond what a static page can show (enforced server-side, see below). Keep the CSS compact: inline styles or a handful of Bulma classes are enough — don't write a large embedded `<style>` block or reinvent what Bulma already gives you for free.
|
|
56
56
|
- Each page is one complete, standalone HTML fragment — not a `data-step` div nested inside a shared blob. A multi-step flow (e.g. cart → payment → confirmation) is three separate pages in the array, each fully self-contained.
|
|
57
|
+
- **Each page may be at most 6000 characters** (the full markup string, including inline styles). The server rejects a longer page with `400 HTML_SCREEN_PAGE_TOO_LONG` (`context: {pageIndex, length, maxLength}`) and saves nothing — the render call fails as a whole. Check each page's length before rendering; if one is over, trim it (fewer inline styles, Bulma classes instead of custom CSS, fewer repeated sample rows) or split the content into another page. Adding a mark also counts toward the limit, since it adds attributes to the page's markup.
|
|
57
58
|
- Inline styles (`style="..."`) are the simplest way to keep each page self-contained.
|
|
58
59
|
- No `<script>` tags, no inline event handlers (`onclick`, `onload`, ...), no `javascript:` URIs — these are stripped server-side from every page before persisting regardless of what's sent. This is a static visual mockup, not an interactive prototype.
|
|
59
60
|
- A real page background (e.g. a light gray full-bleed background behind a centered white card) reads more realistically than a bare form floating on white.
|
|
@@ -149,7 +150,7 @@ mcp__eventmodelers__create_screen {
|
|
|
149
150
|
|
|
150
151
|
**Fallback (no MCP):** see `references/api-fallback.md` — "Step 4 — Creating a new node".
|
|
151
152
|
|
|
152
|
-
Expect `204 No Content` on success from either curl call.
|
|
153
|
+
Expect `204 No Content` on success from either curl call. A `HTML_SCREEN_PAGE_TOO_LONG` error means a page exceeds 6000 characters — shorten or split that page (see Step 3's guidelines) and send the call again; don't give up on the render.
|
|
153
154
|
|
|
154
155
|
## Step 5 — Define field data lineage (mandatory)
|
|
155
156
|
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Only needed when MCP is not connected. Every call below has an MCP equivalent in the main SKILL.md — always prefer that.
|
|
4
4
|
|
|
5
|
+
Each entry in `pages` may be at most 6000 characters — a longer page returns `400 {"code": "HTML_SCREEN_PAGE_TOO_LONG", "context": {"pageIndex", "length", "maxLength"}}` and nothing is saved.
|
|
6
|
+
|
|
5
7
|
## Step 2 — Load existing pages
|
|
6
8
|
|
|
7
9
|
```bash
|
|
@@ -43,7 +43,7 @@ Then **create one task per screen** using TaskCreate, naming each task after the
|
|
|
43
43
|
|
|
44
44
|
## HTML page design (default)
|
|
45
45
|
|
|
46
|
-
Design each page as real, full-size HTML/CSS following the `html-screen` skill's conventions: full-size markup (16px body text, generous padding — the canvas scales it down, don't shrink it yourself), one complete self-contained fragment per page (no `<html>`/`<head>`/`<body>` wrapper), no `<script>`/inline handlers (stripped server-side), and Bulma CSS classes (`title`, `button`, `is-primary`, `field`/`control`/`input`, etc. — remember heading size modifiers like `class="title is-1"`) since Bulma 0.9.4 is loaded by default.
|
|
46
|
+
Design each page as real, full-size HTML/CSS following the `html-screen` skill's conventions: full-size markup (16px body text, generous padding — the canvas scales it down, don't shrink it yourself), one complete self-contained fragment per page (no `<html>`/`<head>`/`<body>` wrapper), no `<script>`/inline handlers (stripped server-side), and Bulma CSS classes (`title`, `button`, `is-primary`, `field`/`control`/`input`, etc. — remember heading size modifiers like `class="title is-1"`) since Bulma 0.9.4 is loaded by default. Keep every page at most 6000 characters per page (the server rejects a longer page with `400 HTML_SCREEN_PAGE_TOO_LONG` — split the screen into more pages or trim the markup instead).
|
|
47
47
|
|
|
48
48
|
## Sketch path (explicit request only)
|
|
49
49
|
|
|
@@ -53,10 +53,7 @@ Steps:
|
|
|
53
53
|
chain they belong to, the timeline around them — and spend a full-`meta` `get_nodes` only
|
|
54
54
|
on the handful you conclude you are actually going to touch. One `get_node` per changed
|
|
55
55
|
node, or a second outline call for a chapter you already read this turn, is the same
|
|
56
|
-
fetch paid for twice (see `connect` Step 5).
|
|
57
|
-
`mcp__eventmodelers__get_board_events` with the header's `seq` range tells you what the
|
|
58
|
-
change actually was when the node's current state doesn't make it obvious. Then judge the
|
|
59
|
-
board as a whole: run `/analyze-existing-model` once per session to get that picture and
|
|
56
|
+
fetch paid for twice (see `connect` Step 5). Then judge the board as a whole: run `/analyze-existing-model` once per session to get that picture and
|
|
60
57
|
keep it in mind across turns, refreshing it when a turn's changes invalidate it. On a
|
|
61
58
|
`BOARD_REVIEW` turn that model-wide picture *is* the starting point.
|
|
62
59
|
**What you read here is what you hand down in step 3.** Index it and keep it: every fact
|