moflo 4.9.18 → 4.9.19

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.
@@ -34,20 +34,15 @@ Pick the smallest tier the diff genuinely fits.
34
34
 
35
35
  Critical-surface files (launcher, hooks, MCP wiring) raise the *care* of the agent prompt — sharper checklist, blast-radius framing — they do **not** automatically escalate to NORMAL. Risk-weighted ≠ headcount-weighted.
36
36
 
37
- ## Phase 3: Route the model (skip for TRIVIAL)
37
+ ## Phase 3: Use the classifier's model (skip for TRIVIAL)
38
38
 
39
- Before spawning any Agent, ask the moflo router which model to use:
39
+ The classifier returns the right model for the tier no separate router call needed:
40
40
 
41
- ```
42
- mcp__moflo__hooks_model-route{
43
- task: "Review N-line change in <files> for reuse, quality, efficiency",
44
- preferCost: true
45
- }
46
- ```
41
+ - `sonnet` (default) — real logic edits, single agent or 3-agent fan-out.
42
+ - `haiku` mostly-relocation diffs (mechanical moves; pattern-matching beats deep reasoning).
43
+ - `opus` never returned. Code review is breadth-bound, not depth-bound; sonnet 3-way IS the high-effort tier.
47
44
 
48
- **Wording rules:** the router's complexity score is keyword-sensitive. Avoid `refactor`, `architect`, `audit`, `system`, `redesign`, `migrate` those force opus even when scoring suggests sonnet. State LOC count, file count, and "review for reuse, quality, efficiency". Nothing more.
49
-
50
- **Hard rule for `/simplify`: opus is never correct.** Code review never needs Opus reasoning, even on critical surface. If the router returns `opus`, downgrade to `sonnet`. On router failure, default to `sonnet`. Comment trims and pure formatting → `haiku`.
45
+ Pass the classifier's `model` field verbatim to Agent's `model` parameter. If you fell back to prose rules in Phase 2 (no classifier), default to `sonnet`.
51
46
 
52
47
  ## Phase 4: Validation pass (re-run after fixes from a prior simplify)
53
48
 
@@ -57,8 +52,11 @@ Escalate one tier (self-review → SMALL agent) only if the fix introduced a new
57
52
 
58
53
  ## Phase 5: Run the appropriate review
59
54
 
60
- ### TRIVIAL / Validation
61
- Run the three category checks (reuse / quality / efficiency) yourself in one pass against the diff. Most TRIVIAL diffs are clean confirm and exit. Budget: ~30 seconds, no Agent.
55
+ ### TRIVIAL
56
+ Print one confirmation line (`simplify: TRIVIAL N LOC, 1 file — stamped`) and exit. **Do not** walk the three-category check; the classifier already concluded the diff is below the review-value threshold. Budget: <5 seconds, no Agent.
57
+
58
+ ### Validation pass
59
+ Run the three category checks against the post-fix diff in one pass. Most are clean — confirm and exit. Budget: ~30 seconds, no Agent.
62
60
 
63
61
  ### SMALL — one agent
64
62
  ```
@@ -7,7 +7,7 @@ var cp = require('child_process');
7
7
  var PROJECT_DIR = (process.env.CLAUDE_PROJECT_DIR || process.cwd()).replace(/^\/([a-z])\//i, '$1:/');
8
8
  var STATE_FILE = path.join(PROJECT_DIR, '.claude', 'workflow-state.json');
9
9
 
10
- var STATE_DEFAULTS = { tasksCreated: false, taskCount: 0, memorySearched: false, memorySearchedBy: {}, memoryRequired: true, learningsStored: false, testsRun: false, simplifyRun: false, interactionCount: 0, sessionStart: null, lastBlockedAt: null };
10
+ var STATE_DEFAULTS = { tasksCreated: false, taskCount: 0, memorySearched: false, memorySearchedBy: {}, memoryRequired: true, learningsStored: false, testsRun: false, simplifyRun: false, simplifySnapshotSha: null, interactionCount: 0, sessionStart: null, lastBlockedAt: null };
11
11
 
12
12
  // Per-actor memory-search tracking (#838). The legacy `memorySearched` boolean
13
13
  // is session-wide, so once the parent searches memory, every spawned subagent
@@ -100,6 +100,79 @@ var EDIT_RESET_SKIP_SIMPLIFY_ONLY_RE = /(?:^|[\\\/])(__tests__|__mocks__|tests?|
100
100
  // on purpose — those are inert for edit-reset (above) but not "documentation".
101
101
  var DOCS_ONLY_RE = /\.(md|markdown|txt|rst|adoc|html?|pdf|png|jpe?g|gif|svg|webp|ico|bmp)$/i;
102
102
 
103
+ // Classifier-aware simplify gate skip. Returns a string reason if the gate
104
+ // can be auto-passed, or null if /simplify must run. Uses simplify-classify.cjs
105
+ // so the gate's "trivial" definition matches the skill's exactly.
106
+ //
107
+ // Two paths:
108
+ // 1. snapshot path — /simplify ran earlier on this branch. Classify the diff
109
+ // between simplifySnapshotSha and current HEAD/working-tree. If TRIVIAL,
110
+ // the prior review still covers the branch — no re-run needed.
111
+ // 2. baseline path — no snapshot (first time). Classify the entire branch
112
+ // diff vs merge-base. If TRIVIAL, the whole PR is below the threshold
113
+ // where /simplify provides value — auto-pass without ever invoking it.
114
+ //
115
+ // Fail-safe: any error (no classifier, no git, no merge-base) returns null,
116
+ // which forces /simplify to run as today.
117
+ function classifyForGateSkip(state) {
118
+ var classify;
119
+ try {
120
+ classify = require('./simplify-classify.cjs').classifyDiff;
121
+ } catch (e) { return null; }
122
+ if (typeof classify !== 'function') return null;
123
+
124
+ function tryClassify(diffText, label) {
125
+ try {
126
+ var dec = classify(diffText);
127
+ if (dec.tier === 'TRIVIAL') {
128
+ var loc = (dec.stats.added || 0) + (dec.stats.deleted || 0);
129
+ return label + ' is TRIVIAL (' + loc + ' LOC, ' + (dec.stats.fileCount || 0) + ' file(s))';
130
+ }
131
+ } catch (e) { /* fall through */ }
132
+ return null;
133
+ }
134
+
135
+ function gitDiff(args) {
136
+ try {
137
+ return cp.execFileSync('git', args, {
138
+ cwd: PROJECT_DIR, encoding: 'utf-8', timeout: 5000, windowsHide: true,
139
+ stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 8 * 1024 * 1024
140
+ });
141
+ } catch (e) { return null; }
142
+ }
143
+
144
+ // Snapshot path: classify everything since /simplify last ran.
145
+ if (state.simplifySnapshotSha) {
146
+ var snapDiff = gitDiff(['diff', state.simplifySnapshotSha + '...HEAD']);
147
+ var workTreeA = gitDiff(['diff', 'HEAD']) || '';
148
+ if (snapDiff !== null) {
149
+ var combined = snapDiff + (workTreeA ? '\n' + workTreeA : '');
150
+ var hit = tryClassify(combined, 'delta since last /simplify');
151
+ if (hit) return hit;
152
+ }
153
+ }
154
+
155
+ // Baseline path: classify the whole branch vs merge-base.
156
+ var bases = ['origin/main', 'main', 'origin/master', 'master'];
157
+ for (var i = 0; i < bases.length; i++) {
158
+ var base;
159
+ try {
160
+ base = cp.execFileSync('git', ['merge-base', 'HEAD', bases[i]], {
161
+ cwd: PROJECT_DIR, encoding: 'utf-8', timeout: 2000, windowsHide: true,
162
+ stdio: ['ignore', 'pipe', 'ignore']
163
+ }).trim();
164
+ } catch (e) { continue; }
165
+ if (!base) continue;
166
+ var branchDiff = gitDiff(['diff', base + '...HEAD']);
167
+ var workTreeB = gitDiff(['diff', 'HEAD']) || '';
168
+ if (branchDiff !== null) {
169
+ return tryClassify(branchDiff + (workTreeB ? '\n' + workTreeB : ''), 'branch diff');
170
+ }
171
+ break;
172
+ }
173
+ return null;
174
+ }
175
+
103
176
  // Get the file list changed on the current branch vs the merge-base with origin/main
104
177
  // (falling back to local main). Returns an array of repo-relative paths, or null on
105
178
  // failure — in which case callers MUST fall through to the standard gate (fail-safe).
@@ -206,10 +279,19 @@ switch (command) {
206
279
  case 'record-skill-run': {
207
280
  if ((process.env.TOOL_INPUT_skill || '') === 'simplify') {
208
281
  var s = readState();
209
- if (!s.simplifyRun) {
210
- s.simplifyRun = true;
211
- writeState(s);
212
- }
282
+ var changed = false;
283
+ if (!s.simplifyRun) { s.simplifyRun = true; changed = true; }
284
+ // Snapshot HEAD so check-before-pr can classify delta-since-simplify and
285
+ // skip a redundant /simplify re-run when only trivial fixes followed.
286
+ // Non-fatal — gate falls through to current behaviour without the snapshot.
287
+ try {
288
+ var sha = cp.execFileSync('git', ['rev-parse', 'HEAD'], {
289
+ cwd: PROJECT_DIR, encoding: 'utf-8', timeout: 2000, windowsHide: true,
290
+ stdio: ['ignore', 'pipe', 'ignore']
291
+ }).trim();
292
+ if (sha && s.simplifySnapshotSha !== sha) { s.simplifySnapshotSha = sha; changed = true; }
293
+ } catch (e) { /* no git or detached state — skip snapshot, gate still works */ }
294
+ if (changed) writeState(s);
213
295
  }
214
296
  break;
215
297
  }
@@ -249,6 +331,19 @@ switch (command) {
249
331
  break;
250
332
  }
251
333
  var s = readState();
334
+ // Classifier-aware skip: if delta-since-snapshot or whole-branch diff is
335
+ // TRIVIAL, satisfy the simplify gate silently. Reuses the same classifier
336
+ // the skill uses — same "trivial" definition, no drift. Same threshold that
337
+ // already maps to TRIVIAL=0 agents inside /simplify, so trusting it at the
338
+ // gate level is the same trust profile, just one decision earlier.
339
+ if (config.simplify_gate && !s.simplifyRun) {
340
+ var skipReason = classifyForGateSkip(s);
341
+ if (skipReason) {
342
+ s.simplifyRun = true;
343
+ writeState(s);
344
+ process.stdout.write('Simplify gate auto-passed: ' + skipReason + '\n');
345
+ }
346
+ }
252
347
  var missing = [];
253
348
  if (config.testing_gate && !s.testsRun) missing.push('tests have not run since the last code edit (run npm test, vitest, jest, pytest, or similar)');
254
349
  if (config.simplify_gate && !s.simplifyRun) missing.push('/simplify has not run since the last code edit');
@@ -159,7 +159,10 @@ function decide(stats) {
159
159
  reasoning.push(
160
160
  `mostly relocation: ${stats.declAdded} decls added, ${stats.declRemoved} removed, net ${stats.netDecls >= 0 ? '+' : ''}${stats.netDecls}`,
161
161
  );
162
- return { tier: 'SMALL', model: 'sonnet', agentCount: 1, reasoning, stats };
162
+ // Haiku is sufficient for mechanical moves: code already existed and worked,
163
+ // so review reduces to copy-paste-divergence / dead-after-move pattern checks
164
+ // — exactly haiku's strength. ~5x cheaper than sonnet on relocation-shape diffs.
165
+ return { tier: 'SMALL', model: 'haiku', agentCount: 1, reasoning, stats };
163
166
  }
164
167
 
165
168
  // Escalation triggers — any one trips NORMAL (3 agents).
@@ -42,13 +42,8 @@ Tier definitions the classifier encodes (for reference, not for re-derivation):
42
42
 
43
43
  Pick the **smallest tier** the diff genuinely fits. When in doubt, escalate one step (not two).
44
44
 
45
- ### TRIVIAL — self-review, no agent spawn
46
- ALL of these must hold:
47
- - ≤10 net LOC changed (insertions + deletions, excluding pure whitespace)
48
- - Single file
49
- - No logic changes — only comments, formatting, renames of local vars, JSDoc, or string-literal edits
50
- - No new imports, no new exports, no new function/class declarations
51
- - No removed safety checks, error handlers, or guards
45
+ ### TRIVIAL — gate stamp, no review
46
+ The classifier already proved the diff is below the threshold where review provides value (≤10 net LOC, single file, no declaration/import/export changes, no removed guards). **Stamp the gate with one line of confirmation and exit immediately.** Do not walk the three-category check yourself — the classifier IS the review at this tier, and at the gate level the classifier-aware skip (`bin/gate.cjs:classifyForGateSkip`) often satisfies the gate without invoking this skill at all.
52
47
 
53
48
  Examples that qualify: trimming a comment, fixing a typo in a log message, renaming a private helper, reformatting a single block.
54
49
  Examples that DON'T qualify: changing an `if` condition, reordering function args, deleting a try/catch.
@@ -88,14 +83,21 @@ Do **not** escalate to NORMAL on a validation pass. If the fix is so structural
88
83
 
89
84
  ## Phase 2.7: Model selection
90
85
 
91
- **Use the model the classifier returned** always `sonnet` for `/simplify`. Opus is never correct here; the classifier enforces this. No router call needed; the classifier IS the router for this skill.
86
+ **Use the model the classifier returned.** The classifier IS the router for this skill no separate router call needed. Possible outputs:
87
+
88
+ - `sonnet` (default) — real logic changes, single agent or three-agent fan-out.
89
+ - `haiku` — mostly-relocation diffs (mechanical moves where pattern-matching beats deep reasoning, ~5x cheaper).
90
+ - `opus` — never. Code review is breadth-bound, not depth-bound; the three-agent fan-out at sonnet is the high-effort tier.
92
91
 
93
- If you fell back to prose rules in Phase 2 (no classifier available), use `sonnet` unconditionally. Pass the model verbatim to Agent's `model` parameter.
92
+ If you fell back to prose rules in Phase 2 (no classifier available), use `sonnet` unconditionally. Pass the classifier's `model` field verbatim to Agent's `model` parameter.
94
93
 
95
94
  ## Phase 3: Run the appropriate review
96
95
 
97
- ### TRIVIAL / Validation: self-review
98
- Run the same three category checks (reuse / quality / efficiency) yourself, in one pass, against the diff. Most TRIVIAL and validation diffs will be clean the goal is to confirm, not to fan out. If you find an issue, fix it; otherwise stamp clean. Total budget: ~30 seconds, no Agent calls. No router call needed.
96
+ ### TRIVIAL: stamp and exit
97
+ The classifier proved the diff is below the review-value threshold. Print one confirmation line (e.g. `simplify: TRIVIAL N LOC, 1 filestamped`) and exit. **Do not** walk the three-category check; the classifier already concluded the answer is "clean." Budget: <5 seconds, no Agent calls.
98
+
99
+ ### Validation pass: confirm clean
100
+ Run the three category checks (reuse / quality / efficiency) yourself, in one pass, against the post-fix diff. Most validation passes are clean — the goal is to confirm fixes didn't introduce new concerns, not to fan out. Budget: ~30 seconds, no Agent calls.
99
101
 
100
102
  ### SMALL: one agent (model from router)
101
103
  Launch a SINGLE Agent with subagent_type `reviewer`, passing the model returned by Phase 2.7's router call. Cap the agent's tool budget by being explicit:
package/bin/gate.cjs CHANGED
@@ -7,7 +7,7 @@ var cp = require('child_process');
7
7
  var PROJECT_DIR = (process.env.CLAUDE_PROJECT_DIR || process.cwd()).replace(/^\/([a-z])\//i, '$1:/');
8
8
  var STATE_FILE = path.join(PROJECT_DIR, '.claude', 'workflow-state.json');
9
9
 
10
- var STATE_DEFAULTS = { tasksCreated: false, taskCount: 0, memorySearched: false, memorySearchedBy: {}, memoryRequired: true, learningsStored: false, testsRun: false, simplifyRun: false, interactionCount: 0, sessionStart: null, lastBlockedAt: null };
10
+ var STATE_DEFAULTS = { tasksCreated: false, taskCount: 0, memorySearched: false, memorySearchedBy: {}, memoryRequired: true, learningsStored: false, testsRun: false, simplifyRun: false, simplifySnapshotSha: null, interactionCount: 0, sessionStart: null, lastBlockedAt: null };
11
11
 
12
12
  // Per-actor memory-search tracking (#838). The legacy `memorySearched` boolean
13
13
  // is session-wide, so once the parent searches memory, every spawned subagent
@@ -100,6 +100,79 @@ var EDIT_RESET_SKIP_SIMPLIFY_ONLY_RE = /(?:^|[\\\/])(__tests__|__mocks__|tests?|
100
100
  // on purpose — those are inert for edit-reset (above) but not "documentation".
101
101
  var DOCS_ONLY_RE = /\.(md|markdown|txt|rst|adoc|html?|pdf|png|jpe?g|gif|svg|webp|ico|bmp)$/i;
102
102
 
103
+ // Classifier-aware simplify gate skip. Returns a string reason if the gate
104
+ // can be auto-passed, or null if /simplify must run. Uses simplify-classify.cjs
105
+ // so the gate's "trivial" definition matches the skill's exactly.
106
+ //
107
+ // Two paths:
108
+ // 1. snapshot path — /simplify ran earlier on this branch. Classify the diff
109
+ // between simplifySnapshotSha and current HEAD/working-tree. If TRIVIAL,
110
+ // the prior review still covers the branch — no re-run needed.
111
+ // 2. baseline path — no snapshot (first time). Classify the entire branch
112
+ // diff vs merge-base. If TRIVIAL, the whole PR is below the threshold
113
+ // where /simplify provides value — auto-pass without ever invoking it.
114
+ //
115
+ // Fail-safe: any error (no classifier, no git, no merge-base) returns null,
116
+ // which forces /simplify to run as today.
117
+ function classifyForGateSkip(state) {
118
+ var classify;
119
+ try {
120
+ classify = require('./simplify-classify.cjs').classifyDiff;
121
+ } catch (e) { return null; }
122
+ if (typeof classify !== 'function') return null;
123
+
124
+ function tryClassify(diffText, label) {
125
+ try {
126
+ var dec = classify(diffText);
127
+ if (dec.tier === 'TRIVIAL') {
128
+ var loc = (dec.stats.added || 0) + (dec.stats.deleted || 0);
129
+ return label + ' is TRIVIAL (' + loc + ' LOC, ' + (dec.stats.fileCount || 0) + ' file(s))';
130
+ }
131
+ } catch (e) { /* fall through */ }
132
+ return null;
133
+ }
134
+
135
+ function gitDiff(args) {
136
+ try {
137
+ return cp.execFileSync('git', args, {
138
+ cwd: PROJECT_DIR, encoding: 'utf-8', timeout: 5000, windowsHide: true,
139
+ stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 8 * 1024 * 1024
140
+ });
141
+ } catch (e) { return null; }
142
+ }
143
+
144
+ // Snapshot path: classify everything since /simplify last ran.
145
+ if (state.simplifySnapshotSha) {
146
+ var snapDiff = gitDiff(['diff', state.simplifySnapshotSha + '...HEAD']);
147
+ var workTreeA = gitDiff(['diff', 'HEAD']) || '';
148
+ if (snapDiff !== null) {
149
+ var combined = snapDiff + (workTreeA ? '\n' + workTreeA : '');
150
+ var hit = tryClassify(combined, 'delta since last /simplify');
151
+ if (hit) return hit;
152
+ }
153
+ }
154
+
155
+ // Baseline path: classify the whole branch vs merge-base.
156
+ var bases = ['origin/main', 'main', 'origin/master', 'master'];
157
+ for (var i = 0; i < bases.length; i++) {
158
+ var base;
159
+ try {
160
+ base = cp.execFileSync('git', ['merge-base', 'HEAD', bases[i]], {
161
+ cwd: PROJECT_DIR, encoding: 'utf-8', timeout: 2000, windowsHide: true,
162
+ stdio: ['ignore', 'pipe', 'ignore']
163
+ }).trim();
164
+ } catch (e) { continue; }
165
+ if (!base) continue;
166
+ var branchDiff = gitDiff(['diff', base + '...HEAD']);
167
+ var workTreeB = gitDiff(['diff', 'HEAD']) || '';
168
+ if (branchDiff !== null) {
169
+ return tryClassify(branchDiff + (workTreeB ? '\n' + workTreeB : ''), 'branch diff');
170
+ }
171
+ break;
172
+ }
173
+ return null;
174
+ }
175
+
103
176
  // Get the file list changed on the current branch vs the merge-base with origin/main
104
177
  // (falling back to local main). Returns an array of repo-relative paths, or null on
105
178
  // failure — in which case callers MUST fall through to the standard gate (fail-safe).
@@ -206,10 +279,19 @@ switch (command) {
206
279
  case 'record-skill-run': {
207
280
  if ((process.env.TOOL_INPUT_skill || '') === 'simplify') {
208
281
  var s = readState();
209
- if (!s.simplifyRun) {
210
- s.simplifyRun = true;
211
- writeState(s);
212
- }
282
+ var changed = false;
283
+ if (!s.simplifyRun) { s.simplifyRun = true; changed = true; }
284
+ // Snapshot HEAD so check-before-pr can classify delta-since-simplify and
285
+ // skip a redundant /simplify re-run when only trivial fixes followed.
286
+ // Non-fatal — gate falls through to current behaviour without the snapshot.
287
+ try {
288
+ var sha = cp.execFileSync('git', ['rev-parse', 'HEAD'], {
289
+ cwd: PROJECT_DIR, encoding: 'utf-8', timeout: 2000, windowsHide: true,
290
+ stdio: ['ignore', 'pipe', 'ignore']
291
+ }).trim();
292
+ if (sha && s.simplifySnapshotSha !== sha) { s.simplifySnapshotSha = sha; changed = true; }
293
+ } catch (e) { /* no git or detached state — skip snapshot, gate still works */ }
294
+ if (changed) writeState(s);
213
295
  }
214
296
  break;
215
297
  }
@@ -249,6 +331,19 @@ switch (command) {
249
331
  break;
250
332
  }
251
333
  var s = readState();
334
+ // Classifier-aware skip: if delta-since-snapshot or whole-branch diff is
335
+ // TRIVIAL, satisfy the simplify gate silently. Reuses the same classifier
336
+ // the skill uses — same "trivial" definition, no drift. Same threshold that
337
+ // already maps to TRIVIAL=0 agents inside /simplify, so trusting it at the
338
+ // gate level is the same trust profile, just one decision earlier.
339
+ if (config.simplify_gate && !s.simplifyRun) {
340
+ var skipReason = classifyForGateSkip(s);
341
+ if (skipReason) {
342
+ s.simplifyRun = true;
343
+ writeState(s);
344
+ process.stdout.write('Simplify gate auto-passed: ' + skipReason + '\n');
345
+ }
346
+ }
252
347
  var missing = [];
253
348
  if (config.testing_gate && !s.testsRun) missing.push('tests have not run since the last code edit (run npm test, vitest, jest, pytest, or similar)');
254
349
  if (config.simplify_gate && !s.simplifyRun) missing.push('/simplify has not run since the last code edit');
@@ -159,7 +159,10 @@ function decide(stats) {
159
159
  reasoning.push(
160
160
  `mostly relocation: ${stats.declAdded} decls added, ${stats.declRemoved} removed, net ${stats.netDecls >= 0 ? '+' : ''}${stats.netDecls}`,
161
161
  );
162
- return { tier: 'SMALL', model: 'sonnet', agentCount: 1, reasoning, stats };
162
+ // Haiku is sufficient for mechanical moves: code already existed and worked,
163
+ // so review reduces to copy-paste-divergence / dead-after-move pattern checks
164
+ // — exactly haiku's strength. ~5x cheaper than sonnet on relocation-shape diffs.
165
+ return { tier: 'SMALL', model: 'haiku', agentCount: 1, reasoning, stats };
163
166
  }
164
167
 
165
168
  // Escalation triggers — any one trips NORMAL (3 agents).
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Spell Credentials CLI
3
+ *
4
+ * `moflo spell credentials list/set/unset/clear/passphrase` — manage the
5
+ * encrypted credential store the spell runner uses for unattended casts.
6
+ *
7
+ * Story #925 (epic #880).
8
+ */
9
+ import { existsSync } from 'node:fs';
10
+ import { output } from '../output.js';
11
+ import { confirm, input } from '../prompt.js';
12
+ import { CredentialStore, CredentialStoreError, MIN_PASSPHRASE_LENGTH, resolveCredentialFilePath, resolvePassphrase, } from '../spells/credentials/index.js';
13
+ /**
14
+ * Build a CredentialStore using the runner's passphrase resolution.
15
+ * Returns null when no passphrase is available — caller surfaces the actionable error.
16
+ */
17
+ function openStore() {
18
+ const filePath = resolveCredentialFilePath();
19
+ const passphrase = resolvePassphrase();
20
+ if (!passphrase)
21
+ return null;
22
+ try {
23
+ return new CredentialStore({ filePath, passphrase });
24
+ }
25
+ catch (err) {
26
+ output.printError(`Could not open credential store: ${err.message}`);
27
+ return null;
28
+ }
29
+ }
30
+ function noStoreError() {
31
+ output.printError('Credential store is locked.');
32
+ output.writeln('');
33
+ output.writeln(' Set MOFLO_CREDENTIALS_PASSPHRASE in your environment, or run:');
34
+ output.writeln(' flo spell credentials passphrase');
35
+ output.writeln('');
36
+ output.writeln(' to initialise a per-machine encryption key.');
37
+ return { success: false, exitCode: 1 };
38
+ }
39
+ const listCommand = {
40
+ name: 'list',
41
+ aliases: ['ls'],
42
+ description: 'List stored credential names (never values)',
43
+ action: async (ctx) => {
44
+ const store = openStore();
45
+ if (!store)
46
+ return noStoreError();
47
+ const meta = await store.list();
48
+ if (ctx.flags.format === 'json') {
49
+ output.printJson(meta);
50
+ return { success: true, data: meta };
51
+ }
52
+ if (meta.length === 0) {
53
+ output.printInfo('No credentials stored. Use `flo spell credentials set <name>` to add one.');
54
+ return { success: true };
55
+ }
56
+ output.writeln();
57
+ output.writeln(output.bold('Stored credentials'));
58
+ output.writeln();
59
+ output.printTable({
60
+ columns: [
61
+ { key: 'name', header: 'Name', width: 28 },
62
+ { key: 'description', header: 'Description', width: 35, format: (v) => v ? String(v) : '-' },
63
+ { key: 'updatedAt', header: 'Updated', width: 22, format: (v) => v ? new Date(String(v)).toLocaleString() : '-' },
64
+ ],
65
+ data: meta,
66
+ });
67
+ output.writeln();
68
+ output.printInfo(`${meta.length} credential${meta.length === 1 ? '' : 's'}`);
69
+ return { success: true, data: meta };
70
+ },
71
+ };
72
+ const setCommand = {
73
+ name: 'set',
74
+ description: 'Store a credential (prompts for the value with hidden input)',
75
+ options: [
76
+ { name: 'description', short: 'd', description: 'Optional description', type: 'string' },
77
+ ],
78
+ examples: [
79
+ { command: 'moflo spell credentials set GRAPH_ACCESS_TOKEN', description: 'Store the Graph token' },
80
+ { command: 'moflo spell credentials set SLACK_WEBHOOK_URL --description "OAP target"', description: 'With a description' },
81
+ ],
82
+ action: async (ctx) => {
83
+ const name = ctx.args[0];
84
+ if (!name) {
85
+ output.printError('Credential name is required (e.g. `flo spell credentials set TOKEN`)');
86
+ return { success: false, exitCode: 1 };
87
+ }
88
+ const store = openStore();
89
+ if (!store)
90
+ return noStoreError();
91
+ if (!ctx.interactive) {
92
+ output.printError('`set` requires an interactive TTY (the value is entered with hidden input).');
93
+ return { success: false, exitCode: 1 };
94
+ }
95
+ const value = await input({ message: `Value for ${name}:`, mask: true });
96
+ if (!value) {
97
+ output.printError('No value entered — credential not stored.');
98
+ return { success: false, exitCode: 1 };
99
+ }
100
+ const description = ctx.flags.description;
101
+ await store.store(name, value, description);
102
+ output.printSuccess(`Stored credential ${output.highlight(name)}.`);
103
+ return { success: true };
104
+ },
105
+ };
106
+ const unsetCommand = {
107
+ name: 'unset',
108
+ aliases: ['delete', 'rm'],
109
+ description: 'Remove a single credential',
110
+ action: async (ctx) => {
111
+ const name = ctx.args[0];
112
+ if (!name) {
113
+ output.printError('Credential name is required (e.g. `flo spell credentials unset TOKEN`)');
114
+ return { success: false, exitCode: 1 };
115
+ }
116
+ const store = openStore();
117
+ if (!store)
118
+ return noStoreError();
119
+ const removed = await store.delete(name);
120
+ if (!removed) {
121
+ output.printError(`Credential ${output.highlight(name)} not found.`);
122
+ return { success: false, exitCode: 1 };
123
+ }
124
+ output.printSuccess(`Removed credential ${output.highlight(name)}.`);
125
+ return { success: true };
126
+ },
127
+ };
128
+ const clearCommand = {
129
+ name: 'clear',
130
+ description: 'Remove all stored credentials',
131
+ options: [
132
+ { name: 'force', short: 'f', description: 'Skip confirmation', type: 'boolean', default: false },
133
+ ],
134
+ action: async (ctx) => {
135
+ const store = openStore();
136
+ if (!store)
137
+ return noStoreError();
138
+ const meta = await store.list();
139
+ if (meta.length === 0) {
140
+ output.printInfo('No credentials to clear.');
141
+ return { success: true };
142
+ }
143
+ if (!ctx.flags.force) {
144
+ if (!ctx.interactive) {
145
+ output.printError('Refusing to clear without --force in non-interactive mode.');
146
+ return { success: false, exitCode: 1 };
147
+ }
148
+ const ok = await confirm({
149
+ message: `Delete all ${meta.length} stored credential${meta.length === 1 ? '' : 's'}?`,
150
+ default: false,
151
+ });
152
+ if (!ok) {
153
+ output.printInfo('Cancelled.');
154
+ return { success: true };
155
+ }
156
+ }
157
+ const removed = await store.clear();
158
+ output.printSuccess(`Removed ${removed} credential${removed === 1 ? '' : 's'}.`);
159
+ return { success: true };
160
+ },
161
+ };
162
+ const passphraseCommand = {
163
+ name: 'passphrase',
164
+ aliases: ['init', 'rotate'],
165
+ description: 'Initialise the credential store passphrase, or rotate an existing one',
166
+ action: async (ctx) => {
167
+ if (!ctx.interactive) {
168
+ output.printError('`passphrase` requires an interactive TTY.');
169
+ return { success: false, exitCode: 1 };
170
+ }
171
+ const filePath = resolveCredentialFilePath();
172
+ const exists = existsSync(filePath);
173
+ const minHint = `(min ${MIN_PASSPHRASE_LENGTH} chars)`;
174
+ if (!exists) {
175
+ const newPass = await input({ message: `Choose a passphrase ${minHint}:`, mask: true });
176
+ const confirmed = await input({ message: 'Confirm passphrase:', mask: true });
177
+ if (newPass !== confirmed) {
178
+ output.printError('Passphrases do not match.');
179
+ return { success: false, exitCode: 1 };
180
+ }
181
+ try {
182
+ const store = new CredentialStore({ filePath, passphrase: newPass });
183
+ // Force a salted file write so the chosen passphrase locks the store.
184
+ await store.store('__init__', 'init');
185
+ await store.delete('__init__');
186
+ }
187
+ catch (err) {
188
+ if (err instanceof CredentialStoreError && err.code === 'WEAK_PASSPHRASE') {
189
+ output.printError(err.message);
190
+ return { success: false, exitCode: 1 };
191
+ }
192
+ throw err;
193
+ }
194
+ output.printSuccess(`Initialised credential store at ${filePath}`);
195
+ output.printInfo('Set MOFLO_CREDENTIALS_PASSPHRASE in your environment to keep schedules running unattended.');
196
+ return { success: true };
197
+ }
198
+ const oldPass = await input({ message: 'Current passphrase:', mask: true });
199
+ const newPass = await input({ message: `New passphrase ${minHint}:`, mask: true });
200
+ const confirmed = await input({ message: 'Confirm new passphrase:', mask: true });
201
+ if (newPass !== confirmed) {
202
+ output.printError('Passphrases do not match.');
203
+ return { success: false, exitCode: 1 };
204
+ }
205
+ try {
206
+ const store = new CredentialStore({ filePath, passphrase: oldPass });
207
+ await store.rotate(oldPass, newPass);
208
+ }
209
+ catch (err) {
210
+ if (err instanceof CredentialStoreError) {
211
+ output.printError(err.message);
212
+ return { success: false, exitCode: 1 };
213
+ }
214
+ throw err;
215
+ }
216
+ output.printSuccess('Passphrase rotated. Update MOFLO_CREDENTIALS_PASSPHRASE if you set it in your environment.');
217
+ return { success: true };
218
+ },
219
+ };
220
+ export const spellCredentialsCommand = {
221
+ name: 'credentials',
222
+ aliases: ['creds'],
223
+ description: 'Manage the encrypted credential store used for unattended spell casts',
224
+ subcommands: [listCommand, setCommand, unsetCommand, clearCommand, passphraseCommand],
225
+ options: [],
226
+ examples: [
227
+ { command: 'moflo spell credentials list', description: 'Show stored credential names' },
228
+ { command: 'moflo spell credentials set GRAPH_TOKEN', description: 'Store a credential' },
229
+ { command: 'moflo spell credentials unset GRAPH_TOKEN', description: 'Remove a credential' },
230
+ { command: 'moflo spell credentials clear --force', description: 'Wipe all credentials' },
231
+ ],
232
+ action: async () => {
233
+ output.writeln();
234
+ output.writeln(output.bold('Spell Credentials'));
235
+ output.writeln();
236
+ output.writeln('Usage: moflo spell credentials <subcommand>');
237
+ output.writeln();
238
+ output.printList([
239
+ `${output.highlight('list')} - List stored credential names`,
240
+ `${output.highlight('set')} - Store a credential (hidden input)`,
241
+ `${output.highlight('unset')} - Remove a single credential`,
242
+ `${output.highlight('clear')} - Remove all stored credentials`,
243
+ `${output.highlight('passphrase')} - Initialise or rotate the passphrase`,
244
+ ]);
245
+ return { success: true };
246
+ },
247
+ };
248
+ //# sourceMappingURL=spell-credentials.js.map
@@ -13,6 +13,7 @@ import { callMCPTool } from '../mcp-client.js';
13
13
  import { TOOL_SPELL_CAST, TOOL_SPELL_LIST, TOOL_SPELL_STATUS, TOOL_SPELL_CANCEL, TOOL_SPELL_TEMPLATE, } from '../mcp-tools/tool-names.js';
14
14
  import { formatStatus, handleMCPError } from '../services/cli-formatters.js';
15
15
  import { scheduleCommand } from './spell-schedule.js';
16
+ import { spellCredentialsCommand } from './spell-credentials.js';
16
17
  import { loadSpellEngine } from '../services/engine-loader.js';
17
18
  // Re-export formatStatus as formatStageStatus for table column references
18
19
  const formatStageStatus = formatStatus;
@@ -587,7 +588,7 @@ export const spellCommand = {
587
588
  name: 'spell',
588
589
  aliases: ['workflow'],
589
590
  description: 'Spell casting and management',
590
- subcommands: [castCommand, validateCommand, listCommand, statusCommand, stopCommand, templateCommand, scheduleCommand],
591
+ subcommands: [castCommand, validateCommand, listCommand, statusCommand, stopCommand, templateCommand, scheduleCommand, spellCredentialsCommand],
591
592
  options: [],
592
593
  examples: [
593
594
  { command: 'moflo spell cast -n development', description: 'Cast spell by name' },
@@ -609,8 +610,9 @@ export const spellCommand = {
609
610
  `${output.highlight('list')} - List spells (grimoire + casts)`,
610
611
  `${output.highlight('status')} - Show spell status`,
611
612
  `${output.highlight('stop')} - Dispel a running spell`,
612
- `${output.highlight('template')} - Browse grimoire templates`,
613
- `${output.highlight('schedule')} - Manage scheduled spells`,
613
+ `${output.highlight('template')} - Browse grimoire templates`,
614
+ `${output.highlight('schedule')} - Manage scheduled spells`,
615
+ `${output.highlight('credentials')} - Manage stored secrets for unattended casts`,
614
616
  ]);
615
617
  output.writeln();
616
618
  output.writeln('Run "moflo spell <subcommand> --help" for more info');
@@ -19,6 +19,7 @@
19
19
  * `cli/services/cherry-pick-learnings.ts` and is dynamically imported from
20
20
  * the compiled `dist/` by the launcher.
21
21
  */
22
+ import { homedir } from 'node:os';
22
23
  import { join } from 'node:path';
23
24
  export const MOFLO_DIR = '.moflo';
24
25
  /** Canonical memory DB filename (post-#727). Lives at `<root>/.moflo/moflo.db`. */
@@ -39,6 +40,10 @@ export const LEGACY_MEMORY_DB_BAK_SUFFIX = '.bak';
39
40
  export function mofloDir(projectRoot) {
40
41
  return join(projectRoot, MOFLO_DIR);
41
42
  }
43
+ /** User-scope MoFlo state dir: `~/.moflo`. Holds credentials and other per-machine state. */
44
+ export function mofloHomeDir() {
45
+ return join(homedir(), MOFLO_DIR);
46
+ }
42
47
  export function legacyClaudeFlowDir(projectRoot) {
43
48
  return join(projectRoot, LEGACY_CLAUDE_FLOW_DIR);
44
49
  }
@@ -46,6 +46,10 @@ export const promptCommand = {
46
46
  type: 'number',
47
47
  description: 'Used when default resolves to empty/null — produces ISO timestamp N days ago',
48
48
  },
49
+ saveAs: {
50
+ type: 'string',
51
+ description: 'Persist the response under this name in the credential store; later casts read it back without prompting',
52
+ },
49
53
  },
50
54
  required: ['message'],
51
55
  },
@@ -65,11 +69,41 @@ export const promptCommand = {
65
69
  if (config.fallbackDaysAgo !== undefined && typeof config.fallbackDaysAgo !== 'number') {
66
70
  errors.push({ path: 'fallbackDaysAgo', message: 'fallbackDaysAgo must be a number' });
67
71
  }
72
+ if (config.saveAs !== undefined && typeof config.saveAs !== 'string') {
73
+ errors.push({ path: 'saveAs', message: 'saveAs must be a string' });
74
+ }
75
+ if (typeof config.saveAs === 'string' && config.saveAs.trim().length === 0) {
76
+ errors.push({ path: 'saveAs', message: 'saveAs cannot be empty' });
77
+ }
68
78
  return { valid: errors.length === 0, errors };
69
79
  },
70
80
  async execute(config, context) {
71
81
  const start = Date.now();
72
82
  const message = interpolateString(config.message, context);
83
+ const saveAs = config.saveAs?.trim();
84
+ if (saveAs) {
85
+ try {
86
+ const stored = await context.credentials.get(saveAs);
87
+ if (typeof stored === 'string' && stored.length > 0) {
88
+ return {
89
+ success: true,
90
+ data: {
91
+ message,
92
+ options: config.options ?? null,
93
+ outputVar: config.outputVar ?? 'response',
94
+ response: stored,
95
+ default: '',
96
+ interactive: false,
97
+ fromStore: true,
98
+ },
99
+ duration: Date.now() - start,
100
+ };
101
+ }
102
+ }
103
+ catch {
104
+ // Store unavailable — fall through to normal prompt path.
105
+ }
106
+ }
73
107
  // Interpolate default — pure-ref interpolation may yield null; tolerate it.
74
108
  let interpolatedDefault = null;
75
109
  if (typeof config.default === 'string') {
@@ -96,6 +130,15 @@ export const promptCommand = {
96
130
  lock.release();
97
131
  }
98
132
  }
133
+ // Best-effort persist — a failed save shouldn't fail a cast that already has a working answer.
134
+ if (saveAs && interactive && response) {
135
+ try {
136
+ await context.credentials.store(saveAs, response);
137
+ }
138
+ catch (err) {
139
+ console.warn(`[moflo:credentials] could not persist "${saveAs}": ${err.message}`);
140
+ }
141
+ }
99
142
  return {
100
143
  success: true,
101
144
  data: {
@@ -105,6 +148,7 @@ export const promptCommand = {
105
148
  response,
106
149
  default: effectiveDefault,
107
150
  interactive,
151
+ fromStore: false,
108
152
  },
109
153
  duration: Date.now() - start,
110
154
  };
@@ -116,6 +160,7 @@ export const promptCommand = {
116
160
  { name: 'outputVar', type: 'string' },
117
161
  { name: 'default', type: 'string', description: 'Effective default after fallback chain' },
118
162
  { name: 'interactive', type: 'boolean', description: 'Whether the user was actually prompted' },
163
+ { name: 'fromStore', type: 'boolean', description: 'True when the response was returned from the credential store' },
119
164
  ];
120
165
  },
121
166
  };
@@ -151,17 +151,25 @@ export function formatPrerequisiteErrors(results) {
151
151
  return '';
152
152
  const lines = ['Missing prerequisites:'];
153
153
  for (const f of failed) {
154
- lines.push(` - ${f.name}: ${f.installHint}`);
155
- if (f.url)
156
- lines.push(` ${f.url}`);
154
+ appendPrereqLine(lines, f.name, f.installHint, f.url);
157
155
  }
158
156
  return lines.join('\n');
159
157
  }
158
+ function appendPrereqLine(lines, name, hint, url) {
159
+ const hintSuffix = hint ? `: ${hint}` : '';
160
+ lines.push(` - ${name}${hintSuffix}`);
161
+ if (url)
162
+ lines.push(` ${url}`);
163
+ }
160
164
  /**
161
- * Evaluate all prereqs. On a TTY, prompts the user for unmet env-type prereqs
162
- * whose spec opted into `promptOnMissing`, writes answers into process.env,
163
- * then re-checks. Non-TTY calls and non-promptable unmet prereqs short-circuit
164
- * to a single formatted failure report.
165
+ * Evaluate all prereqs. Resolution chain for env-type prereqs:
166
+ * 1. process.env[key] already set satisfied.
167
+ * 2. credentials.get(key) returns a value write to process.env, satisfied.
168
+ * 3. TTY interactive prompt write to process.env AND credentials.store.
169
+ * 4. Non-TTY or no credentials → fail fast with `errorCode: 'MISSING_CREDENTIAL'`.
170
+ *
171
+ * Non-env prereqs (`command`, `file`) bypass the credential chain and surface
172
+ * through the standard "Missing prerequisites" path.
165
173
  */
166
174
  export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
167
175
  if (prerequisites.length === 0) {
@@ -170,22 +178,58 @@ export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
170
178
  const interactive = options.interactive
171
179
  ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
172
180
  const log = options.log ?? ((line) => console.log(line));
181
+ const credentials = options.credentials;
173
182
  const initial = await checkPrerequisites(prerequisites);
174
- const unmet = prerequisites.filter((_, i) => !initial[i].satisfied);
175
- if (unmet.length === 0) {
183
+ const unmetIndices = initial.flatMap((r, i) => r.satisfied ? [] : [i]);
184
+ if (unmetIndices.length === 0) {
176
185
  return { ok: true, resolvedNames: [] };
177
186
  }
178
- const promptable = unmet.filter(p => interactive && p.promptOnMissing === true && typeof p.envKey === 'string');
187
+ // Pull env-type prereqs from the store in parallel each resolved index
188
+ // lets us skip a re-check of the cheap env detector.
189
+ const resolvedFromStoreNames = [];
190
+ const storeResolved = new Set();
191
+ if (credentials) {
192
+ await Promise.all(unmetIndices.map(async (i) => {
193
+ const prereq = prerequisites[i];
194
+ if (!prereq.envKey)
195
+ return;
196
+ const stored = await credentials.get(prereq.envKey);
197
+ if (typeof stored === 'string' && stored.length > 0) {
198
+ process.env[prereq.envKey] = stored;
199
+ storeResolved.add(i);
200
+ resolvedFromStoreNames.push(prereq.name);
201
+ }
202
+ }));
203
+ }
204
+ const stillUnmetIdx = unmetIndices.filter(i => !storeResolved.has(i));
205
+ if (stillUnmetIdx.length === 0) {
206
+ return { ok: true, resolvedNames: resolvedFromStoreNames };
207
+ }
208
+ const stillUnmet = stillUnmetIdx.map(i => prerequisites[i]);
209
+ const promptable = stillUnmet.filter(p => interactive && p.promptOnMissing === true && typeof p.envKey === 'string');
179
210
  if (!interactive || promptable.length === 0) {
211
+ const promptableEnvKeys = stillUnmet
212
+ .filter(p => p.promptOnMissing === true && typeof p.envKey === 'string')
213
+ .map(p => p.envKey);
214
+ if (promptableEnvKeys.length > 0) {
215
+ return {
216
+ ok: false,
217
+ message: formatMissingCredentialMessage(promptableEnvKeys, stillUnmet),
218
+ resolvedNames: resolvedFromStoreNames,
219
+ errorCode: 'MISSING_CREDENTIAL',
220
+ missingCredentials: promptableEnvKeys,
221
+ };
222
+ }
180
223
  return {
181
224
  ok: false,
182
- message: formatPrerequisiteErrors(initial),
183
- resolvedNames: [],
225
+ message: formatPrerequisiteErrors(stillUnmetIdx.map(i => initial[i])),
226
+ resolvedNames: resolvedFromStoreNames,
184
227
  };
185
228
  }
186
- printPreflightBanner(log, unmet.length);
229
+ printPreflightBanner(log, stillUnmet.length);
187
230
  const promptLine = options.promptLine ?? readLineFromStdin;
188
- const resolvedNames = [];
231
+ const promptedNames = [];
232
+ const promptableSet = new Set(promptable);
189
233
  const lock = acquireTTYLock();
190
234
  try {
191
235
  for (const prereq of promptable) {
@@ -193,7 +237,7 @@ export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
193
237
  return {
194
238
  ok: false,
195
239
  message: 'Prerequisite resolution aborted',
196
- resolvedNames,
240
+ resolvedNames: [...resolvedFromStoreNames, ...promptedNames],
197
241
  };
198
242
  }
199
243
  if (prereq.description)
@@ -209,37 +253,56 @@ export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
209
253
  return {
210
254
  ok: false,
211
255
  message: `Prerequisite "${prereq.name}" prompt failed: ${err.message}`,
212
- resolvedNames,
256
+ resolvedNames: [...resolvedFromStoreNames, ...promptedNames],
213
257
  };
214
258
  }
215
259
  if (!answer || answer.length === 0) {
216
260
  return {
217
261
  ok: false,
218
262
  message: `Prerequisite "${prereq.name}" was not provided`,
219
- resolvedNames,
263
+ resolvedNames: [...resolvedFromStoreNames, ...promptedNames],
220
264
  };
221
265
  }
222
266
  if (prereq.envKey) {
223
267
  process.env[prereq.envKey] = answer;
268
+ if (credentials) {
269
+ try {
270
+ await credentials.store(prereq.envKey, answer);
271
+ }
272
+ catch (err) {
273
+ log(` (could not persist credential "${prereq.envKey}": ${err.message})`);
274
+ }
275
+ }
224
276
  }
225
- resolvedNames.push(prereq.name);
277
+ promptedNames.push(prereq.name);
226
278
  }
227
279
  }
228
280
  finally {
229
281
  lock.release();
230
282
  }
231
- // Re-check everything any still unmet (e.g. command/file prereqs that
232
- // couldn't be resolved via prompt) fail now with the up-to-date report.
233
- const rerun = await checkPrerequisites(prerequisites);
234
- const stillUnmet = rerun.filter(r => !r.satisfied);
235
- if (stillUnmet.length > 0) {
283
+ // Anything in stillUnmet that wasn't promptable (typically command/file
284
+ // prereqs) is still broken carry forward the initial detector result.
285
+ const unfixableIdx = stillUnmetIdx.filter(i => !promptableSet.has(prerequisites[i]));
286
+ if (unfixableIdx.length > 0) {
236
287
  return {
237
288
  ok: false,
238
- message: formatPrerequisiteErrors(rerun),
239
- resolvedNames,
289
+ message: formatPrerequisiteErrors(unfixableIdx.map(i => initial[i])),
290
+ resolvedNames: [...resolvedFromStoreNames, ...promptedNames],
240
291
  };
241
292
  }
242
- return { ok: true, resolvedNames };
293
+ return { ok: true, resolvedNames: [...resolvedFromStoreNames, ...promptedNames] };
294
+ }
295
+ function formatMissingCredentialMessage(envKeys, prereqs) {
296
+ const lines = ['Missing credentials (cannot prompt — non-interactive run):'];
297
+ for (const key of envKeys) {
298
+ const prereq = prereqs.find(p => p.envKey === key);
299
+ const label = `${prereq?.name ?? key} (${key})`;
300
+ appendPrereqLine(lines, label, prereq?.installHint, prereq?.url);
301
+ }
302
+ lines.push('');
303
+ lines.push('Prime these by casting the spell once interactively, or run:');
304
+ lines.push(' flo spell credentials set <name>');
305
+ return lines.join('\n');
243
306
  }
244
307
  function printPreflightBanner(log, unmetCount) {
245
308
  log('');
@@ -99,17 +99,18 @@ export class SpellCaster {
99
99
  }
100
100
  }
101
101
  }
102
- // Pre-flight prerequisite checks — walks YAML + step-command sources,
103
- // prompts on a TTY for unmet env-type prereqs (issue #460).
102
+ // Pre-flight prerequisite checks (issue #460) — walks YAML + step-command
103
+ // sources, pulls from credential store, prompts on TTY, persists answers.
104
104
  if (!options.dryRun) {
105
105
  const prerequisites = collectPrerequisites(definition, this.registry);
106
106
  if (prerequisites.length > 0) {
107
107
  const resolution = await resolveUnmetPrerequisites(prerequisites, {
108
108
  abortSignal: options.signal,
109
+ credentials: this.credentials,
109
110
  });
110
111
  if (!resolution.ok) {
111
112
  return this.failureResult(spellId, startTime, [{
112
- code: 'PREREQUISITES_FAILED',
113
+ code: resolution.errorCode ?? 'PREREQUISITES_FAILED',
113
114
  message: resolution.message ?? 'Prerequisites failed',
114
115
  }], definition.name);
115
116
  }
@@ -20,6 +20,8 @@ const SALT_BYTES = 32;
20
20
  const KEY_BYTES = 32;
21
21
  const PBKDF2_ITERATIONS = 100_000;
22
22
  const PBKDF2_DIGEST = 'sha512';
23
+ /** User-typeable floor for any passphrase that protects the store. */
24
+ export const MIN_PASSPHRASE_LENGTH = 8;
23
25
  // ============================================================================
24
26
  // Encryption Helpers
25
27
  // ============================================================================
@@ -64,8 +66,8 @@ export class CredentialStore {
64
66
  * Derives the encryption key and loads existing data.
65
67
  */
66
68
  unlock(passphrase) {
67
- if (passphrase.length < 8) {
68
- throw new CredentialStoreError('Passphrase must be at least 8 characters', 'WEAK_PASSPHRASE');
69
+ if (passphrase.length < MIN_PASSPHRASE_LENGTH) {
70
+ throw new CredentialStoreError(`Passphrase must be at least ${MIN_PASSPHRASE_LENGTH} characters`, 'WEAK_PASSPHRASE');
69
71
  }
70
72
  this.data = this.readFile();
71
73
  const salt = Buffer.from(this.data.salt, 'hex');
@@ -132,6 +134,20 @@ export class CredentialStore {
132
134
  this.writeFile(this.data);
133
135
  return true;
134
136
  }
137
+ /**
138
+ * Remove every credential in a single write — preferred over a delete loop
139
+ * because each `delete()` rewrites the entire encrypted file.
140
+ * Returns the number of credentials removed.
141
+ */
142
+ async clear() {
143
+ this.ensureUnlocked();
144
+ const count = Object.keys(this.data.credentials).length;
145
+ if (count === 0)
146
+ return 0;
147
+ this.data.credentials = {};
148
+ this.writeFile(this.data);
149
+ return count;
150
+ }
135
151
  /**
136
152
  * List credential metadata (names + descriptions, never values).
137
153
  */
@@ -168,8 +184,8 @@ export class CredentialStore {
168
184
  * and derived key, re-encrypts everything, and writes atomically.
169
185
  */
170
186
  async rotate(oldPassphrase, newPassphrase) {
171
- if (newPassphrase.length < 8) {
172
- throw new CredentialStoreError('Passphrase must be at least 8 characters', 'WEAK_PASSPHRASE');
187
+ if (newPassphrase.length < MIN_PASSPHRASE_LENGTH) {
188
+ throw new CredentialStoreError(`Passphrase must be at least ${MIN_PASSPHRASE_LENGTH} characters`, 'WEAK_PASSPHRASE');
173
189
  }
174
190
  // Verify old passphrase by unlocking with it
175
191
  const fileData = this.readFile();
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Default Credential Store
3
+ *
4
+ * Singleton `CredentialAccessor` wired into the runner factory, MCP bridge,
5
+ * and daemon executor so spells persist secrets between casts.
6
+ *
7
+ * Passphrase resolution: `MOFLO_CREDENTIALS_PASSPHRASE` env var, else an
8
+ * auto-generated `~/.moflo/credentials.key` (32 random bytes hex, mode
9
+ * 0o600) created on first need. The auto-key gives zero-config at-rest
10
+ * encryption — the threat model matches `~/.aws/credentials`: filesystem
11
+ * read by an attacker compromises the store either way.
12
+ *
13
+ * Failures degrade to `lockedNoopAccessor` (returns undefined / no-op
14
+ * persists) so the spell engine never crashes on a missing or unreadable
15
+ * credentials path.
16
+ */
17
+ import { randomBytes } from 'node:crypto';
18
+ import { mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';
19
+ import { dirname, join } from 'node:path';
20
+ import { CredentialStore, MIN_PASSPHRASE_LENGTH } from './credential-store.js';
21
+ import { mofloHomeDir } from '../../services/moflo-paths.js';
22
+ const KEY_BYTES = 32;
23
+ /** 64 hex chars from `KEY_BYTES`; floor of 16 catches truncation/corruption. */
24
+ const KEY_FILE_MIN_HEX_LENGTH = 16;
25
+ export const DEFAULT_CREDENTIALS_FILE = join(mofloHomeDir(), 'credentials.json');
26
+ export const DEFAULT_CREDENTIALS_KEY_FILE = join(mofloHomeDir(), 'credentials.key');
27
+ let cached = null;
28
+ /**
29
+ * Resolve the singleton default credential store. Returns an unlocked
30
+ * `CredentialStore` when a passphrase is available, otherwise a locked
31
+ * no-op accessor. Never throws.
32
+ */
33
+ export function getDefaultCredentialStore(options = {}) {
34
+ if (cached)
35
+ return cached;
36
+ const filePath = resolveCredentialFilePath(options.filePath);
37
+ const passphrase = resolvePassphrase(options.passphrase, options.keyFilePath);
38
+ let accessor;
39
+ if (passphrase) {
40
+ try {
41
+ accessor = new CredentialStore({ filePath, passphrase });
42
+ }
43
+ catch (err) {
44
+ console.warn(`[moflo:credentials] store unavailable: ${err.message}`);
45
+ accessor = lockedNoopAccessor;
46
+ }
47
+ }
48
+ else {
49
+ accessor = lockedNoopAccessor;
50
+ }
51
+ cached = accessor;
52
+ return accessor;
53
+ }
54
+ /** Reset the cached singleton — used by tests and CLI subcommands. */
55
+ export function resetDefaultCredentialStore() {
56
+ cached = null;
57
+ }
58
+ /** Resolve the credentials JSON path (override → env → default). */
59
+ export function resolveCredentialFilePath(explicit) {
60
+ return explicit ?? process.env.MOFLO_CREDENTIALS_FILE ?? DEFAULT_CREDENTIALS_FILE;
61
+ }
62
+ /** Resolve the key file path (override → env → default). */
63
+ export function resolveKeyFilePath(explicit) {
64
+ return explicit ?? process.env.MOFLO_CREDENTIALS_KEY_FILE ?? DEFAULT_CREDENTIALS_KEY_FILE;
65
+ }
66
+ /**
67
+ * Resolve a passphrase from explicit override, `MOFLO_CREDENTIALS_PASSPHRASE`,
68
+ * or the auto-generated key file. Returns `undefined` when no path yields a
69
+ * usable value (locked-store mode).
70
+ */
71
+ export function resolvePassphrase(explicit, keyFilePathOverride) {
72
+ if (explicit)
73
+ return explicit;
74
+ const envPass = process.env.MOFLO_CREDENTIALS_PASSPHRASE;
75
+ if (envPass && envPass.length >= MIN_PASSPHRASE_LENGTH)
76
+ return envPass;
77
+ return resolveKeyFilePassphrase(keyFilePathOverride);
78
+ }
79
+ function resolveKeyFilePassphrase(override) {
80
+ const keyPath = resolveKeyFilePath(override);
81
+ try {
82
+ let content = null;
83
+ try {
84
+ content = readFileSync(keyPath, 'utf-8').trim();
85
+ }
86
+ catch (err) {
87
+ if (err.code !== 'ENOENT')
88
+ throw err;
89
+ }
90
+ if (content !== null) {
91
+ if (content.length >= KEY_FILE_MIN_HEX_LENGTH)
92
+ return content;
93
+ // Refuse to regenerate over a corrupt key when credentials exist —
94
+ // a fresh key would silently invalidate every stored secret.
95
+ const credPath = join(dirname(keyPath), 'credentials.json');
96
+ try {
97
+ readFileSync(credPath);
98
+ console.warn(`[moflo:credentials] key file at ${keyPath} is corrupt; refusing to regenerate while ${credPath} exists`);
99
+ return undefined;
100
+ }
101
+ catch (err) {
102
+ if (err.code !== 'ENOENT')
103
+ throw err;
104
+ }
105
+ }
106
+ mkdirSync(dirname(keyPath), { recursive: true });
107
+ const generated = randomBytes(KEY_BYTES).toString('hex');
108
+ writeFileSync(keyPath, generated, { encoding: 'utf-8', mode: 0o600 });
109
+ // chmod after write in case the create mode was masked by umask.
110
+ try {
111
+ chmodSync(keyPath, 0o600);
112
+ }
113
+ catch { /* best-effort on Windows */ }
114
+ return generated;
115
+ }
116
+ catch (err) {
117
+ console.warn(`[moflo:credentials] could not read/create key file: ${err.message}`);
118
+ return undefined;
119
+ }
120
+ }
121
+ export const lockedNoopAccessor = {
122
+ async get() { return undefined; },
123
+ async has() { return false; },
124
+ async store() { },
125
+ };
126
+ //# sourceMappingURL=default-store.js.map
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Credentials barrel — public surface of the credential subsystem.
3
+ */
4
+ export { CredentialStore, CredentialStoreError, MIN_PASSPHRASE_LENGTH, } from './credential-store.js';
5
+ export { getDefaultCredentialStore, resetDefaultCredentialStore, resolveCredentialFilePath, resolveKeyFilePath, resolvePassphrase, lockedNoopAccessor, DEFAULT_CREDENTIALS_FILE, DEFAULT_CREDENTIALS_KEY_FILE, } from './default-store.js';
6
+ //# sourceMappingURL=index.js.map
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { loadSandboxConfigFromProject } from '../core/platform-sandbox.js';
12
12
  import { createRunner, runSpellFromContent } from './runner-factory.js';
13
+ import { getDefaultCredentialStore } from '../credentials/default-store.js';
13
14
  /**
14
15
  * Resolve sandbox config: prefer caller-supplied; fall back to auto-loading
15
16
  * from moflo.yaml at projectRoot. Returns undefined when neither is available
@@ -36,13 +37,14 @@ export async function bridgeRunSpell(content, sourceFile, args, options = {}) {
36
37
  activeSpells.set(spellId, controller);
37
38
  try {
38
39
  const sandboxConfig = await resolveSandbox(options.sandboxConfig, options.projectRoot);
40
+ const credentials = options.credentials ?? getDefaultCredentialStore();
39
41
  const result = await runSpellFromContent(content, sourceFile, {
40
42
  spellId,
41
43
  args,
42
44
  dryRun: options.dryRun,
43
45
  signal: controller.signal,
44
46
  memory: options.memory,
45
- credentials: options.credentials,
47
+ credentials,
46
48
  ...(options.projectRoot ? { projectRoot: options.projectRoot } : {}),
47
49
  ...(sandboxConfig ? { sandboxConfig } : {}),
48
50
  });
@@ -61,7 +63,8 @@ export async function bridgeExecuteSpell(definition, args, options = {}) {
61
63
  activeSpells.set(spellId, controller);
62
64
  try {
63
65
  const sandboxConfig = await resolveSandbox(options.sandboxConfig, options.projectRoot);
64
- const runner = createRunner({ memory: options.memory, credentials: options.credentials });
66
+ const credentials = options.credentials ?? getDefaultCredentialStore();
67
+ const runner = createRunner({ memory: options.memory, credentials });
65
68
  return await runner.run(definition, args, {
66
69
  spellId,
67
70
  signal: controller.signal,
@@ -14,6 +14,7 @@ import { validateSpellDefinition } from '../schema/validator.js';
14
14
  import { SpellConnectorRegistry } from '../registry/connector-registry.js';
15
15
  import { loadSandboxConfigFromProject } from '../core/platform-sandbox.js';
16
16
  import { errorDetail } from '../../shared/utils/error-detail.js';
17
+ import { getDefaultCredentialStore } from '../credentials/default-store.js';
17
18
  // ============================================================================
18
19
  // Factory
19
20
  // ============================================================================
@@ -33,7 +34,7 @@ export function createRunner(options = {}) {
33
34
  if (options.stepDirs?.length) {
34
35
  registry.loadFromDirectories(options.stepDirs);
35
36
  }
36
- const credentials = options.credentials ?? noopCredentials;
37
+ const credentials = options.credentials ?? getDefaultCredentialStore();
37
38
  const memory = options.memory ?? noopMemory;
38
39
  // Auto-register shipped connectors into the connector registry
39
40
  const connectorRegistry = options.connectorRegistry ?? new SpellConnectorRegistry();
@@ -92,9 +93,10 @@ export async function runSpellFromContent(content, sourceFile, options = {}) {
92
93
  // ============================================================================
93
94
  // Noop Accessors (for standalone usage without full CLI context)
94
95
  // ============================================================================
95
- const noopCredentials = {
96
+ export const noopCredentials = {
96
97
  async get() { return undefined; },
97
98
  async has() { return false; },
99
+ async store() { },
98
100
  };
99
101
  export const noopMemory = {
100
102
  async read() { return null; },
@@ -49,7 +49,7 @@ export { buildPausedState, persistPausedState, resumeSpell, cleanupStalePaused,
49
49
  // ============================================================================
50
50
  // Credential Store
51
51
  // ============================================================================
52
- export { CredentialStore, CredentialStoreError, } from './credentials/credential-store.js';
52
+ export { CredentialStore, CredentialStoreError, getDefaultCredentialStore, resetDefaultCredentialStore, lockedNoopAccessor, } from './credentials/index.js';
53
53
  // ============================================================================
54
54
  // Spell Registry (abbreviation lookup + list/info)
55
55
  // ============================================================================
@@ -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.18';
5
+ export const VERSION = '4.9.19';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.9.18",
3
+ "version": "4.9.19",
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",
@@ -81,7 +81,7 @@
81
81
  "@typescript-eslint/eslint-plugin": "^7.18.0",
82
82
  "@typescript-eslint/parser": "^7.18.0",
83
83
  "eslint": "^8.0.0",
84
- "moflo": "^4.9.17",
84
+ "moflo": "^4.9.18",
85
85
  "tsx": "^4.21.0",
86
86
  "typescript": "^5.9.3",
87
87
  "vitest": "^4.0.0"