moflo 4.9.28 → 4.9.30

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.
@@ -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, { spellId, sandboxConfig });
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, { dryRun, sandboxConfig });
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
  // --------------------------------------------------------------------------
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Credential Validation
3
+ *
4
+ * Lightweight, no-config shape checks applied to values pulled from the
5
+ * encrypted credential store before they are promoted to `process.env`.
6
+ *
7
+ * Two heuristics, both conservative — only invalidate when there is
8
+ * positive evidence the stored value is bad. Anything we can't classify
9
+ * passes through unchanged.
10
+ *
11
+ * - JWT-shaped values (3 base64url segments) get their `exp` claim
12
+ * parsed and compared to "now". An expired JWT is reported as such.
13
+ * - Env keys ending in `_URL` must parse via the WHATWG `URL`
14
+ * constructor and have a non-empty host.
15
+ *
16
+ * Story #1007: avoid silently reusing stale stored credentials (e.g.
17
+ * Microsoft Graph access tokens, which expire in ~1h) so the resolver
18
+ * can fall through to the prompt path and the user understands why.
19
+ */
20
+ const VALID_JWT_SEGMENT = /^[A-Za-z0-9_-]+$/;
21
+ export function validateStoredCredential(envKey, value) {
22
+ if (envKey.endsWith('_URL')) {
23
+ return validateUrlValue(value);
24
+ }
25
+ if (looksLikeJwt(value)) {
26
+ return validateJwtExpiry(value);
27
+ }
28
+ return { valid: true };
29
+ }
30
+ function validateUrlValue(value) {
31
+ try {
32
+ const parsed = new URL(value);
33
+ if (!parsed.host) {
34
+ return { valid: false, reason: 'stored value is not a valid URL (missing host)' };
35
+ }
36
+ return { valid: true };
37
+ }
38
+ catch {
39
+ return { valid: false, reason: 'stored value is not a valid URL' };
40
+ }
41
+ }
42
+ function looksLikeJwt(value) {
43
+ const parts = value.split('.');
44
+ if (parts.length !== 3)
45
+ return false;
46
+ return parts.every(p => p.length > 0 && VALID_JWT_SEGMENT.test(p));
47
+ }
48
+ function validateJwtExpiry(value) {
49
+ const exp = readJwtExp(value);
50
+ if (exp == null)
51
+ return { valid: true };
52
+ const expiryMs = exp * 1000;
53
+ const now = Date.now();
54
+ if (expiryMs >= now)
55
+ return { valid: true };
56
+ return { valid: false, reason: `JWT expired ${formatDuration(now - expiryMs)} ago` };
57
+ }
58
+ function readJwtExp(value) {
59
+ try {
60
+ const payload = value.split('.')[1];
61
+ const padLen = (4 - (payload.length % 4)) % 4;
62
+ const b64 = payload.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat(padLen);
63
+ const decoded = Buffer.from(b64, 'base64').toString('utf-8');
64
+ const parsed = JSON.parse(decoded);
65
+ if (typeof parsed === 'object' && parsed !== null) {
66
+ const exp = parsed.exp;
67
+ if (typeof exp === 'number' && Number.isFinite(exp))
68
+ return exp;
69
+ }
70
+ return null;
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ }
76
+ function formatDuration(ms) {
77
+ const s = Math.floor(ms / 1000);
78
+ if (s < 60)
79
+ return `${s}s`;
80
+ const m = Math.floor(s / 60);
81
+ if (m < 60)
82
+ return `${m}m`;
83
+ const h = Math.floor(m / 60);
84
+ if (h < 24)
85
+ return `${h}h ${m % 60}m`;
86
+ const d = Math.floor(h / 24);
87
+ return `${d}d ${h % 24}h`;
88
+ }
89
+ //# sourceMappingURL=credential-validation.js.map
@@ -18,6 +18,7 @@ import { access } from 'node:fs/promises';
18
18
  import { promisify } from 'node:util';
19
19
  import { acquireTTYLock } from './tty-lock.js';
20
20
  import { readLineFromStdin } from './stdin-reader.js';
21
+ import { validateStoredCredential } from './credential-validation.js';
21
22
  const execFileAsync = promisify(execFile);
22
23
  /**
23
24
  * Check whether a CLI command is available on the system PATH.
@@ -165,7 +166,9 @@ function appendPrereqLine(lines, name, hint, url) {
165
166
  * Evaluate all prereqs. Resolution chain for env-type prereqs:
166
167
  * 1. process.env[key] already set → satisfied.
167
168
  * 2. credentials.get(key) returns a value → write to process.env, satisfied.
168
- * 3. TTY interactive → prompt → write to process.env AND credentials.store.
169
+ * 3. TTY interactive → prompt → write to process.env. After ALL prompts,
170
+ * one Y/n offer asks whether to persist the collected answers to the
171
+ * credential store as a batch (default Y). Story #1002.
169
172
  * 4. Non-TTY or no credentials → fail fast with `errorCode: 'MISSING_CREDENTIAL'`.
170
173
  *
171
174
  * Non-env prereqs (`command`, `file`) bypass the credential chain and surface
@@ -186,19 +189,30 @@ export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
186
189
  }
187
190
  // Pull env-type prereqs from the store in parallel — each resolved index
188
191
  // lets us skip a re-check of the cheap env detector.
192
+ // `forceCredentialReprompt` bypasses this step so users can rotate or
193
+ // correct stored credentials by re-running through the prompt path.
194
+ // Stored values are shape-validated (#1007): expired JWTs and malformed
195
+ // _URL values are rejected and surface in the preflight banner, so the
196
+ // resolver re-prompts instead of silently feeding garbage to the spell.
189
197
  const resolvedFromStoreNames = [];
190
198
  const storeResolved = new Set();
191
- if (credentials) {
199
+ const rejectedFromStore = [];
200
+ if (credentials && !options.forceCredentialReprompt) {
192
201
  await Promise.all(unmetIndices.map(async (i) => {
193
202
  const prereq = prerequisites[i];
194
203
  if (!prereq.envKey)
195
204
  return;
196
205
  const stored = await credentials.get(prereq.envKey);
197
- if (typeof stored === 'string' && stored.length > 0) {
198
- process.env[prereq.envKey] = stored;
199
- storeResolved.add(i);
200
- resolvedFromStoreNames.push(prereq.name);
206
+ if (typeof stored !== 'string' || stored.length === 0)
207
+ return;
208
+ const validation = validateStoredCredential(prereq.envKey, stored);
209
+ if (!validation.valid) {
210
+ rejectedFromStore.push({ envKey: prereq.envKey, reason: validation.reason });
211
+ return;
201
212
  }
213
+ process.env[prereq.envKey] = stored;
214
+ storeResolved.add(i);
215
+ resolvedFromStoreNames.push(prereq.name);
202
216
  }));
203
217
  }
204
218
  const stillUnmetIdx = unmetIndices.filter(i => !storeResolved.has(i));
@@ -214,7 +228,7 @@ export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
214
228
  if (promptableEnvKeys.length > 0) {
215
229
  return {
216
230
  ok: false,
217
- message: formatMissingCredentialMessage(promptableEnvKeys, stillUnmet),
231
+ message: formatMissingCredentialMessage(promptableEnvKeys, stillUnmet, rejectedFromStore),
218
232
  resolvedNames: resolvedFromStoreNames,
219
233
  errorCode: 'MISSING_CREDENTIAL',
220
234
  missingCredentials: promptableEnvKeys,
@@ -227,10 +241,18 @@ export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
227
241
  };
228
242
  }
229
243
  printPreflightBanner(log, stillUnmet.length);
244
+ if (rejectedFromStore.length > 0) {
245
+ for (const r of rejectedFromStore) {
246
+ log(` Stored ${r.envKey} rejected — ${r.reason}. Re-enter below.`);
247
+ }
248
+ log('');
249
+ }
230
250
  const promptLine = options.promptLine ?? readLineFromStdin;
231
251
  const promptedNames = [];
232
252
  const promptableSet = new Set(promptable);
253
+ const pendingSaves = [];
233
254
  const lock = acquireTTYLock();
255
+ let shouldSave = false;
234
256
  try {
235
257
  for (const prereq of promptable) {
236
258
  if (options.abortSignal?.aborted) {
@@ -265,21 +287,32 @@ export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
265
287
  }
266
288
  if (prereq.envKey) {
267
289
  process.env[prereq.envKey] = answer;
268
- if (credentials) {
269
- try {
270
- await credentials.store(prereq.envKey, answer);
271
- }
272
- catch (err) {
273
- log(` (could not persist credential "${prereq.envKey}": ${err.message})`);
274
- }
275
- }
290
+ if (credentials)
291
+ pendingSaves.push({ envKey: prereq.envKey, value: answer });
276
292
  }
277
293
  promptedNames.push(prereq.name);
278
294
  }
295
+ // One batched save offer covers every collected answer — issuing N
296
+ // confirmations would be repetitive when most users want all-or-nothing.
297
+ if (credentials && pendingSaves.length > 0) {
298
+ shouldSave = await promptSaveCredentialsConfirmation(promptLine, pendingSaves.length, options.abortSignal);
299
+ }
279
300
  }
280
301
  finally {
281
302
  lock.release();
282
303
  }
304
+ // Persist outside the TTY lock so slow disk/keychain writes don't block
305
+ // concurrent stdin readers waiting on the same lock.
306
+ if (credentials && shouldSave) {
307
+ for (const { envKey, value } of pendingSaves) {
308
+ try {
309
+ await credentials.store(envKey, value);
310
+ }
311
+ catch (err) {
312
+ log(` (could not persist credential "${envKey}": ${err.message})`);
313
+ }
314
+ }
315
+ }
283
316
  // Anything in stillUnmet that wasn't promptable (typically command/file
284
317
  // prereqs) is still broken — carry forward the initial detector result.
285
318
  const unfixableIdx = stillUnmetIdx.filter(i => !promptableSet.has(prerequisites[i]));
@@ -292,18 +325,44 @@ export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
292
325
  }
293
326
  return { ok: true, resolvedNames: [...resolvedFromStoreNames, ...promptedNames] };
294
327
  }
295
- function formatMissingCredentialMessage(envKeys, prereqs) {
328
+ function formatMissingCredentialMessage(envKeys, prereqs, rejectedFromStore) {
296
329
  const lines = ['Missing credentials (cannot prompt — non-interactive run):'];
297
330
  for (const key of envKeys) {
298
331
  const prereq = prereqs.find(p => p.envKey === key);
299
332
  const label = `${prereq?.name ?? key} (${key})`;
300
333
  appendPrereqLine(lines, label, prereq?.installHint, prereq?.url);
301
334
  }
335
+ if (rejectedFromStore.length > 0) {
336
+ lines.push('');
337
+ lines.push('Stored value(s) rejected as stale or invalid:');
338
+ for (const r of rejectedFromStore) {
339
+ lines.push(` - ${r.envKey}: ${r.reason}`);
340
+ }
341
+ }
302
342
  lines.push('');
303
343
  lines.push('Prime these by casting the spell once interactively, or run:');
304
344
  lines.push(' flo spell credentials set <name>');
305
345
  return lines.join('\n');
306
346
  }
347
+ /**
348
+ * Ask once whether to persist every collected prereq answer to the
349
+ * credential store. Empty input or `y/yes` → true (default Y); `n/no` →
350
+ * false. A failed prompt (e.g. abort) silently declines so the cast
351
+ * still proceeds with the in-memory env values.
352
+ */
353
+ async function promptSaveCredentialsConfirmation(promptLine, count, abortSignal) {
354
+ const noun = count === 1 ? 'credential' : 'credentials';
355
+ const prompt = ` Save ${count} ${noun} to the encrypted credential store for future runs? [Y/n] `;
356
+ let answer;
357
+ try {
358
+ answer = await promptLine(prompt, abortSignal);
359
+ }
360
+ catch {
361
+ return false;
362
+ }
363
+ const trimmed = answer.trim().toLowerCase();
364
+ return trimmed === '' || trimmed === 'y' || trimmed === 'yes';
365
+ }
307
366
  function printPreflightBanner(log, unmetCount) {
308
367
  log('');
309
368
  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
- * Wait for every fire-and-forget write currently in flight to settle.
106
- * No-op when no writes are queued. Bounded by `Promise.allSettled` so a
107
- * single hung write can't block forever (each individual write has its
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
- if (this.pendingWrites.size === 0)
112
- return;
113
- await Promise.allSettled([...this.pendingWrites]);
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)) {
@@ -2,5 +2,5 @@
2
2
  * Auto-generated by build. Do not edit manually.
3
3
  * Source of truth: root package.json → scripts/sync-version.mjs
4
4
  */
5
- export const VERSION = '4.9.28';
5
+ export const VERSION = '4.9.30';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.9.28",
3
+ "version": "4.9.30",
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.27",
87
+ "moflo": "^4.9.29",
88
88
  "tsx": "^4.21.0",
89
89
  "typescript": "^5.9.3",
90
90
  "vitest": "^4.0.0"