moflo 4.9.11 → 4.9.13
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/.claude/commands/simplify.md +78 -30
- package/.claude/skills/eldar/SKILL.md +305 -0
- package/.claude/skills/simplify/SKILL.md +90 -21
- package/README.md +25 -0
- package/bin/hooks.mjs +2 -2
- package/bin/index-guidance.mjs +14 -24
- package/bin/index-patterns.mjs +13 -10
- package/bin/session-start-launcher.mjs +205 -11
- package/dist/src/cli/commands/doctor-checks-deep.js +76 -0
- package/dist/src/cli/commands/doctor.js +53 -1
- package/dist/src/cli/config/moflo-config.js +14 -3
- package/dist/src/cli/init/moflo-init.js +20 -266
- package/dist/src/cli/init/moflo-yaml-template.js +370 -0
- package/dist/src/cli/mcp-tools/hooks-tools.js +3 -1
- package/dist/src/cli/movector/model-router.js +66 -20
- package/dist/src/cli/services/hook-block-hash.js +341 -0
- package/dist/src/cli/services/index.js +2 -0
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings.json hook-block drift detection (#881).
|
|
3
|
+
*
|
|
4
|
+
* Hashes the consumer's `.claude/settings.json` `hooks` block and the
|
|
5
|
+
* reference hook block that `generateHooksConfig()` would produce for the
|
|
6
|
+
* current moflo version. When the hashes differ, the session-start launcher
|
|
7
|
+
* surfaces the diff (or, in `regenerate` mode, adds purely-additive missing
|
|
8
|
+
* hooks). This is the broader complement to the per-bug `repairHookWiring`
|
|
9
|
+
* and `rewriteIncorrectHookWiring` rules — it catches drift in any direction,
|
|
10
|
+
* including future hook events we haven't shipped yet.
|
|
11
|
+
*
|
|
12
|
+
* IMPORTANT: This module must remain self-contained with ZERO imports from
|
|
13
|
+
* other moflo modules (mirrors the constraint on `services/hook-wiring.ts`).
|
|
14
|
+
* It is dynamically imported at runtime by `bin/session-start-launcher.mjs`
|
|
15
|
+
* in consumer projects, where transitive dependencies may not resolve.
|
|
16
|
+
*
|
|
17
|
+
* The reference hook block is duplicated from `init/settings-generator.ts`
|
|
18
|
+
* on purpose — the launcher cannot pull in `init/types.js` at runtime, and a
|
|
19
|
+
* unit test (`hook-block-hash.test.ts`) asserts the two stay in sync.
|
|
20
|
+
*/
|
|
21
|
+
import { createHash } from 'crypto';
|
|
22
|
+
export const DRIFT_MODES = ['warn', 'regenerate', 'off'];
|
|
23
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
24
|
+
// Reference hook block — kept in sync with init/settings-generator.ts
|
|
25
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
26
|
+
const HELPERS_PREFIX = '$CLAUDE_PROJECT_DIR/.claude/helpers';
|
|
27
|
+
const SCRIPTS_PREFIX = '$CLAUDE_PROJECT_DIR/.claude/scripts';
|
|
28
|
+
/** Build a `node "<helper> <subcommand>"` hook entry. */
|
|
29
|
+
const helperHook = (helper, sub, timeout) => ({
|
|
30
|
+
type: 'command',
|
|
31
|
+
command: `node "${HELPERS_PREFIX}/${helper}"${sub ? ` ${sub}` : ''}`,
|
|
32
|
+
timeout,
|
|
33
|
+
});
|
|
34
|
+
/** Build a `node "<scripts/file>"` hook entry (no subcommand). */
|
|
35
|
+
const scriptHook = (file, timeout) => ({
|
|
36
|
+
type: 'command',
|
|
37
|
+
command: `node "${SCRIPTS_PREFIX}/${file}"`,
|
|
38
|
+
timeout,
|
|
39
|
+
});
|
|
40
|
+
const gateHook = (sub, timeout) => helperHook('gate-hook.mjs', sub, timeout);
|
|
41
|
+
const gateCjs = (sub, timeout) => helperHook('gate.cjs', sub, timeout);
|
|
42
|
+
const handler = (sub, timeout) => helperHook('hook-handler.cjs', sub, timeout);
|
|
43
|
+
const autoMemory = (sub, timeout) => helperHook('auto-memory-hook.mjs', sub, timeout);
|
|
44
|
+
/**
|
|
45
|
+
* Build the reference hook block — the canonical block `generateHooksConfig()`
|
|
46
|
+
* produces with all hook flags enabled (the default for `flo init`).
|
|
47
|
+
*
|
|
48
|
+
* If you change `generateHooksConfig()` in `init/settings-generator.ts`, also
|
|
49
|
+
* change this function — and the unit test `getReferenceHookBlock matches
|
|
50
|
+
* generateHooksConfig` will fail until the two agree.
|
|
51
|
+
*/
|
|
52
|
+
export function getReferenceHookBlock() {
|
|
53
|
+
return {
|
|
54
|
+
PreToolUse: [
|
|
55
|
+
{ matcher: '^(Write|Edit|MultiEdit)$', hooks: [handler('post-edit', 5000)] },
|
|
56
|
+
{ matcher: '^(Glob|Grep)$', hooks: [gateHook('check-before-scan', 3000)] },
|
|
57
|
+
{ matcher: '^Read$', hooks: [gateHook('check-before-read', 3000)] },
|
|
58
|
+
{
|
|
59
|
+
matcher: '^Bash$',
|
|
60
|
+
hooks: [gateHook('check-dangerous-command', 2000), gateHook('check-before-pr', 2000)],
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
PostToolUse: [
|
|
64
|
+
{
|
|
65
|
+
matcher: '^(Write|Edit|MultiEdit)$',
|
|
66
|
+
hooks: [handler('post-edit', 5000), gateHook('reset-edit-gates', 2000)],
|
|
67
|
+
},
|
|
68
|
+
{ matcher: '^Agent$', hooks: [handler('post-task', 5000)] },
|
|
69
|
+
{ matcher: '^TaskCreate$', hooks: [gateCjs('record-task-created', 2000)] },
|
|
70
|
+
{
|
|
71
|
+
matcher: '^Bash$',
|
|
72
|
+
hooks: [gateHook('check-bash-memory', 2000), gateHook('record-test-run', 2000)],
|
|
73
|
+
},
|
|
74
|
+
{ matcher: '^Skill$', hooks: [gateHook('record-skill-run', 2000)] },
|
|
75
|
+
{ matcher: 'mcp__moflo__memory_', hooks: [gateHook('record-memory-searched', 3000)] },
|
|
76
|
+
{ matcher: '^TaskUpdate$', hooks: [gateCjs('check-task-transition', 2000)] },
|
|
77
|
+
{ matcher: '^mcp__moflo__memory_store$', hooks: [gateCjs('record-learnings-stored', 2000)] },
|
|
78
|
+
],
|
|
79
|
+
UserPromptSubmit: [
|
|
80
|
+
{ hooks: [helperHook('prompt-hook.mjs', '', 3000)] },
|
|
81
|
+
{ hooks: [gateHook('prompt-reminder', 3000)] },
|
|
82
|
+
],
|
|
83
|
+
SubagentStart: [
|
|
84
|
+
{ hooks: [helperHook('subagent-start.cjs', '', 2000)] },
|
|
85
|
+
],
|
|
86
|
+
SessionStart: [
|
|
87
|
+
{
|
|
88
|
+
hooks: [scriptHook('session-start-launcher.mjs', 3000), autoMemory('import', 8000)],
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
Stop: [
|
|
92
|
+
{ hooks: [handler('session-end', 5000), autoMemory('sync', 10000)] },
|
|
93
|
+
],
|
|
94
|
+
PreCompact: [
|
|
95
|
+
{ hooks: [gateCjs('compact-guidance', 3000)] },
|
|
96
|
+
],
|
|
97
|
+
Notification: [
|
|
98
|
+
{ hooks: [handler('notification', 3000)] },
|
|
99
|
+
],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
103
|
+
// Normalisation + hashing
|
|
104
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
105
|
+
function normaliseHookEntry(raw) {
|
|
106
|
+
if (!raw || typeof raw !== 'object')
|
|
107
|
+
return null;
|
|
108
|
+
const r = raw;
|
|
109
|
+
if (typeof r.command !== 'string')
|
|
110
|
+
return null;
|
|
111
|
+
return {
|
|
112
|
+
type: typeof r.type === 'string' ? r.type : 'command',
|
|
113
|
+
command: r.command.replace(/\s+/g, ' ').trim(),
|
|
114
|
+
timeout: typeof r.timeout === 'number' && isFinite(r.timeout) ? r.timeout : 0,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function normaliseHookBlock(raw) {
|
|
118
|
+
if (!raw || typeof raw !== 'object')
|
|
119
|
+
return null;
|
|
120
|
+
const r = raw;
|
|
121
|
+
const hooksIn = Array.isArray(r.hooks) ? r.hooks : [];
|
|
122
|
+
const hooks = hooksIn.map(normaliseHookEntry).filter((h) => h !== null);
|
|
123
|
+
if (hooks.length === 0)
|
|
124
|
+
return null;
|
|
125
|
+
hooks.sort((a, b) => a.command.localeCompare(b.command));
|
|
126
|
+
const out = { hooks };
|
|
127
|
+
if (typeof r.matcher === 'string' && r.matcher.length > 0)
|
|
128
|
+
out.matcher = r.matcher;
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Produce a stable, sorted view of a hook tree suitable for hashing or diffing.
|
|
133
|
+
* Drops unknown keys, coerces missing fields to defaults, and sorts events,
|
|
134
|
+
* matchers, and commands so semantically-equal trees compare equal.
|
|
135
|
+
*/
|
|
136
|
+
export function normaliseHooks(raw) {
|
|
137
|
+
if (!raw || typeof raw !== 'object')
|
|
138
|
+
return {};
|
|
139
|
+
const events = raw;
|
|
140
|
+
const out = {};
|
|
141
|
+
const eventNames = Object.keys(events).sort();
|
|
142
|
+
for (const event of eventNames) {
|
|
143
|
+
const arr = events[event];
|
|
144
|
+
if (!Array.isArray(arr))
|
|
145
|
+
continue;
|
|
146
|
+
const blocks = arr
|
|
147
|
+
.map(normaliseHookBlock)
|
|
148
|
+
.filter((b) => b !== null);
|
|
149
|
+
if (blocks.length === 0)
|
|
150
|
+
continue;
|
|
151
|
+
blocks.sort((a, b) => {
|
|
152
|
+
const am = a.matcher ?? '';
|
|
153
|
+
const bm = b.matcher ?? '';
|
|
154
|
+
if (am !== bm)
|
|
155
|
+
return am.localeCompare(bm);
|
|
156
|
+
return (a.hooks[0]?.command ?? '').localeCompare(b.hooks[0]?.command ?? '');
|
|
157
|
+
});
|
|
158
|
+
out[event] = blocks;
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
function hashNormalised(tree) {
|
|
163
|
+
return createHash('sha256').update(JSON.stringify(tree)).digest('hex').slice(0, 16);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Hash a hook tree. Stable across runs (deterministic normalisation), and
|
|
167
|
+
* insensitive to key order, whitespace inside commands, or matcher block
|
|
168
|
+
* grouping. Returns a 16-char hex prefix of sha256 — long enough to make
|
|
169
|
+
* collisions a non-concern for the small space of valid hook trees while
|
|
170
|
+
* staying readable in launcher output.
|
|
171
|
+
*/
|
|
172
|
+
export function computeHookBlockHash(raw) {
|
|
173
|
+
return hashNormalised(normaliseHooks(raw));
|
|
174
|
+
}
|
|
175
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
176
|
+
// Diff
|
|
177
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
178
|
+
function entryKey(event, matcher, command) {
|
|
179
|
+
return `${event} ${matcher} ${command}`;
|
|
180
|
+
}
|
|
181
|
+
function flatten(tree) {
|
|
182
|
+
const out = new Map();
|
|
183
|
+
for (const event of Object.keys(tree)) {
|
|
184
|
+
for (const block of tree[event]) {
|
|
185
|
+
const matcher = block.matcher ?? '';
|
|
186
|
+
for (const hook of block.hooks) {
|
|
187
|
+
const entry = { event, matcher, command: hook.command };
|
|
188
|
+
out.set(entryKey(event, matcher, hook.command), entry);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
let cachedReference = null;
|
|
195
|
+
function getCachedReference() {
|
|
196
|
+
if (!cachedReference) {
|
|
197
|
+
const tree = getReferenceHookBlock();
|
|
198
|
+
const normalised = normaliseHooks(tree);
|
|
199
|
+
cachedReference = { tree, normalised, hash: hashNormalised(normalised), flat: flatten(normalised) };
|
|
200
|
+
}
|
|
201
|
+
return cachedReference;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Compare a consumer hook block against the reference and report what's
|
|
205
|
+
* missing / extra. Pass an explicit `referenceHooks` to test against a
|
|
206
|
+
* frozen reference (used by tests); omit it to use the current moflo
|
|
207
|
+
* reference from `getReferenceHookBlock()` (memoised — built once per process).
|
|
208
|
+
*/
|
|
209
|
+
export function computeHookBlockDrift(consumerHooks, referenceHooks) {
|
|
210
|
+
const consumerNormalised = normaliseHooks(consumerHooks);
|
|
211
|
+
const consumerHash = hashNormalised(consumerNormalised);
|
|
212
|
+
const consumerFlat = flatten(consumerNormalised);
|
|
213
|
+
let referenceHash;
|
|
214
|
+
let referenceFlat;
|
|
215
|
+
if (referenceHooks === undefined) {
|
|
216
|
+
const ref = getCachedReference();
|
|
217
|
+
referenceHash = ref.hash;
|
|
218
|
+
referenceFlat = ref.flat;
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
const refNormalised = normaliseHooks(referenceHooks);
|
|
222
|
+
referenceHash = hashNormalised(refNormalised);
|
|
223
|
+
referenceFlat = flatten(refNormalised);
|
|
224
|
+
}
|
|
225
|
+
const missing = [];
|
|
226
|
+
for (const [k, v] of referenceFlat) {
|
|
227
|
+
if (!consumerFlat.has(k))
|
|
228
|
+
missing.push(v);
|
|
229
|
+
}
|
|
230
|
+
const extra = [];
|
|
231
|
+
for (const [k, v] of consumerFlat) {
|
|
232
|
+
if (!referenceFlat.has(k))
|
|
233
|
+
extra.push(v);
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
consumerHash,
|
|
237
|
+
referenceHash,
|
|
238
|
+
drifted: consumerHash !== referenceHash,
|
|
239
|
+
missing,
|
|
240
|
+
extra,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
244
|
+
// Settings.json helpers — shared between launcher + doctor
|
|
245
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
246
|
+
/**
|
|
247
|
+
* True when the user has set `claudeFlow.hooks.locked: true` in their
|
|
248
|
+
* settings.json — a sentinel that suppresses drift surfacing entirely.
|
|
249
|
+
*/
|
|
250
|
+
export function isHookBlockLocked(settings) {
|
|
251
|
+
const root = settings;
|
|
252
|
+
const cf = root?.claudeFlow;
|
|
253
|
+
const hooks = cf?.hooks;
|
|
254
|
+
return hooks?.locked === true;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Additively repair drift: for every entry in `report.missing`, locate the
|
|
258
|
+
* corresponding hook in the reference block and graft it into the consumer's
|
|
259
|
+
* settings. Only safe when `report.extra.length === 0` — otherwise the
|
|
260
|
+
* caller should fall back to `warn` mode to avoid clobbering customisations.
|
|
261
|
+
*
|
|
262
|
+
* Mutates `settings` in place; caller is responsible for writing the file.
|
|
263
|
+
*/
|
|
264
|
+
export function applyAdditiveRegeneration(settings, report) {
|
|
265
|
+
if (report.missing.length === 0)
|
|
266
|
+
return { settings, added: 0, removed: 0 };
|
|
267
|
+
const ref = getCachedReference().tree;
|
|
268
|
+
const hooks = (settings.hooks ?? {});
|
|
269
|
+
let added = 0;
|
|
270
|
+
for (const miss of report.missing) {
|
|
271
|
+
const arr = Array.isArray(hooks[miss.event]) ? hooks[miss.event] : [];
|
|
272
|
+
let block = arr.find(b => (b?.matcher ?? '') === miss.matcher);
|
|
273
|
+
if (!block) {
|
|
274
|
+
block = { hooks: [] };
|
|
275
|
+
if (miss.matcher)
|
|
276
|
+
block.matcher = miss.matcher;
|
|
277
|
+
arr.push(block);
|
|
278
|
+
}
|
|
279
|
+
if (!Array.isArray(block.hooks))
|
|
280
|
+
block.hooks = [];
|
|
281
|
+
const refArr = ref[miss.event] ?? [];
|
|
282
|
+
const refBlock = refArr.find(b => (b?.matcher ?? '') === miss.matcher);
|
|
283
|
+
const refHook = refBlock?.hooks.find(h => h.command === miss.command);
|
|
284
|
+
if (refHook && !block.hooks.some(h => h?.command === miss.command)) {
|
|
285
|
+
block.hooks.push(refHook);
|
|
286
|
+
added++;
|
|
287
|
+
}
|
|
288
|
+
hooks[miss.event] = arr;
|
|
289
|
+
}
|
|
290
|
+
if (added > 0)
|
|
291
|
+
settings.hooks = hooks;
|
|
292
|
+
return { settings, added, removed: 0 };
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Wholesale regeneration: replace `settings.hooks` with the canonical reference
|
|
296
|
+
* block. Drops extras (stale entries from previous moflo versions, e.g. the
|
|
297
|
+
* `gate.cjs session-reset` SessionStart hook removed in #842) AND adds missing
|
|
298
|
+
* entries — the additive variant only does the latter.
|
|
299
|
+
*
|
|
300
|
+
* The caller MUST check `isHookBlockLocked(settings)` first; if locked, the
|
|
301
|
+
* user has opted out and this function should not be called. Non-hooks fields
|
|
302
|
+
* on `settings` (permissions, env, claudeFlow.*, etc.) are preserved.
|
|
303
|
+
*
|
|
304
|
+
* Mutates `settings` in place; caller is responsible for writing the file.
|
|
305
|
+
*/
|
|
306
|
+
export function applyWholesaleRegeneration(settings, report) {
|
|
307
|
+
if (!report.drifted)
|
|
308
|
+
return { settings, added: 0, removed: 0 };
|
|
309
|
+
// Clone the cached reference so a later mutation of settings.hooks (by the
|
|
310
|
+
// launcher's settings.json migrations, doctor --fix, etc.) cannot corrupt
|
|
311
|
+
// the cached tree shared across `computeHookBlockDrift` calls in this process.
|
|
312
|
+
settings.hooks = structuredClone(getCachedReference().tree);
|
|
313
|
+
return { settings, added: report.missing.length, removed: report.extra.length };
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Format a drift report for human-readable output (multi-line, no colour).
|
|
317
|
+
* Used by `flo doctor` and the session-start launcher's stdout summary.
|
|
318
|
+
*/
|
|
319
|
+
export function formatDriftReport(report) {
|
|
320
|
+
if (!report.drifted) {
|
|
321
|
+
return `hook block matches reference (${report.consumerHash})`;
|
|
322
|
+
}
|
|
323
|
+
const lines = [];
|
|
324
|
+
lines.push(`hook block drift detected (consumer ${report.consumerHash} vs reference ${report.referenceHash})`);
|
|
325
|
+
if (report.missing.length > 0) {
|
|
326
|
+
lines.push(` ${report.missing.length} missing:`);
|
|
327
|
+
for (const m of report.missing) {
|
|
328
|
+
const m2 = m.matcher ? ` ${m.matcher}` : '';
|
|
329
|
+
lines.push(` - ${m.event}${m2}: ${m.command}`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (report.extra.length > 0) {
|
|
333
|
+
lines.push(` ${report.extra.length} extra (likely customisations):`);
|
|
334
|
+
for (const e of report.extra) {
|
|
335
|
+
const m2 = e.matcher ? ` ${e.matcher}` : '';
|
|
336
|
+
lines.push(` + ${e.event}${m2}: ${e.command}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return lines.join('\n');
|
|
340
|
+
}
|
|
341
|
+
//# sourceMappingURL=hook-block-hash.js.map
|
|
@@ -15,6 +15,8 @@ export { AgentRouter, getAgentRouter, routeTask, AGENT_CAPABILITIES, } from './a
|
|
|
15
15
|
export { startDashboard, createDashboardMemoryAccessor, DEFAULT_DASHBOARD_PORT, } from './daemon-dashboard.js';
|
|
16
16
|
// Hook Wiring (shared between doctor, upgrade, and session-start)
|
|
17
17
|
export { repairHookWiring, HOOK_ENTRY_MAP, REQUIRED_HOOK_WIRING, } from './hook-wiring.js';
|
|
18
|
+
// Hook Block Drift Detection (#881 — hash-based reconciliation)
|
|
19
|
+
export { computeHookBlockHash, computeHookBlockDrift, formatDriftReport, getReferenceHookBlock, normaliseHooks, isHookBlockLocked, applyAdditiveRegeneration, DRIFT_MODES, } from './hook-block-hash.js';
|
|
18
20
|
// Subagent Bootstrap Directive (single-source for SubagentStart + agent_spawn surfaces)
|
|
19
21
|
export { SUBAGENT_BOOTSTRAP_DIRECTIVE } from './subagent-bootstrap.js';
|
|
20
22
|
//# sourceMappingURL=index.js.map
|
package/dist/src/cli/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "moflo",
|
|
3
|
-
"version": "4.9.
|
|
3
|
+
"version": "4.9.13",
|
|
4
4
|
"description": "MoFlo — AI agent orchestration for Claude Code. A standalone, opinionated toolkit with semantic memory, learned routing, gates, spells, and the /flo issue-execution skill.",
|
|
5
5
|
"main": "dist/src/cli/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
|
82
82
|
"@typescript-eslint/parser": "^7.18.0",
|
|
83
83
|
"eslint": "^8.0.0",
|
|
84
|
-
"moflo": "^4.9.
|
|
84
|
+
"moflo": "^4.9.12",
|
|
85
85
|
"tsx": "^4.21.0",
|
|
86
86
|
"typescript": "^5.9.3",
|
|
87
87
|
"vitest": "^4.0.0"
|