moflo 4.9.28 → 4.9.29
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/dist/src/cli/commands/spell.js +60 -0
- package/dist/src/cli/mcp-tools/spell-tools.js +30 -6
- package/dist/src/cli/spells/core/prerequisite-checker.js +46 -10
- package/dist/src/cli/spells/core/runner.js +1 -0
- package/dist/src/cli/spells/core/spell-tier.js +23 -0
- package/dist/src/cli/spells/core/yaml-defaults-writer.js +169 -0
- package/dist/src/cli/spells/factory/runner-bridge.js +2 -0
- package/dist/src/cli/swarm/message-bus/write-through-adapter.js +10 -9
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
|
@@ -15,6 +15,9 @@ import { formatStatus, handleMCPError } from '../services/cli-formatters.js';
|
|
|
15
15
|
import { scheduleCommand } from './spell-schedule.js';
|
|
16
16
|
import { spellCredentialsCommand } from './spell-credentials.js';
|
|
17
17
|
import { loadSpellEngine } from '../services/engine-loader.js';
|
|
18
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
19
|
+
import { basename } from 'node:path';
|
|
20
|
+
import { updateYamlArgDefaults } from '../spells/core/yaml-defaults-writer.js';
|
|
18
21
|
// Re-export formatStatus as formatStageStatus for table column references
|
|
19
22
|
const formatStageStatus = formatStatus;
|
|
20
23
|
// Shared table column definitions
|
|
@@ -141,6 +144,12 @@ export const castCommand = {
|
|
|
141
144
|
description: 'Single key=value spell argument (repeatable). Numeric/boolean strings are coerced.',
|
|
142
145
|
type: 'array',
|
|
143
146
|
},
|
|
147
|
+
{
|
|
148
|
+
name: 'reprompt-creds',
|
|
149
|
+
description: 'Force re-prompting for env-type prereqs (rotate or correct stored credentials)',
|
|
150
|
+
type: 'boolean',
|
|
151
|
+
default: false,
|
|
152
|
+
},
|
|
144
153
|
],
|
|
145
154
|
examples: [
|
|
146
155
|
{ command: 'moflo spell cast -n development', description: 'Cast spell by name' },
|
|
@@ -148,6 +157,7 @@ export const castCommand = {
|
|
|
148
157
|
{ command: 'moflo spell cast -n sa --dry-run', description: 'Validate via abbreviation' },
|
|
149
158
|
{ command: "moflo spell cast -n oap --args '{\"maxEmails\":50}'", description: 'Pass spell arguments as JSON' },
|
|
150
159
|
{ command: 'moflo spell cast -n oap --arg headless=false --arg maxEmails=50', description: 'Pass arguments as key=value pairs' },
|
|
160
|
+
{ command: 'moflo spell cast -n oap --reprompt-creds', description: 'Re-prompt for credentials (rotate stored secrets)' },
|
|
151
161
|
],
|
|
152
162
|
action: async (ctx) => {
|
|
153
163
|
const name = ctx.flags.name || ctx.args[0];
|
|
@@ -217,6 +227,7 @@ export const castCommand = {
|
|
|
217
227
|
file: file || undefined,
|
|
218
228
|
args,
|
|
219
229
|
dryRun,
|
|
230
|
+
forceCredentialReprompt: ctx.flags.repromptCreds,
|
|
220
231
|
});
|
|
221
232
|
if (result.error) {
|
|
222
233
|
spinner.fail(`Spell failed: ${result.error}`);
|
|
@@ -245,6 +256,22 @@ export const castCommand = {
|
|
|
245
256
|
output.printTable({ columns: STEP_COLUMNS, data: result.steps });
|
|
246
257
|
}
|
|
247
258
|
printSpellErrors(result.errors);
|
|
259
|
+
// #1003 — offer to persist --arg values back into the spell YAML.
|
|
260
|
+
// CLI-interactive only; MCP/headless callers skip by design.
|
|
261
|
+
if (result.success
|
|
262
|
+
&& !dryRun
|
|
263
|
+
&& ctx.interactive
|
|
264
|
+
&& Object.keys(args).length > 0
|
|
265
|
+
&& result.sourceFile) {
|
|
266
|
+
if (result.tier === 'shipped') {
|
|
267
|
+
const hint = name || basename(result.sourceFile).replace(/\.[^.]+$/, '');
|
|
268
|
+
output.writeln();
|
|
269
|
+
output.printInfo(`Shipped spell — copy to spells/dev/${hint}.yaml first to persist defaults.`);
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
await offerArgWriteback(result.sourceFile, args);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
248
275
|
return { success: result.success, data: result };
|
|
249
276
|
}
|
|
250
277
|
catch (error) {
|
|
@@ -256,6 +283,39 @@ export const castCommand = {
|
|
|
256
283
|
}
|
|
257
284
|
},
|
|
258
285
|
};
|
|
286
|
+
async function offerArgWriteback(sourceFile, userArgs) {
|
|
287
|
+
const keys = Object.keys(userArgs);
|
|
288
|
+
output.writeln();
|
|
289
|
+
const noun = keys.length === 1 ? 'argument' : 'arguments';
|
|
290
|
+
const accepted = await confirm({
|
|
291
|
+
message: `Save ${keys.length} ${noun} (${keys.join(', ')}) as the spell's defaults?`,
|
|
292
|
+
default: false,
|
|
293
|
+
});
|
|
294
|
+
if (!accepted)
|
|
295
|
+
return;
|
|
296
|
+
let original;
|
|
297
|
+
try {
|
|
298
|
+
original = await readFile(sourceFile, 'utf-8');
|
|
299
|
+
}
|
|
300
|
+
catch (err) {
|
|
301
|
+
output.printError(`Could not read spell YAML: ${err.message}`);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const result = updateYamlArgDefaults(original, userArgs);
|
|
305
|
+
if (result.updated.length > 0) {
|
|
306
|
+
try {
|
|
307
|
+
await writeFile(sourceFile, result.content, 'utf-8');
|
|
308
|
+
output.printSuccess(`Saved ${result.updated.length} default${result.updated.length === 1 ? '' : 's'}: ${result.updated.join(', ')}`);
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
output.printError(`Could not write spell YAML: ${err.message}`);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (result.skipped.length > 0) {
|
|
316
|
+
output.printWarning(`Skipped ${result.skipped.length} (not declared in arguments:): ${result.skipped.join(', ')}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
259
319
|
// Validate subcommand
|
|
260
320
|
const validateCommand = {
|
|
261
321
|
name: 'validate',
|
|
@@ -11,6 +11,7 @@ import { loadSpellEngine, getCachedEngine, } from '../services/engine-loader.js'
|
|
|
11
11
|
import { findProjectRoot } from '../services/project-root.js';
|
|
12
12
|
import { buildGrimoire } from '../services/grimoire-builder.js';
|
|
13
13
|
import { errorDetail } from '../shared/utils/error-detail.js';
|
|
14
|
+
import { inferSpellTier } from '../spells/core/spell-tier.js';
|
|
14
15
|
// ============================================================================
|
|
15
16
|
// Constants
|
|
16
17
|
// ============================================================================
|
|
@@ -53,14 +54,18 @@ function trackResult(tracked, result) {
|
|
|
53
54
|
tracked.completedAt = new Date().toISOString();
|
|
54
55
|
}
|
|
55
56
|
/** Execute a definition via the engine with tracking and error handling. */
|
|
56
|
-
async function executeAndTrack(engine, definition, args) {
|
|
57
|
+
async function executeAndTrack(engine, definition, args, options = {}) {
|
|
57
58
|
const spellId = `sp-${Date.now()}`;
|
|
58
59
|
const tracked = trackStart(spellId, definition.name, definition.description);
|
|
59
60
|
try {
|
|
60
61
|
const sandboxConfig = await engine.loadSandboxConfigFromProject(findProjectRoot());
|
|
61
|
-
const result = await engine.bridgeExecuteSpell(definition, args, {
|
|
62
|
+
const result = await engine.bridgeExecuteSpell(definition, args, {
|
|
63
|
+
spellId,
|
|
64
|
+
sandboxConfig,
|
|
65
|
+
forceCredentialReprompt: options.forceCredentialReprompt,
|
|
66
|
+
});
|
|
62
67
|
trackResult(tracked, result);
|
|
63
|
-
return serializeResult(result);
|
|
68
|
+
return withSpellSource(serializeResult(result), options.sourceFile, options.tier);
|
|
64
69
|
}
|
|
65
70
|
catch (err) {
|
|
66
71
|
tracked.status = SPELL_STATUS.FAILED;
|
|
@@ -68,6 +73,14 @@ async function executeAndTrack(engine, definition, args) {
|
|
|
68
73
|
return { spellId, error: errorDetail(err) };
|
|
69
74
|
}
|
|
70
75
|
}
|
|
76
|
+
/** Attach sourceFile + tier metadata to a serialized cast response. (#1003) */
|
|
77
|
+
function withSpellSource(base, sourceFile, tier) {
|
|
78
|
+
if (sourceFile)
|
|
79
|
+
base.sourceFile = sourceFile;
|
|
80
|
+
if (tier)
|
|
81
|
+
base.tier = tier;
|
|
82
|
+
return base;
|
|
83
|
+
}
|
|
71
84
|
// ============================================================================
|
|
72
85
|
// Registry singleton (created once per session, refreshable on demand)
|
|
73
86
|
// ============================================================================
|
|
@@ -149,11 +162,16 @@ export const spellTools = [
|
|
|
149
162
|
content: { type: 'string', description: 'Inline YAML/JSON spell incantation' },
|
|
150
163
|
args: { type: 'object', description: 'Reagents (arguments) to pass to the spell' },
|
|
151
164
|
dryRun: { type: 'boolean', description: 'Preview the spell without casting' },
|
|
165
|
+
forceCredentialReprompt: {
|
|
166
|
+
type: 'boolean',
|
|
167
|
+
description: 'Force re-prompting for env-type prereqs even when the credential store has a stored value (used to rotate or correct stored secrets)',
|
|
168
|
+
},
|
|
152
169
|
},
|
|
153
170
|
},
|
|
154
171
|
handler: async (input) => {
|
|
155
172
|
const args = input.args ?? {};
|
|
156
173
|
const dryRun = input.dryRun;
|
|
174
|
+
const forceCredentialReprompt = input.forceCredentialReprompt;
|
|
157
175
|
if (input.name) {
|
|
158
176
|
// Resolve via registry and execute the parsed definition directly
|
|
159
177
|
const registry = await getRegistry();
|
|
@@ -162,7 +180,11 @@ export const spellTools = [
|
|
|
162
180
|
return { error: `Spell not found in grimoire: ${input.name}` };
|
|
163
181
|
}
|
|
164
182
|
const engine = await loadSpellEngine();
|
|
165
|
-
return executeAndTrack(engine, loaded.definition, args
|
|
183
|
+
return executeAndTrack(engine, loaded.definition, args, {
|
|
184
|
+
forceCredentialReprompt,
|
|
185
|
+
sourceFile: loaded.sourceFile,
|
|
186
|
+
tier: loaded.tier,
|
|
187
|
+
});
|
|
166
188
|
}
|
|
167
189
|
// Determine raw content source
|
|
168
190
|
let content;
|
|
@@ -189,10 +211,12 @@ export const spellTools = [
|
|
|
189
211
|
// Run from raw content via bridge
|
|
190
212
|
const engine = await loadSpellEngine();
|
|
191
213
|
const sandboxConfig = await engine.loadSandboxConfigFromProject(findProjectRoot());
|
|
192
|
-
const result = await engine.bridgeRunSpell(content, sourceFile, args, {
|
|
214
|
+
const result = await engine.bridgeRunSpell(content, sourceFile, args, {
|
|
215
|
+
dryRun, sandboxConfig, forceCredentialReprompt,
|
|
216
|
+
});
|
|
193
217
|
const tracked = trackStart(result.spellId, spellName);
|
|
194
218
|
trackResult(tracked, result);
|
|
195
|
-
return serializeResult(result);
|
|
219
|
+
return withSpellSource(serializeResult(result), sourceFile, sourceFile ? inferSpellTier(sourceFile) : undefined);
|
|
196
220
|
},
|
|
197
221
|
},
|
|
198
222
|
// --------------------------------------------------------------------------
|
|
@@ -165,7 +165,9 @@ function appendPrereqLine(lines, name, hint, url) {
|
|
|
165
165
|
* Evaluate all prereqs. Resolution chain for env-type prereqs:
|
|
166
166
|
* 1. process.env[key] already set → satisfied.
|
|
167
167
|
* 2. credentials.get(key) returns a value → write to process.env, satisfied.
|
|
168
|
-
* 3. TTY interactive → prompt → write to process.env
|
|
168
|
+
* 3. TTY interactive → prompt → write to process.env. After ALL prompts,
|
|
169
|
+
* one Y/n offer asks whether to persist the collected answers to the
|
|
170
|
+
* credential store as a batch (default Y). Story #1002.
|
|
169
171
|
* 4. Non-TTY or no credentials → fail fast with `errorCode: 'MISSING_CREDENTIAL'`.
|
|
170
172
|
*
|
|
171
173
|
* Non-env prereqs (`command`, `file`) bypass the credential chain and surface
|
|
@@ -186,9 +188,11 @@ export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
|
|
|
186
188
|
}
|
|
187
189
|
// Pull env-type prereqs from the store in parallel — each resolved index
|
|
188
190
|
// lets us skip a re-check of the cheap env detector.
|
|
191
|
+
// `forceCredentialReprompt` bypasses this step so users can rotate or
|
|
192
|
+
// correct stored credentials by re-running through the prompt path.
|
|
189
193
|
const resolvedFromStoreNames = [];
|
|
190
194
|
const storeResolved = new Set();
|
|
191
|
-
if (credentials) {
|
|
195
|
+
if (credentials && !options.forceCredentialReprompt) {
|
|
192
196
|
await Promise.all(unmetIndices.map(async (i) => {
|
|
193
197
|
const prereq = prerequisites[i];
|
|
194
198
|
if (!prereq.envKey)
|
|
@@ -230,7 +234,9 @@ export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
|
|
|
230
234
|
const promptLine = options.promptLine ?? readLineFromStdin;
|
|
231
235
|
const promptedNames = [];
|
|
232
236
|
const promptableSet = new Set(promptable);
|
|
237
|
+
const pendingSaves = [];
|
|
233
238
|
const lock = acquireTTYLock();
|
|
239
|
+
let shouldSave = false;
|
|
234
240
|
try {
|
|
235
241
|
for (const prereq of promptable) {
|
|
236
242
|
if (options.abortSignal?.aborted) {
|
|
@@ -265,21 +271,32 @@ export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
|
|
|
265
271
|
}
|
|
266
272
|
if (prereq.envKey) {
|
|
267
273
|
process.env[prereq.envKey] = answer;
|
|
268
|
-
if (credentials)
|
|
269
|
-
|
|
270
|
-
await credentials.store(prereq.envKey, answer);
|
|
271
|
-
}
|
|
272
|
-
catch (err) {
|
|
273
|
-
log(` (could not persist credential "${prereq.envKey}": ${err.message})`);
|
|
274
|
-
}
|
|
275
|
-
}
|
|
274
|
+
if (credentials)
|
|
275
|
+
pendingSaves.push({ envKey: prereq.envKey, value: answer });
|
|
276
276
|
}
|
|
277
277
|
promptedNames.push(prereq.name);
|
|
278
278
|
}
|
|
279
|
+
// One batched save offer covers every collected answer — issuing N
|
|
280
|
+
// confirmations would be repetitive when most users want all-or-nothing.
|
|
281
|
+
if (credentials && pendingSaves.length > 0) {
|
|
282
|
+
shouldSave = await promptSaveCredentialsConfirmation(promptLine, pendingSaves.length, options.abortSignal);
|
|
283
|
+
}
|
|
279
284
|
}
|
|
280
285
|
finally {
|
|
281
286
|
lock.release();
|
|
282
287
|
}
|
|
288
|
+
// Persist outside the TTY lock so slow disk/keychain writes don't block
|
|
289
|
+
// concurrent stdin readers waiting on the same lock.
|
|
290
|
+
if (credentials && shouldSave) {
|
|
291
|
+
for (const { envKey, value } of pendingSaves) {
|
|
292
|
+
try {
|
|
293
|
+
await credentials.store(envKey, value);
|
|
294
|
+
}
|
|
295
|
+
catch (err) {
|
|
296
|
+
log(` (could not persist credential "${envKey}": ${err.message})`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
283
300
|
// Anything in stillUnmet that wasn't promptable (typically command/file
|
|
284
301
|
// prereqs) is still broken — carry forward the initial detector result.
|
|
285
302
|
const unfixableIdx = stillUnmetIdx.filter(i => !promptableSet.has(prerequisites[i]));
|
|
@@ -304,6 +321,25 @@ function formatMissingCredentialMessage(envKeys, prereqs) {
|
|
|
304
321
|
lines.push(' flo spell credentials set <name>');
|
|
305
322
|
return lines.join('\n');
|
|
306
323
|
}
|
|
324
|
+
/**
|
|
325
|
+
* Ask once whether to persist every collected prereq answer to the
|
|
326
|
+
* credential store. Empty input or `y/yes` → true (default Y); `n/no` →
|
|
327
|
+
* false. A failed prompt (e.g. abort) silently declines so the cast
|
|
328
|
+
* still proceeds with the in-memory env values.
|
|
329
|
+
*/
|
|
330
|
+
async function promptSaveCredentialsConfirmation(promptLine, count, abortSignal) {
|
|
331
|
+
const noun = count === 1 ? 'credential' : 'credentials';
|
|
332
|
+
const prompt = ` Save ${count} ${noun} to the encrypted credential store for future runs? [Y/n] `;
|
|
333
|
+
let answer;
|
|
334
|
+
try {
|
|
335
|
+
answer = await promptLine(prompt, abortSignal);
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
const trimmed = answer.trim().toLowerCase();
|
|
341
|
+
return trimmed === '' || trimmed === 'y' || trimmed === 'yes';
|
|
342
|
+
}
|
|
307
343
|
function printPreflightBanner(log, unmetCount) {
|
|
308
344
|
log('');
|
|
309
345
|
log('\x1b[1;36m━━━ Preflight: missing prerequisites ━━━\x1b[0m');
|
|
@@ -107,6 +107,7 @@ export class SpellCaster {
|
|
|
107
107
|
const resolution = await resolveUnmetPrerequisites(prerequisites, {
|
|
108
108
|
abortSignal: options.signal,
|
|
109
109
|
credentials: this.credentials,
|
|
110
|
+
forceCredentialReprompt: options.forceCredentialReprompt,
|
|
110
111
|
});
|
|
111
112
|
if (!resolution.ok) {
|
|
112
113
|
return this.failureResult(spellId, startTime, [{
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classify a spell's source path as 'shipped' (ships with moflo, read-only
|
|
3
|
+
* from the consumer's perspective) or 'user' (project-local, writable).
|
|
4
|
+
*
|
|
5
|
+
* Story #1003 — used by the post-cast arg-writeback offer to skip the save
|
|
6
|
+
* prompt for shipped YAMLs (mutating them inside `node_modules/moflo/...`
|
|
7
|
+
* would be wiped on the next `npm install` and confuses provenance).
|
|
8
|
+
*/
|
|
9
|
+
const SHIPPED_MARKERS = [
|
|
10
|
+
// Installed package — both bare 'moflo' and the legacy '@moflo/*' workspace.
|
|
11
|
+
/\/node_modules\/moflo\//,
|
|
12
|
+
/\/node_modules\/@moflo\//,
|
|
13
|
+
// Source-tree shipped definitions (relevant when moflo dogfoods itself).
|
|
14
|
+
/\/src\/cli\/spells\/definitions\//,
|
|
15
|
+
/\/dist\/[^/]*\/?cli\/spells\/definitions\//,
|
|
16
|
+
];
|
|
17
|
+
export function inferSpellTier(sourceFile) {
|
|
18
|
+
if (!sourceFile)
|
|
19
|
+
return 'user';
|
|
20
|
+
const norm = sourceFile.replace(/\\/g, '/');
|
|
21
|
+
return SHIPPED_MARKERS.some((re) => re.test(norm)) ? 'shipped' : 'user';
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=spell-tier.js.map
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// YAML line-editor: rewrite `arguments.<key>.default` without a full
|
|
2
|
+
// round-trip through js-yaml (which would drop comments). Anchors and
|
|
3
|
+
// block-scalar defaults are listed in `skipped` so callers can surface them.
|
|
4
|
+
import * as yaml from 'js-yaml';
|
|
5
|
+
const ARGUMENTS_HEADER_RE = /^arguments\s*:\s*(?:#.*)?$/;
|
|
6
|
+
const KEY_LINE_RE = /^(\s*)([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/;
|
|
7
|
+
const DEFAULT_LINE_RE = /^(\s*default\s*:\s*)(?:[^#\n]*?)(\s*#.*)?$/;
|
|
8
|
+
const BLOCK_SCALAR_DEFAULT_RE = /^\s*default\s*:\s*[|>][+-]?\d*\s*(?:#.*)?$/;
|
|
9
|
+
export function updateYamlArgDefaults(yamlContent, updates) {
|
|
10
|
+
const updateKeys = Object.keys(updates);
|
|
11
|
+
if (updateKeys.length === 0) {
|
|
12
|
+
return { content: yamlContent, updated: [], skipped: [] };
|
|
13
|
+
}
|
|
14
|
+
const usesCRLF = /\r\n/.test(yamlContent);
|
|
15
|
+
const normalized = usesCRLF ? yamlContent.replace(/\r\n/g, '\n') : yamlContent;
|
|
16
|
+
const lines = normalized.split('\n');
|
|
17
|
+
const argumentsLineIdx = findArgumentsHeader(lines);
|
|
18
|
+
if (argumentsLineIdx === -1) {
|
|
19
|
+
return { content: yamlContent, updated: [], skipped: updateKeys };
|
|
20
|
+
}
|
|
21
|
+
const argKeyIndent = findArgKeyIndent(lines, argumentsLineIdx);
|
|
22
|
+
if (argKeyIndent === -1) {
|
|
23
|
+
return { content: yamlContent, updated: [], skipped: updateKeys };
|
|
24
|
+
}
|
|
25
|
+
const remaining = new Set(updateKeys);
|
|
26
|
+
const updated = [];
|
|
27
|
+
const skippedExtra = [];
|
|
28
|
+
let i = argumentsLineIdx + 1;
|
|
29
|
+
while (i < lines.length) {
|
|
30
|
+
const line = lines[i];
|
|
31
|
+
const stripped = line.trim();
|
|
32
|
+
if (!stripped || stripped.startsWith('#')) {
|
|
33
|
+
i++;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const indent = leadingSpaces(line);
|
|
37
|
+
if (indent === 0)
|
|
38
|
+
break;
|
|
39
|
+
if (indent !== argKeyIndent) {
|
|
40
|
+
i++;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const m = KEY_LINE_RE.exec(line);
|
|
44
|
+
if (!m) {
|
|
45
|
+
i++;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const argName = m[2];
|
|
49
|
+
if (!remaining.has(argName)) {
|
|
50
|
+
i = skipBlock(lines, i + 1, argKeyIndent);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const blockStart = i + 1;
|
|
54
|
+
const blockEnd = skipBlock(lines, blockStart, argKeyIndent);
|
|
55
|
+
const propIndent = findPropIndent(lines, blockStart, blockEnd, argKeyIndent);
|
|
56
|
+
const defaultLineIdx = findDefaultLine(lines, blockStart, blockEnd, propIndent);
|
|
57
|
+
const newValueStr = formatYamlValue(updates[argName]);
|
|
58
|
+
// Refuse to touch block-scalar defaults — replacing the header line with
|
|
59
|
+
// an inline scalar would orphan the continuation lines as sibling keys.
|
|
60
|
+
if (defaultLineIdx !== -1 && BLOCK_SCALAR_DEFAULT_RE.test(lines[defaultLineIdx])) {
|
|
61
|
+
skippedExtra.push(argName);
|
|
62
|
+
remaining.delete(argName);
|
|
63
|
+
i = skipBlock(lines, i + 1, argKeyIndent);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
// Refuse multi-line dumped values for the same orphan-line reason.
|
|
67
|
+
if (newValueStr.includes('\n')) {
|
|
68
|
+
skippedExtra.push(argName);
|
|
69
|
+
remaining.delete(argName);
|
|
70
|
+
i = skipBlock(lines, i + 1, argKeyIndent);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (defaultLineIdx !== -1) {
|
|
74
|
+
lines[defaultLineIdx] = replaceDefaultValue(lines[defaultLineIdx], newValueStr);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
const insertIndent = ' '.repeat(propIndent);
|
|
78
|
+
lines.splice(blockStart, 0, `${insertIndent}default: ${newValueStr}`);
|
|
79
|
+
}
|
|
80
|
+
updated.push(argName);
|
|
81
|
+
remaining.delete(argName);
|
|
82
|
+
i = skipBlock(lines, i + 1, argKeyIndent);
|
|
83
|
+
}
|
|
84
|
+
const joined = lines.join('\n');
|
|
85
|
+
const content = usesCRLF ? joined.replace(/\n/g, '\r\n') : joined;
|
|
86
|
+
return {
|
|
87
|
+
content,
|
|
88
|
+
updated,
|
|
89
|
+
skipped: [...remaining, ...skippedExtra],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function findArgumentsHeader(lines) {
|
|
93
|
+
for (let i = 0; i < lines.length; i++) {
|
|
94
|
+
if (leadingSpaces(lines[i]) === 0 && ARGUMENTS_HEADER_RE.test(lines[i].trim())) {
|
|
95
|
+
return i;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return -1;
|
|
99
|
+
}
|
|
100
|
+
function findArgKeyIndent(lines, headerIdx) {
|
|
101
|
+
for (let i = headerIdx + 1; i < lines.length; i++) {
|
|
102
|
+
const stripped = lines[i].trim();
|
|
103
|
+
if (!stripped || stripped.startsWith('#'))
|
|
104
|
+
continue;
|
|
105
|
+
const indent = leadingSpaces(lines[i]);
|
|
106
|
+
if (indent === 0)
|
|
107
|
+
return -1;
|
|
108
|
+
return indent;
|
|
109
|
+
}
|
|
110
|
+
return -1;
|
|
111
|
+
}
|
|
112
|
+
function findPropIndent(lines, blockStart, blockEnd, argKeyIndent) {
|
|
113
|
+
for (let j = blockStart; j < blockEnd; j++) {
|
|
114
|
+
const stripped = lines[j].trim();
|
|
115
|
+
if (!stripped || stripped.startsWith('#'))
|
|
116
|
+
continue;
|
|
117
|
+
return leadingSpaces(lines[j]);
|
|
118
|
+
}
|
|
119
|
+
return argKeyIndent + 2;
|
|
120
|
+
}
|
|
121
|
+
function findDefaultLine(lines, blockStart, blockEnd, propIndent) {
|
|
122
|
+
for (let j = blockStart; j < blockEnd; j++) {
|
|
123
|
+
const stripped = lines[j].trim();
|
|
124
|
+
if (!stripped || stripped.startsWith('#'))
|
|
125
|
+
continue;
|
|
126
|
+
if (leadingSpaces(lines[j]) !== propIndent)
|
|
127
|
+
continue;
|
|
128
|
+
if (/^default\s*:/.test(stripped))
|
|
129
|
+
return j;
|
|
130
|
+
}
|
|
131
|
+
return -1;
|
|
132
|
+
}
|
|
133
|
+
function skipBlock(lines, start, parentIndent) {
|
|
134
|
+
for (let i = start; i < lines.length; i++) {
|
|
135
|
+
const stripped = lines[i].trim();
|
|
136
|
+
if (!stripped || stripped.startsWith('#'))
|
|
137
|
+
continue;
|
|
138
|
+
if (leadingSpaces(lines[i]) <= parentIndent)
|
|
139
|
+
return i;
|
|
140
|
+
}
|
|
141
|
+
return lines.length;
|
|
142
|
+
}
|
|
143
|
+
function leadingSpaces(line) {
|
|
144
|
+
let n = 0;
|
|
145
|
+
while (n < line.length && line[n] === ' ')
|
|
146
|
+
n++;
|
|
147
|
+
return n;
|
|
148
|
+
}
|
|
149
|
+
function replaceDefaultValue(line, newValue) {
|
|
150
|
+
const m = DEFAULT_LINE_RE.exec(line);
|
|
151
|
+
if (!m)
|
|
152
|
+
return line;
|
|
153
|
+
const prefix = m[1];
|
|
154
|
+
const trailingComment = m[2] ?? '';
|
|
155
|
+
return `${prefix}${newValue}${trailingComment}`;
|
|
156
|
+
}
|
|
157
|
+
export function formatYamlValue(value) {
|
|
158
|
+
if (value === null || value === undefined)
|
|
159
|
+
return 'null';
|
|
160
|
+
if (typeof value === 'boolean' || typeof value === 'number')
|
|
161
|
+
return String(value);
|
|
162
|
+
const dumped = yaml.dump(value, {
|
|
163
|
+
flowLevel: 0,
|
|
164
|
+
lineWidth: -1,
|
|
165
|
+
noRefs: true,
|
|
166
|
+
}).replace(/\r?\n$/, '');
|
|
167
|
+
return dumped;
|
|
168
|
+
}
|
|
169
|
+
//# sourceMappingURL=yaml-defaults-writer.js.map
|
|
@@ -45,6 +45,7 @@ export async function bridgeRunSpell(content, sourceFile, args, options = {}) {
|
|
|
45
45
|
signal: controller.signal,
|
|
46
46
|
memory: options.memory,
|
|
47
47
|
credentials,
|
|
48
|
+
forceCredentialReprompt: options.forceCredentialReprompt,
|
|
48
49
|
...(options.projectRoot ? { projectRoot: options.projectRoot } : {}),
|
|
49
50
|
...(sandboxConfig ? { sandboxConfig } : {}),
|
|
50
51
|
});
|
|
@@ -68,6 +69,7 @@ export async function bridgeExecuteSpell(definition, args, options = {}) {
|
|
|
68
69
|
return await runner.run(definition, args, {
|
|
69
70
|
spellId,
|
|
70
71
|
signal: controller.signal,
|
|
72
|
+
forceCredentialReprompt: options.forceCredentialReprompt,
|
|
71
73
|
...(options.projectRoot ? { projectRoot: options.projectRoot } : {}),
|
|
72
74
|
...(sandboxConfig ? { sandboxConfig } : {}),
|
|
73
75
|
});
|
|
@@ -101,16 +101,17 @@ export class WriteThroughAdapter {
|
|
|
101
101
|
// Best-effort cleanup
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
* own daemon-write-client timeout).
|
|
109
|
-
*/
|
|
104
|
+
// #1003 — yield to the microtask queue and re-check until pendingWrites
|
|
105
|
+
// settles. The single-snapshot pattern lost a race on fast hosts (Ubuntu CI)
|
|
106
|
+
// where a caller's awaited bus.sendUnified resolved before its `.then()`
|
|
107
|
+
// chain registered the write-through promise.
|
|
110
108
|
async drainPendingWrites() {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
109
|
+
for (let i = 0; i < 5; i++) {
|
|
110
|
+
await Promise.resolve();
|
|
111
|
+
if (this.pendingWrites.size === 0)
|
|
112
|
+
return;
|
|
113
|
+
await Promise.allSettled([...this.pendingWrites]);
|
|
114
|
+
}
|
|
114
115
|
}
|
|
115
116
|
onUnifiedMessage(event) {
|
|
116
117
|
if (!event.namespace || !this.enabledNamespaces.has(event.namespace)) {
|
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.29",
|
|
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",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
|
85
85
|
"@typescript-eslint/parser": "^7.18.0",
|
|
86
86
|
"eslint": "^8.0.0",
|
|
87
|
-
"moflo": "^4.9.
|
|
87
|
+
"moflo": "^4.9.28",
|
|
88
88
|
"tsx": "^4.21.0",
|
|
89
89
|
"typescript": "^5.9.3",
|
|
90
90
|
"vitest": "^4.0.0"
|