moflo 4.8.86 → 4.8.87-rc.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.8.86",
3
+ "version": "4.8.87-rc.1",
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",
@@ -117,7 +117,7 @@
117
117
  "@types/node": "^24.12.2",
118
118
  "@xenova/transformers": "^2.17.0",
119
119
  "eslint": "^8.0.0",
120
- "moflo": "^4.8.85",
120
+ "moflo": "^4.8.86",
121
121
  "tsx": "^4.21.0",
122
122
  "typescript": "^5.9.3",
123
123
  "vitest": "^4.0.0"
@@ -32,6 +32,32 @@ arguments:
32
32
 
33
33
  mofloLevel: hooks
34
34
 
35
+ # Command-presence checks — fail fast with an actionable message if any
36
+ # of these aren't on PATH. Runtime-state checks (auth, dirty tree, remote)
37
+ # still live on the individual steps that need them via `preflight:`.
38
+ prerequisites:
39
+ - name: gh-cli
40
+ description: GitHub CLI is required to read issues and manage PRs
41
+ docsUrl: https://cli.github.com
42
+ detect:
43
+ type: command
44
+ command: gh
45
+ promptOnMissing: false
46
+ - name: git
47
+ description: Git is required for branch management
48
+ docsUrl: https://git-scm.com/downloads
49
+ detect:
50
+ type: command
51
+ command: git
52
+ promptOnMissing: false
53
+ - name: claude-cli
54
+ description: Claude Code CLI is required to run the story implementer subagent
55
+ docsUrl: https://docs.anthropic.com/en/docs/claude-code
56
+ detect:
57
+ type: command
58
+ command: claude
59
+ promptOnMissing: false
60
+
35
61
  steps:
36
62
  # Step 1: Initialize epic state in memory (enables resume)
37
63
  - id: init-state
@@ -37,6 +37,32 @@ arguments:
37
37
 
38
38
  mofloLevel: hooks
39
39
 
40
+ # Command-presence checks — fail fast with an actionable message if any
41
+ # of these aren't on PATH. Runtime-state checks (auth, dirty tree, remote)
42
+ # still live on the individual steps that need them via `preflight:`.
43
+ prerequisites:
44
+ - name: gh-cli
45
+ description: GitHub CLI is required to read issues and manage PRs
46
+ docsUrl: https://cli.github.com
47
+ detect:
48
+ type: command
49
+ command: gh
50
+ promptOnMissing: false
51
+ - name: git
52
+ description: Git is required for branch management
53
+ docsUrl: https://git-scm.com/downloads
54
+ detect:
55
+ type: command
56
+ command: git
57
+ promptOnMissing: false
58
+ - name: claude-cli
59
+ description: Claude Code CLI is required to run the story implementer subagent
60
+ docsUrl: https://docs.anthropic.com/en/docs/claude-code
61
+ detect:
62
+ type: command
63
+ command: claude
64
+ promptOnMissing: false
65
+
40
66
  steps:
41
67
  # Step 1: Initialize epic state in memory (enables resume)
42
68
  - id: init-state
@@ -340,6 +340,23 @@ export function getWorkerConfig(type) {
340
340
  // ============================================
341
341
  // HeadlessWorkerExecutor Class
342
342
  // ============================================
343
+ // Module-level registry + single process 'exit' listener.
344
+ // Attaching the listener per instance leaked handlers (MaxListenersExceededWarning
345
+ // after ~11 daemons), so we install one global listener that walks a live registry.
346
+ // Instances remove themselves on dispose() for long-running hosts / test suites.
347
+ const liveExecutors = new Set();
348
+ let exitListenerInstalled = false;
349
+ function ensureExitListener() {
350
+ if (exitListenerInstalled)
351
+ return;
352
+ exitListenerInstalled = true;
353
+ // 'exit' (not 'beforeExit') fires on explicit process.exit(); handler must be sync.
354
+ process.on('exit', () => {
355
+ for (const executor of liveExecutors) {
356
+ executor._killPoolOnExit();
357
+ }
358
+ });
359
+ }
343
360
  /**
344
361
  * HeadlessWorkerExecutor - Executes workers using Claude Code in headless mode
345
362
  *
@@ -374,20 +391,29 @@ export class HeadlessWorkerExecutor extends EventEmitter {
374
391
  };
375
392
  // Ensure log directory exists
376
393
  this.ensureLogDir();
377
- // Kill child processes on parent exit to prevent orphaned node processes.
378
- // Uses 'exit' (not 'beforeExit') so it fires even on explicit process.exit().
379
- // The handler must be synchronous — no async work allowed in 'exit' handlers.
380
- process.on('exit', () => {
381
- for (const [, entry] of this.processPool) {
382
- try {
383
- clearTimeout(entry.timeout);
384
- entry.process.kill('SIGTERM');
385
- }
386
- catch {
387
- // Process already gone — ignore
388
- }
394
+ // Register for process-exit cleanup via the shared listener.
395
+ ensureExitListener();
396
+ liveExecutors.add(this);
397
+ }
398
+ /**
399
+ * Remove this executor from the exit-cleanup registry. Call when the host
400
+ * (daemon / test) is done with the instance — otherwise the registry
401
+ * retains a strong ref via the pool/cache Maps.
402
+ */
403
+ dispose() {
404
+ liveExecutors.delete(this);
405
+ }
406
+ /** @internal — invoked by the shared process-exit listener. Must stay sync. */
407
+ _killPoolOnExit() {
408
+ for (const [, entry] of this.processPool) {
409
+ try {
410
+ clearTimeout(entry.timeout);
411
+ entry.process.kill('SIGTERM');
389
412
  }
390
- });
413
+ catch {
414
+ // Process already gone — ignore
415
+ }
416
+ }
391
417
  }
392
418
  // ============================================
393
419
  // Public API
@@ -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.86';
5
+ export const VERSION = '4.8.87-rc.1';
6
6
  //# sourceMappingURL=version.js.map
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moflo/cli",
3
- "version": "4.8.86",
3
+ "version": "4.8.87-rc.1",
4
4
  "type": "module",
5
5
  "main": "dist/src/index.js",
6
6
  "types": "dist/src/index.d.ts",
@@ -10,9 +10,9 @@
10
10
  * 2. ISO timestamp `fallbackDaysAgo` days in the past, if provided
11
11
  * 3. empty string
12
12
  */
13
- import * as readline from 'node:readline';
14
13
  import { interpolateString } from '../core/interpolation.js';
15
14
  import { acquireTTYLock } from '../core/tty-lock.js';
15
+ import { readLineFromStdin } from '../core/stdin-reader.js';
16
16
  /**
17
17
  * Resolve the effective default, walking the fallback chain.
18
18
  * Exported for testing.
@@ -31,30 +31,6 @@ export function resolveDefault(raw, fallbackDaysAgo, now = Date.now()) {
31
31
  }
32
32
  return '';
33
33
  }
34
- /**
35
- * Read one line from stdin. Resolves with the trimmed line (empty string
36
- * if the user just hit enter). Honors the provided abort signal.
37
- */
38
- async function readLineFromStdin(prompt, abortSignal) {
39
- return new Promise((resolve, reject) => {
40
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
41
- const onAbort = () => { rl.close(); reject(new Error('Prompt aborted')); };
42
- if (abortSignal) {
43
- if (abortSignal.aborted) {
44
- rl.close();
45
- reject(new Error('Prompt aborted'));
46
- return;
47
- }
48
- abortSignal.addEventListener('abort', onAbort, { once: true });
49
- }
50
- rl.question(prompt, (answer) => {
51
- if (abortSignal)
52
- abortSignal.removeEventListener('abort', onAbort);
53
- rl.close();
54
- resolve(answer.trim());
55
- });
56
- });
57
- }
58
34
  export const promptCommand = {
59
35
  type: 'prompt',
60
36
  description: 'Ask the user a question and capture the response',
@@ -27,8 +27,10 @@ export const VAR_REF_PATTERN = /(?<!\$)\{([A-Za-z_][A-Za-z0-9_.-]*)\}/g;
27
27
  /**
28
28
  * Resolution precedence:
29
29
  * 1. {args.key} — explicit args prefix (depth-2 only)
30
- * 2. {stepId.outputKey} — walk context.variables
31
- * 3. {key} single-segment fallback to context.args
30
+ * 2. {env.KEY} — environment variable (depth-2 only); typically populated
31
+ * by a spell-level `prerequisites:` entry with `promptOnMissing: true`
32
+ * 3. {stepId.outputKey} — walk context.variables
33
+ * 4. {key} — single-segment fallback to context.args
32
34
  */
33
35
  function resolveVariable(path, context) {
34
36
  const segments = path.split('.');
@@ -38,6 +40,12 @@ function resolveVariable(path, context) {
38
40
  if (val !== undefined)
39
41
  return val;
40
42
  }
43
+ // Environment variable prefix: {env.KEY}
44
+ if (segments[0] === 'env' && segments.length === 2) {
45
+ const val = process.env[segments[1]];
46
+ if (val !== undefined && val.length > 0)
47
+ return val;
48
+ }
41
49
  // Walk context.variables (step outputs): {stepId.outputKey}
42
50
  let value = context.variables;
43
51
  for (const segment of segments) {
@@ -1,13 +1,23 @@
1
1
  /**
2
2
  * Prerequisite Checker
3
3
  *
4
- * Collects and deduplicates prerequisites from all spell steps,
5
- * runs each check once, and returns structured results.
4
+ * Collects prerequisites from three sources (declarative YAML at spell and
5
+ * step level + imperative step-command-owned), dedupes by name, runs each
6
+ * detector once, and — when stdin is a TTY — prompts for unmet env-type
7
+ * prereqs and writes the answer into process.env so downstream steps
8
+ * (including nested loop bodies) inherit it.
6
9
  *
7
- * Story #193: Spell engine prerequisites system.
10
+ * Non-TTY failure path reports every unmet prereq with its docs URL so the
11
+ * caller sees the full picture rather than failing mid-cast.
12
+ *
13
+ * Story #193: initial step-command-owned prereqs.
14
+ * Issue #460: YAML-declared + interactive preflight walker.
8
15
  */
9
16
  import { execFile } from 'node:child_process';
17
+ import { access } from 'node:fs/promises';
10
18
  import { promisify } from 'node:util';
19
+ import { acquireTTYLock } from './tty-lock.js';
20
+ import { readLineFromStdin } from './stdin-reader.js';
11
21
  const execFileAsync = promisify(execFile);
12
22
  /** Check whether a CLI command is available on the system PATH. */
13
23
  export async function commandExists(cmd) {
@@ -21,23 +31,92 @@ export async function commandExists(cmd) {
21
31
  }
22
32
  }
23
33
  /**
24
- * Collect unique prerequisites from all steps in a spell definition.
25
- * Deduplicates by prerequisite name (first occurrence wins).
34
+ * Compile a declarative YAML `PrerequisiteSpec` into the imperative
35
+ * `Prerequisite` shape synthesizes a `check()` that dispatches on the
36
+ * detector type and populates the prompt-resolution metadata.
37
+ */
38
+ export function compilePrerequisiteSpec(spec) {
39
+ const detect = spec.detect;
40
+ const check = async () => {
41
+ switch (detect.type) {
42
+ case 'env': {
43
+ const val = process.env[detect.key];
44
+ return typeof val === 'string' && val.length > 0;
45
+ }
46
+ case 'command':
47
+ return commandExists(detect.command);
48
+ case 'file':
49
+ try {
50
+ await access(detect.path);
51
+ return true;
52
+ }
53
+ catch {
54
+ return false;
55
+ }
56
+ }
57
+ };
58
+ const installHint = spec.description ?? defaultHintForDetect(spec);
59
+ const envKey = detect.type === 'env' ? detect.key : undefined;
60
+ const promptOnMissing = spec.promptOnMissing ?? true;
61
+ return {
62
+ name: spec.name,
63
+ check,
64
+ installHint,
65
+ url: spec.docsUrl,
66
+ description: spec.description,
67
+ promptOnMissing,
68
+ envKey,
69
+ };
70
+ }
71
+ function defaultHintForDetect(spec) {
72
+ switch (spec.detect.type) {
73
+ case 'env': return `Set the ${spec.detect.key} environment variable`;
74
+ case 'command': return `Install "${spec.detect.command}" and add it to your PATH`;
75
+ case 'file': return `Ensure the file exists: ${spec.detect.path}`;
76
+ }
77
+ }
78
+ /**
79
+ * Collect unique prerequisites from a spell. Sources, in order:
80
+ * 1. spell-level YAML (`definition.prerequisites`)
81
+ * 2. step-level YAML (including nested loop/condition/parallel bodies)
82
+ * 3. step-command built-ins (imperative, from the registry)
83
+ *
84
+ * Deduplicates by name — first occurrence wins.
26
85
  */
27
86
  export function collectPrerequisites(definition, registry) {
28
87
  const seen = new Map();
29
- for (const step of definition.steps) {
30
- const command = registry.get(step.type);
31
- if (!command?.prerequisites)
32
- continue;
33
- for (const prereq of command.prerequisites) {
34
- if (!seen.has(prereq.name)) {
35
- seen.set(prereq.name, prereq);
88
+ if (definition.prerequisites) {
89
+ for (const spec of definition.prerequisites) {
90
+ if (!seen.has(spec.name)) {
91
+ seen.set(spec.name, compilePrerequisiteSpec(spec));
36
92
  }
37
93
  }
38
94
  }
95
+ collectFromSteps(definition.steps, registry, seen);
39
96
  return Array.from(seen.values());
40
97
  }
98
+ function collectFromSteps(steps, registry, seen) {
99
+ for (const step of steps) {
100
+ if (step.prerequisites) {
101
+ for (const spec of step.prerequisites) {
102
+ if (!seen.has(spec.name)) {
103
+ seen.set(spec.name, compilePrerequisiteSpec(spec));
104
+ }
105
+ }
106
+ }
107
+ const command = registry.get(step.type);
108
+ if (command?.prerequisites) {
109
+ for (const prereq of command.prerequisites) {
110
+ if (!seen.has(prereq.name)) {
111
+ seen.set(prereq.name, prereq);
112
+ }
113
+ }
114
+ }
115
+ if (step.steps && step.steps.length > 0) {
116
+ collectFromSteps(step.steps, registry, seen);
117
+ }
118
+ }
119
+ }
41
120
  /**
42
121
  * Run all prerequisite checks concurrently. Errors are treated as unsatisfied.
43
122
  */
@@ -73,4 +152,94 @@ export function formatPrerequisiteErrors(results) {
73
152
  }
74
153
  return lines.join('\n');
75
154
  }
155
+ /**
156
+ * Evaluate all prereqs. On a TTY, prompts the user for unmet env-type prereqs
157
+ * whose spec opted into `promptOnMissing`, writes answers into process.env,
158
+ * then re-checks. Non-TTY calls and non-promptable unmet prereqs short-circuit
159
+ * to a single formatted failure report.
160
+ */
161
+ export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
162
+ if (prerequisites.length === 0) {
163
+ return { ok: true, resolvedNames: [] };
164
+ }
165
+ const interactive = options.interactive
166
+ ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
167
+ const log = options.log ?? ((line) => console.log(line));
168
+ const initial = await checkPrerequisites(prerequisites);
169
+ const unmet = prerequisites.filter((_, i) => !initial[i].satisfied);
170
+ if (unmet.length === 0) {
171
+ return { ok: true, resolvedNames: [] };
172
+ }
173
+ const promptable = unmet.filter(p => interactive && p.promptOnMissing === true && typeof p.envKey === 'string');
174
+ if (!interactive || promptable.length === 0) {
175
+ return {
176
+ ok: false,
177
+ message: formatPrerequisiteErrors(initial),
178
+ resolvedNames: [],
179
+ };
180
+ }
181
+ printPreflightBanner(log, unmet.length);
182
+ const promptLine = options.promptLine ?? readLineFromStdin;
183
+ const resolvedNames = [];
184
+ const lock = acquireTTYLock();
185
+ try {
186
+ for (const prereq of promptable) {
187
+ if (options.abortSignal?.aborted) {
188
+ return {
189
+ ok: false,
190
+ message: 'Prerequisite resolution aborted',
191
+ resolvedNames,
192
+ };
193
+ }
194
+ if (prereq.description)
195
+ log(prereq.description);
196
+ if (prereq.url)
197
+ log(` Docs: ${prereq.url}`);
198
+ const prompt = ` ${prereq.name} > `;
199
+ let answer;
200
+ try {
201
+ answer = await promptLine(prompt, options.abortSignal);
202
+ }
203
+ catch (err) {
204
+ return {
205
+ ok: false,
206
+ message: `Prerequisite "${prereq.name}" prompt failed: ${err.message}`,
207
+ resolvedNames,
208
+ };
209
+ }
210
+ if (!answer || answer.length === 0) {
211
+ return {
212
+ ok: false,
213
+ message: `Prerequisite "${prereq.name}" was not provided`,
214
+ resolvedNames,
215
+ };
216
+ }
217
+ if (prereq.envKey) {
218
+ process.env[prereq.envKey] = answer;
219
+ }
220
+ resolvedNames.push(prereq.name);
221
+ }
222
+ }
223
+ finally {
224
+ lock.release();
225
+ }
226
+ // Re-check everything — any still unmet (e.g. command/file prereqs that
227
+ // couldn't be resolved via prompt) fail now with the up-to-date report.
228
+ const rerun = await checkPrerequisites(prerequisites);
229
+ const stillUnmet = rerun.filter(r => !r.satisfied);
230
+ if (stillUnmet.length > 0) {
231
+ return {
232
+ ok: false,
233
+ message: formatPrerequisiteErrors(rerun),
234
+ resolvedNames,
235
+ };
236
+ }
237
+ return { ok: true, resolvedNames };
238
+ }
239
+ function printPreflightBanner(log, unmetCount) {
240
+ log('');
241
+ log('\x1b[1;36m━━━ Preflight: missing prerequisites ━━━\x1b[0m');
242
+ log(`${unmetCount} prerequisite${unmetCount === 1 ? '' : 's'} need${unmetCount === 1 ? 's' : ''} a value before this spell can cast.`);
243
+ log('');
244
+ }
76
245
  //# sourceMappingURL=prerequisite-checker.js.map
@@ -8,7 +8,7 @@ import { executeParallelSteps } from './parallel-executor.js';
8
8
  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
- import { collectPrerequisites, checkPrerequisites, formatPrerequisiteErrors } from './prerequisite-checker.js';
11
+ import { collectPrerequisites, resolveUnmetPrerequisites } from './prerequisite-checker.js';
12
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';
@@ -99,15 +99,18 @@ export class SpellCaster {
99
99
  }
100
100
  }
101
101
  }
102
- // Pre-flight prerequisite checks (Story #193)
102
+ // Pre-flight prerequisite checks walks YAML + step-command sources,
103
+ // prompts on a TTY for unmet env-type prereqs (issue #460).
103
104
  if (!options.dryRun) {
104
105
  const prerequisites = collectPrerequisites(definition, this.registry);
105
106
  if (prerequisites.length > 0) {
106
- const prereqResults = await checkPrerequisites(prerequisites);
107
- if (prereqResults.some(r => !r.satisfied)) {
107
+ const resolution = await resolveUnmetPrerequisites(prerequisites, {
108
+ abortSignal: options.signal,
109
+ });
110
+ if (!resolution.ok) {
108
111
  return this.failureResult(spellId, startTime, [{
109
112
  code: 'PREREQUISITES_FAILED',
110
- message: formatPrerequisiteErrors(prereqResults),
113
+ message: resolution.message ?? 'Prerequisites failed',
111
114
  }], definition.name);
112
115
  }
113
116
  }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Stdin reader — shared interactive-line-of-input helper.
3
+ *
4
+ * Used by both the `prompt` step command and the issue-#460 prerequisite
5
+ * preflight walker; keeps the abort-signal + trimming semantics in one place.
6
+ */
7
+ import * as readline from 'node:readline';
8
+ /**
9
+ * Read one line from stdin, emitting `promptText` first. Resolves with the
10
+ * user's trimmed answer (empty string if they just hit enter). Honors an
11
+ * optional abort signal — rejects with `Error('Prompt aborted')` if fired.
12
+ */
13
+ export async function readLineFromStdin(promptText, abortSignal) {
14
+ return new Promise((resolve, reject) => {
15
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
16
+ const onAbort = () => { rl.close(); reject(new Error('Prompt aborted')); };
17
+ if (abortSignal) {
18
+ if (abortSignal.aborted) {
19
+ rl.close();
20
+ reject(new Error('Prompt aborted'));
21
+ return;
22
+ }
23
+ abortSignal.addEventListener('abort', onAbort, { once: true });
24
+ }
25
+ rl.question(promptText, (answer) => {
26
+ if (abortSignal)
27
+ abortSignal.removeEventListener('abort', onAbort);
28
+ rl.close();
29
+ resolve(answer.trim());
30
+ });
31
+ });
32
+ }
33
+ //# sourceMappingURL=stdin-reader.js.map
@@ -18,7 +18,7 @@ export { ConnectorAccessorImpl } from './core/connector-accessor.js';
18
18
  export { GatedConnectorAccessor } from './core/gated-connector-accessor.js';
19
19
  export { checkCapabilities, } from './core/capability-validator.js';
20
20
  export { CapabilityGateway, CapabilityDeniedError, DenyAllGateway, DENY_ALL_GATEWAY, discloseStep, discloseSpell, formatStepDisclosure, formatSpellDisclosure, } from './core/capability-gateway.js';
21
- export { collectPrerequisites, checkPrerequisites, formatPrerequisiteErrors, commandExists, } from './core/prerequisite-checker.js';
21
+ export { collectPrerequisites, checkPrerequisites, formatPrerequisiteErrors, commandExists, compilePrerequisiteSpec, resolveUnmetPrerequisites, } from './core/prerequisite-checker.js';
22
22
  export { detectSandboxCapability, resetSandboxCache, resolveSandboxConfig, resolveEffectiveSandbox, formatSandboxLog, loadSandboxConfigFromProject, DEFAULT_SANDBOX_CONFIG, } from './core/platform-sandbox.js';
23
23
  export { resolveScopePath, } from './core/sandbox-utils.js';
24
24
  export { generateSandboxProfile, wrapWithSandboxExec, } from './core/sandbox-profile.js';