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.
@@ -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
  // --------------------------------------------------------------------------
@@ -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 AND credentials.store.
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
- try {
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
- * 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.29';
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.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.27",
87
+ "moflo": "^4.9.28",
88
88
  "tsx": "^4.21.0",
89
89
  "typescript": "^5.9.3",
90
90
  "vitest": "^4.0.0"