moflo 4.9.22 → 4.9.24

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.
Files changed (29) hide show
  1. package/.claude/guidance/shipped/moflo-cli-reference.md +19 -16
  2. package/.claude/guidance/shipped/moflo-core-guidance.md +0 -2
  3. package/.claude/guidance/shipped/moflo-spell-runner.md +1 -0
  4. package/.claude/guidance/shipped/moflo-spell-scheduling.md +225 -0
  5. package/.claude/guidance/shipped/moflo-spell-troubleshooting.md +1 -0
  6. package/.claude/skills/fl/phases.md +67 -0
  7. package/.claude/skills/spell-schedule/SKILL.md +18 -5
  8. package/README.md +1 -1
  9. package/bin/index-guidance.mjs +32 -6
  10. package/bin/session-start-launcher.mjs +15 -8
  11. package/dist/src/cli/commands/daemon.js +13 -17
  12. package/dist/src/cli/commands/hooks.js +3 -6
  13. package/dist/src/cli/commands/spell-schedule.js +237 -49
  14. package/dist/src/cli/init/settings-generator.js +5 -6
  15. package/dist/src/cli/mcp-tools/memory-tools.js +16 -5
  16. package/dist/src/cli/memory/bridge-embedder.js +26 -6
  17. package/dist/src/cli/memory/bridge-entries.js +33 -15
  18. package/dist/src/cli/services/daemon-autostart-lifecycle.js +62 -0
  19. package/dist/src/cli/services/daemon-dashboard.js +192 -18
  20. package/dist/src/cli/services/daemon-readiness.js +19 -31
  21. package/dist/src/cli/services/ephemeral-namespace-purge.js +61 -33
  22. package/dist/src/cli/services/headless-worker-executor.js +7 -94
  23. package/dist/src/cli/services/worker-daemon.js +40 -66
  24. package/dist/src/cli/spells/core/runner.js +12 -0
  25. package/dist/src/cli/spells/scheduler/scheduler.js +24 -9
  26. package/dist/src/cli/spells/schema/validator.js +2 -1
  27. package/dist/src/cli/spells/schema/validators/top-level.js +18 -0
  28. package/dist/src/cli/version.js +1 -1
  29. package/package.json +4 -2
@@ -1,49 +1,53 @@
1
1
  /**
2
- * Idempotent ephemeral-namespace purge for moflo's memory DB (`.moflo/moflo.db`).
2
+ * Idempotent session-start memory cleanup for moflo's memory DB
3
+ * (`.moflo/moflo.db`).
3
4
  *
4
- * Story #729 retired four namespaces from the persistent memory layer because
5
- * they store internal moflo run-tracking — not user knowledge — and embedding
6
- * them polluted the search index:
5
+ * Two passes run in a single sql.js open:
7
6
  *
8
- * - `hive-mind` (MCP broadcast traffic)
9
- * - `tasklist` (spell run records)
10
- * - `epic-state` (epic progress tracking)
11
- * - `test-bridge-fix` (single-row leftover from a one-off test)
7
+ * 1. **Hard-purge** namespaces in {@link PURGE_ON_SESSION_START_NAMESPACES} —
8
+ * `hive-mind`, `epic-state`, `test-bridge-fix`. These store internal
9
+ * run-tracking that does not need to survive a session restart. (#729)
12
10
  *
13
- * This service hard-deletes any rows in those namespaces left over from prior
14
- * moflo versions, then VACUUMs to reclaim disk. Future writes to these
15
- * namespaces still land in the DB — but skip embedding generation entirely
16
- * (see {@link EPHEMERAL_NAMESPACES} in `memory/bridge-embedder.ts`).
11
+ * 2. **Retention trim** the `tasklist` namespace down to the most recent
12
+ * {@link TASKLIST_RETENTION_CAP} rows. `tasklist` is the dashboard's
13
+ * "Flo Runs" tab data source (`daemon-dashboard.ts handleSpells`); the
14
+ * pre-#968 contract hard-purged it on every session start, leaving the tab
15
+ * permanently empty. Trim instead so users see recent history without
16
+ * unbounded growth.
17
+ *
18
+ * Both passes share the file open + final VACUUM + atomic write, so disk I/O
19
+ * is the same as before. Writes back to disk only when something changed.
17
20
  *
18
21
  * Lives in `services/` so it has no dependency on the CLI command machinery.
19
- * That lets `bin/session-start-launcher.mjs` dynamic-import it and run the
20
- * purge in foreground BEFORE long-lived sql.js consumers (MCP server, daemon)
21
- * open the DB — sql.js dumps the whole snapshot on every flush and would
22
+ * That lets `bin/session-start-launcher.mjs` dynamic-import it and run in
23
+ * foreground BEFORE long-lived sql.js consumers (MCP server, daemon) open
24
+ * the DB — sql.js dumps the whole snapshot on every flush and would
22
25
  * otherwise clobber our cleanup (see #727's clobber-hazard analysis).
23
26
  *
24
27
  * @module cli/services/ephemeral-namespace-purge
25
28
  */
26
29
  /* eslint-disable @typescript-eslint/no-explicit-any */
27
- import { EPHEMERAL_NAMESPACES } from '../memory/bridge-embedder.js';
30
+ import { PURGE_ON_SESSION_START_NAMESPACES, TASKLIST_RETENTION_CAP, } from '../memory/bridge-embedder.js';
28
31
  import { mofloImport } from './moflo-require.js';
29
32
  import { atomicWriteFileSync } from './atomic-file-write.js';
30
33
  import { memoryDbPath } from './moflo-paths.js';
31
34
  /**
32
- * Hard-delete every row whose namespace is in {@link EPHEMERAL_NAMESPACES}
33
- * and VACUUM. Returns `{ purged: 0 }` on the happy path: no DB, sql.js
34
- * unavailable, schema lacks `memory_entries`, or no ephemeral rows present.
35
- * Errors propagate to the caller (the launcher absorbs them so a failed
36
- * purge never blocks session start).
35
+ * Hard-delete rows in {@link PURGE_ON_SESSION_START_NAMESPACES} and trim the
36
+ * `tasklist` namespace to its retention cap, then VACUUM. Returns
37
+ * `{ purged: 0, trimmed: 0 }` on the happy path: no DB, sql.js unavailable,
38
+ * schema lacks `memory_entries`, or nothing to clean. Errors propagate to
39
+ * the caller (the launcher absorbs them so a failed purge never blocks
40
+ * session start).
37
41
  */
38
42
  export async function purgeEphemeralNamespaces(options = {}) {
39
43
  const fs = await import('fs');
40
44
  const path = await import('path');
41
45
  const dbPath = path.resolve(options.dbPath ?? memoryDbPath(process.cwd()));
42
46
  if (!fs.existsSync(dbPath))
43
- return { purged: 0 };
47
+ return { purged: 0, trimmed: 0 };
44
48
  const initSqlJs = (await mofloImport('sql.js'))?.default;
45
49
  if (!initSqlJs)
46
- return { purged: 0 };
50
+ return { purged: 0, trimmed: 0 };
47
51
  const SQL = await initSqlJs();
48
52
  const buffer = fs.readFileSync(dbPath);
49
53
  const db = new SQL.Database(buffer);
@@ -52,21 +56,45 @@ export async function purgeEphemeralNamespaces(options = {}) {
52
56
  // a no-op so we don't VACUUM unrelated SQLite files.
53
57
  const probe = db.exec(`SELECT name FROM sqlite_master WHERE type='table' AND name='memory_entries' LIMIT 1`);
54
58
  if (!probe[0]?.values?.[0])
55
- return { purged: 0 };
56
- const namespaces = Array.from(EPHEMERAL_NAMESPACES);
59
+ return { purged: 0, trimmed: 0 };
60
+ // Single COUNT pass to gate both DELETEs — a clean DB is the steady
61
+ // state and we don't want two no-op DELETEs (with their query-planner
62
+ // overhead) on every session start.
63
+ const namespaces = Array.from(PURGE_ON_SESSION_START_NAMESPACES);
64
+ const cap = options.tasklistRetentionCap ?? TASKLIST_RETENTION_CAP;
57
65
  const placeholders = namespaces.map(() => '?').join(', ');
58
- // Single-scan delete + rowsModified: skips a redundant COUNT pass on dirty
59
- // DBs and avoids the prepare/bind/step/free overhead on clean ones. VACUUM
60
- // (and the disk write) only run when something was actually deleted.
61
- db.run(`DELETE FROM memory_entries WHERE namespace IN (${placeholders})`, namespaces);
62
- const purged = db.getRowsModified?.() ?? 0;
63
- if (purged === 0)
64
- return { purged: 0 };
66
+ const countRows = db.exec(`SELECT
67
+ (SELECT COUNT(*) FROM memory_entries WHERE namespace IN (${placeholders})) AS purgeable,
68
+ (SELECT COUNT(*) FROM memory_entries WHERE namespace = 'tasklist') AS tasklistTotal`, namespaces);
69
+ const counts = countRows[0]?.values?.[0] ?? [0, 0];
70
+ const purgeable = Number(counts[0] ?? 0);
71
+ const tasklistTotal = Number(counts[1] ?? 0);
72
+ let purged = 0;
73
+ if (purgeable > 0) {
74
+ db.run(`DELETE FROM memory_entries WHERE namespace IN (${placeholders})`, namespaces);
75
+ purged = db.getRowsModified?.() ?? 0;
76
+ }
77
+ let trimmed = 0;
78
+ if (tasklistTotal > cap) {
79
+ // Keep the newest `cap` rows by created_at, falling back to `id DESC`
80
+ // for legacy rows that predate the created_at-not-null schema (#728-era).
81
+ db.run(`DELETE FROM memory_entries
82
+ WHERE namespace = 'tasklist'
83
+ AND id NOT IN (
84
+ SELECT id FROM memory_entries
85
+ WHERE namespace = 'tasklist'
86
+ ORDER BY created_at DESC, id DESC
87
+ LIMIT ?
88
+ )`, [cap]);
89
+ trimmed = db.getRowsModified?.() ?? 0;
90
+ }
91
+ if (purged === 0 && trimmed === 0)
92
+ return { purged: 0, trimmed: 0 };
65
93
  // VACUUM has to run outside any open transaction; sql.js auto-commits
66
94
  // each `db.run`, so this is safe to chain.
67
95
  db.run('VACUUM');
68
96
  atomicWriteFileSync(dbPath, db.export());
69
- return { purged };
97
+ return { purged, trimmed };
70
98
  }
71
99
  finally {
72
100
  db.close();
@@ -2,21 +2,14 @@
2
2
  * Headless Worker Executor
3
3
  * Enables workers to invoke Claude Code in headless mode with configurable sandbox profiles.
4
4
  *
5
- * ADR-020: Headless Worker Integration Architecture
5
+ * ADR-020: Headless Worker Integration Architecture (#970 dropped the
6
+ * `audit`/`document`/`predict` workers — those entries from the original
7
+ * ADR are superseded; the rest still applies).
6
8
  * - Integrates with CLAUDE_CODE_HEADLESS and CLAUDE_CODE_SANDBOX_MODE environment variables
7
9
  * - Provides process pool for concurrent execution
8
10
  * - Builds context from file glob patterns
9
11
  * - Supports prompt templates and output parsing
10
12
  * - Implements timeout and graceful error handling
11
- *
12
- * Key Features:
13
- * - Process pool with configurable maxConcurrent
14
- * - Context building from file glob patterns with caching
15
- * - Prompt template system with context injection
16
- * - Output parsing (text, json, markdown)
17
- * - Timeout handling with graceful termination
18
- * - Execution logging for debugging
19
- * - Event emission for monitoring
20
13
  */
21
14
  import { spawn, execSync } from 'child_process';
22
15
  import { EventEmitter } from 'events';
@@ -27,17 +20,14 @@ import { errorDetail } from '../shared/utils/error-detail.js';
27
20
  // Constants
28
21
  // ============================================
29
22
  /**
30
- * Array of headless worker types for runtime checking
23
+ * Array of headless worker types for runtime checking.
31
24
  */
32
25
  export const HEADLESS_WORKER_TYPES = [
33
- 'audit',
34
26
  'optimize',
35
27
  'testgaps',
36
- 'document',
37
28
  'ultralearn',
38
29
  'refactor',
39
30
  'deepdive',
40
- 'predict',
41
31
  ];
42
32
  /**
43
33
  * Array of local worker types
@@ -57,37 +47,11 @@ const MODEL_IDS = {
57
47
  haiku: 'claude-haiku-4-5-20251001',
58
48
  };
59
49
  /**
60
- * Default headless worker configurations based on ADR-020
50
+ * Default headless worker configurations based on ADR-020 (the
51
+ * `audit`/`document`/`predict` entries from the original ADR were dropped
52
+ * in #970 — see worker-daemon.ts header for rationale).
61
53
  */
62
54
  export const HEADLESS_WORKER_CONFIGS = {
63
- audit: {
64
- type: 'audit',
65
- mode: 'headless',
66
- intervalMs: 30 * 60 * 1000,
67
- priority: 'critical',
68
- description: 'AI-powered security analysis',
69
- enabled: true,
70
- headless: {
71
- promptTemplate: `Analyze this codebase for security vulnerabilities:
72
- - Check for hardcoded secrets (API keys, passwords)
73
- - Identify SQL injection risks
74
- - Find XSS vulnerabilities
75
- - Check for insecure dependencies
76
- - Identify authentication/authorization issues
77
-
78
- Provide a JSON report with:
79
- {
80
- "vulnerabilities": [{ "severity": "high|medium|low", "file": "...", "line": N, "description": "..." }],
81
- "riskScore": 0-100,
82
- "recommendations": ["..."]
83
- }`,
84
- sandbox: 'strict',
85
- model: 'haiku',
86
- outputFormat: 'json',
87
- contextPatterns: ['**/*.ts', '**/*.js', '**/.env*', '**/package.json'],
88
- timeoutMs: 5 * 60 * 1000,
89
- },
90
- },
91
55
  optimize: {
92
56
  type: 'optimize',
93
57
  mode: 'headless',
@@ -134,29 +98,6 @@ For each gap, provide a test skeleton.`,
134
98
  timeoutMs: 10 * 60 * 1000,
135
99
  },
136
100
  },
137
- document: {
138
- type: 'document',
139
- mode: 'headless',
140
- intervalMs: 120 * 60 * 1000,
141
- priority: 'low',
142
- description: 'AI documentation generation',
143
- enabled: false,
144
- headless: {
145
- promptTemplate: `Generate documentation for undocumented code:
146
- - Add JSDoc comments to functions
147
- - Create README sections for modules
148
- - Document API endpoints
149
- - Add inline comments for complex logic
150
- - Generate usage examples
151
-
152
- Focus on public APIs and exported functions.`,
153
- sandbox: 'permissive',
154
- model: 'haiku',
155
- outputFormat: 'markdown',
156
- contextPatterns: ['src/**/*.ts'],
157
- timeoutMs: 10 * 60 * 1000,
158
- },
159
- },
160
101
  ultralearn: {
161
102
  type: 'ultralearn',
162
103
  mode: 'headless',
@@ -232,34 +173,6 @@ Provide comprehensive report.`,
232
173
  timeoutMs: 15 * 60 * 1000,
233
174
  },
234
175
  },
235
- predict: {
236
- type: 'predict',
237
- mode: 'headless',
238
- intervalMs: 10 * 60 * 1000,
239
- priority: 'low',
240
- description: 'Predictive preloading',
241
- enabled: false,
242
- headless: {
243
- promptTemplate: `Based on recent activity, predict what the developer needs:
244
- - Files likely to be edited next
245
- - Tests that should be run
246
- - Documentation to reference
247
- - Dependencies to check
248
-
249
- Provide preload suggestions as JSON:
250
- {
251
- "filesToPreload": ["..."],
252
- "testsToRun": ["..."],
253
- "docsToReference": ["..."],
254
- "confidence": 0.0-1.0
255
- }`,
256
- sandbox: 'strict',
257
- model: 'haiku',
258
- outputFormat: 'json',
259
- contextPatterns: ['.moflo/metrics/*.json'],
260
- timeoutMs: 2 * 60 * 1000,
261
- },
262
- },
263
176
  };
264
177
  /**
265
178
  * Local worker configurations
@@ -1,13 +1,21 @@
1
1
  /**
2
2
  * Worker Daemon Service
3
- * Node.js-based background worker system that auto-runs like shell daemons
3
+ * Node.js-based background worker system that auto-runs like shell daemons.
4
4
  *
5
- * Workers:
6
- * - map: Codebase mapping (5 min interval)
7
- * - audit: Security analysis (10 min interval)
5
+ * Default workers:
6
+ * - map: Codebase mapping (15 min interval)
8
7
  * - optimize: Performance optimization (15 min interval)
9
8
  * - consolidate: Memory consolidation (30 min interval)
10
9
  * - testgaps: Test coverage analysis (20 min interval)
10
+ *
11
+ * Manual-trigger-only workers (disabled by default, no scheduled run):
12
+ * ultralearn, refactor, deepdive, benchmark, preload.
13
+ *
14
+ * The `audit`, `predict`, and `document` workers were removed in #970 —
15
+ * they were default-disabled with no surfacing layer for findings, and the
16
+ * dashboard rendered them as "disabled" rows that read as broken. If a
17
+ * security or doc scan returns it should land as an opt-in `flo doctor`
18
+ * one-shot with a real findings UI, not as a recurring background worker.
11
19
  */
12
20
  import { EventEmitter } from 'events';
13
21
  import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
@@ -21,18 +29,32 @@ import { attachSignalHandlers } from '../shared/resilience/signal-handlers.js';
21
29
  import { calculateDelay } from '../production/retry.js';
22
30
  import { CircuitBreaker } from '../production/circuit-breaker.js';
23
31
  import { errorDetail } from '../shared/utils/error-detail.js';
32
+ /**
33
+ * Runtime allow-list of known {@link WorkerType} values. Used by
34
+ * `initializeWorkerStates` to silently drop entries from a stale
35
+ * `daemon-state.json` after a worker is removed from the union (#970).
36
+ * Keep in sync with the `WorkerType` definition above.
37
+ */
38
+ const KNOWN_WORKER_TYPES = new Set([
39
+ 'ultralearn',
40
+ 'optimize',
41
+ 'consolidate',
42
+ 'map',
43
+ 'preload',
44
+ 'deepdive',
45
+ 'refactor',
46
+ 'benchmark',
47
+ 'testgaps',
48
+ ]);
49
+ function isKnownWorkerType(value) {
50
+ return typeof value === 'string' && KNOWN_WORKER_TYPES.has(value);
51
+ }
24
52
  // Default worker configurations with improved intervals (P0 fix: map 5min -> 15min)
25
53
  const DEFAULT_WORKERS = [
26
54
  { type: 'map', intervalMs: 15 * 60 * 1000, offsetMs: 0, priority: 'normal', description: 'Codebase mapping', enabled: true },
27
- // Default-disabled until the perf regression in #631 is remediated. The
28
- // worker averages 238 s/run on real installs, saturating cores back-to-back
29
- // when scheduled at the 10-minute interval. Re-enable here when #631 ships.
30
- { type: 'audit', intervalMs: 10 * 60 * 1000, offsetMs: 2 * 60 * 1000, priority: 'critical', description: 'Security analysis', enabled: false },
31
55
  { type: 'optimize', intervalMs: 15 * 60 * 1000, offsetMs: 4 * 60 * 1000, priority: 'high', description: 'Performance optimization', enabled: true },
32
56
  { type: 'consolidate', intervalMs: 30 * 60 * 1000, offsetMs: 6 * 60 * 1000, priority: 'low', description: 'Memory consolidation', enabled: true },
33
57
  { type: 'testgaps', intervalMs: 20 * 60 * 1000, offsetMs: 8 * 60 * 1000, priority: 'normal', description: 'Test coverage analysis', enabled: true },
34
- { type: 'predict', intervalMs: 10 * 60 * 1000, offsetMs: 0, priority: 'low', description: 'Predictive preloading', enabled: false },
35
- { type: 'document', intervalMs: 60 * 60 * 1000, offsetMs: 0, priority: 'low', description: 'Auto-documentation', enabled: false },
36
58
  ];
37
59
  // Worker timeout (5 minutes max per worker)
38
60
  const DEFAULT_WORKER_TIMEOUT_MS = 5 * 60 * 1000;
@@ -326,9 +348,16 @@ export class WorkerDaemon extends EventEmitter {
326
348
  if (typeof saved.config?.workerTimeoutMs === 'number' && saved.config.workerTimeoutMs > 0) {
327
349
  this.config.workerTimeoutMs = saved.config.workerTimeoutMs;
328
350
  }
329
- // Restore worker runtime states (runCount, successCount, etc.)
351
+ // Restore worker runtime states (runCount, successCount, etc.).
352
+ // Unknown worker types (left over from a prior moflo version where
353
+ // `audit`/`predict`/`document` existed) are silently dropped — see
354
+ // KNOWN_WORKER_TYPES + #970 — so consumers upgrading don't crash on
355
+ // stale state, and the orphan entries don't get re-persisted on the
356
+ // next saveState.
330
357
  if (saved.workers) {
331
358
  for (const [type, state] of Object.entries(saved.workers)) {
359
+ if (!isKnownWorkerType(type))
360
+ continue;
332
361
  const savedState = state;
333
362
  const lastRunValue = savedState.lastRun;
334
363
  const restoredState = {
@@ -749,18 +778,12 @@ export class WorkerDaemon extends EventEmitter {
749
778
  switch (workerConfig.type) {
750
779
  case 'map':
751
780
  return this.runMapWorker();
752
- case 'audit':
753
- return this.runAuditWorkerLocal();
754
781
  case 'optimize':
755
782
  return this.runOptimizeWorkerLocal();
756
783
  case 'consolidate':
757
784
  return this.runConsolidateWorker();
758
785
  case 'testgaps':
759
786
  return this.runTestGapsWorkerLocal();
760
- case 'predict':
761
- return this.runPredictWorkerLocal();
762
- case 'document':
763
- return this.runDocumentWorkerLocal();
764
787
  case 'ultralearn':
765
788
  return this.runUltralearnWorkerLocal();
766
789
  case 'refactor':
@@ -797,31 +820,6 @@ export class WorkerDaemon extends EventEmitter {
797
820
  writeFileSync(metricsFile, JSON.stringify(map, null, 2));
798
821
  return map;
799
822
  }
800
- /**
801
- * Local audit worker (fallback when headless unavailable)
802
- */
803
- async runAuditWorkerLocal() {
804
- // Basic security checks
805
- const auditFile = join(this.projectRoot, '.moflo', 'metrics', 'security-audit.json');
806
- const metricsDir = join(this.projectRoot, '.moflo', 'metrics');
807
- if (!existsSync(metricsDir)) {
808
- mkdirSync(metricsDir, { recursive: true });
809
- }
810
- const audit = {
811
- timestamp: new Date().toISOString(),
812
- mode: 'local',
813
- checks: {
814
- envFilesProtected: !existsSync(join(this.projectRoot, '.env.local')),
815
- gitIgnoreExists: existsSync(join(this.projectRoot, '.gitignore')),
816
- noHardcodedSecrets: true, // Would need actual scanning
817
- },
818
- riskLevel: 'low',
819
- recommendations: [],
820
- note: 'Install Claude Code CLI for AI-powered security analysis',
821
- };
822
- writeFileSync(auditFile, JSON.stringify(audit, null, 2));
823
- return audit;
824
- }
825
823
  /**
826
824
  * Local optimize worker (fallback when headless unavailable)
827
825
  */
@@ -883,30 +881,6 @@ export class WorkerDaemon extends EventEmitter {
883
881
  writeFileSync(testGapsFile, JSON.stringify(result, null, 2));
884
882
  return result;
885
883
  }
886
- /**
887
- * Local predict worker (fallback when headless unavailable)
888
- */
889
- async runPredictWorkerLocal() {
890
- return {
891
- timestamp: new Date().toISOString(),
892
- mode: 'local',
893
- predictions: [],
894
- preloaded: [],
895
- note: 'Install Claude Code CLI for AI-powered predictions',
896
- };
897
- }
898
- /**
899
- * Local document worker (fallback when headless unavailable)
900
- */
901
- async runDocumentWorkerLocal() {
902
- return {
903
- timestamp: new Date().toISOString(),
904
- mode: 'local',
905
- filesDocumented: 0,
906
- suggestedDocs: [],
907
- note: 'Install Claude Code CLI for AI-powered documentation generation',
908
- };
909
- }
910
884
  /**
911
885
  * Local ultralearn worker (fallback when headless unavailable)
912
886
  */
@@ -150,6 +150,18 @@ export class SpellCaster {
150
150
  message: err.message,
151
151
  }], definition.name);
152
152
  }
153
+ // Per-spell sandbox requirement (#878) — "more strict wins": the spell
154
+ // can opt in to sandboxing even when the global config is off, and the
155
+ // runner refuses to cast if no OS sandbox is active.
156
+ if (definition.sandbox?.required === true && !effectiveSandbox.useOsSandbox) {
157
+ return this.failureResult(spellId, startTime, [{
158
+ code: 'SANDBOX_REQUIRED',
159
+ message: `Spell "${definition.name}" requires an OS sandbox but none is active ` +
160
+ `(${effectiveSandbox.displayStatus}). Enable sandboxing by setting ` +
161
+ `\`sandbox.enabled: true\` in moflo.yaml (and \`sandbox.tier: auto\` ` +
162
+ `or \`full\`), or remove \`sandbox.required: true\` from the spell.`,
163
+ }], definition.name);
164
+ }
153
165
  return this.executeSteps(definition, resolvedArgs, spellId, options, startTime, effectiveSandbox);
154
166
  }
155
167
  async dryRun(definition, resolvedArgs, options = {}) {
@@ -369,15 +369,30 @@ export class SpellScheduler {
369
369
  duration: completedAt - now,
370
370
  };
371
371
  await this.memory.write(NAMESPACE_EXECUTIONS, executionId, finalRecord);
372
- this.emit({
373
- type: result.success ? 'schedule:completed' : 'schedule:failed',
374
- scheduleId: schedule.id,
375
- spellName: schedule.spellName,
376
- message: result.success
377
- ? `Completed in ${finalRecord.duration}ms`
378
- : `Failed: ${finalRecord.error}`,
379
- timestamp: completedAt,
380
- });
372
+ // SANDBOX_REQUIRED (#878) is a configuration mismatch, not a runtime
373
+ // failure surface it as a skip so dashboards/oncall don't page on a
374
+ // missing sandbox the user can resolve in moflo.yaml.
375
+ const sandboxRequiredErr = result.errors.find(e => e.code === 'SANDBOX_REQUIRED');
376
+ if (!result.success && sandboxRequiredErr) {
377
+ this.emit({
378
+ type: 'schedule:skipped',
379
+ scheduleId: schedule.id,
380
+ spellName: schedule.spellName,
381
+ message: sandboxRequiredErr.message,
382
+ timestamp: completedAt,
383
+ });
384
+ }
385
+ else {
386
+ this.emit({
387
+ type: result.success ? 'schedule:completed' : 'schedule:failed',
388
+ scheduleId: schedule.id,
389
+ spellName: schedule.spellName,
390
+ message: result.success
391
+ ? `Completed in ${finalRecord.duration}ms`
392
+ : `Failed: ${finalRecord.error}`,
393
+ timestamp: completedAt,
394
+ });
395
+ }
381
396
  }
382
397
  catch (err) {
383
398
  const completedAt = Date.now();
@@ -16,7 +16,7 @@
16
16
  import { isValidMofloLevel } from '../core/capability-validator.js';
17
17
  import { MOFLO_LEVEL_ORDER } from '../types/step-command.types.js';
18
18
  import { validateSchedule } from '../scheduler/cron-parser.js';
19
- import { validateTopLevel, validateArguments, matchesArgumentType } from './validators/top-level.js';
19
+ import { validateTopLevel, validateArguments, matchesArgumentType, validateSandbox } from './validators/top-level.js';
20
20
  import { validateSteps } from './validators/steps.js';
21
21
  import { validatePrerequisites } from './validators/prerequisites.js';
22
22
  import { validateVariableReferences } from './validators/references.js';
@@ -27,6 +27,7 @@ import { detectCircularJumps } from './validators/jumps.js';
27
27
  export function validateSpellDefinition(def, options) {
28
28
  const errors = [];
29
29
  validateTopLevel(def, errors);
30
+ validateSandbox(def, errors);
30
31
  if (def.mofloLevel !== undefined && !isValidMofloLevel(def.mofloLevel)) {
31
32
  errors.push({
32
33
  path: 'mofloLevel',
@@ -58,6 +58,24 @@ export function validateArguments(args, errors) {
58
58
  }
59
59
  }
60
60
  }
61
+ /**
62
+ * Validate the optional `sandbox` block on a spell definition.
63
+ * Accepts: missing, `{}`, `{ required: boolean }`.
64
+ * Rejects: non-object value, non-boolean `required`.
65
+ */
66
+ export function validateSandbox(def, errors) {
67
+ const sandbox = def.sandbox;
68
+ if (sandbox === undefined)
69
+ return;
70
+ if (sandbox === null || typeof sandbox !== 'object' || Array.isArray(sandbox)) {
71
+ errors.push({ path: 'sandbox', message: 'sandbox must be an object' });
72
+ return;
73
+ }
74
+ const { required } = sandbox;
75
+ if (required !== undefined && typeof required !== 'boolean') {
76
+ errors.push({ path: 'sandbox.required', message: 'sandbox.required must be a boolean' });
77
+ }
78
+ }
61
79
  /** Check whether a value matches a declared ArgumentType. */
62
80
  export function matchesArgumentType(value, type) {
63
81
  switch (type) {
@@ -2,5 +2,5 @@
2
2
  * Auto-generated by build. Do not edit manually.
3
3
  * Source of truth: root package.json → scripts/sync-version.mjs
4
4
  */
5
- export const VERSION = '4.9.22';
5
+ export const VERSION = '4.9.24';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.9.22",
3
+ "version": "4.9.24",
4
4
  "description": "MoFlo — AI agent orchestration for Claude Code. A standalone, opinionated toolkit with semantic memory, learned routing, gates, spells, and the /flo issue-execution skill.",
5
5
  "main": "dist/src/cli/index.js",
6
6
  "type": "module",
@@ -29,6 +29,7 @@
29
29
  "src/cli/spells/definitions/**/*.yaml",
30
30
  "!**/*.test.js",
31
31
  "!**/*.spec.js",
32
+ "!**/*.perf.js",
32
33
  "!**/__tests__/**",
33
34
  ".claude/commands/**/*.md",
34
35
  ".claude/agents/**/*.md",
@@ -54,6 +55,7 @@
54
55
  "test:ui": "vitest --ui",
55
56
  "test:smoke": "node harness/consumer-smoke/run.mjs",
56
57
  "test:smoke:populated": "node harness/consumer-smoke/run-populated.mjs",
58
+ "bench": "vitest run --config vitest.bench.config.ts",
57
59
  "lint": "eslint src/ bin/ .claude/scripts/ --ext .ts,.tsx,.mts,.cts,.js,.mjs,.cjs --max-warnings 0",
58
60
  "security:audit": "npm audit --omit=dev --audit-level high",
59
61
  "security:fix": "npm audit fix",
@@ -82,7 +84,7 @@
82
84
  "@typescript-eslint/eslint-plugin": "^7.18.0",
83
85
  "@typescript-eslint/parser": "^7.18.0",
84
86
  "eslint": "^8.0.0",
85
- "moflo": "^4.9.21",
87
+ "moflo": "^4.9.23",
86
88
  "tsx": "^4.21.0",
87
89
  "typescript": "^5.9.3",
88
90
  "vitest": "^4.0.0"