moflo 4.9.27 → 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.27';
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.27",
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.26",
87
+ "moflo": "^4.9.28",
88
88
  "tsx": "^4.21.0",
89
89
  "typescript": "^5.9.3",
90
90
  "vitest": "^4.0.0"
@@ -87,8 +87,12 @@ steps:
87
87
  # elevated — bwrap network access for git pull (see single-branch create-branch).
88
88
  permissionLevel: elevated
89
89
  preflight:
90
+ # `git diff --name-only --diff-filter=U` always exits 0 — it just
91
+ # lists paths. Use `--quiet`, which exits 1 when any unmerged path
92
+ # exists, so this preflight actually catches a half-merged index
93
+ # (e.g. left by an earlier failed run).
90
94
  - name: "no unmerged files"
91
- command: "git diff --name-only --diff-filter=U"
95
+ command: "git diff --quiet --diff-filter=U"
92
96
  expectExitCode: 0
93
97
  hint: "You have unresolved merge conflicts. Resolve them and commit before running this spell."
94
98
  - name: "working tree clean (tracked changes)"
@@ -97,7 +101,7 @@ steps:
97
101
  hint: "You have uncommitted changes to tracked files. If you want them carried onto the epic branch, pick 'Stash and carry over'."
98
102
  resolutions:
99
103
  - label: "Stash changes and carry them onto the epic branch"
100
- command: "git stash push --include-untracked --message 'moflo-epic-autostash'"
104
+ command: "git stash push --include-untracked --message 'moflo-epic-{args.epic_number}-autostash'"
101
105
  - label: "Commit changes to the current branch first, then continue"
102
106
  command: "git commit -am 'wip: pre-epic snapshot'"
103
107
  - name: "working tree clean (staged changes)"
@@ -106,7 +110,7 @@ steps:
106
110
  hint: "You have staged changes that aren't committed. If you want them carried onto the epic branch, pick 'Stash and carry over'."
107
111
  resolutions:
108
112
  - label: "Stash staged changes and carry them onto the epic branch"
109
- command: "git stash push --include-untracked --message 'moflo-epic-autostash'"
113
+ command: "git stash push --include-untracked --message 'moflo-epic-{args.epic_number}-autostash'"
110
114
  - label: "Commit staged changes to the current branch first, then continue"
111
115
  command: "git commit -m 'wip: pre-epic snapshot'"
112
116
  - name: "gh cli authenticated"
@@ -116,9 +120,28 @@ steps:
116
120
  command: "git remote get-url origin"
117
121
  hint: "This repo has no 'origin' remote. Set one with: git remote add origin <url>"
118
122
  config:
119
- # set -e: fail fast if checkout/pull fails; otherwise the trailing
120
- # `stash pop ... || true` would mask the real failure.
121
- command: "set -e; git stash --include-untracked -q 2>/dev/null || true; git checkout {args.base_branch}; git pull origin {args.base_branch}; git stash pop -q 2>/dev/null || true"
123
+ # set -e: fail fast if checkout/pull fails.
124
+ #
125
+ # We deliberately do NOT auto-stash here. Preflight enforces a clean
126
+ # tree (or runs the user-chosen stash-and-carry resolution), so the
127
+ # only stash we should ever pop is the preflight's
128
+ # `moflo-epic-autostash` marker. Popping the unconditionally-top
129
+ # stash (the previous design) was the source of #287's stash-pop
130
+ # conflict that left the index half-merged.
131
+ command: |
132
+ set -e
133
+ git checkout {args.base_branch}
134
+ git pull origin {args.base_branch}
135
+ # Scoped by epic_number to prevent a stale autostash from an
136
+ # unrelated abandoned run getting popped here.
137
+ EXPECTED_STASH_TAG="moflo-epic-{args.epic_number}-autostash"
138
+ TOP_MSG=$(git stash list --format='%s' -1)
139
+ if [[ "$TOP_MSG" == *"$EXPECTED_STASH_TAG"* ]]; then
140
+ if ! git stash pop -q; then
141
+ echo "[epic] carrying-over stash conflicted on {args.base_branch} — resolve unmerged paths and re-run the spell" >&2
142
+ exit 1
143
+ fi
144
+ fi
122
145
  failOnError: true
123
146
 
124
147
  # 2: Spawn Claude agent to implement the story (creates branch + PR)
@@ -155,7 +178,20 @@ steps:
155
178
  permissionLevel: elevated
156
179
  config:
157
180
  # set -e: fail fast if checkout/pull fails (see checkout-base).
158
- command: "set -e; git stash --include-untracked -q 2>/dev/null || true; git checkout {args.base_branch}; git pull origin {args.base_branch}; git stash pop -q 2>/dev/null || true"
181
+ # No in-step stash; pop only the preflight's autostash marker
182
+ # (scoped by epic_number — see checkout-base).
183
+ command: |
184
+ set -e
185
+ git checkout {args.base_branch}
186
+ git pull origin {args.base_branch}
187
+ EXPECTED_STASH_TAG="moflo-epic-{args.epic_number}-autostash"
188
+ TOP_MSG=$(git stash list --format='%s' -1)
189
+ if [[ "$TOP_MSG" == *"$EXPECTED_STASH_TAG"* ]]; then
190
+ if ! git stash pop -q; then
191
+ echo "[epic] carrying-over stash conflicted on {args.base_branch} — resolve unmerged paths and re-run the spell" >&2
192
+ exit 1
193
+ fi
194
+ fi
159
195
  failOnError: true
160
196
 
161
197
  # 6: Comment on epic with progress
@@ -87,8 +87,12 @@ steps:
87
87
  # has working network.
88
88
  permissionLevel: elevated
89
89
  preflight:
90
+ # `git diff --name-only --diff-filter=U` always exits 0 — it just lists
91
+ # paths. Use `--quiet`, which exits 1 when any unmerged path exists, so
92
+ # the preflight actually catches a half-merged index left by an earlier
93
+ # failed run (e.g. a stash-pop conflict).
90
94
  - name: "no unmerged files"
91
- command: "git diff --name-only --diff-filter=U"
95
+ command: "git diff --quiet --diff-filter=U"
92
96
  expectExitCode: 0
93
97
  hint: "You have unresolved merge conflicts. Resolve them and commit before running this spell."
94
98
  - name: "working tree clean (tracked changes)"
@@ -97,7 +101,7 @@ steps:
97
101
  hint: "You have uncommitted changes to tracked files. If you want them carried onto the epic branch, pick 'Stash and carry over'."
98
102
  resolutions:
99
103
  - label: "Stash changes and carry them onto the epic branch"
100
- command: "git stash push --include-untracked --message 'moflo-epic-autostash'"
104
+ command: "git stash push --include-untracked --message 'moflo-epic-{args.epic_number}-autostash'"
101
105
  - label: "Commit changes to the current branch first, then continue"
102
106
  command: "git commit -am 'wip: pre-epic snapshot'"
103
107
  - name: "working tree clean (staged changes)"
@@ -106,18 +110,45 @@ steps:
106
110
  hint: "You have staged changes that aren't committed. If you want them carried onto the epic branch, pick 'Stash and carry over'."
107
111
  resolutions:
108
112
  - label: "Stash staged changes and carry them onto the epic branch"
109
- command: "git stash push --include-untracked --message 'moflo-epic-autostash'"
113
+ command: "git stash push --include-untracked --message 'moflo-epic-{args.epic_number}-autostash'"
110
114
  - label: "Commit staged changes to the current branch first, then continue"
111
115
  command: "git commit -m 'wip: pre-epic snapshot'"
112
116
  - name: "gh cli authenticated"
113
117
  command: "gh auth status"
114
118
  hint: "The GitHub CLI isn't signed in. Run: gh auth login"
115
119
  config:
116
- # set -e: any failing step aborts the whole command. Without it, the
117
- # trailing `git stash pop ... || true` would swallow real failures
118
- # (e.g., checkout/pull/branch-create errors) and report success,
119
- # leaving later steps to crash when the epic branch isn't there.
120
- command: "set -e; git stash --include-untracked -q 2>/dev/null || true; git checkout {args.base_branch}; git pull origin {args.base_branch}; BRANCH=\"epic/{args.epic_number}-{args.epic_slug}\"; if git show-ref --verify --quiet \"refs/heads/$BRANCH\"; then git checkout \"$BRANCH\"; else git checkout -b \"$BRANCH\"; fi; git stash pop -q 2>/dev/null || true"
120
+ # set -e: any failing step aborts the whole command.
121
+ #
122
+ # We deliberately do NOT auto-stash here. Preflight has already enforced
123
+ # a clean tree (or run the user-chosen stash-and-carry resolution), so
124
+ # the only stash we should ever pop is the preflight's `moflo-epic-autostash`
125
+ # marker. Popping the unconditionally-top stash (the previous design)
126
+ # was the source of #287's stash-pop conflict that left the index
127
+ # half-merged — `git stash pop ... || true` masked the conflict
128
+ # failure and the next step's `git checkout` then died with
129
+ # "you need to resolve your current index first".
130
+ command: |
131
+ set -e
132
+ git checkout {args.base_branch}
133
+ git pull origin {args.base_branch}
134
+ BRANCH="epic/{args.epic_number}-{args.epic_slug}"
135
+ if git show-ref --verify --quiet "refs/heads/$BRANCH"; then
136
+ git checkout "$BRANCH"
137
+ else
138
+ git checkout -b "$BRANCH"
139
+ fi
140
+ # Scope the marker by epic_number so a stale autostash from an
141
+ # abandoned previous run of a DIFFERENT epic doesn't get popped onto
142
+ # this branch unintentionally. Same-epic re-runs still carry over,
143
+ # which is the intended recovery path.
144
+ EXPECTED_STASH_TAG="moflo-epic-{args.epic_number}-autostash"
145
+ TOP_MSG=$(git stash list --format='%s' -1)
146
+ if [[ "$TOP_MSG" == *"$EXPECTED_STASH_TAG"* ]]; then
147
+ if ! git stash pop -q; then
148
+ echo "[epic] carrying-over stash conflicted on $BRANCH — resolve unmerged paths and re-run the spell" >&2
149
+ exit 1
150
+ fi
151
+ fi
121
152
  timeout: 120000
122
153
  failOnError: true
123
154