moflo 4.8.56-rc.8 → 4.8.56

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.
@@ -0,0 +1,34 @@
1
+ # User-Facing Language Guidelines
2
+
3
+ **Purpose:** Ensure all text shown to end users is approachable and non-alarming. MoFlo is used by developers and non-technical users alike — the language we use in output, prompts, reports, and error messages must be clear to someone who has never written code.
4
+
5
+ ---
6
+
7
+ ## Principles
8
+
9
+ 1. **Avoid technical jargon in user-visible output.** Terms like "destructive", "mutation", "elevated privileges", or "sandbox escape" are meaningful to engineers but alarming or confusing to non-technical users. Prefer plain risk-level language: "No risk", "Low risk", "Moderate risk", "Higher risk".
10
+
11
+ 2. **Explain what happens, not the mechanism.** Instead of "shell command execution", say "Runs commands on your machine". Instead of "fs:write capability", say "Creates, overwrites, or deletes files".
12
+
13
+ 3. **Reserve technical terms for internal code.** Type names, variable names, config keys, log prefixes, and developer documentation can use precise technical language. The boundary is: if the user sees it, simplify it.
14
+
15
+ 4. **Err on the side of calm.** When something requires attention, state the facts without dramatizing. "This step modifies files" is better than "WARNING: DESTRUCTIVE OPERATION DETECTED".
16
+
17
+ ---
18
+
19
+ ## Where This Applies
20
+
21
+ - Permission disclosure / risk analysis reports
22
+ - Error messages shown to users (not debug logs)
23
+ - CLI output and status messages
24
+ - MCP tool descriptions and response messages
25
+ - Spell dry-run and acceptance gate output
26
+ - Any `console.log` that a user will see during normal operation
27
+
28
+ ## Where This Does NOT Apply
29
+
30
+ - Internal type definitions and variable names
31
+ - Developer-facing config keys (e.g., `allowDestructive` in spell YAML)
32
+ - Debug/trace logs gated behind verbose flags
33
+ - Code comments and docstrings
34
+ - Test descriptions
@@ -0,0 +1,164 @@
1
+ ---
2
+ name: publish
3
+ description: Bump version, build, test, publish to npm, and install locally
4
+ arguments: "[patch|minor|major] [-rc]"
5
+ ---
6
+
7
+ # /publish - Version Bump, Build, Test & Publish
8
+
9
+ Automated release pipeline for moflo. Bumps version, commits, builds, tests, runs doctor, publishes to npm, and installs the new version locally.
10
+
11
+ **Arguments:** $ARGUMENTS
12
+
13
+ ## Usage
14
+
15
+ ```
16
+ /publish # patch bump (4.8.56 → 4.8.57)
17
+ /publish minor # minor bump (4.8.56 → 4.9.0)
18
+ /publish major # major bump (4.8.56 → 5.0.0)
19
+ /publish -rc # patch RC bump (4.8.56 → 4.8.57-rc.1, or 4.8.57-rc.1 → 4.8.57-rc.2)
20
+ /publish minor -rc # minor RC bump (4.8.56 → 4.9.0-rc.1)
21
+ /publish patch # explicit patch bump
22
+ ```
23
+
24
+ ## Step-by-Step Procedure
25
+
26
+ Follow docs/BUILD.md exactly. Every step must succeed before proceeding to the next.
27
+
28
+ ### Step 1: Parse Arguments
29
+
30
+ - Default bump type is `patch` if not specified
31
+ - If `-rc` flag is present, produce a release candidate version
32
+ - Determine the new version string:
33
+ - **Without `-rc`:** Use `npm version <patch|minor|major> --no-git-tag-version`
34
+ - **With `-rc`:** Calculate manually:
35
+ - If current version is already an RC of the same bump level (e.g., `4.8.57-rc.3`), increment the RC number (→ `4.8.57-rc.4`)
36
+ - Otherwise, bump the base version and append `-rc.1` (e.g., `4.8.56` → `4.8.57-rc.1`)
37
+ - Write with: `npm version <new-version> --no-git-tag-version`
38
+
39
+ ### Step 2: Rebuild After Version Bump
40
+
41
+ ```bash
42
+ npm run build
43
+ ```
44
+
45
+ This syncs the version to `src/modules/cli/src/version.ts` and `src/modules/cli/package.json` via the `version` lifecycle script, then compiles all TypeScript.
46
+
47
+ **Must exit 0.** If it fails, stop and fix the build error.
48
+
49
+ ### Step 3: Run Tests
50
+
51
+ ```bash
52
+ npm test
53
+ ```
54
+
55
+ **Must have 0 test file failures.** If any test files fail, retest them individually to distinguish real failures from flaky ones (per broken window theory). Fix all real failures before proceeding.
56
+
57
+ ### Step 4: Run Doctor
58
+
59
+ ```bash
60
+ npx moflo doctor --fix
61
+ ```
62
+
63
+ All checks must pass (warnings are acceptable on Windows for sandbox tier). If doctor finds fixable issues, it will auto-fix them. If manual fixes are needed, stop and address them.
64
+
65
+ ### Step 5: Commit & Push
66
+
67
+ Commit the version bump files:
68
+
69
+ ```bash
70
+ git add package.json package-lock.json src/modules/cli/package.json src/modules/cli/src/version.ts
71
+ git commit -m "chore: bump version to <new-version>"
72
+ git push origin main
73
+ ```
74
+
75
+ Only commit version-related files. Do not stage unrelated changes.
76
+
77
+ ### Step 6: Verify npm Authentication
78
+
79
+ Before publishing, verify npm auth is valid:
80
+
81
+ ```bash
82
+ npm whoami
83
+ ```
84
+
85
+ If this returns a 401 or "not logged in" error, the auth token in `~/.npmrc` is missing or expired. Ask the user to provide a valid npm publish token.
86
+
87
+ **How to create a token** (provide these instructions to the user if needed):
88
+ 1. Go to https://www.npmjs.com/settings/~/tokens
89
+ 2. Click "Generate New Token" → select "Granular Access Token"
90
+ 3. Set permissions: "Read and write" for the `moflo` package
91
+ 4. Copy the token (starts with `npm_...`)
92
+
93
+ Once the user provides the token, write it to `~/.npmrc`:
94
+
95
+ ```bash
96
+ echo "//registry.npmjs.org/:_authToken=<token>" > ~/.npmrc
97
+ ```
98
+
99
+ Then verify with `npm whoami` before proceeding.
100
+
101
+ ### Step 7: Publish to npm
102
+
103
+ ```bash
104
+ npm publish --tag <tag>
105
+ ```
106
+
107
+ - For stable releases: `npm publish` (publishes to `latest` tag by default)
108
+ - For RC releases: `npm publish --tag rc` (publishes to `rc` tag so it doesn't become `latest`)
109
+
110
+ **OTP handling:**
111
+ - If npm returns an OTP/one-time-password error, ask the user for their authenticator code
112
+ - Then retry with: `npm publish --otp=<code> --tag <tag>`
113
+ - Tip: Granular access tokens created on npmjs.com do NOT require OTP — prefer those over legacy tokens
114
+
115
+ ### Step 8: Verify Publication
116
+
117
+ ```bash
118
+ npm view moflo version
119
+ npm view moflo dist-tags
120
+ ```
121
+
122
+ Confirm the published version matches what we just built.
123
+
124
+ ### Step 9: Install Locally
125
+
126
+ ```bash
127
+ npm install moflo@<new-version> --save-dev
128
+ ```
129
+
130
+ This updates `package.json` and `package-lock.json` to use the newly published version as a devDependency.
131
+
132
+ ### Step 10: Final Commit
133
+
134
+ If `package.json` or `package-lock.json` changed from the install:
135
+
136
+ ```bash
137
+ git add package.json package-lock.json
138
+ git commit -m "chore: install moflo@<new-version>"
139
+ git push origin main
140
+ ```
141
+
142
+ ## Output
143
+
144
+ Print a summary at the end:
145
+
146
+ ```
147
+ Publish Summary
148
+ ───────────────
149
+ Version: <old> → <new>
150
+ Tag: latest | rc
151
+ Build: passed
152
+ Tests: <N> files passed, <N> tests passed
153
+ Doctor: <N> passed, <N> warnings
154
+ Published: moflo@<new-version>
155
+ Installed: moflo@<new-version> (devDependency)
156
+ ```
157
+
158
+ ## Important
159
+
160
+ - All commands run from the project root — never cd into subdirectories
161
+ - Never use `--force` on npm publish
162
+ - Never install moflo globally — it is always a local devDependency
163
+ - If any step fails, stop and fix the issue before proceeding — do not skip steps
164
+ - The `prepublishOnly` script in package.json runs `check-version-sync.mjs && npm run build` automatically, so npm publish will fail-fast if versions are out of sync
@@ -26,7 +26,8 @@ Given an epic issue number, perform ALL of the following:
26
26
  4. **Delete local branches**: Delete any local branches matching the same patterns (skip the current branch)
27
27
  5. **Reopen issues**: Reopen all story issues and the epic itself if any were closed
28
28
  6. **Uncheck task lists**: Edit the epic body to uncheck all `- [x]` checkboxes back to `- [ ]`
29
- 7. **Verify clean state**: Confirm all stories are OPEN, no related PRs are open, no related branches exist
29
+ 7. **Clear epic-state memory**: Delete all entries in the `epic-state` memory namespace for this epic and its stories (keys: `epic-<N>`, `story-<N>` for each story). Use `mcp__moflo__memory_list` with namespace `epic-state` to find entries, then `mcp__moflo__memory_delete` for each.
30
+ 8. **Verify clean state**: Confirm all stories are OPEN, no related PRs are open, no related branches exist, and epic-state memory is empty
30
31
 
31
32
  ## Output
32
33
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.8.56-rc.8",
3
+ "version": "4.8.56",
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.7",
114
+ "moflo": "^4.8.56-rc.20",
115
115
  "tsx": "^4.21.0",
116
116
  "typescript": "^5.9.3",
117
117
  "vitest": "^4.0.0"
@@ -12,6 +12,7 @@
12
12
  * flo epic reset <epic-number> Clear epic memory state
13
13
  */
14
14
  import { readFileSync } from 'node:fs';
15
+ import { execSync } from 'node:child_process';
15
16
  import { join, dirname } from 'node:path';
16
17
  import { fileURLToPath } from 'node:url';
17
18
  import { isEpicIssue, fetchEpicIssue, extractStories, enrichStoryNames, resolveExecutionOrder, } from '../epic/index.js';
@@ -80,8 +81,34 @@ async function runEpic(source, strategy, dryRun) {
80
81
  catch {
81
82
  // No prior state — fresh run
82
83
  }
84
+ // 3b. Reconcile memory with branch state — if the branch doesn't exist
85
+ // or has no commits ahead of main, memory is stale (e.g. after a reset)
83
86
  if (completedStories.size > 0) {
84
- console.log(`[epic] Resuming: ${completedStories.size} stories already completed`);
87
+ const slug = issue.title
88
+ .toLowerCase()
89
+ .replace(/[^a-z0-9]+/g, '-')
90
+ .replace(/^-|-$/g, '')
91
+ .substring(0, 40);
92
+ const epicBranch = `epic/${issueNumber}-${slug}`;
93
+ let branchHasCommits = false;
94
+ try {
95
+ // Check if the epic branch exists (local or remote) and has commits ahead of main
96
+ const revCheck = execSync(`git rev-parse --verify refs/heads/${epicBranch} 2>/dev/null || git rev-parse --verify refs/remotes/origin/${epicBranch} 2>/dev/null`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
97
+ if (revCheck) {
98
+ const aheadCount = execSync(`git rev-list --count main..${revCheck}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
99
+ branchHasCommits = parseInt(aheadCount, 10) > 0;
100
+ }
101
+ }
102
+ catch {
103
+ // Branch doesn't exist — memory is stale
104
+ }
105
+ if (branchHasCommits) {
106
+ console.log(`[epic] Resuming: ${completedStories.size} stories already completed (branch verified)`);
107
+ }
108
+ else {
109
+ console.log(`[epic] Memory shows ${completedStories.size} completed stories, but epic branch is missing or empty — starting fresh`);
110
+ completedStories = new Set();
111
+ }
85
112
  }
86
113
  // 4. Build slug for branch name
87
114
  const slug = issue.title
@@ -244,13 +271,33 @@ async function resetEpic(epicNumber) {
244
271
  return { success: false, message: 'Usage: flo epic reset <epic-number>' };
245
272
  }
246
273
  console.log(`[epic] Clearing state for epic #${epicNumber}...`);
274
+ // 1. Find all story keys for this epic from memory
275
+ let storyKeys = [];
247
276
  try {
248
- await runEpicSpell(`name: epic-reset\nsteps:\n - id: clear-state\n type: memory\n config:\n action: write\n namespace: epic-state\n key: "epic-${epicNumber}"\n value: null`, { args: {} });
249
- console.log(`[epic] State cleared for epic #${epicNumber}`);
277
+ const searchResult = await runEpicSpell(`name: epic-reset-search\nsteps:\n - id: find-stories\n type: memory\n config:\n action: search\n namespace: epic-state\n query: "epic ${epicNumber} story completed"`, { args: {} });
278
+ if (searchResult.success && searchResult.outputs['find-stories']) {
279
+ const data = searchResult.outputs['find-stories'];
280
+ if (data.results) {
281
+ storyKeys = data.results.map(r => r.key).filter(k => k.startsWith('story-'));
282
+ }
283
+ }
250
284
  }
251
285
  catch {
252
- console.log(`[epic] Could not clear epic state.`);
286
+ // Couldn't search — just clear the epic key below
287
+ }
288
+ // 2. Clear epic key + all story keys
289
+ const keysToDelete = [`epic-${epicNumber}`, ...storyKeys];
290
+ let cleared = 0;
291
+ for (const key of keysToDelete) {
292
+ try {
293
+ await runEpicSpell(`name: epic-reset-delete\nsteps:\n - id: delete-key\n type: memory\n config:\n action: write\n namespace: epic-state\n key: "${key}"\n value: null`, { args: {} });
294
+ cleared++;
295
+ }
296
+ catch {
297
+ // Key may not exist — safe to ignore
298
+ }
253
299
  }
300
+ console.log(`[epic] Cleared ${cleared} memory entries for epic #${epicNumber}`);
254
301
  return { success: true };
255
302
  }
256
303
  // ============================================================================
@@ -7,10 +7,24 @@
7
7
  * Story #197: Thin adapter for running spell YAML from epic command.
8
8
  * Story #229: Uses shared engine loader instead of inline dynamic import.
9
9
  */
10
+ import * as readline from 'node:readline';
10
11
  import { loadSpellEngine } from '../services/engine-loader.js';
11
12
  import { createDashboardMemoryAccessor } from '../services/daemon-dashboard.js';
12
13
  /** Cached memory accessor — created once per process. */
13
14
  let memoryAccessor = null;
15
+ /** Prompt the user to accept or decline spell permissions. */
16
+ async function promptAcceptPermissions() {
17
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
18
+ try {
19
+ const answer = await new Promise(resolve => {
20
+ rl.question('\n[epic] Accept these permissions? (y/N) ', resolve);
21
+ });
22
+ return answer.trim().toLowerCase() === 'y' || answer.trim().toLowerCase() === 'yes';
23
+ }
24
+ finally {
25
+ rl.close();
26
+ }
27
+ }
14
28
  /**
15
29
  * Run a spell YAML string via the spell engine.
16
30
  *
@@ -31,6 +45,34 @@ export async function runEpicSpell(yamlContent, options = {}) {
31
45
  console.warn('[epic] ⚠ Spell executions will NOT appear in the dashboard');
32
46
  }
33
47
  }
34
- return engine.runSpellFromContent(yamlContent, undefined, { ...options, ...(memoryAccessor ? { memory: memoryAccessor } : {}) });
48
+ const runOpts = { ...options, projectRoot: process.cwd(), ...(memoryAccessor ? { memory: memoryAccessor } : {}) };
49
+ const result = await engine.runSpellFromContent(yamlContent, undefined, runOpts);
50
+ // Auto-accept permissions on first run: the spell runner already printed
51
+ // the full risk analysis to the console. The user initiated the epic
52
+ // command, so we accept on their behalf and retry immediately.
53
+ const hasAcceptanceError = !result.success &&
54
+ result.errors.some(e => e.code === 'ACCEPTANCE_REQUIRED');
55
+ if (hasAcceptanceError) {
56
+ const accepted = await promptAcceptPermissions();
57
+ if (!accepted) {
58
+ return result;
59
+ }
60
+ // Use the already-loaded engine module (dynamic import) for spells internals.
61
+ // Static cross-package imports break when installed as a dependency because
62
+ // the relative paths from dist/ don't match the source layout.
63
+ const spells = engine;
64
+ const { parseSpell, StepCommandRegistry, builtinCommands, analyzeSpellPermissions, recordAcceptance } = spells;
65
+ const projectRoot = process.cwd();
66
+ const parsed = parseSpell(yamlContent);
67
+ const stepRegistry = new StepCommandRegistry();
68
+ for (const cmd of builtinCommands) {
69
+ stepRegistry.register(cmd, 'built-in');
70
+ }
71
+ const report = analyzeSpellPermissions(parsed.definition, stepRegistry);
72
+ await recordAcceptance(projectRoot, parsed.definition.name, report.permissionHash);
73
+ console.log(`[epic] Permissions accepted for "${parsed.definition.name}" — retrying...\n`);
74
+ return engine.runSpellFromContent(yamlContent, undefined, runOpts);
75
+ }
76
+ return result;
35
77
  }
36
78
  //# sourceMappingURL=runner-adapter.js.map
@@ -33,14 +33,6 @@ arguments:
33
33
  mofloLevel: hooks
34
34
 
35
35
  steps:
36
- # Step 0: Pre-flight — fail fast if worktree is dirty or has unmerged files
37
- - id: preflight-check
38
- type: bash
39
- config:
40
- command: "if [ -n \"$(git diff --name-only --diff-filter=U 2>/dev/null)\" ]; then echo 'ERROR: Unmerged files detected. Resolve conflicts before running epic.' >&2; git diff --name-only --diff-filter=U 2>/dev/null >&2; exit 1; fi && if [ -n \"$(git -c core.autocrlf=false -c core.safecrlf=false status --porcelain 2>/dev/null)\" ]; then echo 'ERROR: Working tree is dirty. Commit, stash, or discard changes before running epic.' >&2; git -c core.autocrlf=false -c core.safecrlf=false status --short 2>/dev/null >&2; exit 1; fi"
41
- timeout: 120000
42
- failOnError: true
43
-
44
36
  # Step 1: Initialize epic state in memory (enables resume)
45
37
  - id: init-state
46
38
  type: memory
@@ -62,8 +54,22 @@ steps:
62
54
  output: story_results
63
55
  steps:
64
56
  # 1: Checkout and pull latest base branch
57
+ # Preflights run once BEFORE any step (not per iteration) — fail fast
58
+ # on dirty tree, missing auth, or unreachable remote.
65
59
  - id: checkout-base
66
60
  type: bash
61
+ preflight:
62
+ - name: "no unmerged files"
63
+ command: "git diff --name-only --diff-filter=U"
64
+ expectExitCode: 0
65
+ - name: "working tree clean (tracked changes)"
66
+ command: "git diff --quiet"
67
+ - name: "working tree clean (staged changes)"
68
+ command: "git diff --cached --quiet"
69
+ - name: "gh cli authenticated"
70
+ command: "gh auth status"
71
+ - name: "origin remote configured"
72
+ command: "git remote get-url origin"
67
73
  config:
68
74
  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"
69
75
  failOnError: true
@@ -38,14 +38,6 @@ arguments:
38
38
  mofloLevel: hooks
39
39
 
40
40
  steps:
41
- # Step 0: Pre-flight — fail fast if worktree is dirty or has unmerged files
42
- - id: preflight-check
43
- type: bash
44
- config:
45
- command: "if [ -n \"$(git diff --name-only --diff-filter=U 2>/dev/null)\" ]; then echo 'ERROR: Unmerged files detected. Resolve conflicts before running epic.' >&2; git diff --name-only --diff-filter=U 2>/dev/null >&2; exit 1; fi && if [ -n \"$(git -c core.autocrlf=false -c core.safecrlf=false status --porcelain 2>/dev/null)\" ]; then echo 'ERROR: Working tree is dirty. Commit, stash, or discard changes before running epic.' >&2; git -c core.autocrlf=false -c core.safecrlf=false status --short 2>/dev/null >&2; exit 1; fi"
46
- timeout: 120000
47
- failOnError: true
48
-
49
41
  # Step 1: Initialize epic state in memory (enables resume)
50
42
  - id: init-state
51
43
  type: memory
@@ -59,9 +51,20 @@ steps:
59
51
  stories: "{args.stories}"
60
52
  branch: "epic/{args.epic_number}-{args.epic_slug}"
61
53
 
62
- # Step 1: Create epic branch from base
54
+ # Step 2: Create epic branch from base
55
+ # Preflights run BEFORE any step — fail fast on dirty tree or auth issues
63
56
  - id: create-branch
64
57
  type: bash
58
+ preflight:
59
+ - name: "no unmerged files"
60
+ command: "git diff --name-only --diff-filter=U"
61
+ expectExitCode: 0
62
+ - name: "working tree clean (tracked changes)"
63
+ command: "git diff --quiet"
64
+ - name: "working tree clean (staged changes)"
65
+ command: "git diff --cached --quiet"
66
+ - name: "gh cli authenticated"
67
+ command: "gh auth status"
65
68
  config:
66
69
  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"
67
70
  timeout: 120000
@@ -104,8 +107,12 @@ steps:
104
107
  epic: "{args.epic_number}"
105
108
 
106
109
  # Step 3: Push epic branch
110
+ # Preflight: verify origin remote exists so push won't fail mid-way
107
111
  - id: push-branch
108
112
  type: bash
113
+ preflight:
114
+ - name: "origin remote configured"
115
+ command: "git remote get-url origin"
109
116
  config:
110
117
  command: "git push -u origin epic/{args.epic_number}-{args.epic_slug}"
111
118
  timeout: 120000
@@ -607,5 +607,49 @@ export const spellTools = [
607
607
  return { action, error: `Unknown action: ${action}. Use 'list' or 'info'.` };
608
608
  },
609
609
  },
610
+ // --------------------------------------------------------------------------
611
+ // spell_accept — Accept a spell's permissions after reviewing risk analysis
612
+ // --------------------------------------------------------------------------
613
+ {
614
+ name: 'spell_accept',
615
+ description: 'Accept a spell\'s permissions after reviewing the risk analysis. Required before first execution.',
616
+ category: 'spell',
617
+ inputSchema: {
618
+ type: 'object',
619
+ properties: {
620
+ name: { type: 'string', description: 'Spell name (as shown in the risk analysis)' },
621
+ },
622
+ required: ['name'],
623
+ },
624
+ handler: async (input) => {
625
+ const spellName = input.name;
626
+ // Dynamically import to avoid circular deps with the spell engine
627
+ const { recordAcceptance } = await import('../../../../modules/spells/src/core/permission-acceptance.js');
628
+ const { analyzeSpellPermissions } = await import('../../../../modules/spells/src/core/permission-disclosure.js');
629
+ const { StepCommandRegistry } = await import('../../../../modules/spells/src/core/step-command-registry.js');
630
+ const { builtinCommands } = await import('../../../../modules/spells/src/commands/index.js');
631
+ const projectRoot = findProjectRoot();
632
+ // Resolve the spell definition
633
+ const registry = await getRegistry();
634
+ const loaded = registry.resolve(spellName);
635
+ if (!loaded) {
636
+ return { error: `Spell not found in grimoire: ${spellName}` };
637
+ }
638
+ // Build a step command registry for permission analysis
639
+ const stepRegistry = new StepCommandRegistry();
640
+ for (const cmd of builtinCommands) {
641
+ stepRegistry.register(cmd, 'built-in');
642
+ }
643
+ const report = analyzeSpellPermissions(loaded.definition, stepRegistry);
644
+ await recordAcceptance(projectRoot, loaded.definition.name, report.permissionHash);
645
+ return {
646
+ accepted: true,
647
+ spell: loaded.definition.name,
648
+ permissionHash: report.permissionHash,
649
+ overallRisk: report.overallRisk,
650
+ message: `Permissions accepted for "${loaded.definition.name}". You can now cast this spell.`,
651
+ };
652
+ },
653
+ },
610
654
  ];
611
655
  //# sourceMappingURL=spell-tools.js.map
@@ -10,6 +10,7 @@ export const TOOL_SPELL_LIST = 'spell_list';
10
10
  export const TOOL_SPELL_STATUS = 'spell_status';
11
11
  export const TOOL_SPELL_CANCEL = 'spell_cancel';
12
12
  export const TOOL_SPELL_TEMPLATE = 'spell_template';
13
+ export const TOOL_SPELL_ACCEPT = 'spell_accept';
13
14
  // Memory tools
14
15
  export const TOOL_MEMORY_STORE = 'memory_store';
15
16
  export const TOOL_MEMORY_RETRIEVE = 'memory_retrieve';
@@ -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-rc.8';
5
+ export const VERSION = '4.8.56';
6
6
  //# sourceMappingURL=version.js.map
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moflo/cli",
3
- "version": "4.8.56-rc.8",
3
+ "version": "4.8.56",
4
4
  "type": "module",
5
5
  "main": "dist/src/index.js",
6
6
  "types": "dist/src/index.d.ts",
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "scripts": {
32
32
  "preversion": "echo 'ERROR: Run npm version from the repo root, not from src/modules/cli/' && exit 1",
33
- "build": "tsc && node -e \"const{cpSync}=require('fs');cpSync('src/epic/workflows','dist/src/epic/workflows',{recursive:true})\"",
33
+ "build": "tsc && node -e \"const{cpSync}=require('fs');cpSync('src/epic/spells','dist/src/epic/spells',{recursive:true})\"",
34
34
  "test": "vitest run",
35
35
  "test:plugin-store": "npx tsx src/plugins/tests/standalone-test.ts",
36
36
  "test:pattern-store": "npx tsx src/transfer/store/tests/standalone-test.ts",
@@ -102,7 +102,6 @@ export { Security, validateString, validateNumber, validateBoolean, validateArra
102
102
  // ============================================================================
103
103
  export const VERSION = '3.0.0-alpha.1';
104
104
  export const SDK_VERSION = '1.0.0';
105
- export const MODULE_ID = '@claude-flow/plugins';
106
105
  // ============================================================================
107
106
  // Quick Start Utilities
108
107
  // ============================================================================
@@ -1,8 +1,6 @@
1
1
  /**
2
2
  * Cross-platform utilities for shell commands and path handling.
3
3
  */
4
- /** Date of the cross-platform audit */
5
- export const PLATFORM_AUDIT_DATE = '2026-04-01';
6
4
  /** True when running on Windows */
7
5
  export const IS_WINDOWS = process.platform === 'win32';
8
6
  /** Platform-appropriate null device for stderr/stdout redirection */
@@ -134,7 +134,7 @@ export const bashCommand = {
134
134
  };
135
135
  }
136
136
  const elapsed = () => `${((Date.now() - start) / 1000).toFixed(1)}s`;
137
- const cmdPreview = command.length > 80 ? command.slice(0, 77) + '...' : command;
137
+ const cmdPreview = redactSensitiveFlags(command.length > 80 ? command.slice(0, 77) + '...' : command);
138
138
  // Resolve shell: prefer Git Bash on Windows to avoid WSL bash hanging.
139
139
  const resolvedShell = platform() === 'win32' ? resolveGitBash() : 'bash';
140
140
  // ── OS sandbox wrapping (#410 macOS, #411 Linux) ────────────────────
@@ -347,7 +347,7 @@ function resolveGitBash() {
347
347
  * Interactive Claude invocations are left untouched.
348
348
  */
349
349
  const CLAUDE_HEADLESS_RE = /\bclaude\b[^|;&]*\s-p\s/;
350
- const EXISTING_PERM_FLAGS_RE = /\s*--dangerously-skip-permissions\b/g;
350
+ const EXISTING_PERM_FLAGS_RE = /\s*(?:--dangerously-skip-permissions|--permission-mode\s+\S+)\b/g;
351
351
  const EXISTING_ALLOWED_TOOLS_RE = /\s*--allowedTools\s+[^\s]+/g;
352
352
  function applyClaudePermissions(command, explicitLevel, capabilities) {
353
353
  if (!CLAUDE_HEADLESS_RE.test(command))
@@ -364,6 +364,11 @@ function applyClaudePermissions(command, explicitLevel, capabilities) {
364
364
  rewritten = rewritten.replace(/ {2,}/g, ' ');
365
365
  return rewritten;
366
366
  }
367
+ // ── Log redaction — strip alarming flags from heartbeat output ───────────
368
+ const REDACT_FLAGS_RE = /\s*(?:--dangerously-skip-permissions|--permission-mode\s+\S+)\b/g;
369
+ function redactSensitiveFlags(cmd) {
370
+ return cmd.replace(REDACT_FLAGS_RE, '').replace(/ {2,}/g, ' ');
371
+ }
367
372
  // ── Best-effort path extraction for scope enforcement ────────────────────
368
373
  /**
369
374
  * Extract absolute paths from a shell command string.
@@ -37,6 +37,41 @@ const githubPrerequisites = [
37
37
  },
38
38
  ];
39
39
  // ============================================================================
40
+ // Preflights — runtime state checks
41
+ // ============================================================================
42
+ /** Confirm an issue/PR number referenced in config actually exists. */
43
+ const githubPreflights = [
44
+ {
45
+ name: 'issue-exists',
46
+ check: async (config) => {
47
+ if (typeof config.issue !== 'number')
48
+ return { passed: true };
49
+ // Skip for create-type actions that don't require the issue to exist.
50
+ if (config.action === 'issue-edit' || config.action === 'issue-fetch' || config.action === 'comment' || config.action === 'label') {
51
+ const result = await execAsync(`gh issue view ${config.issue} --json number`, 5000);
52
+ if (result.exitCode === 0)
53
+ return { passed: true };
54
+ return { passed: false, reason: `issue #${config.issue} not found or not accessible` };
55
+ }
56
+ return { passed: true };
57
+ },
58
+ },
59
+ {
60
+ name: 'pr-exists',
61
+ check: async (config) => {
62
+ if (typeof config.pr !== 'number')
63
+ return { passed: true };
64
+ if (config.action === 'pr-merge' || config.action === 'pr-find') {
65
+ const result = await execAsync(`gh pr view ${config.pr} --json number`, 5000);
66
+ if (result.exitCode === 0)
67
+ return { passed: true };
68
+ return { passed: false, reason: `PR #${config.pr} not found or not accessible` };
69
+ }
70
+ return { passed: true };
71
+ },
72
+ },
73
+ ];
74
+ // ============================================================================
40
75
  // GitHub Step Command (thin wrapper delegating to github-cli tool)
41
76
  // ============================================================================
42
77
  export const githubCommand = {
@@ -45,6 +80,7 @@ export const githubCommand = {
45
80
  capabilities: [{ type: 'shell' }, { type: 'net' }],
46
81
  defaultMofloLevel: 'none',
47
82
  prerequisites: githubPrerequisites,
83
+ preflight: githubPreflights,
48
84
  configSchema: {
49
85
  type: 'object',
50
86
  properties: {
@@ -16,37 +16,34 @@
16
16
  import { resolvePermissions, } from './permission-resolver.js';
17
17
  import { checkCapabilities } from './capability-validator.js';
18
18
  import { createHash } from 'node:crypto';
19
- // ============================================================================
20
- // Destructive Capability Classification
21
- // ============================================================================
22
19
  /**
23
- * Capabilities classified as destructive — can permanently modify or delete
20
+ * Capabilities classified as higher-risk — can permanently modify or delete
24
21
  * data, spawn processes, or access credentials. Users must be made aware.
25
22
  */
26
- const DESTRUCTIVE_CAPABILITIES = new Set([
23
+ const HIGHER_RISK_CAPABILITIES = new Set([
27
24
  'shell',
28
25
  'fs:write',
29
26
  'browser:evaluate',
30
27
  'credentials',
31
28
  ]);
32
29
  /**
33
- * Capabilities classified as sensitive — can read private data or spawn
34
- * autonomous processes. Not destructive, but worth calling out.
30
+ * Capabilities classified as moderate-risk — can read private data or spawn
31
+ * autonomous processes. Worth calling out but not directly destructive.
35
32
  */
36
- const SENSITIVE_CAPABILITIES = new Set([
33
+ const MODERATE_RISK_CAPABILITIES = new Set([
37
34
  'agent',
38
35
  'net',
39
36
  'browser',
40
37
  ]);
41
38
  /** Human-readable risk explanations for each capability. */
42
39
  const RISK_EXPLANATIONS = {
43
- 'shell': 'Can execute arbitrary shell commands (rm, git push, etc.)',
44
- 'fs:write': 'Can create, overwrite, or delete files on disk',
45
- 'browser:evaluate': 'Can execute JavaScript in a browser context',
46
- 'credentials': 'Can access stored secrets and API keys',
47
- 'agent': 'Can spawn autonomous Claude sub-agents',
48
- 'net': 'Can make network requests to external services',
49
- 'browser': 'Can launch and control browser sessions',
40
+ 'shell': 'Runs shell commands on your machine',
41
+ 'fs:write': 'Creates, overwrites, or deletes files',
42
+ 'browser:evaluate': 'Executes JavaScript in a browser context',
43
+ 'credentials': 'Accesses stored secrets and API keys',
44
+ 'agent': 'Spawns autonomous AI sub-agents',
45
+ 'net': 'Makes network requests to external services',
46
+ 'browser': 'Launches and controls browser sessions',
50
47
  };
51
48
  // ============================================================================
52
49
  // Analysis Functions
@@ -104,18 +101,18 @@ export function analyzeSpellPermissions(definition, registry) {
104
101
  function classifyCapabilities(caps) {
105
102
  const warnings = [];
106
103
  for (const cap of caps) {
107
- if (DESTRUCTIVE_CAPABILITIES.has(cap.type)) {
104
+ if (HIGHER_RISK_CAPABILITIES.has(cap.type)) {
108
105
  warnings.push({
109
106
  capability: cap.type,
110
- riskLevel: 'destructive',
107
+ riskLevel: 'higher',
111
108
  explanation: RISK_EXPLANATIONS[cap.type] ?? `Has ${cap.type} access`,
112
109
  scope: cap.scope,
113
110
  });
114
111
  }
115
- else if (SENSITIVE_CAPABILITIES.has(cap.type)) {
112
+ else if (MODERATE_RISK_CAPABILITIES.has(cap.type)) {
116
113
  warnings.push({
117
114
  capability: cap.type,
118
- riskLevel: 'sensitive',
115
+ riskLevel: 'moderate',
119
116
  explanation: RISK_EXPLANATIONS[cap.type] ?? `Has ${cap.type} access`,
120
117
  scope: cap.scope,
121
118
  });
@@ -124,11 +121,11 @@ function classifyCapabilities(caps) {
124
121
  return warnings;
125
122
  }
126
123
  function computeRiskLevel(warnings) {
127
- if (warnings.some(w => w.riskLevel === 'destructive'))
128
- return 'destructive';
129
- if (warnings.some(w => w.riskLevel === 'sensitive'))
130
- return 'sensitive';
131
- return 'safe';
124
+ if (warnings.some(w => w.riskLevel === 'higher'))
125
+ return 'higher';
126
+ if (warnings.some(w => w.riskLevel === 'moderate'))
127
+ return 'moderate';
128
+ return 'none';
132
129
  }
133
130
  // ============================================================================
134
131
  // Permission Hash (for acceptance tracking)
@@ -159,18 +156,19 @@ function computePermissionHash(steps) {
159
156
  // ============================================================================
160
157
  // Formatting — Human-Readable Output
161
158
  // ============================================================================
162
- const RISK_ICONS = {
163
- safe: '[SAFE]',
164
- sensitive: '[SENSITIVE]',
165
- destructive: '[DESTRUCTIVE]',
159
+ const RISK_LABELS = {
160
+ none: '[No risk]',
161
+ low: '[Low risk]',
162
+ moderate: '[Moderate risk]',
163
+ higher: '[Higher risk]',
166
164
  };
167
165
  /**
168
166
  * Format a single step's permission report for display.
169
167
  */
170
168
  export function formatStepPermissionReport(report) {
171
169
  const lines = [];
172
- const icon = RISK_ICONS[report.riskLevel];
173
- lines.push(` ${icon} ${report.stepId} (${report.stepType})`);
170
+ const label = RISK_LABELS[report.riskLevel];
171
+ lines.push(` ${label} ${report.stepId} (${report.stepType})`);
174
172
  lines.push(` Permission level: ${report.permissionLevel}`);
175
173
  if (report.resolved.allowedTools) {
176
174
  lines.push(` Allowed tools: ${report.resolved.allowedTools.join(', ')}`);
@@ -179,13 +177,11 @@ export function formatStepPermissionReport(report) {
179
177
  lines.push(` Allowed tools: ALL (autonomous)`);
180
178
  }
181
179
  if (report.warnings.length > 0) {
182
- lines.push(' Warnings:');
183
180
  for (const w of report.warnings) {
184
181
  const scopeNote = w.scope?.length
185
182
  ? ` (scoped to: ${w.scope.join(', ')})`
186
183
  : '';
187
- const marker = w.riskLevel === 'destructive' ? '!!' : '!';
188
- lines.push(` ${marker} ${w.capability}: ${w.explanation}${scopeNote}`);
184
+ lines.push(` - ${w.capability}: ${w.explanation}${scopeNote}`);
189
185
  }
190
186
  }
191
187
  return lines.join('\n');
@@ -196,22 +192,19 @@ export function formatStepPermissionReport(report) {
196
192
  */
197
193
  export function formatSpellPermissionReport(report) {
198
194
  const lines = [];
199
- lines.push(`Permission Report: ${report.spellName}`);
200
- lines.push(`Overall risk: ${RISK_ICONS[report.overallRisk]} ${report.overallRisk}`);
195
+ lines.push(`--- Risk Analysis: ${report.spellName} ---`);
196
+ lines.push(`Overall: ${RISK_LABELS[report.overallRisk]}`);
201
197
  lines.push(`Permission hash: ${report.permissionHash}`);
202
198
  lines.push('');
203
199
  for (const step of report.steps) {
204
200
  lines.push(formatStepPermissionReport(step));
205
201
  lines.push('');
206
202
  }
207
- if (report.overallRisk === 'destructive') {
208
- const destructiveSteps = report.steps.filter(s => s.riskLevel === 'destructive');
209
- lines.push('--- DESTRUCTIVE STEPS ---');
210
- lines.push(`${destructiveSteps.length} step(s) can make destructive changes:`);
211
- for (const s of destructiveSteps) {
212
- const caps = s.warnings
213
- .filter(w => w.riskLevel === 'destructive')
214
- .map(w => w.capability);
203
+ if (report.overallRisk === 'higher' || report.overallRisk === 'moderate') {
204
+ const riskySteps = report.steps.filter(s => s.riskLevel === 'higher' || s.riskLevel === 'moderate');
205
+ lines.push(`--- ${riskySteps.length} step(s) require elevated permissions ---`);
206
+ for (const s of riskySteps) {
207
+ const caps = s.warnings.map(w => w.capability);
215
208
  lines.push(` - ${s.stepId}: ${caps.join(', ')}`);
216
209
  }
217
210
  lines.push('');
@@ -2,13 +2,12 @@
2
2
  * Permission Resolver — Least-Privilege Escalation for Spell Steps
3
3
  *
4
4
  * Determines the minimum Claude Code CLI permission flags needed for a step.
5
- * Always uses --dangerously-skip-permissions (required for non-interactive -p mode)
6
- * but varies --allowedTools to enforce least-privilege:
5
+ * Uses the least-permissive --permission-mode for each level:
7
6
  *
8
- * readonly → Read,Glob,Grep (analysis only)
9
- * standard → Edit,Write,Read,Glob,Grep (code changes, no shell)
10
- * elevated → Edit,Write,Bash,Read,Glob,Grep (shell access for git/npm/etc.)
11
- * autonomous → (no --allowedTools restriction) (explicit opt-in only)
7
+ * readonly → Read,Glob,Grep (no permission bypass)
8
+ * standard → Edit,Write,Read,Glob,Grep (--permission-mode acceptEdits)
9
+ * elevated → Edit,Write,Bash,Read,Glob,Grep (--permission-mode bypassPermissions)
10
+ * autonomous → (no --allowedTools restriction) (--permission-mode bypassPermissions)
12
11
  *
13
12
  * Resolution order:
14
13
  * 1. Explicit `permissionLevel` on the step definition → use directly
@@ -37,8 +36,17 @@ export function resolvePermissions(explicitLevel, capabilities, additionalTools)
37
36
  const level = explicitLevel && isValidPermissionLevel(explicitLevel)
38
37
  ? explicitLevel
39
38
  : deriveFromCapabilities(capabilities);
40
- const args = ['--dangerously-skip-permissions'];
39
+ const args = [];
40
+ const needsBypass = level === 'elevated' || level === 'autonomous';
41
41
  let allowedTools;
42
+ // Only bypass permissions when the step actually needs it (shell/autonomous)
43
+ if (needsBypass) {
44
+ args.push('--permission-mode', 'bypassPermissions');
45
+ }
46
+ else if (level === 'standard') {
47
+ args.push('--permission-mode', 'acceptEdits');
48
+ }
49
+ // readonly: no permission mode needed — Read/Glob/Grep don't require approval
42
50
  if (level !== 'autonomous') {
43
51
  const baseTools = [...TOOL_SETS[level]];
44
52
  if (additionalTools) {
@@ -53,7 +61,7 @@ export function resolvePermissions(explicitLevel, capabilities, additionalTools)
53
61
  return {
54
62
  level,
55
63
  cliArgs: args,
56
- skipPermissions: true,
64
+ skipPermissions: needsBypass,
57
65
  allowedTools: allowedTools ? Object.freeze([...allowedTools]) : undefined,
58
66
  };
59
67
  }
@@ -64,7 +72,7 @@ export function resolvePermissions(explicitLevel, capabilities, additionalTools)
64
72
  * @param explicitLevel - Optional explicit `permissionLevel` from step config.
65
73
  * @param capabilities - The step's effective capabilities.
66
74
  * @param additionalTools - Extra tools beyond the permission level's defaults.
67
- * @returns The complete command string (e.g. `claude --dangerously-skip-permissions --allowedTools Edit,Write,Read,Glob,Grep -p "..."`)
75
+ * @returns The complete command string (e.g. `claude --permission-mode acceptEdits --allowedTools Edit,Write,Read,Glob,Grep -p "..."`)
68
76
  */
69
77
  export function buildClaudeCommand(prompt, explicitLevel, capabilities, additionalTools) {
70
78
  const resolved = resolvePermissions(explicitLevel, capabilities, additionalTools);
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Preflight Checker
3
+ *
4
+ * Runs runtime-state validation BEFORE any step executes. Complements
5
+ * prerequisite-checker.ts (static capability probes) by validating the
6
+ * actual state each step depends on (e.g. issue is open, branch is clean).
7
+ *
8
+ * Two sources contribute preflights:
9
+ * 1. Step commands may declare a `preflight` array on their interface —
10
+ * these run with the resolved step config + args.
11
+ * 2. Step definitions may declare a YAML `preflight:` list of shell
12
+ * commands — these cover ad-hoc runtime checks for generic steps
13
+ * (e.g. a bash step that needs `git diff --quiet` first).
14
+ *
15
+ * All preflights for all steps run in parallel before step execution begins.
16
+ * Any failure aborts the spell with a structured PRECHECK_FAILED error.
17
+ */
18
+ import { execFile } from 'node:child_process';
19
+ import { interpolateString } from './interpolation.js';
20
+ // ============================================================================
21
+ // Collection
22
+ // ============================================================================
23
+ /**
24
+ * Collect every preflight that will run for this spell.
25
+ * Walks every step, pulls both step-command preflights and YAML preflights,
26
+ * and binds each with the step's own context/config.
27
+ */
28
+ export function collectPreflights(definition, registry, context) {
29
+ const out = [];
30
+ collectFromSteps(definition.steps, registry, context, out, 0);
31
+ return out;
32
+ }
33
+ function collectFromSteps(steps, registry, context, out, startIndex) {
34
+ let index = startIndex;
35
+ for (const step of steps) {
36
+ const command = registry.get(step.type);
37
+ // 1. Step-command preflights
38
+ if (command?.preflight) {
39
+ for (const check of command.preflight) {
40
+ out.push(bindCommandPreflight(step, index, check, context));
41
+ }
42
+ }
43
+ // 2. Declarative YAML preflights
44
+ if (step.preflight) {
45
+ for (const spec of step.preflight) {
46
+ out.push(bindYamlPreflight(step, index, spec, context));
47
+ }
48
+ }
49
+ // Recurse into nested steps (loops/conditions)
50
+ if (step.steps && step.steps.length > 0) {
51
+ index = collectFromSteps(step.steps, registry, context, out, index + 1);
52
+ }
53
+ else {
54
+ index++;
55
+ }
56
+ }
57
+ return index;
58
+ }
59
+ function bindCommandPreflight(step, stepIndex, check, context) {
60
+ return {
61
+ stepId: step.id,
62
+ stepIndex,
63
+ name: check.name,
64
+ run: () => {
65
+ const ctx = {
66
+ args: context.args,
67
+ credentials: context.credentials,
68
+ stepId: step.id,
69
+ stepIndex,
70
+ };
71
+ return check.check(step.config, ctx);
72
+ },
73
+ };
74
+ }
75
+ function bindYamlPreflight(step, stepIndex, spec, context) {
76
+ return {
77
+ stepId: step.id,
78
+ stepIndex,
79
+ name: spec.name,
80
+ run: async () => {
81
+ const command = interpolateSafe(spec.command, context.args);
82
+ const expected = spec.expectExitCode ?? 0;
83
+ const timeoutMs = spec.timeoutMs ?? 10_000;
84
+ const actualExitCode = await runShellExitCode(command, timeoutMs);
85
+ if (actualExitCode === expected)
86
+ return { passed: true };
87
+ return {
88
+ passed: false,
89
+ reason: `command "${command}" exited with ${actualExitCode}, expected ${expected}`,
90
+ };
91
+ },
92
+ };
93
+ }
94
+ // ============================================================================
95
+ // Execution
96
+ // ============================================================================
97
+ /** Run all preflights in parallel. Errors are treated as failures. */
98
+ export async function checkPreflights(preflights) {
99
+ return Promise.all(preflights.map(async (pf) => {
100
+ try {
101
+ const result = await pf.run();
102
+ return {
103
+ stepId: pf.stepId,
104
+ name: pf.name,
105
+ passed: result.passed,
106
+ reason: result.reason,
107
+ };
108
+ }
109
+ catch (err) {
110
+ return {
111
+ stepId: pf.stepId,
112
+ name: pf.name,
113
+ passed: false,
114
+ reason: err.message,
115
+ };
116
+ }
117
+ }));
118
+ }
119
+ /** Format failed preflights into a user-friendly error message. */
120
+ export function formatPreflightErrors(results) {
121
+ const failed = results.filter(r => !r.passed);
122
+ if (failed.length === 0)
123
+ return '';
124
+ const lines = ['Preflight checks failed:'];
125
+ for (const f of failed) {
126
+ lines.push(` - [${f.stepId}] ${f.name}${f.reason ? `: ${f.reason}` : ''}`);
127
+ }
128
+ return lines.join('\n');
129
+ }
130
+ // ============================================================================
131
+ // Internals
132
+ // ============================================================================
133
+ function interpolateSafe(template, args) {
134
+ try {
135
+ const ctx = { args, variables: {} };
136
+ return interpolateString(template, ctx);
137
+ }
138
+ catch {
139
+ return template;
140
+ }
141
+ }
142
+ async function runShellExitCode(command, timeoutMs) {
143
+ return new Promise((resolve) => {
144
+ const shell = process.platform === 'win32' ? process.env.ComSpec ?? 'cmd.exe' : '/bin/sh';
145
+ const shellArgs = process.platform === 'win32' ? ['/d', '/s', '/c', command] : ['-c', command];
146
+ const child = execFile(shell, shellArgs, { timeout: timeoutMs }, (err) => {
147
+ if (err && typeof err.code === 'number') {
148
+ resolve(err.code);
149
+ }
150
+ else if (err && err.signal) {
151
+ resolve(124); // timeout-ish
152
+ }
153
+ });
154
+ child.on('exit', (code) => resolve(code ?? 1));
155
+ child.on('error', () => resolve(1));
156
+ });
157
+ }
158
+ //# sourceMappingURL=preflight-checker.js.map
@@ -1,11 +1,3 @@
1
- /**
2
- * Spell Runner
3
- *
4
- * Thin orchestrator that drives a parsed SpellDefinition step by step,
5
- * delegating step execution, validation, loop iteration, rollback,
6
- * credential masking, and timeout handling to focused modules.
7
- */
8
- export const ENGINE_VERSION = '1.0.0';
9
1
  import { ConnectorAccessorImpl } from './connector-accessor.js';
10
2
  import { validateSpellDefinition, resolveArguments } from '../schema/validator.js';
11
3
  import { compareMofloLevels } from './capability-validator.js';
@@ -17,8 +9,11 @@ import { rollbackSteps } from './rollback-orchestrator.js';
17
9
  import { buildCredentialPatterns, addCredentialPattern, collectCredentialNames } from './credential-masker.js';
18
10
  import { executeSingleStep } from './step-executor.js';
19
11
  import { collectPrerequisites, checkPrerequisites, formatPrerequisiteErrors } from './prerequisite-checker.js';
12
+ import { collectPreflights, checkPreflights, formatPreflightErrors } from './preflight-checker.js';
20
13
  import { DENY_ALL_GATEWAY } from './capability-gateway.js';
21
14
  import { resolveEffectiveSandbox, formatSandboxLog, DEFAULT_SANDBOX_CONFIG, } from './platform-sandbox.js';
15
+ import { checkAcceptance, recordAcceptance } from './permission-acceptance.js';
16
+ import { analyzeSpellPermissions, formatSpellPermissionReport } from './permission-disclosure.js';
22
17
  export class SpellCaster {
23
18
  registry;
24
19
  credentials;
@@ -63,6 +58,45 @@ export class SpellCaster {
63
58
  details: argErrors,
64
59
  }], definition.name);
65
60
  }
61
+ // ---------------------------------------------------------------
62
+ // Permission acceptance gate: first-run spells show a risk
63
+ // analysis and block execution until the user explicitly accepts
64
+ // via the spell_accept MCP tool.
65
+ // ---------------------------------------------------------------
66
+ if (!options.dryRun && !options.skipAcceptanceCheck && options.projectRoot) {
67
+ const permReport = analyzeSpellPermissions(definition, this.registry);
68
+ const acceptance = await checkAcceptance(options.projectRoot, definition.name, permReport.permissionHash);
69
+ if (!acceptance.accepted) {
70
+ // Auto-accept spells with no real risk — no need to prompt the user
71
+ if (permReport.overallRisk === 'none' || permReport.overallRisk === 'low') {
72
+ await recordAcceptance(options.projectRoot, definition.name, permReport.permissionHash);
73
+ console.log(`[spell] Auto-accepted "${definition.name}" (${permReport.overallRisk} risk)`);
74
+ }
75
+ else {
76
+ const reason = acceptance.reason === 'hash-mismatch'
77
+ ? 'Spell permissions have changed since last acceptance'
78
+ : 'First run — reviewing spell permissions';
79
+ const report = formatSpellPermissionReport(permReport);
80
+ console.log(`[spell] ${reason}`);
81
+ console.log(`[spell] Running automatic dry-run validation...\n`);
82
+ console.log(report);
83
+ // Run dry-run validation so the user also sees structural issues
84
+ const dryResult = await dryRunValidate(definition, resolvedArgs, defValidation, options, this.registry, (variables, wfId, stepIndex) => this.buildContext(variables, resolvedArgs, wfId, stepIndex, options.signal));
85
+ if (!dryResult.valid) {
86
+ console.log('\n[spell] Dry-run validation found errors:');
87
+ for (const err of [...dryResult.definitionErrors, ...dryResult.argumentErrors]) {
88
+ console.log(` - ${err.message}`);
89
+ }
90
+ }
91
+ // Block — user must explicitly accept
92
+ console.log(`\n[spell] To accept these permissions, run: spell_accept({ name: "${definition.name}" })`);
93
+ return this.failureResult(spellId, startTime, [{
94
+ code: 'ACCEPTANCE_REQUIRED',
95
+ message: `${reason}. Review the risk analysis above and accept with spell_accept({ name: "${definition.name}" }).`,
96
+ }], definition.name);
97
+ }
98
+ }
99
+ }
66
100
  // Pre-flight prerequisite checks (Story #193)
67
101
  if (!options.dryRun) {
68
102
  const prerequisites = collectPrerequisites(definition, this.registry);
@@ -75,6 +109,21 @@ export class SpellCaster {
75
109
  }], definition.name);
76
110
  }
77
111
  }
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
+ }
126
+ }
78
127
  }
79
128
  if (options.dryRun) {
80
129
  const dryResult = await dryRunValidate(definition, resolvedArgs, defValidation, options, this.registry, (variables, wfId, stepIndex) => this.buildContext(variables, resolvedArgs, wfId, stepIndex, options.signal));
@@ -29,6 +29,7 @@ export async function bridgeRunSpell(content, sourceFile, args, options = {}) {
29
29
  signal: controller.signal,
30
30
  memory: options.memory,
31
31
  credentials: options.credentials,
32
+ ...(options.projectRoot ? { projectRoot: options.projectRoot } : {}),
32
33
  });
33
34
  return result;
34
35
  }
@@ -48,6 +49,7 @@ export async function bridgeExecuteSpell(definition, args, options = {}) {
48
49
  return await runner.run(definition, args, {
49
50
  spellId,
50
51
  signal: controller.signal,
52
+ ...(options.projectRoot ? { projectRoot: options.projectRoot } : {}),
51
53
  });
52
54
  }
53
55
  finally {
@@ -76,7 +76,8 @@ export async function runSpellFromContent(content, sourceFile, options = {}) {
76
76
  };
77
77
  }
78
78
  const runner = createRunner(options);
79
- const { args = {}, ...runnerOptions } = options;
79
+ // Strip factory-only fields; keep RunSpellOptions (extends RunnerOptions) for runner.run()
80
+ const { args = {}, stepDirs: _s, credentials: _c, memory: _m, connectorRegistry: _cr, ...runnerOptions } = options;
80
81
  return runner.run(definition, args, runnerOptions);
81
82
  }
82
83
  // ============================================================================
@@ -155,6 +155,33 @@ function validateSteps(steps, errors, stepIds, outputVars, options, prefix = 'st
155
155
  message: `invalid permissionLevel: "${step.permissionLevel}". Valid levels: ${VALID_PERMISSION_LEVELS.join(', ')}`,
156
156
  });
157
157
  }
158
+ // Validate declarative preflight specs (runtime state checks)
159
+ if (step.preflight !== undefined) {
160
+ if (!Array.isArray(step.preflight)) {
161
+ errors.push({ path: `${path}.preflight`, message: 'preflight must be an array' });
162
+ }
163
+ else {
164
+ step.preflight.forEach((pf, pi) => {
165
+ const pfPath = `${path}.preflight[${pi}]`;
166
+ if (!pf || typeof pf !== 'object') {
167
+ errors.push({ path: pfPath, message: 'preflight entry must be an object' });
168
+ return;
169
+ }
170
+ if (typeof pf.name !== 'string' || pf.name.length === 0) {
171
+ errors.push({ path: `${pfPath}.name`, message: 'preflight.name is required' });
172
+ }
173
+ if (typeof pf.command !== 'string' || pf.command.length === 0) {
174
+ errors.push({ path: `${pfPath}.command`, message: 'preflight.command is required' });
175
+ }
176
+ if (pf.expectExitCode !== undefined && typeof pf.expectExitCode !== 'number') {
177
+ errors.push({ path: `${pfPath}.expectExitCode`, message: 'expectExitCode must be a number' });
178
+ }
179
+ if (pf.timeoutMs !== undefined && (typeof pf.timeoutMs !== 'number' || pf.timeoutMs <= 0)) {
180
+ errors.push({ path: `${pfPath}.timeoutMs`, message: 'timeoutMs must be a positive number' });
181
+ }
182
+ });
183
+ }
184
+ }
158
185
  // Recurse into nested steps (condition/loop/parallel)
159
186
  if (step.steps && Array.isArray(step.steps)) {
160
187
  validateSteps(step.steps, errors, stepIds, outputVars, options, `${path}.steps`, spellLevel);