moflo 4.8.56 → 4.8.57
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.
- package/.claude/skills/connector-builder/SKILL.md +52 -0
- package/.claude/skills/spell-builder/SKILL.md +66 -0
- package/package.json +2 -2
- package/src/modules/cli/dist/src/commands/epic.js +44 -5
- package/src/modules/cli/dist/src/epic/runner-adapter.js +1 -1
- package/src/modules/cli/dist/src/epic/spells/auto-merge.yaml +17 -0
- package/src/modules/cli/dist/src/epic/spells/single-branch.yaml +16 -0
- package/src/modules/cli/dist/src/init/moflo-init.js +13 -4
- package/src/modules/cli/dist/src/version.js +1 -1
- package/src/modules/cli/package.json +1 -1
- package/src/modules/spells/dist/core/preflight-checker.js +42 -2
- package/src/modules/spells/dist/core/runner.js +52 -14
- package/src/modules/spells/dist/schema/validator.js +29 -0
|
@@ -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.
|
|
3
|
+
"version": "4.8.57",
|
|
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
|
|
114
|
+
"moflo": "^4.8.56",
|
|
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 = [
|
|
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
|
|
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
|
-
|
|
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
|
// ============================================================================
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
114
|
-
|
|
115
|
-
|
|
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);
|
|
@@ -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
|
}
|