@miadi/stateloom-mcp 0.2.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -1
- package/dist/atomicWrite.d.ts +1 -0
- package/dist/atomicWrite.js +28 -0
- package/dist/erd.d.ts +9 -0
- package/dist/erd.js +21 -2
- package/dist/live.d.ts +26 -0
- package/dist/live.js +131 -0
- package/dist/projectSwitch.d.ts +6 -2
- package/dist/projectSwitch.js +18 -6
- package/dist/sequence.d.ts +55 -0
- package/dist/sequence.js +337 -0
- package/dist/server.d.ts +23 -4
- package/dist/server.js +216 -68
- package/dist/system.d.ts +88 -0
- package/dist/system.js +813 -0
- package/package.json +3 -3
package/dist/system.js
ADDED
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
|
|
3
|
+
import { writeFileAtomic } from "./atomicWrite.js";
|
|
4
|
+
import { basename, dirname, join, resolve } from "path";
|
|
5
|
+
import { RECONCILE_MODES, SYSTEM_ACTOR_KINDS, addActor, addMember, checkSystem, docKindOfPath, emptySystem, formatReplay, isSqdfPath, isSysdfPath, isSystemDefinition, machineName, membersByKind, parseFocus, parseMessageRef, reconcileModeOf, reconcileSystem, relativeMemberPath, removeActor, removeMember, renderMermaid, renderMermaidEr, renderMermaidSequence, replayScenario, resolveMemberPath, setReconcileMode, stateOfList, summarizeReconcile, summarizeSystem, updateMember, updateSystemSettings, } from "@miadi/stateloom-protocol";
|
|
6
|
+
import { stampedOutputPath } from "@miadi/stateloom-cli/render";
|
|
7
|
+
import { formatNotes } from "./erd.js";
|
|
8
|
+
const ok = (text) => ({ content: [{ type: "text", text }] });
|
|
9
|
+
const fail = (text) => ({ content: [{ type: "text", text }], isError: true });
|
|
10
|
+
const message = (e) => (e instanceof Error ? e.message : String(e));
|
|
11
|
+
const STOPPED = ["refused", "unknown-event", "ended", "state-mismatch"];
|
|
12
|
+
// ─── Disk ────────────────────────────────────────────────────────────
|
|
13
|
+
export function readSystem(path) {
|
|
14
|
+
if (!existsSync(path))
|
|
15
|
+
return null;
|
|
16
|
+
try {
|
|
17
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
18
|
+
return isSystemDefinition(parsed) ? parsed : null;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function writeJson(path, doc) {
|
|
25
|
+
writeFileAtomic(path, JSON.stringify(doc, null, 2) + "\n");
|
|
26
|
+
}
|
|
27
|
+
/** Write a machine back in the shape its file had: wrapped in `stateMachine` (or `StateMachine`), or bare. */
|
|
28
|
+
function writeMachine(path, def) {
|
|
29
|
+
let wrapper = null;
|
|
30
|
+
let rest = {};
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
33
|
+
if ("stateMachine" in parsed)
|
|
34
|
+
wrapper = "stateMachine";
|
|
35
|
+
else if ("StateMachine" in parsed)
|
|
36
|
+
wrapper = "StateMachine";
|
|
37
|
+
if (wrapper)
|
|
38
|
+
rest = parsed;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
/* a new file is written bare */
|
|
42
|
+
}
|
|
43
|
+
writeJson(path, wrapper ? { ...rest, [wrapper]: def } : def);
|
|
44
|
+
}
|
|
45
|
+
const mtimeOf = (path) => {
|
|
46
|
+
try {
|
|
47
|
+
return statSync(path).mtimeMs;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return Date.now();
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Read one member as `checkSystem` wants it. A machine is unwrapped from
|
|
55
|
+
* `{ stateMachine }`. A member outside the document root is not read at all,
|
|
56
|
+
* and a file that is not JSON is said to be so — its content is never quoted.
|
|
57
|
+
*/
|
|
58
|
+
function loadMember(host, path, absolute) {
|
|
59
|
+
const kind = docKindOfPath(path);
|
|
60
|
+
if (host.outsideRoot(absolute)) {
|
|
61
|
+
return { path, kind, def: null, error: "it is outside the permitted document root (STATELOOM_MCP_ROOT), so it was not read" };
|
|
62
|
+
}
|
|
63
|
+
if (!existsSync(absolute))
|
|
64
|
+
return { path, kind, def: null, error: `no file at ${absolute}` };
|
|
65
|
+
let raw;
|
|
66
|
+
try {
|
|
67
|
+
raw = readFileSync(absolute, "utf8");
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
return { path, kind, def: null, error: `the file cannot be read (${e.code ?? "error"})` };
|
|
71
|
+
}
|
|
72
|
+
let parsed;
|
|
73
|
+
try {
|
|
74
|
+
parsed = JSON.parse(raw);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return { path, kind, def: null, error: "it is not valid JSON" };
|
|
78
|
+
}
|
|
79
|
+
const def = kind === "machine" ? (parsed?.stateMachine ?? parsed?.StateMachine ?? parsed) : parsed;
|
|
80
|
+
return { path, kind, def };
|
|
81
|
+
}
|
|
82
|
+
/** One line for set_project_file / get_project_file when the active document is a system. */
|
|
83
|
+
export function systemSummary(path) {
|
|
84
|
+
const def = readSystem(path);
|
|
85
|
+
if (def) {
|
|
86
|
+
const count = (kind) => def.members.filter((m) => docKindOfPath(m.path) === kind).length;
|
|
87
|
+
return (`existing system '${def.settings?.name ?? ""}' (${def.members.length} members: ${count("machine")} machine(s), ` +
|
|
88
|
+
`${count("erd")} ERD(s), ${count("sequence")} sequence(s); ${(def.actors ?? []).length} actors)`);
|
|
89
|
+
}
|
|
90
|
+
return existsSync(path)
|
|
91
|
+
? "file exists but is not a readable system definition"
|
|
92
|
+
: "no file yet — create_system or load_definition will write it";
|
|
93
|
+
}
|
|
94
|
+
// ─── Which system, which sequence ────────────────────────────────────
|
|
95
|
+
const lists = (systemPath, system, absolute) => system.members.some((m) => resolveMemberPath(systemPath, m.path) === absolute);
|
|
96
|
+
/** `systemPath` is a readable system inside the document root that lists `absolute`. */
|
|
97
|
+
function listedBy(host, systemPath, absolute) {
|
|
98
|
+
if (host.outsideRoot(systemPath))
|
|
99
|
+
return false;
|
|
100
|
+
const s = readSystem(systemPath);
|
|
101
|
+
return !!s && lists(systemPath, s, absolute);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The system that lists `absolute`: the last system when it does, else the
|
|
105
|
+
* nearest `.sysdf.json` that does, in the document's own folder and then each
|
|
106
|
+
* parent folder up to the document root (or `/`).
|
|
107
|
+
*/
|
|
108
|
+
function systemFor(host, absolute) {
|
|
109
|
+
const last = host.lastSystem();
|
|
110
|
+
if (last && listedBy(host, last, absolute))
|
|
111
|
+
return last;
|
|
112
|
+
let dir = dirname(absolute);
|
|
113
|
+
while (!host.outsideRoot(dir)) {
|
|
114
|
+
let names = [];
|
|
115
|
+
try {
|
|
116
|
+
names = readdirSync(dir).filter(isSysdfPath).sort();
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
/* an unreadable folder lists nothing */
|
|
120
|
+
}
|
|
121
|
+
for (const f of names)
|
|
122
|
+
if (listedBy(host, join(dir, f), absolute))
|
|
123
|
+
return join(dir, f);
|
|
124
|
+
const parent = dirname(dir);
|
|
125
|
+
if (parent === dir)
|
|
126
|
+
break;
|
|
127
|
+
dir = parent;
|
|
128
|
+
}
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
function resolveScope(host, args) {
|
|
132
|
+
const active = host.projectFile();
|
|
133
|
+
const sequencePath = args.sequence ? resolve(args.sequence) : isSqdfPath(active) ? active : undefined;
|
|
134
|
+
if (sequencePath && !isSqdfPath(sequencePath))
|
|
135
|
+
return `A sequence document ends in .sqdf.json (got '${sequencePath}').`;
|
|
136
|
+
let systemPath;
|
|
137
|
+
if (args.system) {
|
|
138
|
+
systemPath = resolve(args.system);
|
|
139
|
+
}
|
|
140
|
+
else if (sequencePath) {
|
|
141
|
+
// A sequence belongs to the system that lists it. The active system is used
|
|
142
|
+
// only when it does: another system's machines must not learn its events.
|
|
143
|
+
systemPath = isSysdfPath(active) && listedBy(host, active, sequencePath) ? active : systemFor(host, sequencePath);
|
|
144
|
+
}
|
|
145
|
+
else if (isSysdfPath(active)) {
|
|
146
|
+
systemPath = active;
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
systemPath = systemFor(host, active) ?? host.lastSystem();
|
|
150
|
+
}
|
|
151
|
+
if (systemPath && !isSysdfPath(systemPath))
|
|
152
|
+
return `A system document ends in .sysdf.json (got '${systemPath}').`;
|
|
153
|
+
for (const p of [systemPath, sequencePath]) {
|
|
154
|
+
const denial = p ? host.outsideRoot(p) : undefined;
|
|
155
|
+
if (denial)
|
|
156
|
+
return denial;
|
|
157
|
+
}
|
|
158
|
+
if (systemPath) {
|
|
159
|
+
const sysPath = systemPath;
|
|
160
|
+
const system = readSystem(sysPath);
|
|
161
|
+
if (!system) {
|
|
162
|
+
return existsSync(sysPath)
|
|
163
|
+
? `${sysPath} is not a readable system definition.`
|
|
164
|
+
: `No system at ${sysPath}. Use create_system first.`;
|
|
165
|
+
}
|
|
166
|
+
host.rememberSystem(sysPath);
|
|
167
|
+
const members = system.members.map((m) => loadMember(host, m.path, resolveMemberPath(sysPath, m.path)));
|
|
168
|
+
if (sequencePath && !lists(sysPath, system, sequencePath))
|
|
169
|
+
members.push(loadMember(host, sequencePath, sequencePath));
|
|
170
|
+
return {
|
|
171
|
+
systemPath: sysPath,
|
|
172
|
+
system,
|
|
173
|
+
sequencePath,
|
|
174
|
+
members,
|
|
175
|
+
absolute: (p) => resolveMemberPath(sysPath, p),
|
|
176
|
+
label: `system '${system.settings?.name ?? ""}' (${sysPath})`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
if (sequencePath) {
|
|
180
|
+
if (!existsSync(sequencePath))
|
|
181
|
+
return `No sequence at ${sequencePath}.`;
|
|
182
|
+
const dir = dirname(sequencePath);
|
|
183
|
+
const beside = readdirSync(dir)
|
|
184
|
+
.filter((f) => /\.(smdf|erdf)\.json$/i.test(f))
|
|
185
|
+
.sort()
|
|
186
|
+
.map((f) => join(dir, f));
|
|
187
|
+
return {
|
|
188
|
+
system: null,
|
|
189
|
+
sequencePath,
|
|
190
|
+
members: [loadMember(host, sequencePath, sequencePath), ...beside.map((p) => loadMember(host, p, p))],
|
|
191
|
+
absolute: (p) => p,
|
|
192
|
+
label: `sequence ${basename(sequencePath)} with the machines and ERDs beside it (no system lists it)`,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
return "No system or sequence at hand: pass `system` (a .sysdf.json) or `sequence` (a .sqdf.json), or set_project_file to one.";
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* The system a system-editing tool (and `show`) acts on: the argument, else the
|
|
199
|
+
* active document when it is a system, else the system that lists the active
|
|
200
|
+
* document, else the last system this server created, set or checked.
|
|
201
|
+
*/
|
|
202
|
+
function systemPathOf(host, arg) {
|
|
203
|
+
const active = host.projectFile();
|
|
204
|
+
const path = arg ? resolve(arg) : isSysdfPath(active) ? active : (systemFor(host, active) ?? host.lastSystem());
|
|
205
|
+
if (!path)
|
|
206
|
+
return { error: "No system at hand: pass `system` (a .sysdf.json), set_project_file to one, or create_system." };
|
|
207
|
+
if (!isSysdfPath(path))
|
|
208
|
+
return { error: `A system document ends in .sysdf.json (got '${path}').` };
|
|
209
|
+
const denial = host.outsideRoot(path);
|
|
210
|
+
return denial ? { error: denial } : path;
|
|
211
|
+
}
|
|
212
|
+
// ─── Reconcile mode ──────────────────────────────────────────────────
|
|
213
|
+
function modeOf(host, system) {
|
|
214
|
+
const override = host.reconcileOverride();
|
|
215
|
+
const mode = reconcileModeOf(system, override);
|
|
216
|
+
if (override === "auto" || override === "propose")
|
|
217
|
+
return { mode, how: " (STATELOOM_RECONCILE, for this session)" };
|
|
218
|
+
if (override)
|
|
219
|
+
return { mode, how: ` (STATELOOM_RECONCILE='${override}' ignored: auto or propose)` };
|
|
220
|
+
return { mode, how: "" };
|
|
221
|
+
}
|
|
222
|
+
/** The mode that applies to a scope: auto writes only within a system, never into files a sequence merely sits beside. */
|
|
223
|
+
function scopeMode(host, system) {
|
|
224
|
+
const m = modeOf(host, system);
|
|
225
|
+
if (m.mode === "auto" && !system) {
|
|
226
|
+
return { mode: "propose", how: " (auto applies only within a system, and no system lists this sequence)", withheld: true };
|
|
227
|
+
}
|
|
228
|
+
return { ...m, withheld: false };
|
|
229
|
+
}
|
|
230
|
+
/** The reconcile line for get_project_file: a system's mode, or the mode of the system that lists a sequence. */
|
|
231
|
+
export function reconcileModeLine(host, path) {
|
|
232
|
+
if (!isSysdfPath(path) && !isSqdfPath(path))
|
|
233
|
+
return undefined;
|
|
234
|
+
const systemPath = isSysdfPath(path) ? path : systemFor(host, path);
|
|
235
|
+
const { mode, how, withheld } = scopeMode(host, systemPath ? readSystem(systemPath) : null);
|
|
236
|
+
const where = !isSqdfPath(path) || withheld ? "" : systemPath ? ` — system ${systemPath}` : " — no system lists this sequence";
|
|
237
|
+
return `reconcile: ${mode}${how}${where}`;
|
|
238
|
+
}
|
|
239
|
+
const opLine = (op) => {
|
|
240
|
+
switch (op.op) {
|
|
241
|
+
case "event.add":
|
|
242
|
+
return `+ event ${op.event.id} (source ${op.sourceIndex + 1})`;
|
|
243
|
+
case "eventSource.add":
|
|
244
|
+
return `+ event source ${op.source.name} with ${(op.source.events ?? []).map((e) => e.id).join(", ") || "no event"}`;
|
|
245
|
+
case "state.add":
|
|
246
|
+
return `+ state ${op.state.name} under ${op.parent ?? "the root"}`;
|
|
247
|
+
case "transition.add":
|
|
248
|
+
return `+ transition ${op.state} --[${op.transition.event}]--> ${op.transition.nextState ?? "(internal)"}`;
|
|
249
|
+
case "settings.update":
|
|
250
|
+
return `~ settings ${Object.keys(op.patch).join(", ")}`;
|
|
251
|
+
default:
|
|
252
|
+
return op.op;
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
/**
|
|
256
|
+
* Write every change a reconcile found, one document at a time, then mirror it:
|
|
257
|
+
* a machine's ops are patched to its room, an ERD and the system are sent whole.
|
|
258
|
+
* A document that cannot be written is reported and the others still are; the
|
|
259
|
+
* file stands whatever the hub does.
|
|
260
|
+
*/
|
|
261
|
+
async function applyReconcile(host, scope, r) {
|
|
262
|
+
const out = { lines: [], written: [], failed: [], notLive: [] };
|
|
263
|
+
const one = async (who, where, changes, what, path, write, push) => {
|
|
264
|
+
if (host.outsideRoot(path)) {
|
|
265
|
+
out.failed.push(`${who} (outside the document root)`);
|
|
266
|
+
out.lines.push(`${who} (${where}): NOT written — it is outside the permitted document root`);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
write();
|
|
271
|
+
}
|
|
272
|
+
catch (e) {
|
|
273
|
+
out.failed.push(`${who} (${message(e)})`);
|
|
274
|
+
out.lines.push(`${who} (${where}): NOT written — ${message(e)}`);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
out.written.push(`${who} +${changes}`);
|
|
278
|
+
const live = await push().catch((e) => ({ ok: false, text: `not live: ${message(e)}` }));
|
|
279
|
+
if (!live.ok && !live.off)
|
|
280
|
+
out.notLive.push(`${who}: ${live.text}`);
|
|
281
|
+
out.lines.push(`${who} (${where}): wrote ${what} — ${live.text}`);
|
|
282
|
+
};
|
|
283
|
+
for (const m of r.machines) {
|
|
284
|
+
const path = scope.absolute(m.member);
|
|
285
|
+
await one(m.machine, m.member, m.reasons.length, `${m.ops.length} op(s)`, path, () => writeMachine(path, m.def), () => host.live.patch(path, m.ops, m.def, mtimeOf(path)));
|
|
286
|
+
}
|
|
287
|
+
for (const e of r.erds) {
|
|
288
|
+
const path = scope.absolute(e.member);
|
|
289
|
+
await one(e.def.settings?.name ?? e.member, e.member, e.reasons.length, `${e.reasons.length} change(s)`, path, () => writeJson(path, e.def), () => host.live.full(path, e.def, mtimeOf(path)));
|
|
290
|
+
}
|
|
291
|
+
if (r.system && scope.systemPath) {
|
|
292
|
+
const path = scope.systemPath;
|
|
293
|
+
const def = r.system.def;
|
|
294
|
+
await one("system", basename(path), r.system.reasons.length, `${r.system.reasons.length} change(s)`, path, () => writeJson(path, def), async () => {
|
|
295
|
+
if (path !== host.projectFile())
|
|
296
|
+
return host.live.full(path, def, mtimeOf(path));
|
|
297
|
+
host.emitFull(def);
|
|
298
|
+
return { ok: true, text: "mirrored to the active room" };
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* After a sequence edit (sequence.ts `afterEdit`): reconcile the system that
|
|
305
|
+
* lists it, and return the ONE line the edit's result carries. In auto mode the
|
|
306
|
+
* changes are applied; in propose mode — or when no system lists the sequence —
|
|
307
|
+
* they are counted.
|
|
308
|
+
*/
|
|
309
|
+
export async function reconcileAfterEdit(host, sequencePath) {
|
|
310
|
+
const scope = resolveScope(host, { sequence: sequencePath });
|
|
311
|
+
if (typeof scope === "string")
|
|
312
|
+
return undefined;
|
|
313
|
+
const { mode, withheld } = scopeMode(host, scope.system);
|
|
314
|
+
const r = reconcileSystem(scope.system, scope.members);
|
|
315
|
+
if (!r.count && !r.unresolved.length)
|
|
316
|
+
return undefined;
|
|
317
|
+
const open = r.unresolved.length ? " (see check_system)" : "";
|
|
318
|
+
if (!r.count)
|
|
319
|
+
return `reconcile ${mode}: ${r.unresolved.length} left open${open}`;
|
|
320
|
+
if (mode === "auto") {
|
|
321
|
+
const a = await applyReconcile(host, scope, r);
|
|
322
|
+
const notLive = a.notLive.length ? `; not live — ${a.notLive.join("; ")}` : "";
|
|
323
|
+
if (!a.failed.length)
|
|
324
|
+
return `${summarizeReconcile(r, "auto")}${open}${notLive}`;
|
|
325
|
+
const left = r.unresolved.length ? `; ${r.unresolved.length} left open${open}` : "";
|
|
326
|
+
return (`auto, partly applied: wrote ${a.written.join(", ") || "nothing"}; NOT written: ${a.failed.join("; ")}` +
|
|
327
|
+
`${left}${notLive} — reconcile_scenario apply:true retries what is missing`);
|
|
328
|
+
}
|
|
329
|
+
const next = withheld
|
|
330
|
+
? " — no system lists this sequence, so auto mode does not apply; reconcile_scenario apply:true writes it"
|
|
331
|
+
: " — reconcile_scenario apply:true, or set_reconcile_mode auto";
|
|
332
|
+
return `${summarizeReconcile(r, "propose")}${open}${next}`;
|
|
333
|
+
}
|
|
334
|
+
// ─── Reports ─────────────────────────────────────────────────────────
|
|
335
|
+
function issueLines(issues) {
|
|
336
|
+
const lines = [];
|
|
337
|
+
for (const severity of ["error", "warning"]) {
|
|
338
|
+
const mine = issues.filter((i) => i.severity === severity);
|
|
339
|
+
if (!mine.length)
|
|
340
|
+
continue;
|
|
341
|
+
lines.push(`${severity === "error" ? "Errors" : "Warnings"} (${mine.length})`);
|
|
342
|
+
for (const rule of [...new Set(mine.map((i) => i.ruleId))].sort()) {
|
|
343
|
+
const group = mine.filter((i) => i.ruleId === rule);
|
|
344
|
+
lines.push(` ${rule} ×${group.length}`);
|
|
345
|
+
for (const i of group)
|
|
346
|
+
lines.push(` ${i.member ? `${i.member}: ` : ""}${i.message}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return lines;
|
|
350
|
+
}
|
|
351
|
+
const endOf = (path) => Object.entries(path.end)
|
|
352
|
+
.map(([m, s]) => `${m} in ${s.join(" or ") || "no state"}`)
|
|
353
|
+
.join("; ") || "no machine";
|
|
354
|
+
function replayLine(scenario, path) {
|
|
355
|
+
if (path.accepted)
|
|
356
|
+
return ` ${scenario} · ${path.name}: accepted — ends with ${endOf(path)}`;
|
|
357
|
+
const stop = path.steps.find((s) => STOPPED.includes(s.result));
|
|
358
|
+
return ` ${scenario} · ${path.name}: stops at message ${stop.ref} — ${stop.note ?? stop.result}`;
|
|
359
|
+
}
|
|
360
|
+
function runCheck(host, scope) {
|
|
361
|
+
const check = checkSystem(scope.system, scope.members);
|
|
362
|
+
const issues = [...check.issues];
|
|
363
|
+
for (const { member, def } of membersByKind(scope.members).machines) {
|
|
364
|
+
let found;
|
|
365
|
+
try {
|
|
366
|
+
found = host.validateMachine(def);
|
|
367
|
+
}
|
|
368
|
+
catch (e) {
|
|
369
|
+
found = [{ ruleId: "V000", message: `cannot be validated: ${message(e)}` }];
|
|
370
|
+
}
|
|
371
|
+
for (const v of found)
|
|
372
|
+
issues.push({ ruleId: v.ruleId, message: v.message, severity: "error", member });
|
|
373
|
+
}
|
|
374
|
+
const errors = issues.filter((i) => i.severity === "error").length;
|
|
375
|
+
const warnings = issues.length - errors;
|
|
376
|
+
const { mode, how } = scopeMode(host, scope.system);
|
|
377
|
+
const lines = [
|
|
378
|
+
`Checked ${scope.label}: ${errors} error(s), ${warnings} warning(s) across ${scope.members.length} member(s). Reconcile: ${mode}${how}.`,
|
|
379
|
+
...issueLines(issues),
|
|
380
|
+
];
|
|
381
|
+
if (check.replays.length) {
|
|
382
|
+
lines.push("Replay");
|
|
383
|
+
for (const { member, report } of check.replays) {
|
|
384
|
+
for (const path of report.paths)
|
|
385
|
+
lines.push(replayLine(report.scenario || member, path));
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return { text: lines.join("\n"), errors };
|
|
389
|
+
}
|
|
390
|
+
export function checkSystemReport(host, args = {}) {
|
|
391
|
+
const scope = resolveScope(host, args);
|
|
392
|
+
if (typeof scope === "string")
|
|
393
|
+
return fail(scope);
|
|
394
|
+
const { text, errors } = runCheck(host, scope);
|
|
395
|
+
return errors ? fail(text) : ok(text);
|
|
396
|
+
}
|
|
397
|
+
// ─── The tools that answer by extension (called from server.ts) ──────
|
|
398
|
+
export function systemGetDefinition(host) {
|
|
399
|
+
const def = readSystem(host.projectFile());
|
|
400
|
+
return def ? ok(JSON.stringify(def, null, 2)) : fail(`No system at ${host.projectFile()}.`);
|
|
401
|
+
}
|
|
402
|
+
export function systemLoadDefinition(host, json) {
|
|
403
|
+
let parsed;
|
|
404
|
+
try {
|
|
405
|
+
parsed = JSON.parse(json);
|
|
406
|
+
}
|
|
407
|
+
catch (e) {
|
|
408
|
+
return fail(`Invalid JSON: ${e}`);
|
|
409
|
+
}
|
|
410
|
+
const path = host.projectFile();
|
|
411
|
+
if (!isSystemDefinition(parsed)) {
|
|
412
|
+
return fail(`The active document ${path} is a system, and this JSON is not one (it needs a "members" array, and no "state", "entities" or "participants"). ` +
|
|
413
|
+
`To load another kind of document, set_project_file to a document of that kind first.`);
|
|
414
|
+
}
|
|
415
|
+
writeJson(path, parsed);
|
|
416
|
+
host.emitFull(parsed);
|
|
417
|
+
host.rememberSystem(path);
|
|
418
|
+
return ok(`Loaded system → ${path}: ${summarizeSystem(parsed)}`);
|
|
419
|
+
}
|
|
420
|
+
/** Every member's mermaid in one markdown file: `<name>.sys.md` beside the system. */
|
|
421
|
+
export function systemRender(host, opts) {
|
|
422
|
+
const doc = host.projectFile();
|
|
423
|
+
const def = readSystem(doc);
|
|
424
|
+
if (!def)
|
|
425
|
+
return fail(`No system at ${doc}.`);
|
|
426
|
+
const format = opts.format ?? "mermaid";
|
|
427
|
+
if (format !== "mermaid") {
|
|
428
|
+
return fail(`A system renders as one markdown file of its members' mermaid (asked for ${format}); set_project_file to a member to draw it as svg or png.`);
|
|
429
|
+
}
|
|
430
|
+
if (opts.path) {
|
|
431
|
+
const denial = host.outsideRoot(opts.path);
|
|
432
|
+
if (denial)
|
|
433
|
+
return fail(denial);
|
|
434
|
+
}
|
|
435
|
+
const out = opts.path
|
|
436
|
+
? resolve(opts.path)
|
|
437
|
+
: opts.stamp
|
|
438
|
+
? stampedOutputPath(doc, def.settings?.name, "mermaid", new Date()).replace(/\.mmd$/i, ".md")
|
|
439
|
+
: resolve(doc).replace(/\.sysdf\.json$/i, "") + ".sys.md";
|
|
440
|
+
const lines = [`# ${def.settings?.name ?? ""} — system`, ""];
|
|
441
|
+
if (def.settings?.description)
|
|
442
|
+
lines.push(def.settings.description, "");
|
|
443
|
+
for (const m of def.members) {
|
|
444
|
+
const loaded = loadMember(host, m.path, resolveMemberPath(doc, m.path));
|
|
445
|
+
const d = loaded.def;
|
|
446
|
+
const name = d?.settings?.name ?? "";
|
|
447
|
+
lines.push(`## ${loaded.kind}: ${m.path}${name ? ` — ${name}` : ""}`, "");
|
|
448
|
+
if (!d) {
|
|
449
|
+
lines.push(`_cannot be read: ${loaded.error ?? "no content"}_`, "");
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
try {
|
|
453
|
+
const text = loaded.kind === "erd"
|
|
454
|
+
? renderMermaidEr(d)
|
|
455
|
+
: loaded.kind === "sequence"
|
|
456
|
+
? renderMermaidSequence(d)
|
|
457
|
+
: renderMermaid(d);
|
|
458
|
+
lines.push("```mermaid", text, "```", "");
|
|
459
|
+
}
|
|
460
|
+
catch (e) {
|
|
461
|
+
lines.push(`_cannot be drawn: ${message(e)}_`, "");
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
const text = lines.join("\n");
|
|
465
|
+
writeFileSync(out, text, "utf8");
|
|
466
|
+
return {
|
|
467
|
+
content: [
|
|
468
|
+
{ type: "text", text: `Rendered system '${def.settings?.name ?? ""}' (${def.members.length} members) → ${out} (${text.length} bytes)` },
|
|
469
|
+
{ type: "text", text },
|
|
470
|
+
],
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
export function systemGetNotes(host) {
|
|
474
|
+
const path = host.projectFile();
|
|
475
|
+
const def = readSystem(path);
|
|
476
|
+
if (!def)
|
|
477
|
+
return fail(`No system at ${path}.`);
|
|
478
|
+
const entries = [];
|
|
479
|
+
if (def.settings?.notes?.trim())
|
|
480
|
+
entries.push({ target: null, notes: def.settings.notes });
|
|
481
|
+
for (const m of def.members)
|
|
482
|
+
if (m.notes?.trim())
|
|
483
|
+
entries.push({ target: m.path, notes: m.notes });
|
|
484
|
+
return ok(formatNotes(def.settings?.name ?? "", path, entries));
|
|
485
|
+
}
|
|
486
|
+
/** `target` is a member's path (as the system writes it, or absolute); omitted, the whole system. */
|
|
487
|
+
export function systemSetNotes(host, target, notes) {
|
|
488
|
+
const path = host.projectFile();
|
|
489
|
+
const def = readSystem(path);
|
|
490
|
+
if (!def)
|
|
491
|
+
return fail(`No system at ${path}. Use create_system first.`);
|
|
492
|
+
let next;
|
|
493
|
+
if (!target) {
|
|
494
|
+
next = updateSystemSettings(def, { notes });
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
const want = resolveMemberPath(path, target);
|
|
498
|
+
const hit = def.members.find((m) => resolveMemberPath(path, m.path) === want);
|
|
499
|
+
if (!hit)
|
|
500
|
+
return fail(`'${target}' is not a member of '${def.settings?.name ?? ""}'. Members: ${def.members.map((m) => m.path).join(", ") || "none"}.`);
|
|
501
|
+
next = updateMember(def, hit.path, { notes: notes.trim() ? notes : "" });
|
|
502
|
+
}
|
|
503
|
+
writeJson(path, next);
|
|
504
|
+
host.emitFull(next);
|
|
505
|
+
return ok(`${notes.trim() ? "Saved" : "Cleared"} the notes on ${target ? `member '${target}'` : "the system"}.`);
|
|
506
|
+
}
|
|
507
|
+
// ─── generate_rispec for a system ────────────────────────────────────
|
|
508
|
+
function scenarioBlock(seq, path) {
|
|
509
|
+
const name = seq.settings?.name ?? "scenario";
|
|
510
|
+
const L = [`**Creative Advancement Scenario**: ${path.kind === "main" ? name : `${name} · ${path.name}`}`, ""];
|
|
511
|
+
const last = path.steps.length ? parseMessageRef(seq, path.steps[path.steps.length - 1].ref)?.message : undefined;
|
|
512
|
+
const fragment = path.fragment === undefined ? undefined : seq.fragments?.[path.fragment];
|
|
513
|
+
const desired = path.kind === "main" || path.kind === "without-optional"
|
|
514
|
+
? (seq.settings?.description ?? last?.label ?? "")
|
|
515
|
+
: `when ${fragment?.label ?? path.name}: ${last?.label ?? ""}`;
|
|
516
|
+
L.push(`**Desired Outcome**: ${desired}`, "");
|
|
517
|
+
const start = new Map();
|
|
518
|
+
for (const s of path.steps)
|
|
519
|
+
if (s.machine && !start.has(s.machine) && s.from.length)
|
|
520
|
+
start.set(s.machine, s.from);
|
|
521
|
+
for (const [m, states] of Object.entries(path.end))
|
|
522
|
+
if (!start.has(m))
|
|
523
|
+
start.set(m, states);
|
|
524
|
+
L.push(`**Current Reality**: ${[...start].map(([m, s]) => `${m} in ${s.join(" or ") || "no state"}`).join("; ") || "no member machine"}`, "");
|
|
525
|
+
L.push("**Natural Progression**:", "");
|
|
526
|
+
let previous = "";
|
|
527
|
+
for (const s of path.steps) {
|
|
528
|
+
const move = s.result === "data"
|
|
529
|
+
? ""
|
|
530
|
+
: `${s.machine ?? "no machine"}: ${s.from.join("|") || "·"} → ${s.to.join("|") || "·"} (${s.result}${s.guards?.length ? `, assuming ${s.guards.join("; ")}` : ""})`;
|
|
531
|
+
if (s.ref === previous && move) {
|
|
532
|
+
L[L.length - 1] += ` · ${move}`;
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
previous = s.ref;
|
|
536
|
+
const m = parseMessageRef(seq, s.ref)?.message;
|
|
537
|
+
const head = m ? `${m.from} → ${m.to}: ${m.label}` : s.label;
|
|
538
|
+
const fires = s.event ? ` — fires \`${s.event}\`` : "";
|
|
539
|
+
const then = m?.state ? `, then \`${m.state}\`` : "";
|
|
540
|
+
L.push(`- **${s.ref}.** ${head}${fires}${then}${move ? `; ${move}` : ""}`);
|
|
541
|
+
}
|
|
542
|
+
L.push("");
|
|
543
|
+
const stop = path.steps.find((s) => STOPPED.includes(s.result));
|
|
544
|
+
L.push(`**Resolution**: ${endOf(path)}${stop ? ` — the walk stops at message ${stop.ref}: ${stop.note ?? stop.result}` : ""}`, "");
|
|
545
|
+
return L;
|
|
546
|
+
}
|
|
547
|
+
export function systemRispec(host, args) {
|
|
548
|
+
const scope = resolveScope(host, { system: args.system });
|
|
549
|
+
if (typeof scope === "string")
|
|
550
|
+
return fail(scope);
|
|
551
|
+
if (!scope.system)
|
|
552
|
+
return fail("generate_rispec for a system needs a .sysdf.json: pass `system`, or set_project_file to one.");
|
|
553
|
+
const sys = scope.system;
|
|
554
|
+
const name = sys.settings?.name ?? "system";
|
|
555
|
+
const { machines, erds, sequences } = membersByKind(scope.members);
|
|
556
|
+
const check = checkSystem(sys, scope.members);
|
|
557
|
+
const reconcile = reconcileSystem(sys, scope.members);
|
|
558
|
+
const intent = args.intent ?? sys.settings?.description;
|
|
559
|
+
const L = [];
|
|
560
|
+
L.push(`# ${name} — RISE rispec (system)`, "", `> ${intent ?? `${name} system`}`, "");
|
|
561
|
+
L.push(`**Namespace:** \`${sys.settings?.namespace ?? ""}\` · **Members:** ${machines.length} machine(s), ${erds.length} ERD(s), ${sequences.length} scenario(s)`, "", "---", "");
|
|
562
|
+
L.push("## Creative Intent", "", intent ?? "_(no description — set the system's description or pass `intent`)_", "");
|
|
563
|
+
if (sys.actors?.length) {
|
|
564
|
+
L.push("**Actors**", "");
|
|
565
|
+
for (const a of sys.actors)
|
|
566
|
+
L.push(`- **${a.name}** (${a.kind})${a.description ? ` — ${a.description}` : ""}`);
|
|
567
|
+
L.push("");
|
|
568
|
+
}
|
|
569
|
+
L.push("## Data", "");
|
|
570
|
+
if (!erds.length)
|
|
571
|
+
L.push("_No ERD in this system._", "");
|
|
572
|
+
for (const { member, def } of erds) {
|
|
573
|
+
L.push(`### ${def.settings?.name ?? member} (\`${member}\`)`, "");
|
|
574
|
+
if (def.settings?.description)
|
|
575
|
+
L.push(def.settings.description, "");
|
|
576
|
+
L.push("**Entities**", "");
|
|
577
|
+
for (const e of def.entities ?? []) {
|
|
578
|
+
L.push(`- **${e.name}**${e.weak ? " *(weak)*" : ""}${e.description ? ` — ${e.description}` : ""}`);
|
|
579
|
+
const keys = (e.attributes ?? [])
|
|
580
|
+
.filter((a) => a.key || a.references || a.stateOf)
|
|
581
|
+
.map((a) => `\`${a.name}\`${a.key ? ` ${a.key}` : ""}${a.references ? ` → ${a.references}` : ""}${a.stateOf ? ` (state of ${stateOfList(a).join(", ")})` : ""}`);
|
|
582
|
+
if (keys.length)
|
|
583
|
+
L.push(` - key attributes: ${keys.join(", ")}`);
|
|
584
|
+
}
|
|
585
|
+
L.push("");
|
|
586
|
+
if (def.relationships?.length) {
|
|
587
|
+
L.push("**Relationships**", "");
|
|
588
|
+
for (const r of def.relationships)
|
|
589
|
+
L.push(`- ${r.from} ${r.cardinality} ${r.to}${r.label ? ` — ${r.label}` : ""}`);
|
|
590
|
+
L.push("");
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
L.push("## Behaviour", "");
|
|
594
|
+
if (!machines.length)
|
|
595
|
+
L.push("_No state machine in this system._", "");
|
|
596
|
+
machines.forEach(({ member, def }, i) => {
|
|
597
|
+
L.push(`### ${machineName(def, i)} (\`${member}\`)`, "");
|
|
598
|
+
if (def.settings?.description)
|
|
599
|
+
L.push(def.settings.description, "");
|
|
600
|
+
const objects = def.settings?.objects ?? [];
|
|
601
|
+
if (objects.length)
|
|
602
|
+
L.push(`Objects: ${objects.map((o) => `\`${o.instance}\`: ${o.class}`).join(", ")}`, "");
|
|
603
|
+
L.push(...host.machineSpec(def, 4));
|
|
604
|
+
});
|
|
605
|
+
L.push("## Creative Advancement Scenarios", "");
|
|
606
|
+
if (!sequences.length)
|
|
607
|
+
L.push("_No scenario in this system._", "");
|
|
608
|
+
for (const { member, def } of sequences) {
|
|
609
|
+
const report = check.replays.find((r) => r.member === member)?.report ?? replayScenario(def, machines.map((m) => m.def));
|
|
610
|
+
L.push(`### ${def.settings?.name ?? member} (\`${member}\`)`, "");
|
|
611
|
+
for (const path of report.paths)
|
|
612
|
+
L.push(...scenarioBlock(def, path));
|
|
613
|
+
}
|
|
614
|
+
L.push("## Open questions", "");
|
|
615
|
+
const stops = check.issues.filter((i) => i.ruleId === "L008").map((i) => `${i.member ? `${i.member}: ` : ""}${i.message}`);
|
|
616
|
+
if (stops.length)
|
|
617
|
+
L.push("**Where a scenario and the machines disagree (L008)**", "", ...stops.map((q) => `- ${q}`), "");
|
|
618
|
+
if (reconcile.unresolved.length) {
|
|
619
|
+
L.push("**What reconcile leaves to a person**", "", ...reconcile.unresolved.map((q) => `- ${q}`), "");
|
|
620
|
+
}
|
|
621
|
+
if (!stops.length && !reconcile.unresolved.length)
|
|
622
|
+
L.push("_None: every scenario path is a walk the machines accept._", "");
|
|
623
|
+
return ok(L.join("\n"));
|
|
624
|
+
}
|
|
625
|
+
// ─── Tool registration ───────────────────────────────────────────────
|
|
626
|
+
/** Read the system a tool acts on, apply `edit`, write, mirror when active, remember, and report. */
|
|
627
|
+
function mutateSystem(host, arg, edit, report) {
|
|
628
|
+
const path = systemPathOf(host, arg);
|
|
629
|
+
if (typeof path !== "string")
|
|
630
|
+
return fail(path.error);
|
|
631
|
+
const def = readSystem(path);
|
|
632
|
+
if (!def)
|
|
633
|
+
return fail(existsSync(path) ? `${path} is not a readable system definition.` : `No system at ${path}. Use create_system first.`);
|
|
634
|
+
let next;
|
|
635
|
+
try {
|
|
636
|
+
next = edit(def, path);
|
|
637
|
+
}
|
|
638
|
+
catch (e) {
|
|
639
|
+
return fail(message(e));
|
|
640
|
+
}
|
|
641
|
+
writeJson(path, next);
|
|
642
|
+
if (path === host.projectFile())
|
|
643
|
+
host.emitFull(next);
|
|
644
|
+
host.rememberSystem(path);
|
|
645
|
+
return ok(report(next, path));
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* A member path from a caller: relative to the system file, or absolute; stored
|
|
649
|
+
* relative when beneath it. A member outside the document root is refused.
|
|
650
|
+
*/
|
|
651
|
+
function memberPathFor(host, systemPath, given) {
|
|
652
|
+
const absolute = resolveMemberPath(systemPath, given.trim());
|
|
653
|
+
if (host.outsideRoot(absolute)) {
|
|
654
|
+
throw new Error(`'${given}' is outside the permitted document root (STATELOOM_MCP_ROOT); a system can only hold documents inside it.`);
|
|
655
|
+
}
|
|
656
|
+
return relativeMemberPath(systemPath, absolute);
|
|
657
|
+
}
|
|
658
|
+
const systemArg = z.string().optional().describe("The .sysdf.json to act on; default the active system, else the system that lists the active document, else the last system");
|
|
659
|
+
export function registerSystemTools(server, host) {
|
|
660
|
+
server.tool("create_system", "Create a system definition (.sysdf.json): the one document that names the drawings of one thing being built — its ERDs (.erdf.json), machines (.smdf.json) and scenarios (.sqdf.json) — and the actors they share. `members` are paths, relative to the system file or absolute; they are stored relative when beneath it. Without `path` it lands next to the active document as <name>.sysdf.json. The active document does not move. The new system is remembered: the system tools and show use it when neither an argument, the active document, nor a system listing the active document names another. Refuses to replace a system that has members unless overwrite is true.", {
|
|
661
|
+
namespace: z.string(),
|
|
662
|
+
name: z.string(),
|
|
663
|
+
description: z.string().optional(),
|
|
664
|
+
path: z.string().optional(),
|
|
665
|
+
members: z.array(z.string()).optional(),
|
|
666
|
+
overwrite: z.boolean().optional(),
|
|
667
|
+
}, async ({ namespace, name, description, path, members, overwrite }) => {
|
|
668
|
+
const active = host.projectFile();
|
|
669
|
+
const target = resolve(path ?? (isSysdfPath(active) ? active : join(dirname(active), `${name}.sysdf.json`)));
|
|
670
|
+
if (!isSysdfPath(target))
|
|
671
|
+
return fail(`A system document ends in .sysdf.json (got '${target}').`);
|
|
672
|
+
const denial = host.outsideRoot(target);
|
|
673
|
+
if (denial)
|
|
674
|
+
return fail(denial);
|
|
675
|
+
const existing = readSystem(target);
|
|
676
|
+
if (existing && existing.members.length > 0 && !overwrite) {
|
|
677
|
+
return fail(`${target} already holds system '${existing.settings?.name ?? ""}' with ${existing.members.length} members. ` +
|
|
678
|
+
`Pass overwrite: true to replace it, or pass system: '${target}' to the system tools to keep working on it.`);
|
|
679
|
+
}
|
|
680
|
+
let def = emptySystem(namespace, name, description);
|
|
681
|
+
const missing = [];
|
|
682
|
+
try {
|
|
683
|
+
for (const m of members ?? []) {
|
|
684
|
+
const p = memberPathFor(host, target, m);
|
|
685
|
+
def = addMember(def, { path: p });
|
|
686
|
+
if (!existsSync(resolveMemberPath(target, p)))
|
|
687
|
+
missing.push(p);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
catch (e) {
|
|
691
|
+
return fail(message(e));
|
|
692
|
+
}
|
|
693
|
+
writeJson(target, def);
|
|
694
|
+
if (target === active)
|
|
695
|
+
host.emitFull(def);
|
|
696
|
+
host.rememberSystem(target);
|
|
697
|
+
return ok(`Created system '${name}' in namespace '${namespace}' → ${target} with ${def.members.length} member(s).` +
|
|
698
|
+
(missing.length ? ` No file yet at: ${missing.join(", ")}.` : "") +
|
|
699
|
+
` Remembered as this session's system; pass system: to name it explicitly.`);
|
|
700
|
+
});
|
|
701
|
+
server.tool("add_member", "Add a drawing to a system: a .erdf.json, .smdf.json or .sqdf.json path, relative to the system file or absolute (stored relative when it is beneath the system's folder). A system is never a member of another.", { path: z.string(), notes: z.string().optional(), system: systemArg }, async ({ path, notes, system }) => {
|
|
702
|
+
let stored = "";
|
|
703
|
+
return mutateSystem(host, system, (def, sys) => {
|
|
704
|
+
stored = memberPathFor(host, sys, path);
|
|
705
|
+
return addMember(def, { path: stored, notes });
|
|
706
|
+
}, (_def, sys) => `Added member ${docKindOfPath(stored)} '${stored}'${existsSync(resolveMemberPath(sys, stored)) ? "" : " (no file there yet)"}.`);
|
|
707
|
+
});
|
|
708
|
+
server.tool("remove_member", "Remove a drawing from a system (the file stays on disk). `path` as the system writes it, relative to the system file, or absolute.", { path: z.string(), system: systemArg }, async ({ path, system }) => {
|
|
709
|
+
let stored = "";
|
|
710
|
+
return mutateSystem(host, system, (def, sys) => {
|
|
711
|
+
const want = resolveMemberPath(sys, path.trim());
|
|
712
|
+
stored = def.members.find((m) => resolveMemberPath(sys, m.path) === want)?.path ?? path;
|
|
713
|
+
return removeMember(def, stored);
|
|
714
|
+
}, () => `Removed member '${stored}'. The file is untouched.`);
|
|
715
|
+
});
|
|
716
|
+
server.tool("add_actor", `Add an actor to a system: someone or something that acts in its scenarios, written once and shared by all of them. kind is one of ${SYSTEM_ACTOR_KINDS.join(", ")}. A sequence participant names it with \`actor\`.`, {
|
|
717
|
+
name: z.string(),
|
|
718
|
+
kind: z.enum(SYSTEM_ACTOR_KINDS),
|
|
719
|
+
description: z.string().optional(),
|
|
720
|
+
wheelNode: z.string().optional().describe("The medicine-wheel node this actor is (node:human:…), when there is one"),
|
|
721
|
+
system: systemArg,
|
|
722
|
+
}, async ({ system, ...actor }) => mutateSystem(host, system, (def) => addActor(def, actor), () => `Added ${actor.kind} actor '${actor.name}'.`));
|
|
723
|
+
server.tool("remove_actor", "Remove an actor from a system. Participants that name it are left in place; check_system names them.", { name: z.string(), system: systemArg }, async ({ name, system }) => mutateSystem(host, system, (def) => removeActor(def, name), () => `Removed actor '${name}'.`));
|
|
724
|
+
server.tool("set_reconcile_mode", "Choose how a system answers a scenario edit. propose (the default): each sequence edit says in one line how many changes the scenario implies for the machines, the ERD and the system, and reconcile_scenario apply:true applies them. auto: each sequence edit applies them at once — machines written and patched live, ERDs and the system written — and says in one line what changed. Written to the system's settings.reconcile; STATELOOM_RECONCILE overrides it for a session.", { mode: z.enum(RECONCILE_MODES), system: systemArg }, async ({ mode, system }) => mutateSystem(host, system, (def) => setReconcileMode(def, mode), (def) => {
|
|
725
|
+
const effective = modeOf(host, def);
|
|
726
|
+
return `Reconcile mode of '${def.settings?.name ?? ""}' is now ${mode}.${effective.mode !== mode || effective.how ? ` In force for this session: ${effective.mode}${effective.how}.` : ""}`;
|
|
727
|
+
}));
|
|
728
|
+
server.tool("check_system", "Check a system: its own document (Y001–Y004), each member's own rules (E, S, and each machine's V rules), the ERD ↔ machine links (L001–L004), and the scenarios against the machines and the data (L005 events, L006 participants, L007 carried entities, L008 the replay). Reported by rule, errors first, then warnings, then one line per scenario path. With no argument: the active system, else the system that lists the active sequence or member, else the last system.", { system: systemArg }, async ({ system }) => checkSystemReport(host, { system }));
|
|
729
|
+
server.tool("replay_scenario", "Walk a scenario through the machines, message by message: for each event, the transition each machine takes, the state it is then in, and where a path stops. Every path is walked — main, without its optional messages, and each fragment. With no sequence: the active sequence, else every sequence of the system. A sequence no system lists is replayed against the machines beside it.", { sequence: z.string().optional(), system: z.string().optional() }, async ({ sequence, system }) => {
|
|
730
|
+
const scope = resolveScope(host, { sequence, system });
|
|
731
|
+
if (typeof scope === "string")
|
|
732
|
+
return fail(scope);
|
|
733
|
+
const { machines, sequences } = membersByKind(scope.members);
|
|
734
|
+
const chosen = scope.sequencePath
|
|
735
|
+
? sequences.filter((s) => scope.absolute(s.member) === scope.sequencePath)
|
|
736
|
+
: sequences;
|
|
737
|
+
if (!chosen.length) {
|
|
738
|
+
const loaded = scope.members.find((m) => m.kind === "sequence" && (!scope.sequencePath || scope.absolute(m.path) === scope.sequencePath));
|
|
739
|
+
return fail(loaded?.error ? `${loaded.path} cannot be read: ${loaded.error}` : `No readable sequence in ${scope.label}.`);
|
|
740
|
+
}
|
|
741
|
+
const reports = chosen.map(({ def }) => formatReplay(replayScenario(def, machines.map((m) => m.def))));
|
|
742
|
+
return ok(reports.join("\n\n"));
|
|
743
|
+
});
|
|
744
|
+
server.tool("reconcile_scenario", "Bring the drawings level with the scenarios. The scenario says what should happen; reconcile proposes the additions that make it so: events and transitions (and states) on the machines, objects a participant is, entities a message carries or a participant holds on the ERD, actors on the system. With a system at hand, every scenario of the system is reconciled. Nothing is removed or rewritten. Default is a dry run: each change with its reason, and what is left open for a person to decide. apply:true writes each changed file and pushes each machine's ops to its live room, so a canvas open on the machine changes as you watch; if the hub is unreachable the files still stand.", { sequence: z.string().optional(), system: z.string().optional(), apply: z.boolean().optional() }, async ({ sequence, system, apply }) => {
|
|
745
|
+
const scope = resolveScope(host, { sequence, system });
|
|
746
|
+
if (typeof scope === "string")
|
|
747
|
+
return fail(scope);
|
|
748
|
+
const r = reconcileSystem(scope.system, scope.members);
|
|
749
|
+
const { mode, how } = scopeMode(host, scope.system);
|
|
750
|
+
const head = `Reconcile ${scope.label} — mode ${mode}${how}: ${r.count} change(s), ${r.unresolved.length} left open.`;
|
|
751
|
+
const lines = [head];
|
|
752
|
+
if (!apply) {
|
|
753
|
+
for (const m of r.machines) {
|
|
754
|
+
lines.push(`${m.machine} (${m.member}):`);
|
|
755
|
+
for (const why of m.reasons)
|
|
756
|
+
lines.push(` - ${why}`);
|
|
757
|
+
for (const op of m.ops)
|
|
758
|
+
lines.push(` ${opLine(op)}`);
|
|
759
|
+
}
|
|
760
|
+
for (const e of r.erds) {
|
|
761
|
+
lines.push(`${e.def.settings?.name ?? e.member} (${e.member}):`);
|
|
762
|
+
for (const why of e.reasons)
|
|
763
|
+
lines.push(` - ${why}`);
|
|
764
|
+
}
|
|
765
|
+
if (r.system) {
|
|
766
|
+
lines.push(`system (${scope.systemPath ? basename(scope.systemPath) : ""}):`);
|
|
767
|
+
for (const why of r.system.reasons)
|
|
768
|
+
lines.push(` - ${why}`);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
let failed = 0;
|
|
772
|
+
if (apply && r.count) {
|
|
773
|
+
const applied = await applyReconcile(host, scope, r);
|
|
774
|
+
failed = applied.failed.length;
|
|
775
|
+
lines.push("Applied:", ...applied.lines.map((l) => ` ${l}`));
|
|
776
|
+
if (failed)
|
|
777
|
+
lines.push(`${failed} document(s) NOT written; the others were. Fix the cause and run reconcile_scenario apply:true again.`);
|
|
778
|
+
}
|
|
779
|
+
if (r.unresolved.length) {
|
|
780
|
+
lines.push(`Left open (${r.unresolved.length}):`, ...r.unresolved.map((u) => ` - ${u}`));
|
|
781
|
+
}
|
|
782
|
+
if (!apply && r.count)
|
|
783
|
+
lines.push("Dry run: nothing was written. Pass apply:true to write it.");
|
|
784
|
+
if (!r.count && !r.unresolved.length)
|
|
785
|
+
lines.push("The drawings already agree with the scenarios.");
|
|
786
|
+
return failed ? fail(lines.join("\n")) : ok(lines.join("\n"));
|
|
787
|
+
});
|
|
788
|
+
server.tool("show", "Tell the canvases open on a system what to show: which member to open and which element to focus, with an optional sentence beside it. `member` is a member's path (relative to the system file, or absolute) and must be listed; `focus` is <kind>:<name>, kind one of entity, attribute, machine, object, event, state, participant, message (e.g. 'message:9', 'entity:WaveCount', 'state:Published_Tradable'). No member: the system map. The system is the argument, else the active document when it is a .sysdf.json, else the system that lists the active document (in its folder or a parent folder), else the last system this server created, set or checked. A view changes no document.", { member: z.string().optional(), focus: z.string().optional(), note: z.string().optional(), system: z.string().optional() }, async ({ member, focus, note, system }) => {
|
|
789
|
+
const path = systemPathOf(host, system);
|
|
790
|
+
if (typeof path !== "string")
|
|
791
|
+
return fail(path.error);
|
|
792
|
+
const def = readSystem(path);
|
|
793
|
+
if (!def)
|
|
794
|
+
return fail(existsSync(path) ? `${path} is not a readable system definition.` : `No system at ${path}.`);
|
|
795
|
+
let stored;
|
|
796
|
+
if (member) {
|
|
797
|
+
const want = resolveMemberPath(path, member.trim());
|
|
798
|
+
stored = def.members.find((m) => resolveMemberPath(path, m.path) === want)?.path;
|
|
799
|
+
if (!stored) {
|
|
800
|
+
return fail(`'${member}' is not a member of '${def.settings?.name ?? ""}'. Members: ${def.members.map((m) => m.path).join(", ") || "none"}.`);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
if (focus && !parseFocus(focus)) {
|
|
804
|
+
return fail(`focus is <kind>:<name>, kind one of entity, attribute, machine, object, event, state, participant, message (got '${focus}').`);
|
|
805
|
+
}
|
|
806
|
+
host.rememberSystem(path);
|
|
807
|
+
const sent = await host.live.view({ docId: path, member: stored, focus, note });
|
|
808
|
+
const what = `${stored ?? "the system map"}${focus ? `, focused on ${focus}` : ""}`;
|
|
809
|
+
return sent.ok
|
|
810
|
+
? ok(`Asked the canvases on '${def.settings?.name ?? ""}' to show ${what}: ${sent.text}.`)
|
|
811
|
+
: fail(`Could not ask the canvases on '${def.settings?.name ?? ""}' to show ${what}: ${sent.text}.`);
|
|
812
|
+
});
|
|
813
|
+
}
|