moflo 4.8.56 → 4.8.58

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.
@@ -414,6 +414,46 @@ export const <type>Command: StepCommand<<Type>StepConfig> = {
414
414
  ];
415
415
  },
416
416
 
417
+ // Optional: runtime preflight checks — run BEFORE any step executes.
418
+ // Use for validating runtime state (issue open, service reachable, etc).
419
+ // CRITICAL: the `reason` string IS the message the end user sees.
420
+ // Write it in plain English. State the problem AND the fix. No tool
421
+ // jargon, no exit codes, no internal identifiers.
422
+ //
423
+ // Default severity is 'fatal' (abort on failure). Set severity: 'warning'
424
+ // + resolutions when the user can safely choose how to proceed; in
425
+ // interactive runs they'll be prompted, in non-interactive runs warnings
426
+ // behave like fatals.
427
+ //
428
+ // preflight: [
429
+ // {
430
+ // name: '<service> reachable',
431
+ // severity: 'fatal',
432
+ // check: async (config, ctx) => {
433
+ // const ok = await ping(config.endpoint);
434
+ // if (ok) return { passed: true };
435
+ // return {
436
+ // passed: false,
437
+ // reason: `Can't reach ${config.endpoint}. Check your network connection or the service URL in your spell config.`,
438
+ // };
439
+ // },
440
+ // },
441
+ // {
442
+ // name: 'local cache fresh',
443
+ // severity: 'warning',
444
+ // resolutions: [
445
+ // { label: 'Refresh the cache now', command: '<type>-cli cache refresh' },
446
+ // { label: 'Continue with stale cache' },
447
+ // ],
448
+ // check: async (config) => {
449
+ // const stale = await isCacheStale(config.endpoint);
450
+ // return stale
451
+ // ? { passed: false, reason: 'Your local cache is more than 24 hours old and may produce outdated results.' }
452
+ // : { passed: true };
453
+ // },
454
+ // },
455
+ // ],
456
+
417
457
  // Optional: rollback on failure
418
458
  // async rollback(config, context) { /* undo side effects */ },
419
459
  };
@@ -421,6 +461,18 @@ export const <type>Command: StepCommand<<Type>StepConfig> = {
421
461
 
422
462
  Alternatively, use the `createStepCommand()` factory from `src/modules/spells/src/commands/create-step-command.ts` for compile-time type safety.
423
463
 
464
+ #### Preflight `reason` strings — write for humans
465
+
466
+ When your step declares `preflight` checks, the `reason` string returned on failure is shown verbatim to end users as the error message. Treat it as user-facing copy:
467
+
468
+ - Plain English, no command names, exit codes, or internal identifiers.
469
+ - State BOTH the problem and the fix.
470
+ - Assume a non-technical reader.
471
+
472
+ Good: `"You're not signed in to GitHub. Run: gh auth login"`
473
+ Bad: `"gh auth status exited with code 1"` — leaks implementation detail
474
+ Bad: `"auth check failed"` — tells the user nothing actionable
475
+
424
476
  ### Step 3: Generate Step Command Test
425
477
 
426
478
  Create at `tests/packages/spells/commands/<type>-command.test.ts`:
@@ -136,6 +136,65 @@ Permissions for step "analyze-logs":
136
136
  - `{credentials.NAME}` — references a credential (resolved at runtime)
137
137
  - `{stepId.outputKey}` — references output from a previous step
138
138
 
139
+ #### REQUIRED: Preflight checks with human-readable hints
140
+
141
+ When a step depends on runtime state the user controls (clean git tree, logged-in CLI, reachable host, etc.), declare a `preflight:` block so the spell fails fast with a helpful message BEFORE any side effects occur.
142
+
143
+ **Every preflight MUST include a `hint:` field.** The hint is what the end user will see when the check fails. Without it they get raw shell output (`command "git diff --quiet" exited with 1, expected 0`), which looks like a bug in the spell engine.
144
+
145
+ Good hints:
146
+ - Speak in plain English (no command names, exit codes, or tool jargon).
147
+ - State the problem AND the fix in one or two sentences.
148
+ - Assume a non-technical reader.
149
+
150
+ ```yaml
151
+ steps:
152
+ - id: create-branch
153
+ type: bash
154
+ preflight:
155
+ - name: "working tree clean (tracked changes)"
156
+ command: "git diff --quiet"
157
+ hint: "You have uncommitted changes to tracked files. Commit them or stash them (git stash) before running this spell."
158
+ - name: "gh cli authenticated"
159
+ command: "gh auth status"
160
+ hint: "The GitHub CLI isn't signed in. Run: gh auth login"
161
+ config:
162
+ command: "git checkout -b feature/new"
163
+ ```
164
+
165
+ Bad hint (don't do this):
166
+ ```yaml
167
+ hint: "git diff --quiet failed with exit code 1" # leaks command name + exit code
168
+ hint: "Precondition violated" # tells user nothing actionable
169
+ ```
170
+
171
+ Preflights with no `hint` still work but produce unfriendly default output — flag this to the user as a quality issue before saving the spell.
172
+
173
+ ##### Fatal vs warning severity
174
+
175
+ By default every preflight is `severity: fatal` — if it fails, the spell aborts. Some preflights are better expressed as `severity: warning`: the user gets to choose how to handle the problem, and the spell continues if they pick a resolution.
176
+
177
+ Use `warning` ONLY when:
178
+ - The underlying problem has a safe, one-step fix the user might reasonably want to apply.
179
+ - Proceeding is viable either way — the step itself is robust to the condition.
180
+
181
+ Warning preflights MUST declare `resolutions:` — a list of options the user can pick from. Each resolution has a `label` and an optional `command` to run before continuing. If `command` is omitted, picking the resolution just proceeds (useful for "I'll handle it myself").
182
+
183
+ ```yaml
184
+ preflight:
185
+ - name: "working tree clean (tracked changes)"
186
+ command: "git diff --quiet"
187
+ severity: "warning"
188
+ hint: "You have uncommitted changes. If you want them carried onto the new branch, pick 'Stash and carry over'."
189
+ resolutions:
190
+ - label: "Stash changes and carry them onto the new branch"
191
+ command: "git stash push --include-untracked --message 'pre-spell autostash'"
192
+ - label: "Commit changes to the current branch first, then continue"
193
+ command: "git commit -am 'wip: pre-spell snapshot'"
194
+ ```
195
+
196
+ In non-interactive contexts (CI, daemons, scheduled spells) warnings automatically behave like fatals, because there is no one to prompt. Don't use `warning` as a way to silently ignore a problem — if ignoring it is always safe, the check shouldn't be there.
197
+
139
198
  ### Step 4: Generate the Spell YAML
140
199
 
141
200
  Assemble the definition into YAML format following this structure:
@@ -407,6 +466,13 @@ arguments:
407
466
  steps:
408
467
  - id: scan-deps
409
468
  type: bash
469
+ preflight:
470
+ - name: "npm available"
471
+ command: "npm --version"
472
+ hint: "npm isn't installed or isn't on your PATH. Install Node.js from https://nodejs.org and try again."
473
+ - name: "target directory exists"
474
+ command: "test -d \"{args.target}\""
475
+ hint: "The directory you passed as --target doesn't exist. Check the path and try again."
410
476
  config:
411
477
  command: "npm audit --json"
412
478
  cwd: "{args.target}"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.8.56",
3
+ "version": "4.8.58",
4
4
  "description": "MoFlo — AI agent orchestration for Claude Code. Forked from ruflo/claude-flow with patches applied to source, plus feature-level orchestration.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -111,7 +111,7 @@
111
111
  "@types/js-yaml": "^4.0.9",
112
112
  "@types/node": "^20.19.37",
113
113
  "eslint": "^8.0.0",
114
- "moflo": "^4.8.56-rc.20",
114
+ "moflo": "^4.8.57",
115
115
  "tsx": "^4.21.0",
116
116
  "typescript": "^5.9.3",
117
117
  "vitest": "^4.0.0"
@@ -16,7 +16,8 @@ import { execSync } from 'node:child_process';
16
16
  import { join, dirname } from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
18
18
  import { isEpicIssue, fetchEpicIssue, extractStories, enrichStoryNames, resolveExecutionOrder, } from '../epic/index.js';
19
- import { runEpicSpell } from '../epic/runner-adapter.js';
19
+ import { runEpicSpell, } from '../epic/runner-adapter.js';
20
+ import { select } from '../prompt.js';
20
21
  // ============================================================================
21
22
  // Spell Template Loader
22
23
  // ============================================================================
@@ -183,6 +184,7 @@ async function runEpic(source, strategy, dryRun) {
183
184
  console.log(`[epic] └─ ${stepResult.error}`);
184
185
  }
185
186
  },
187
+ onPreflightWarnings: isInteractive() ? resolvePreflightWarningsInteractively : undefined,
186
188
  });
187
189
  if (result.success) {
188
190
  console.log(`\n[epic] Epic #${issueNumber} completed successfully`);
@@ -195,8 +197,16 @@ async function runEpic(source, strategy, dryRun) {
195
197
  return { success: true, message: 'Epic completed', data: result };
196
198
  }
197
199
  else {
200
+ const firstErr = result.errors[0];
201
+ const errCode = firstErr?.code;
202
+ // Preflight failures are user environment problems, not epic bugs.
203
+ // Show only the friendly prerequisite message, nothing else.
204
+ if (errCode === 'PREFLIGHT_FAILED' && typeof firstErr?.message === 'string') {
205
+ console.log(`\n${firstErr.message}`);
206
+ console.log('\nThe epic was not started. Fix the item(s) above and try again.');
207
+ return { success: false, message: firstErr.message, data: result };
208
+ }
198
209
  console.log(`\n[epic] Epic #${issueNumber} failed`);
199
- // Print step-level results for visibility
200
210
  if (result.steps && result.steps.length > 0) {
201
211
  for (const step of result.steps) {
202
212
  const icon = step.status === 'succeeded' ? '✓' : step.status === 'skipped' ? '○' : '✗';
@@ -207,7 +217,6 @@ async function runEpic(source, strategy, dryRun) {
207
217
  }
208
218
  }
209
219
  }
210
- // Print spell-level errors with full detail
211
220
  for (const err of result.errors) {
212
221
  const prefix = err.stepId
213
222
  ? ` [${err.stepId}]`
@@ -220,8 +229,6 @@ async function runEpic(source, strategy, dryRun) {
220
229
  }
221
230
  }
222
231
  }
223
- // Build actionable error message from first failure
224
- const firstErr = result.errors[0];
225
232
  const rawMsg = firstErr?.message ?? 'Unknown error';
226
233
  const summary = buildFailureSummary(rawMsg, {
227
234
  stepId: firstErr?.stepId,
@@ -301,6 +308,38 @@ async function resetEpic(epicNumber) {
301
308
  return { success: true };
302
309
  }
303
310
  // ============================================================================
311
+ // Preflight warning interactive resolver
312
+ // ============================================================================
313
+ function isInteractive() {
314
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY) && process.env.CI !== 'true';
315
+ }
316
+ async function resolvePreflightWarningsInteractively(warnings) {
317
+ console.log('\nBefore this spell starts, some things need your attention:\n');
318
+ const decisions = [];
319
+ for (let i = 0; i < warnings.length; i++) {
320
+ const w = warnings[i];
321
+ const choices = [
322
+ ...w.resolutions.map((r, idx) => ({
323
+ label: r.label,
324
+ value: { action: 'resolve', resolutionIndex: idx },
325
+ })),
326
+ { label: "Continue anyway (I'll handle it)", value: { action: 'continue' } },
327
+ { label: 'Abort the spell', value: { action: 'abort' } },
328
+ ];
329
+ const decision = await select({
330
+ message: `${i + 1}. ${w.reason}`,
331
+ options: choices,
332
+ });
333
+ decisions.push(decision);
334
+ if (decision.action === 'abort') {
335
+ while (decisions.length < warnings.length)
336
+ decisions.push({ action: 'abort' });
337
+ break;
338
+ }
339
+ }
340
+ return decisions;
341
+ }
342
+ // ============================================================================
304
343
  // Error remediation hints
305
344
  // ============================================================================
306
345
  const REMEDIATION_PATTERNS = [
@@ -8,7 +8,7 @@
8
8
  * Story #229: Uses shared engine loader instead of inline dynamic import.
9
9
  */
10
10
  import * as readline from 'node:readline';
11
- import { loadSpellEngine } from '../services/engine-loader.js';
11
+ import { loadSpellEngine, } from '../services/engine-loader.js';
12
12
  import { createDashboardMemoryAccessor } from '../services/daemon-dashboard.js';
13
13
  /** Cached memory accessor — created once per process. */
14
14
  let memoryAccessor = null;
@@ -62,14 +62,31 @@ steps:
62
62
  - name: "no unmerged files"
63
63
  command: "git diff --name-only --diff-filter=U"
64
64
  expectExitCode: 0
65
+ hint: "You have unresolved merge conflicts. Resolve them and commit before running this spell."
65
66
  - name: "working tree clean (tracked changes)"
66
67
  command: "git diff --quiet"
68
+ severity: "warning"
69
+ hint: "You have uncommitted changes to tracked files. If you want them carried onto the epic branch, pick 'Stash and carry over'."
70
+ resolutions:
71
+ - label: "Stash changes and carry them onto the epic branch"
72
+ command: "git stash push --include-untracked --message 'moflo-epic-autostash'"
73
+ - label: "Commit changes to the current branch first, then continue"
74
+ command: "git commit -am 'wip: pre-epic snapshot'"
67
75
  - name: "working tree clean (staged changes)"
68
76
  command: "git diff --cached --quiet"
77
+ severity: "warning"
78
+ hint: "You have staged changes that aren't committed. If you want them carried onto the epic branch, pick 'Stash and carry over'."
79
+ resolutions:
80
+ - label: "Stash staged changes and carry them onto the epic branch"
81
+ command: "git stash push --include-untracked --message 'moflo-epic-autostash'"
82
+ - label: "Commit staged changes to the current branch first, then continue"
83
+ command: "git commit -m 'wip: pre-epic snapshot'"
69
84
  - name: "gh cli authenticated"
70
85
  command: "gh auth status"
86
+ hint: "The GitHub CLI isn't signed in. Run: gh auth login"
71
87
  - name: "origin remote configured"
72
88
  command: "git remote get-url origin"
89
+ hint: "This repo has no 'origin' remote. Set one with: git remote add origin <url>"
73
90
  config:
74
91
  command: "git stash --include-untracked -q 2>/dev/null; git checkout {args.base_branch} && git pull origin {args.base_branch}; git stash pop -q 2>/dev/null || true"
75
92
  failOnError: true
@@ -59,12 +59,28 @@ steps:
59
59
  - name: "no unmerged files"
60
60
  command: "git diff --name-only --diff-filter=U"
61
61
  expectExitCode: 0
62
+ hint: "You have unresolved merge conflicts. Resolve them and commit before running this spell."
62
63
  - name: "working tree clean (tracked changes)"
63
64
  command: "git diff --quiet"
65
+ severity: "warning"
66
+ hint: "You have uncommitted changes to tracked files. If you want them carried onto the epic branch, pick 'Stash and carry over'."
67
+ resolutions:
68
+ - label: "Stash changes and carry them onto the epic branch"
69
+ command: "git stash push --include-untracked --message 'moflo-epic-autostash'"
70
+ - label: "Commit changes to the current branch first, then continue"
71
+ command: "git commit -am 'wip: pre-epic snapshot'"
64
72
  - name: "working tree clean (staged changes)"
65
73
  command: "git diff --cached --quiet"
74
+ severity: "warning"
75
+ hint: "You have staged changes that aren't committed. If you want them carried onto the epic branch, pick 'Stash and carry over'."
76
+ resolutions:
77
+ - label: "Stash staged changes and carry them onto the epic branch"
78
+ command: "git stash push --include-untracked --message 'moflo-epic-autostash'"
79
+ - label: "Commit staged changes to the current branch first, then continue"
80
+ command: "git commit -m 'wip: pre-epic snapshot'"
66
81
  - name: "gh cli authenticated"
67
82
  command: "gh auth status"
83
+ hint: "The GitHub CLI isn't signed in. Run: gh auth login"
68
84
  config:
69
85
  command: "git stash --include-untracked -q 2>/dev/null; git checkout {args.base_branch} && git pull origin {args.base_branch} && (git show-ref --verify --quiet refs/heads/epic/{args.epic_number}-{args.epic_slug} && git checkout epic/{args.epic_number}-{args.epic_slug} || git checkout -b epic/{args.epic_number}-{args.epic_slug}); git stash pop -q 2>/dev/null || true"
70
86
  timeout: 120000
@@ -729,20 +729,29 @@ function isStale(srcPath, destPath) {
729
729
  // ============================================================================
730
730
  function updateGitignore(root) {
731
731
  const gitignorePath = path.join(root, '.gitignore');
732
- const entries = ['.claude-epic/', '.swarm/', '.moflo/'];
732
+ const entries = [
733
+ '.claude-epic/',
734
+ '.claude-flow/',
735
+ '.swarm/',
736
+ '.moflo/',
737
+ '.claude/settings.local.json',
738
+ '.claude/scheduled_tasks.lock',
739
+ '**/workflow-state.json',
740
+ ];
733
741
  if (!fs.existsSync(gitignorePath)) {
734
- // Create .gitignore with common defaults + MoFlo entries
735
742
  const defaultEntries = ['node_modules/', 'dist/', '.env', '.env.*', ''];
736
743
  const content = '# Dependencies\n' + defaultEntries.join('\n') + '\n# MoFlo state\n' + entries.join('\n') + '\n';
737
744
  fs.writeFileSync(gitignorePath, content, 'utf-8');
738
745
  return { name: '.gitignore', status: 'created', detail: 'Created with node_modules, .env, and MoFlo entries' };
739
746
  }
740
747
  const existing = fs.readFileSync(gitignorePath, 'utf-8');
741
- const toAdd = entries.filter(e => !existing.includes(e));
748
+ const existingLines = new Set(existing.split(/\r?\n/).map(l => l.trim()).filter(l => l && !l.startsWith('#')));
749
+ const toAdd = entries.filter(e => !existingLines.has(e));
742
750
  if (toAdd.length === 0) {
743
751
  return { name: '.gitignore', status: 'skipped', detail: 'Entries already present' };
744
752
  }
745
- fs.appendFileSync(gitignorePath, '\n# MoFlo state (gitignored)\n' + toAdd.join('\n') + '\n');
753
+ const sep = existing.endsWith('\n') ? '' : '\n';
754
+ fs.appendFileSync(gitignorePath, sep + '\n# MoFlo state (gitignored)\n' + toAdd.join('\n') + '\n');
746
755
  return { name: '.gitignore', status: 'updated', detail: `Added: ${toAdd.join(', ')}` };
747
756
  }
748
757
  // ============================================================================
@@ -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.8.56';
5
+ export const VERSION = '4.8.58';
6
6
  //# sourceMappingURL=version.js.map
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moflo/cli",
3
- "version": "4.8.56",
3
+ "version": "4.8.58",
4
4
  "type": "module",
5
5
  "main": "dist/src/index.js",
6
6
  "types": "dist/src/index.d.ts",
@@ -146,10 +146,14 @@ export const bashCommand = {
146
146
  const projectRoot = context.variables.projectRoot || process.cwd();
147
147
  const caps = context.effectiveCaps ?? [];
148
148
  if (tool === 'sandbox-exec') {
149
- sandboxWrap = wrapWithSandboxExec(command, caps, projectRoot);
149
+ sandboxWrap = wrapWithSandboxExec(command, caps, projectRoot, {
150
+ permissionLevel: context.permissionLevel,
151
+ });
150
152
  }
151
153
  else if (tool === 'bwrap') {
152
- sandboxWrap = wrapWithBwrap(command, caps, projectRoot);
154
+ sandboxWrap = wrapWithBwrap(command, caps, projectRoot, {
155
+ permissionLevel: context.permissionLevel,
156
+ });
153
157
  }
154
158
  }
155
159
  catch (err) {
@@ -9,7 +9,44 @@
9
9
  *
10
10
  * @see https://github.com/eric-cielo/moflo/issues/411
11
11
  */
12
+ import { homedir } from 'node:os';
13
+ import { posix } from 'node:path';
12
14
  import { resolveScopePath } from './sandbox-utils.js';
15
+ /**
16
+ * Home-directory paths that common CLI tools need writable to persist state.
17
+ * These are bound via `--bind-try` so missing entries are silently skipped.
18
+ *
19
+ * Rationale: `elevated`/`autonomous` steps routinely spawn tools like `claude`,
20
+ * `gh`, `git`, and `npm` that write config/credentials/cache under $HOME. A
21
+ * pure `--ro-bind / /` makes those tools fail with EROFS. We narrow the bind
22
+ * set to well-known config paths so the sandbox still protects system dirs
23
+ * (/etc, /usr, /var) and the rest of $HOME.
24
+ */
25
+ const TOOL_HOME_PATHS = [
26
+ // Claude Code
27
+ '.claude',
28
+ '.claude.json',
29
+ // GitHub CLI
30
+ '.config/gh',
31
+ // git
32
+ '.gitconfig',
33
+ '.git-credentials',
34
+ // npm
35
+ '.npmrc',
36
+ '.npm',
37
+ // Shared XDG locations
38
+ '.config',
39
+ '.cache',
40
+ '.local/share',
41
+ '.local/state',
42
+ ];
43
+ /**
44
+ * Permission levels that spawn arbitrary CLI tools and therefore need tool
45
+ * home paths bound writable.
46
+ */
47
+ function needsToolHomeAccess(level) {
48
+ return level === 'elevated' || level === 'autonomous';
49
+ }
13
50
  /**
14
51
  * Build bwrap CLI arguments from step capabilities.
15
52
  *
@@ -20,8 +57,12 @@ import { resolveScopePath } from './sandbox-utils.js';
20
57
  * - fs:write scoped -> --bind (read-write) for each scope path
21
58
  * - fs:write unscoped -> --bind (read-write) for projectRoot
22
59
  * - net -> omit --unshare-net
60
+ *
61
+ * When `options.permissionLevel` is `elevated` or `autonomous`, also bind a
62
+ * narrow allowlist of CLI-tool home paths writable via `--bind-try` so that
63
+ * spawned subcommands (claude, gh, git, npm) can persist their state.
23
64
  */
24
- export function buildBwrapArgs(command, capabilities, projectRoot) {
65
+ export function buildBwrapArgs(command, capabilities, projectRoot, options = {}) {
25
66
  const args = [];
26
67
  // ── Root filesystem (read-only by default) ──────────────────────────
27
68
  args.push('--ro-bind', '/', '/');
@@ -32,16 +73,35 @@ export function buildBwrapArgs(command, capabilities, projectRoot) {
32
73
  args.push('--tmpfs', '/tmp');
33
74
  // ── fs:write — grant read-write bind mounts ─────────────────────────
34
75
  const fsWrite = capabilities.find(c => c.type === 'fs:write');
76
+ const writableScopes = new Set();
35
77
  if (fsWrite) {
36
78
  if (fsWrite.scope && fsWrite.scope.length > 0) {
37
79
  for (const scopePath of fsWrite.scope) {
38
80
  const resolved = resolveScopePath(scopePath, projectRoot);
39
81
  args.push('--bind', resolved, resolved);
82
+ writableScopes.add(resolved);
40
83
  }
41
84
  }
42
85
  else {
43
86
  // Unscoped fs:write -> writable project root
44
87
  args.push('--bind', projectRoot, projectRoot);
88
+ writableScopes.add(projectRoot);
89
+ }
90
+ }
91
+ // ── Tool home paths (elevated/autonomous only) ──────────────────────
92
+ // Bind well-known CLI-tool config/cache paths writable so spawned
93
+ // subcommands (claude, gh, git, npm) can persist their state. Uses
94
+ // --bind-try so missing paths are ignored instead of erroring.
95
+ if (needsToolHomeAccess(options.permissionLevel)) {
96
+ const home = options.homeDir ?? homedir();
97
+ if (home) {
98
+ for (const rel of TOOL_HOME_PATHS) {
99
+ const resolved = posix.join(home, rel);
100
+ if (writableScopes.has(resolved))
101
+ continue;
102
+ args.push('--bind-try', resolved, resolved);
103
+ writableScopes.add(resolved);
104
+ }
45
105
  }
46
106
  }
47
107
  // ── fs:read scoped — explicit read-only bind mounts ─────────────────
@@ -50,9 +110,8 @@ export function buildBwrapArgs(command, capabilities, projectRoot) {
50
110
  if (fsRead && fsRead.scope && fsRead.scope.length > 0) {
51
111
  for (const scopePath of fsRead.scope) {
52
112
  const resolved = resolveScopePath(scopePath, projectRoot);
53
- // Only add if not already covered by an fs:write --bind for same path
54
- const alreadyWritable = fsWrite?.scope?.some(wp => resolveScopePath(wp, projectRoot) === resolved);
55
- if (!alreadyWritable) {
113
+ // Only add if not already covered by a writable bind for same path
114
+ if (!writableScopes.has(resolved)) {
56
115
  args.push('--ro-bind', resolved, resolved);
57
116
  }
58
117
  }
@@ -77,8 +136,8 @@ export function buildBwrapArgs(command, capabilities, projectRoot) {
77
136
  * Unlike sandbox-exec, bwrap uses CLI args only — no temp files are created,
78
137
  * so `cleanup()` is a no-op.
79
138
  */
80
- export function wrapWithBwrap(command, capabilities, projectRoot) {
81
- const args = buildBwrapArgs(command, capabilities, projectRoot);
139
+ export function wrapWithBwrap(command, capabilities, projectRoot, options = {}) {
140
+ const args = buildBwrapArgs(command, capabilities, projectRoot, options);
82
141
  return {
83
142
  bin: 'bwrap',
84
143
  args,
@@ -61,6 +61,8 @@ function bindCommandPreflight(step, stepIndex, check, context) {
61
61
  stepId: step.id,
62
62
  stepIndex,
63
63
  name: check.name,
64
+ severity: check.severity ?? 'fatal',
65
+ resolutions: check.resolutions,
64
66
  run: () => {
65
67
  const ctx = {
66
68
  args: context.args,
@@ -77,6 +79,8 @@ function bindYamlPreflight(step, stepIndex, spec, context) {
77
79
  stepId: step.id,
78
80
  stepIndex,
79
81
  name: spec.name,
82
+ severity: spec.severity ?? 'fatal',
83
+ resolutions: spec.resolutions,
80
84
  run: async () => {
81
85
  const command = interpolateSafe(spec.command, context.args);
82
86
  const expected = spec.expectExitCode ?? 0;
@@ -84,6 +88,9 @@ function bindYamlPreflight(step, stepIndex, spec, context) {
84
88
  const actualExitCode = await runShellExitCode(command, timeoutMs);
85
89
  if (actualExitCode === expected)
86
90
  return { passed: true };
91
+ if (spec.hint) {
92
+ return { passed: false, reason: spec.hint };
93
+ }
87
94
  return {
88
95
  passed: false,
89
96
  reason: `command "${command}" exited with ${actualExitCode}, expected ${expected}`,
@@ -104,6 +111,8 @@ export async function checkPreflights(preflights) {
104
111
  name: pf.name,
105
112
  passed: result.passed,
106
113
  reason: result.reason,
114
+ severity: pf.severity,
115
+ resolutions: pf.resolutions,
107
116
  };
108
117
  }
109
118
  catch (err) {
@@ -112,21 +121,52 @@ export async function checkPreflights(preflights) {
112
121
  name: pf.name,
113
122
  passed: false,
114
123
  reason: err.message,
124
+ severity: pf.severity,
125
+ resolutions: pf.resolutions,
115
126
  };
116
127
  }
117
128
  }));
118
129
  }
130
+ /**
131
+ * Run a resolution shell command chosen by the user.
132
+ * Returns true iff the command exits 0 (or no command was provided).
133
+ */
134
+ export async function runResolutionCommand(resolution, args) {
135
+ if (!resolution.command)
136
+ return { ok: true, exitCode: 0 };
137
+ const cmd = interpolateSafe(resolution.command, args);
138
+ const exitCode = await runShellExitCode(cmd, resolution.timeoutMs ?? 30_000);
139
+ return { ok: exitCode === 0, exitCode };
140
+ }
119
141
  /** Format failed preflights into a user-friendly error message. */
120
142
  export function formatPreflightErrors(results) {
121
143
  const failed = results.filter(r => !r.passed);
122
144
  if (failed.length === 0)
123
145
  return '';
124
- const lines = ['Preflight checks failed:'];
146
+ const header = failed.length === 1
147
+ ? 'A prerequisite for this spell was not met:'
148
+ : `${failed.length} prerequisites for this spell were not met:`;
149
+ const lines = [header];
125
150
  for (const f of failed) {
126
- lines.push(` - [${f.stepId}] ${f.name}${f.reason ? `: ${f.reason}` : ''}`);
151
+ const message = f.reason && f.reason.trim().length > 0 ? f.reason : f.name;
152
+ lines.push(` - ${message}`);
127
153
  }
128
154
  return lines.join('\n');
129
155
  }
156
+ /** Partition preflight results by severity. */
157
+ export function partitionPreflightResults(results) {
158
+ const fatals = [];
159
+ const warnings = [];
160
+ for (const r of results) {
161
+ if (r.passed)
162
+ continue;
163
+ if (r.severity === 'warning')
164
+ warnings.push(r);
165
+ else
166
+ fatals.push(r);
167
+ }
168
+ return { fatals, warnings };
169
+ }
130
170
  // ============================================================================
131
171
  // Internals
132
172
  // ============================================================================
@@ -9,7 +9,7 @@ import { rollbackSteps } from './rollback-orchestrator.js';
9
9
  import { buildCredentialPatterns, addCredentialPattern, collectCredentialNames } from './credential-masker.js';
10
10
  import { executeSingleStep } from './step-executor.js';
11
11
  import { collectPrerequisites, checkPrerequisites, formatPrerequisiteErrors } from './prerequisite-checker.js';
12
- import { collectPreflights, checkPreflights, formatPreflightErrors } from './preflight-checker.js';
12
+ import { collectPreflights, checkPreflights, formatPreflightErrors, partitionPreflightResults, runResolutionCommand, } from './preflight-checker.js';
13
13
  import { DENY_ALL_GATEWAY } from './capability-gateway.js';
14
14
  import { resolveEffectiveSandbox, formatSandboxLog, DEFAULT_SANDBOX_CONFIG, } from './platform-sandbox.js';
15
15
  import { checkAcceptance, recordAcceptance } from './permission-acceptance.js';
@@ -110,19 +110,9 @@ export class SpellCaster {
110
110
  }
111
111
  }
112
112
  // Preflight runtime-state checks — fail fast before any step runs.
113
- const preflights = collectPreflights(definition, this.registry, {
114
- args: resolvedArgs,
115
- credentials: this.credentials,
116
- });
117
- if (preflights.length > 0) {
118
- const preflightResults = await checkPreflights(preflights);
119
- if (preflightResults.some(r => !r.passed)) {
120
- console.log(`[spell] ${formatPreflightErrors(preflightResults)}`);
121
- return this.failureResult(spellId, startTime, [{
122
- code: 'PREFLIGHT_FAILED',
123
- message: formatPreflightErrors(preflightResults),
124
- }], definition.name);
125
- }
113
+ const preflightFailure = await this.runPreflights(definition, resolvedArgs, options);
114
+ if (preflightFailure) {
115
+ return this.failureResult(spellId, startTime, [{ code: 'PREFLIGHT_FAILED', message: preflightFailure }], definition.name);
126
116
  }
127
117
  }
128
118
  if (options.dryRun) {
@@ -320,6 +310,54 @@ export class SpellCaster {
320
310
  // --------------------------------------------------------------------------
321
311
  // Private — Helpers
322
312
  // --------------------------------------------------------------------------
313
+ /**
314
+ * Run every preflight declared by the spell.
315
+ * Returns a user-facing failure message, or null if the spell may proceed.
316
+ */
317
+ async runPreflights(definition, resolvedArgs, options) {
318
+ const preflights = collectPreflights(definition, this.registry, {
319
+ args: resolvedArgs,
320
+ credentials: this.credentials,
321
+ });
322
+ if (preflights.length === 0)
323
+ return null;
324
+ const results = await checkPreflights(preflights);
325
+ const { fatals, warnings } = partitionPreflightResults(results);
326
+ if (fatals.length > 0)
327
+ return formatPreflightErrors(fatals);
328
+ if (warnings.length === 0)
329
+ return null;
330
+ if (!options.onPreflightWarnings)
331
+ return formatPreflightErrors(warnings);
332
+ const payload = warnings.map(w => ({
333
+ stepId: w.stepId,
334
+ name: w.name,
335
+ reason: w.reason ?? w.name,
336
+ resolutions: w.resolutions ?? [],
337
+ }));
338
+ const decisions = await options.onPreflightWarnings(payload);
339
+ if (decisions.length !== warnings.length) {
340
+ return `Preflight warning handler returned ${decisions.length} decisions for ${warnings.length} warnings`;
341
+ }
342
+ for (let i = 0; i < decisions.length; i++) {
343
+ const decision = decisions[i];
344
+ const warn = warnings[i];
345
+ if (decision.action === 'abort') {
346
+ return formatPreflightErrors([warn]);
347
+ }
348
+ if (decision.action === 'resolve') {
349
+ const chosen = (warn.resolutions ?? [])[decision.resolutionIndex];
350
+ if (!chosen) {
351
+ return `Invalid resolution index ${decision.resolutionIndex} for "${warn.name}"`;
352
+ }
353
+ const { ok, exitCode } = await runResolutionCommand(chosen, resolvedArgs);
354
+ if (!ok) {
355
+ return `Resolution "${chosen.label}" failed (exit code ${exitCode}). Fix the underlying issue and try again.`;
356
+ }
357
+ }
358
+ }
359
+ return null;
360
+ }
323
361
  runStep(step, state, index) {
324
362
  const ctxBuilder = (v, a, sid, si, sig) => this.buildContext(v, a, sid, si, sig, state.effectiveSandbox);
325
363
  return executeSingleStep(step, state, index, this.registry, ctxBuilder);
@@ -7,10 +7,42 @@
7
7
  * @see https://github.com/eric-cielo/moflo/issues/410
8
8
  */
9
9
  import { writeFileSync, unlinkSync } from 'node:fs';
10
- import { join } from 'node:path';
11
- import { tmpdir } from 'node:os';
10
+ import { join, posix } from 'node:path';
11
+ import { tmpdir, homedir } from 'node:os';
12
12
  import { randomBytes } from 'node:crypto';
13
13
  import { resolveScopePath } from './sandbox-utils.js';
14
+ /**
15
+ * Home-directory paths that common CLI tools need writable to persist state.
16
+ * Rationale matches the Linux bwrap wrapper: `elevated`/`autonomous` steps
17
+ * spawn tools (claude, gh, git, npm) that write under $HOME; a pure
18
+ * deny-default profile makes those tools fail. System dirs (/etc, /usr,
19
+ * /private/var/root, etc.) remain denied.
20
+ */
21
+ const TOOL_HOME_PATHS = [
22
+ // Claude Code (dotfile + Application Support on macOS)
23
+ '.claude',
24
+ '.claude.json',
25
+ 'Library/Application Support/Claude',
26
+ 'Library/Application Support/claude-cli-nodejs',
27
+ 'Library/Caches/Claude',
28
+ 'Library/Preferences',
29
+ // GitHub CLI
30
+ '.config/gh',
31
+ // git
32
+ '.gitconfig',
33
+ '.git-credentials',
34
+ // npm
35
+ '.npmrc',
36
+ '.npm',
37
+ // Shared XDG locations
38
+ '.config',
39
+ '.cache',
40
+ '.local/share',
41
+ '.local/state',
42
+ ];
43
+ function needsToolHomeAccess(level) {
44
+ return level === 'elevated' || level === 'autonomous';
45
+ }
14
46
  // ============================================================================
15
47
  // Profile Generation
16
48
  // ============================================================================
@@ -28,7 +60,7 @@ import { resolveScopePath } from './sandbox-utils.js';
28
60
  * - net capability → allow network*
29
61
  * - No net capability → network denied (default deny covers it)
30
62
  */
31
- export function generateSandboxProfile(capabilities, projectRoot) {
63
+ export function generateSandboxProfile(capabilities, projectRoot, options = {}) {
32
64
  const lines = [
33
65
  '(version 1)',
34
66
  '(deny default)',
@@ -85,6 +117,23 @@ export function generateSandboxProfile(capabilities, projectRoot) {
85
117
  lines.push(`(allow file-write* (subpath "${escapeSbPath(projectRoot)}"))`);
86
118
  }
87
119
  }
120
+ // ── Tool home paths (elevated/autonomous only) ──────────────────────
121
+ // Allow writes to well-known CLI-tool home paths so spawned subcommands
122
+ // (claude, gh, git, npm) can persist config/credentials/cache. Uses
123
+ // `(subpath ...)` so non-existent paths are simply unmatched — no error.
124
+ if (needsToolHomeAccess(options.permissionLevel)) {
125
+ const home = options.homeDir ?? homedir();
126
+ if (home) {
127
+ lines.push('');
128
+ lines.push('; Tool home paths (elevated)');
129
+ for (const rel of TOOL_HOME_PATHS) {
130
+ // sandbox-exec is macOS-only → always POSIX paths
131
+ const resolved = posix.join(home, rel);
132
+ lines.push(`(allow file-read* (subpath "${escapeSbPath(resolved)}"))`);
133
+ lines.push(`(allow file-write* (subpath "${escapeSbPath(resolved)}"))`);
134
+ }
135
+ }
136
+ }
88
137
  // ── net ──────────────────────────────────────────────────────────────
89
138
  const hasNet = capabilities.some(c => c.type === 'net');
90
139
  if (hasNet) {
@@ -104,8 +153,8 @@ export function generateSandboxProfile(capabilities, projectRoot) {
104
153
  * Writes a temporary `.sb` profile and returns the sandbox-exec invocation.
105
154
  * The caller MUST call `cleanup()` after the process exits.
106
155
  */
107
- export function wrapWithSandboxExec(command, capabilities, projectRoot) {
108
- const profile = generateSandboxProfile(capabilities, projectRoot);
156
+ export function wrapWithSandboxExec(command, capabilities, projectRoot, options = {}) {
157
+ const profile = generateSandboxProfile(capabilities, projectRoot, options);
109
158
  const profilePath = join(tmpdir(), `moflo-sandbox-${randomBytes(8).toString('hex')}.sb`);
110
159
  writeFileSync(profilePath, profile, 'utf8');
111
160
  return {
@@ -179,6 +179,35 @@ function validateSteps(steps, errors, stepIds, outputVars, options, prefix = 'st
179
179
  if (pf.timeoutMs !== undefined && (typeof pf.timeoutMs !== 'number' || pf.timeoutMs <= 0)) {
180
180
  errors.push({ path: `${pfPath}.timeoutMs`, message: 'timeoutMs must be a positive number' });
181
181
  }
182
+ if (pf.hint !== undefined && typeof pf.hint !== 'string') {
183
+ errors.push({ path: `${pfPath}.hint`, message: 'hint must be a string' });
184
+ }
185
+ if (pf.severity !== undefined && pf.severity !== 'fatal' && pf.severity !== 'warning') {
186
+ errors.push({ path: `${pfPath}.severity`, message: 'severity must be "fatal" or "warning"' });
187
+ }
188
+ if (pf.resolutions !== undefined) {
189
+ if (!Array.isArray(pf.resolutions)) {
190
+ errors.push({ path: `${pfPath}.resolutions`, message: 'resolutions must be an array' });
191
+ }
192
+ else {
193
+ pf.resolutions.forEach((r, ri) => {
194
+ const rPath = `${pfPath}.resolutions[${ri}]`;
195
+ if (!r || typeof r !== 'object') {
196
+ errors.push({ path: rPath, message: 'resolution must be an object' });
197
+ return;
198
+ }
199
+ if (typeof r.label !== 'string' || r.label.length === 0) {
200
+ errors.push({ path: `${rPath}.label`, message: 'resolution.label is required' });
201
+ }
202
+ if (r.command !== undefined && typeof r.command !== 'string') {
203
+ errors.push({ path: `${rPath}.command`, message: 'resolution.command must be a string' });
204
+ }
205
+ if (r.timeoutMs !== undefined && (typeof r.timeoutMs !== 'number' || r.timeoutMs <= 0)) {
206
+ errors.push({ path: `${rPath}.timeoutMs`, message: 'resolution.timeoutMs must be a positive number' });
207
+ }
208
+ });
209
+ }
210
+ }
182
211
  });
183
212
  }
184
213
  }