moflo 4.12.1 → 4.12.2

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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: base-template-generator
3
- description: Use this agent when you need to create foundational templates, boilerplate code, or starter configurations for new projects, components, or features. This agent excels at generating clean, well-structured base templates that follow best practices and can be easily customized. Examples: <example>Context: User needs to start a new React component and wants a solid foundation. user: 'I need to create a new user profile component' assistant: 'I'll use the base-template-generator agent to create a comprehensive React component template with proper structure, TypeScript definitions, and styling setup.' <commentary>Since the user needs a foundational template for a new component, use the base-template-generator agent to create a well-structured starting point.</commentary></example> <example>Context: User is setting up a new API endpoint and needs a template. user: 'Can you help me set up a new REST API endpoint for user management?' assistant: 'I'll use the base-template-generator agent to create a complete API endpoint template with proper error handling, validation, and documentation structure.' <commentary>The user needs a foundational template for an API endpoint, so use the base-template-generator agent to provide a comprehensive starting point.</commentary></example>
3
+ description: Create foundational templates, boilerplate code, and starter configurations for new projects, components, or features. Use for scaffolding a new component, API endpoint, model, config, or test suite from scratch.
4
4
  color: orange
5
5
  ---
6
6
 
@@ -19,6 +19,26 @@ On chunk hits where `navigation` is non-null, traverse via `mcp__moflo__memory_g
19
19
 
20
20
  You are a Base Template Generator, an expert architect specializing in creating clean, well-structured foundational templates and boilerplate code. Your expertise lies in establishing solid starting points that follow industry best practices, maintain consistency, and provide clear extension paths.
21
21
 
22
+ ## When you get dispatched
23
+
24
+ Worked examples of the requests that route here — these live in the body, not the
25
+ frontmatter `description`, because the description is always-resident routing
26
+ context while this body only loads on dispatch.
27
+
28
+ <example>
29
+ Context: User needs to start a new React component and wants a solid foundation.
30
+ user: 'I need to create a new user profile component'
31
+ assistant: 'I'll use the base-template-generator agent to create a comprehensive React component template with proper structure, TypeScript definitions, and styling setup.'
32
+ <commentary>Since the user needs a foundational template for a new component, use the base-template-generator agent to create a well-structured starting point.</commentary>
33
+ </example>
34
+
35
+ <example>
36
+ Context: User is setting up a new API endpoint and needs a template.
37
+ user: 'Can you help me set up a new REST API endpoint for user management?'
38
+ assistant: 'I'll use the base-template-generator agent to create a complete API endpoint template with proper error handling, validation, and documentation structure.'
39
+ <commentary>The user needs a foundational template for an API endpoint, so use the base-template-generator agent to provide a comprehensive starting point.</commentary>
40
+ </example>
41
+
22
42
  Your core responsibilities:
23
43
  - Generate comprehensive base templates for components, modules, APIs, configurations, and project structures
24
44
  - Ensure all templates follow established coding standards and best practices from the project's CLAUDE.md guidelines
@@ -123,14 +123,54 @@ auto_update:
123
123
  helpers: true # Sync .claude/helpers/ from moflo source
124
124
  hook_block_drift: regenerate # warn | regenerate | off (default: regenerate since #1227)
125
125
  claudemd_injection_drift: regenerate # warn | regenerate | off
126
+
127
+ # Which skill categories to install (OPTIONAL — omit to get every skill)
128
+ skills:
129
+ categories: [core, memory, spells] # core | memory | spells
126
130
  ```
127
131
 
128
132
  If your `moflo.yaml` predates the `sandbox:` or `auto_update:` blocks, they are auto-appended on the next session start — you never need to re-run `moflo init` after a version bump.
129
133
 
134
+ ### Narrowing Installed Skills with `skills.categories`
135
+
136
+ **Omit the `skills:` block entirely to receive every moflo skill — that is the default and the recommended setting.** Add `skills.categories` only when you want a smaller always-resident skill listing, and expect to lose the commands in the categories you drop.
137
+
138
+ Every installed skill's name and description sit in Claude's context in every session, so a project that will never author a spell can reclaim that space. The cost is real but small — the full set is roughly 2,000 tokens.
139
+
140
+ | Category | Skills | Drop it when |
141
+ |----------|--------|--------------|
142
+ | `core` | `/fl`, `/healer`, `/verify`, `/flo-simplify`, `/eldar`, `/quicken`, `/ward`, `/divine`, `/meditate`, `/guidance`, `/commune`, `/luminarium` | Never — these are the everyday commands |
143
+ | `memory` | `/memory-patterns`, `/vector-search`, `/memory-optimization`, `/memory-team`, `/memory-worktree` | You are not building on moflo's memory stack and have finished any team/worktree sharing setup |
144
+ | `spells` | `/spell-builder`, `/spell-schedule`, `/connector-builder` | You do not author or schedule spells in this project |
145
+
146
+ Both YAML list styles work:
147
+
148
+ ```yaml
149
+ skills:
150
+ categories: [core, memory]
151
+ ```
152
+
153
+ ```yaml
154
+ skills:
155
+ categories:
156
+ - core
157
+ - memory
158
+ ```
159
+
160
+ **Key behaviors:**
161
+
162
+ - **Omitting `skills:` means no restriction.** Every shipped skill syncs on every session start, exactly as before this option existed.
163
+ - **`/flo` and `/fl` are never gated.** The ticket spell is the primary entry point and installs regardless of selection.
164
+ - **An unknown category name is ignored, not fatal.** A typo narrows nothing rather than silently stripping your skills.
165
+ - **Narrowing stops future syncing; it does not delete.** Skills already on disk stay until you remove them — moflo will not re-add them once excluded, so delete the directories under `.claude/skills/` to reclaim the context.
166
+ - **Re-widening is instant.** Add the category back and the next session start restores its skills.
167
+
130
168
  ### Key Behaviors
131
169
 
132
170
  | Config | Effect |
133
171
  |--------|--------|
172
+ | `skills.categories` omitted | Install every skill (default) |
173
+ | `skills.categories: [core]` | Install only core skills; `memory` + `spells` stop syncing |
134
174
  | `auto_index.guidance: false` | Skip guidance indexing on session start |
135
175
  | `auto_index.code_map: false` | Skip code map generation on session start |
136
176
  | `auto_meditate.enabled: false` | Opt out of automatic session-lesson distillation (`/meditate` still works manually) |
@@ -134,6 +134,13 @@ var command = process.argv[2];
134
134
 
135
135
  var EXEMPT = ['.claude/', '.claude\\', 'CLAUDE.md', 'MEMORY.md', 'workflow-state', 'node_modules', 'moflo.yaml'];
136
136
 
137
+ // Appended to every memory-first denial. Without it, a context audit of Claude
138
+ // Code's denial telemetry reads moflo's gate enforcements as permission-setup
139
+ // failures and "fixes" them with allow rules — which cannot override a hook
140
+ // block at all (#1307 finding 5). Naming the gate makes the cause self-evident.
141
+ var GATE_ORIGIN_NOTE = 'This is a moflo hook, not a Claude Code permission rule — allow-rules cannot override it.';
142
+ var GATE_DISABLE_NOTE = 'Disable per-gate via moflo.yaml: gates: memory_first: false';
143
+
137
144
  // #1294 Finding 3 — reads/scans of EPHEMERAL files under the OS temp dir
138
145
  // (background-task output/transcripts, agent scratchpads) are transient tool
139
146
  // I/O and never carry indexable project knowledge, so they must not trip the
@@ -713,7 +720,7 @@ switch (command) {
713
720
  var target = (process.env.TOOL_INPUT_pattern || '') + ' ' + (process.env.TOOL_INPUT_path || '');
714
721
  if (isEphemeralPath(process.env.TOOL_INPUT_path)) break;
715
722
  if (EXEMPT.some(function(p) { return target.indexOf(p) >= 0; })) break;
716
- process.stderr.write('BLOCKED: Search memory before exploring files. Use mcp__moflo__memory_search. On chunk hits, traverse via mcp__moflo__memory_get_neighbors — see .claude/guidance/moflo-memory-protocol.md\n');
723
+ process.stderr.write('BLOCKED [moflo memory_first gate]: Search memory before exploring files. Use mcp__moflo__memory_search. On chunk hits, traverse via mcp__moflo__memory_get_neighbors — see .claude/guidance/moflo-memory-protocol.md\n' + GATE_ORIGIN_NOTE + '\n' + GATE_DISABLE_NOTE + '\n');
717
724
  process.exit(2);
718
725
  }
719
726
  case 'check-before-read': {
@@ -726,7 +733,7 @@ switch (command) {
726
733
  if (isEphemeralPath(fp)) break;
727
734
  var isGuidance = fp.indexOf('.claude/guidance/') >= 0 || fp.indexOf('.claude\\guidance\\') >= 0;
728
735
  if (!isGuidance && EXEMPT.some(function(p) { return fp.indexOf(p) >= 0; })) break;
729
- process.stderr.write('BLOCKED: Search memory before reading files. Use mcp__moflo__memory_search. On chunk hits, traverse via mcp__moflo__memory_get_neighbors — see .claude/guidance/moflo-memory-protocol.md\n');
736
+ process.stderr.write('BLOCKED [moflo memory_first gate]: Search memory before reading files. Use mcp__moflo__memory_search. On chunk hits, traverse via mcp__moflo__memory_get_neighbors — see .claude/guidance/moflo-memory-protocol.md\n' + GATE_ORIGIN_NOTE + '\n' + GATE_DISABLE_NOTE + '\n');
730
737
  process.exit(2);
731
738
  }
732
739
  case 'record-task-created': {
@@ -775,11 +782,12 @@ switch (command) {
775
782
  // "Memory namespace hint: ..." sentence so the BLOCK message stays uniform.
776
783
  var hint = s2.lastNamespaceHint || classifyBashNamespaceHint(cmd) || '';
777
784
  process.stderr.write(
778
- 'BLOCKED: Search memory before reading files via Bash.\n' +
785
+ 'BLOCKED [moflo memory_first gate]: Search memory before reading files via Bash.\n' +
779
786
  'Example: mcp__moflo__memory_search { query: "<topic>", namespace: "<one of: guidance | code-map | patterns | learnings | tests>" }\n' +
780
787
  (hint ? hint + '\n' : '') +
781
788
  'On chunk hits, traverse via mcp__moflo__memory_get_neighbors — see .claude/guidance/moflo-memory-protocol.md\n' +
782
- 'Disable per-gate via moflo.yaml: gates: memory_first: false\n'
789
+ GATE_ORIGIN_NOTE + '\n' +
790
+ GATE_DISABLE_NOTE + '\n'
783
791
  );
784
792
  process.exit(2);
785
793
  break;
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: distill
3
- description: Alias for /flo-simplify. Review changed code for reuse, quality, and efficiency, then fix any issues found effort scales to the diff size.
3
+ description: Alias for /flo-simplify — see that skill's description.
4
4
  ---
5
5
 
6
6
  # /distill is an alias for /flo-simplify
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: perf-audit
3
- description: Alias for /quicken. Ad-hoc performance audit reveals N+1 queries, needless re-renders, missing caching, memory leaks, and redundant computation in your diff (or a named path), and prescribes the fix inline.
3
+ description: Alias for /quicken — see that skill's description.
4
4
  arguments: "[path or area | --all]"
5
5
  ---
6
6
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: test-gaps
3
- description: Alias for /ward. Ad-hoc test-gap audit reveals untested functions, uncovered edge cases, and missing error-handling/integration tests in your diff (or a named path), and conjures ready-to-paste test skeletons inline.
3
+ description: Alias for /ward — see that skill's description.
4
4
  arguments: "[path or area | --all]"
5
5
  ---
6
6
 
package/bin/gate.cjs CHANGED
@@ -134,6 +134,13 @@ var command = process.argv[2];
134
134
 
135
135
  var EXEMPT = ['.claude/', '.claude\\', 'CLAUDE.md', 'MEMORY.md', 'workflow-state', 'node_modules', 'moflo.yaml'];
136
136
 
137
+ // Appended to every memory-first denial. Without it, a context audit of Claude
138
+ // Code's denial telemetry reads moflo's gate enforcements as permission-setup
139
+ // failures and "fixes" them with allow rules — which cannot override a hook
140
+ // block at all (#1307 finding 5). Naming the gate makes the cause self-evident.
141
+ var GATE_ORIGIN_NOTE = 'This is a moflo hook, not a Claude Code permission rule — allow-rules cannot override it.';
142
+ var GATE_DISABLE_NOTE = 'Disable per-gate via moflo.yaml: gates: memory_first: false';
143
+
137
144
  // #1294 Finding 3 — reads/scans of EPHEMERAL files under the OS temp dir
138
145
  // (background-task output/transcripts, agent scratchpads) are transient tool
139
146
  // I/O and never carry indexable project knowledge, so they must not trip the
@@ -713,7 +720,7 @@ switch (command) {
713
720
  var target = (process.env.TOOL_INPUT_pattern || '') + ' ' + (process.env.TOOL_INPUT_path || '');
714
721
  if (isEphemeralPath(process.env.TOOL_INPUT_path)) break;
715
722
  if (EXEMPT.some(function(p) { return target.indexOf(p) >= 0; })) break;
716
- process.stderr.write('BLOCKED: Search memory before exploring files. Use mcp__moflo__memory_search. On chunk hits, traverse via mcp__moflo__memory_get_neighbors — see .claude/guidance/moflo-memory-protocol.md\n');
723
+ process.stderr.write('BLOCKED [moflo memory_first gate]: Search memory before exploring files. Use mcp__moflo__memory_search. On chunk hits, traverse via mcp__moflo__memory_get_neighbors — see .claude/guidance/moflo-memory-protocol.md\n' + GATE_ORIGIN_NOTE + '\n' + GATE_DISABLE_NOTE + '\n');
717
724
  process.exit(2);
718
725
  }
719
726
  case 'check-before-read': {
@@ -726,7 +733,7 @@ switch (command) {
726
733
  if (isEphemeralPath(fp)) break;
727
734
  var isGuidance = fp.indexOf('.claude/guidance/') >= 0 || fp.indexOf('.claude\\guidance\\') >= 0;
728
735
  if (!isGuidance && EXEMPT.some(function(p) { return fp.indexOf(p) >= 0; })) break;
729
- process.stderr.write('BLOCKED: Search memory before reading files. Use mcp__moflo__memory_search. On chunk hits, traverse via mcp__moflo__memory_get_neighbors — see .claude/guidance/moflo-memory-protocol.md\n');
736
+ process.stderr.write('BLOCKED [moflo memory_first gate]: Search memory before reading files. Use mcp__moflo__memory_search. On chunk hits, traverse via mcp__moflo__memory_get_neighbors — see .claude/guidance/moflo-memory-protocol.md\n' + GATE_ORIGIN_NOTE + '\n' + GATE_DISABLE_NOTE + '\n');
730
737
  process.exit(2);
731
738
  }
732
739
  case 'record-task-created': {
@@ -775,11 +782,12 @@ switch (command) {
775
782
  // "Memory namespace hint: ..." sentence so the BLOCK message stays uniform.
776
783
  var hint = s2.lastNamespaceHint || classifyBashNamespaceHint(cmd) || '';
777
784
  process.stderr.write(
778
- 'BLOCKED: Search memory before reading files via Bash.\n' +
785
+ 'BLOCKED [moflo memory_first gate]: Search memory before reading files via Bash.\n' +
779
786
  'Example: mcp__moflo__memory_search { query: "<topic>", namespace: "<one of: guidance | code-map | patterns | learnings | tests>" }\n' +
780
787
  (hint ? hint + '\n' : '') +
781
788
  'On chunk hits, traverse via mcp__moflo__memory_get_neighbors — see .claude/guidance/moflo-memory-protocol.md\n' +
782
- 'Disable per-gate via moflo.yaml: gates: memory_first: false\n'
789
+ GATE_ORIGIN_NOTE + '\n' +
790
+ GATE_DISABLE_NOTE + '\n'
783
791
  );
784
792
  process.exit(2);
785
793
  break;
@@ -33,10 +33,17 @@
33
33
  * @module bin/lib/retired-files
34
34
  */
35
35
 
36
- import { existsSync, readFileSync, unlinkSync } from 'fs';
37
- import { resolve } from 'path';
36
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'fs';
37
+ import { dirname, join, resolve } from 'path';
38
38
  import { createHash } from 'crypto';
39
39
 
40
+ /**
41
+ * Consumer-relative path of the retained-files record written by
42
+ * `writeRetainedRecord`. Exported so tests and `flo healer` resolve the same
43
+ * location instead of re-deriving the string.
44
+ */
45
+ export const RETAINED_RECORD_REL = join('.moflo', 'retired-retained.json');
46
+
40
47
  /**
41
48
  * Compute sha256 of file content. Returns null on read errors so the caller
42
49
  * can decide what to do — we never want a transient stat/read failure to
@@ -156,17 +163,29 @@ export function classifyRetiredFile(projectRoot, entry) {
156
163
  * Failures are non-fatal — a single un-deletable file (Windows AV hold,
157
164
  * EBUSY) must not stop pruning the rest. The caller surfaces the report.
158
165
  *
166
+ * `preserved` is the flat path list the launcher banner samples from;
167
+ * `preservedDetails` carries the same set with the manifest's retirement
168
+ * provenance attached, for the machine-readable record (#1307 finding 3).
169
+ *
159
170
  * @param {string} projectRoot
160
171
  * @param {string} manifestPath
161
- * @returns {{ pruned: string[], preserved: string[], unknown: string[], failed: Array<{path:string, message:string}> }}
172
+ * @returns {{ pruned: string[], preserved: string[], preservedDetails: Array<{path:string, retiredIn?:string, retiredBy?:string}>, unknown: string[], failed: Array<{path:string, message:string}> }}
162
173
  */
163
174
  export function applyRetiredPrune(projectRoot, manifestPath) {
164
175
  const { entries } = loadRetiredManifest(manifestPath);
165
- const report = { pruned: [], preserved: [], unknown: [], failed: [] };
176
+ const report = { pruned: [], preserved: [], preservedDetails: [], unknown: [], failed: [] };
166
177
  for (const entry of entries) {
167
178
  const { action } = classifyRetiredFile(projectRoot, entry);
168
179
  if (action === 'absent') continue;
169
- if (action === 'preserve') { report.preserved.push(entry.path); continue; }
180
+ if (action === 'preserve') {
181
+ report.preserved.push(entry.path);
182
+ report.preservedDetails.push({
183
+ path: entry.path,
184
+ ...(entry.retiredIn ? { retiredIn: entry.retiredIn } : {}),
185
+ ...(entry.retiredBy ? { retiredBy: entry.retiredBy } : {}),
186
+ });
187
+ continue;
188
+ }
170
189
  if (action === 'unknown') { report.unknown.push(entry.path); continue; }
171
190
  // action === 'prune'
172
191
  try {
@@ -181,3 +200,104 @@ export function applyRetiredPrune(projectRoot, manifestPath) {
181
200
  }
182
201
  return report;
183
202
  }
203
+
204
+ /**
205
+ * Reconcile an existing retained record against what is actually on disk.
206
+ *
207
+ * `writeRetainedRecord` only runs inside the launcher's UPGRADE branch (that
208
+ * is where `applyRetiredPrune` lives), so on its own it cannot keep the record
209
+ * honest: a user who reads the record and deletes the files would keep a
210
+ * record naming those files until their next moflo upgrade — the record would
211
+ * outlive what it describes, which is the same "not actionable" complaint
212
+ * #1307 raised about the truncated banner.
213
+ *
214
+ * This runs on EVERY session start instead, and is written to cost nothing in
215
+ * the common case: one `existsSync` when no record is present (overwhelmingly
216
+ * the norm), and only then a stat per retained entry.
217
+ *
218
+ * Never throws — advisory bookkeeping must not break session start.
219
+ *
220
+ * @param {string} projectRoot
221
+ * @returns {{ changed: boolean, removed: string[], remaining: number }}
222
+ */
223
+ export function reconcileRetainedRecord(projectRoot) {
224
+ const result = { changed: false, removed: [], remaining: 0 };
225
+ const abs = resolve(projectRoot, RETAINED_RECORD_REL);
226
+ try {
227
+ if (!existsSync(abs)) return result; // fast path — no record
228
+ // Corrupt/hand-edited record: drop it rather than reason about it. Parsed
229
+ // in its own try so a JSON error lands here and the bad file is actually
230
+ // removed, instead of falling to the outer catch and lingering forever.
231
+ let parsed = null;
232
+ try {
233
+ parsed = JSON.parse(readFileSync(abs, 'utf-8'));
234
+ } catch { /* handled below */ }
235
+ if (!parsed || !Array.isArray(parsed.retained)) {
236
+ unlinkSync(abs);
237
+ result.changed = true;
238
+ return result;
239
+ }
240
+ const stillPresent = [];
241
+ for (const entry of parsed.retained) {
242
+ if (entry && typeof entry.path === 'string' && existsSync(resolve(projectRoot, entry.path))) {
243
+ stillPresent.push(entry);
244
+ } else if (entry && typeof entry.path === 'string') {
245
+ result.removed.push(entry.path);
246
+ }
247
+ }
248
+ result.remaining = stillPresent.length;
249
+ if (result.removed.length === 0) return result; // nothing to do
250
+ result.changed = true;
251
+ if (stillPresent.length === 0) {
252
+ unlinkSync(abs);
253
+ return result;
254
+ }
255
+ writeFileSync(abs, JSON.stringify({ ...parsed, retained: stillPresent }, null, 2) + '\n', 'utf-8');
256
+ return result;
257
+ } catch {
258
+ return result;
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Persist the retained (customized-and-therefore-preserved) retired files to
264
+ * `.moflo/retired-retained.json` (#1307 finding 3).
265
+ *
266
+ * Before this, the only place the retained set appeared was a launcher stdout
267
+ * banner truncated to 5 entries — so "delete manually if unwanted" was not
268
+ * actionable for the remainder, and nothing on disk let you reconstruct the
269
+ * set (`installed-files.json` doesn't track pre-#948 agents/skills).
270
+ *
271
+ * The record is advisory state, not a manifest: it is rewritten from scratch
272
+ * on every launcher run, and deleted when nothing is retained so a stale file
273
+ * never claims paths the consumer has since removed.
274
+ *
275
+ * Never throws — a failure to write an advisory record must not break session
276
+ * start on any consumer. Returns the absolute path written, or null.
277
+ *
278
+ * @param {string} projectRoot
279
+ * @param {Array<{path:string, retiredIn?:string, retiredBy?:string}>} preservedDetails
280
+ * @param {string} [mofloVersion] - version that produced this record, if known
281
+ * @returns {string|null} absolute path written, or null if nothing was written
282
+ */
283
+ export function writeRetainedRecord(projectRoot, preservedDetails, mofloVersion) {
284
+ const abs = resolve(projectRoot, RETAINED_RECORD_REL);
285
+ try {
286
+ if (!Array.isArray(preservedDetails) || preservedDetails.length === 0) {
287
+ // Nothing retained — drop any record from a previous run.
288
+ if (existsSync(abs)) unlinkSync(abs);
289
+ return null;
290
+ }
291
+ mkdirSync(dirname(abs), { recursive: true });
292
+ const payload = {
293
+ version: 1,
294
+ note: 'Retired moflo files preserved because they were customized locally. Safe to delete manually if unwanted; moflo will not remove them.',
295
+ ...(mofloVersion ? { mofloVersion } : {}),
296
+ retained: preservedDetails,
297
+ };
298
+ writeFileSync(abs, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
299
+ return abs;
300
+ } catch {
301
+ return null;
302
+ }
303
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Skill-category selection for the session-start launcher (#1308).
3
+ *
4
+ * ## Why this leaf exists
5
+ *
6
+ * The launcher syncs every shipped skill into the consumer on each run
7
+ * (`syncDirRecursive` in `file-sync.mjs`). Before #1308 that sync was
8
+ * unconditional except for `INTERNAL_SKILLS`, which meant a consumer could not
9
+ * narrow their installed skill set: whatever `flo init` selected, the next
10
+ * session start put everything back. The category structure in `SKILLS_MAP` was
11
+ * therefore dead config.
12
+ *
13
+ * To honour a selection the launcher has to know which skill belongs to which
14
+ * category. The canonical map is `SKILLS_MAP` in `src/cli/init/executor.ts`,
15
+ * but the launcher is a plain `.mjs` and cannot import that TS const across the
16
+ * dist/source depth boundary — so this leaf mirrors it, exactly as
17
+ * `internal-skills.mjs` mirrors `INTERNAL_SKILLS`.
18
+ * `tests/bin/skill-categories-parity.test.ts` asserts the two never drift.
19
+ *
20
+ * ## Default is "everything"
21
+ *
22
+ * A consumer with no `skills:` block in `moflo.yaml` must keep getting every
23
+ * skill — anything else would silently delete capability on upgrade for every
24
+ * existing install (Rule #2). `parseSkillCategories` returns `null` for
25
+ * "unconfigured", and `computeExcludedSkills(null, …)` excludes nothing beyond
26
+ * the internal skills.
27
+ *
28
+ * @module bin/lib/skill-categories
29
+ */
30
+
31
+ /**
32
+ * Mirror of `SKILLS_MAP` in `src/cli/init/executor.ts`. Keep in sync — the
33
+ * parity test fails otherwise.
34
+ */
35
+ export const SKILL_CATEGORIES_MAP = {
36
+ core: [
37
+ 'commune',
38
+ 'eldar',
39
+ 'guidance',
40
+ 'healer',
41
+ 'flo-simplify',
42
+ 'distill',
43
+ 'luminarium',
44
+ 'reasoningbank-intelligence',
45
+ 'meditate',
46
+ 'divine',
47
+ 'quicken',
48
+ 'perf-audit',
49
+ 'ward',
50
+ 'test-gaps',
51
+ 'verify',
52
+ ],
53
+ memory: [
54
+ 'memory-patterns',
55
+ 'memory-optimization',
56
+ 'vector-search',
57
+ 'memory-worktree',
58
+ 'memory-team',
59
+ ],
60
+ spells: [
61
+ 'spell-builder',
62
+ 'spell-schedule',
63
+ 'connector-builder',
64
+ ],
65
+ };
66
+
67
+ /** Every category name, in declaration order. */
68
+ export const SKILL_CATEGORY_NAMES = Object.keys(SKILL_CATEGORIES_MAP);
69
+
70
+ /**
71
+ * Skills installed by `moflo-init.ts` outside `SKILLS_MAP` (the `/flo` + `/fl`
72
+ * ticket spell). They are the primary entry point and are never category-gated
73
+ * — excluding them would break the headline workflow.
74
+ */
75
+ export const ALWAYS_INSTALLED_SKILLS = ['flo', 'fl'];
76
+
77
+ /**
78
+ * Extract the selected skill categories from raw `moflo.yaml` text.
79
+ *
80
+ * Regex-based on purpose: the launcher deliberately avoids a YAML dependency
81
+ * (see the `auto_update` parsing it sits beside). Both YAML list styles are
82
+ * accepted because either is what a hand-editing consumer will write:
83
+ *
84
+ * skills:
85
+ * categories: [core, memory]
86
+ *
87
+ * skills:
88
+ * categories:
89
+ * - core
90
+ * - memory
91
+ *
92
+ * @param {string} yamlContent
93
+ * @returns {string[]|null} selected categories, or null when unconfigured
94
+ * (meaning "no restriction" — sync everything).
95
+ *
96
+ * An explicitly empty list returns `[]`, which is a REAL selection and is not
97
+ * the same as `null`: it excludes every category, leaving only
98
+ * {@link ALWAYS_INSTALLED_SKILLS} (`/flo` + `/fl`). That is a legitimate
99
+ * "bare minimum" choice, but it is a much stronger statement than omitting
100
+ * the block, so the two must never be conflated.
101
+ */
102
+ export function parseSkillCategories(yamlContent) {
103
+ if (typeof yamlContent !== 'string' || yamlContent.length === 0) return null;
104
+
105
+ // Flow style: categories: [a, b]
106
+ const flow = yamlContent.match(/^[ \t]*skills:[ \t]*\r?\n(?:[ \t]+[^\r\n]*\r?\n)*?[ \t]+categories:[ \t]*\[([^\]]*)\]/m);
107
+ if (flow) {
108
+ return flow[1]
109
+ .split(',')
110
+ .map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
111
+ .filter((s) => s.length > 0);
112
+ }
113
+
114
+ // Block style: categories:\n - a\n - b
115
+ const block = yamlContent.match(/^[ \t]*skills:[ \t]*\r?\n(?:[ \t]+[^\r\n]*\r?\n)*?[ \t]+categories:[ \t]*\r?\n((?:[ \t]*-[ \t]*[^\r\n]+\r?\n?)+)/m);
116
+ if (block) {
117
+ return block[1]
118
+ .split(/\r?\n/)
119
+ .map((line) => line.match(/^[ \t]*-[ \t]*(.+?)[ \t]*$/))
120
+ .filter(Boolean)
121
+ .map((m) => m[1].replace(/^['"]|['"]$/g, '').trim())
122
+ .filter((s) => s.length > 0);
123
+ }
124
+
125
+ return null;
126
+ }
127
+
128
+ /**
129
+ * Resolve the set of top-level skill directory names the launcher must NOT
130
+ * sync, given a selection.
131
+ *
132
+ * Unknown category names in the selection are ignored rather than treated as
133
+ * "select nothing" — a typo in `moflo.yaml` should not silently strip a
134
+ * consumer's skills. Skills that belong to no category at all are always kept
135
+ * for the same reason: this function can only ever exclude a skill it can
136
+ * positively attribute to an UNSELECTED category.
137
+ *
138
+ * @param {string[]|null} selected - from `parseSkillCategories`
139
+ * @param {string[]} internalSkills - INTERNAL_SKILLS (never installed)
140
+ * @returns {Set<string>} top-level names to exclude from the sync
141
+ */
142
+ export function computeExcludedSkills(selected, internalSkills = []) {
143
+ const excluded = new Set(internalSkills);
144
+ if (!Array.isArray(selected)) return excluded; // unconfigured → no restriction
145
+
146
+ const keep = new Set(ALWAYS_INSTALLED_SKILLS);
147
+ for (const category of selected) {
148
+ for (const skill of SKILL_CATEGORIES_MAP[category] || []) keep.add(skill);
149
+ }
150
+
151
+ for (const [category, skills] of Object.entries(SKILL_CATEGORIES_MAP)) {
152
+ if (selected.includes(category)) continue;
153
+ for (const skill of skills) {
154
+ if (!keep.has(skill)) excluded.add(skill);
155
+ }
156
+ }
157
+ return excluded;
158
+ }
@@ -14,9 +14,10 @@ import { fileURLToPath, pathToFileURL } from 'url';
14
14
  import { mofloDir, findProjectRoot, findAncestorMofloRoot, COMMON_WALK_SKIP_NAMES } from './lib/moflo-paths.mjs';
15
15
  import { repairMemoryDbIfCorrupt } from './lib/db-repair.mjs';
16
16
  import { resolveMofloBin } from './lib/resolve-bin.mjs';
17
- import { applyRetiredPrune } from './lib/retired-files.mjs';
17
+ import { applyRetiredPrune, writeRetainedRecord, reconcileRetainedRecord, RETAINED_RECORD_REL } from './lib/retired-files.mjs';
18
18
  import { makeSyncer, contentEqual, syncDirRecursive } from './lib/file-sync.mjs';
19
19
  import { INTERNAL_SKILLS } from './lib/internal-skills.mjs';
20
+ import { parseSkillCategories, computeExcludedSkills } from './lib/skill-categories.mjs';
20
21
  import { loadShippedScripts } from './lib/shipped-scripts.mjs';
21
22
  import {
22
23
  readContinuityConfig,
@@ -839,6 +840,9 @@ let autoUpdateConfig = {
839
840
  // refresh CLAUDE.md on their own — there is no other auto-refresh path.
840
841
  claudemdInjectionDrift: 'regenerate',
841
842
  };
843
+ // #1308 — skill categories the consumer opted into via moflo.yaml. `null` =
844
+ // unconfigured = no restriction (every shipped skill syncs, as before).
845
+ let selectedSkillCategories = null;
842
846
  try {
843
847
  const mofloYaml = resolve(projectRoot, 'moflo.yaml');
844
848
  if (existsSync(mofloYaml)) {
@@ -859,6 +863,10 @@ try {
859
863
  if (helpersMatch) autoUpdateConfig.helpers = helpersMatch[1] === 'true';
860
864
  if (driftMatch) autoUpdateConfig.hookBlockDrift = driftMatch[1];
861
865
  if (claudemdMatch) autoUpdateConfig.claudemdInjectionDrift = claudemdMatch[1];
866
+ // #1308 — optional `skills: categories: [...]` selection. null means
867
+ // unconfigured, which must keep meaning "sync everything" so no existing
868
+ // consumer loses skills on upgrade.
869
+ selectedSkillCategories = parseSkillCategories(yamlContent);
862
870
  }
863
871
  } catch (err) {
864
872
  // Defaults (all true) keep the upgrade flow alive but the user should
@@ -1216,10 +1224,24 @@ try {
1216
1224
  '.claude/agents',
1217
1225
  { projectRoot, syncFile, onWarn: emitWarning },
1218
1226
  );
1227
+ // #1308 — excludeTopLevel is INTERNAL_SKILLS plus, when the consumer has
1228
+ // opted into a `skills: categories: [...]` selection in moflo.yaml, every
1229
+ // skill belonging to an unselected category. With no selection the set is
1230
+ // just INTERNAL_SKILLS, i.e. byte-for-byte the pre-#1308 behaviour — a
1231
+ // silent narrowing on upgrade would strip capability from every existing
1232
+ // consumer (Rule #2), so "unconfigured" must always mean "everything".
1233
+ const excludedSkills = computeExcludedSkills(selectedSkillCategories, INTERNAL_SKILLS);
1234
+ if (selectedSkillCategories) {
1235
+ emitMutation(
1236
+ 'applied skills selection',
1237
+ `categories [${selectedSkillCategories.join(', ')}] — ` +
1238
+ `${plural(excludedSkills.size - INTERNAL_SKILLS.length, 'skill')} not synced`,
1239
+ );
1240
+ }
1219
1241
  await syncDirRecursive(
1220
1242
  resolve(projectRoot, 'node_modules/moflo/.claude/skills'),
1221
1243
  '.claude/skills',
1222
- { projectRoot, syncFile, excludeTopLevel: new Set(INTERNAL_SKILLS), onWarn: emitWarning },
1244
+ { projectRoot, syncFile, excludeTopLevel: excludedSkills, onWarn: emitWarning },
1223
1245
  );
1224
1246
 
1225
1247
  // Sync all shipped guidance files from node_modules/moflo/.claude/guidance/shipped/
@@ -1292,17 +1314,51 @@ try {
1292
1314
  `${plural(report.pruned.length, 'file')} matching known-shipped content removed`,
1293
1315
  );
1294
1316
  }
1317
+ // Always reconcile the retained record — including writing none when
1318
+ // nothing is retained, so a record from a previous run doesn't outlive
1319
+ // the files it names (#1307 finding 3).
1320
+ //
1321
+ // The version is only read when there is actually something to record.
1322
+ // Nothing-retained is the overwhelmingly common case, and this runs on
1323
+ // every session start in every consumer — an unconditional read +
1324
+ // JSON.parse here would be pure hot-path waste.
1325
+ //
1326
+ // Read locally rather than reusing §2's `installedVersion`: that
1327
+ // binding is scoped to the auto-update branch, and an out-of-scope
1328
+ // reference would be swallowed by the surrounding catch as a bogus
1329
+ // "prune skipped" warning.
1330
+ let recordVersion;
1331
+ if (report.preservedDetails.length > 0) {
1332
+ try {
1333
+ recordVersion = JSON.parse(readFileSync(
1334
+ resolve(projectRoot, 'node_modules/moflo/package.json'), 'utf-8',
1335
+ )).version;
1336
+ } catch { /* record just omits the version */ }
1337
+ }
1338
+ const retainedRecordPath = writeRetainedRecord(
1339
+ projectRoot,
1340
+ report.preservedDetails,
1341
+ recordVersion,
1342
+ );
1295
1343
  if (report.preserved.length > 0) {
1296
1344
  // stdout (not stderr) so Claude sees this in `additionalContext`
1297
1345
  // and can surface to the user — these aren't failures, just
1298
1346
  // consumer-customized files we deliberately left alone.
1347
+ //
1348
+ // The banner samples; the full list goes to the record file. Before
1349
+ // the record existed the truncated banner was the ONLY place these
1350
+ // paths appeared, and it scrolls away — so "delete manually" wasn't
1351
+ // actionable past the 5th entry.
1299
1352
  const sample = report.preserved.slice(0, 5).map((p) => ` - ${p}`).join('\n');
1300
1353
  const more = report.preserved.length > 5
1301
1354
  ? `\n …and ${report.preserved.length - 5} more`
1302
1355
  : '';
1356
+ const where = retainedRecordPath
1357
+ ? `\n full list: ${RETAINED_RECORD_REL}`
1358
+ : '';
1303
1359
  try {
1304
1360
  process.stdout.write(
1305
- `moflo: retained ${plural(report.preserved.length, 'customized retired file')} (delete manually if unwanted):\n${sample}${more}\n`,
1361
+ `moflo: retained ${plural(report.preserved.length, 'customized retired file')} (delete manually if unwanted):\n${sample}${more}${where}\n`,
1306
1362
  );
1307
1363
  } catch { /* non-fatal */ }
1308
1364
  }
@@ -2390,6 +2446,26 @@ if (upgradeNoticeContext) {
2390
2446
  // preserved because that commit is gated on sync success; every stage after the
2391
2447
  // sync block runs unconditionally + idempotently each session.
2392
2448
 
2449
+ // ── 3g-1307. Keep the retained-retired record honest every session ──────────
2450
+ // The record is WRITTEN in §3's upgrade branch (that is where the retired-file
2451
+ // prune runs), but it must not survive the files it names — a user who reads
2452
+ // it and deletes those files would otherwise keep a record listing them until
2453
+ // their next moflo upgrade. Reconciling here, unconditionally, closes that.
2454
+ // Costs one existsSync when no record is present, which is the normal state.
2455
+ try {
2456
+ const rec = reconcileRetainedRecord(projectRoot);
2457
+ if (rec.changed) {
2458
+ emitMutation(
2459
+ 'reconciled retained-retired record',
2460
+ rec.remaining === 0
2461
+ ? 'all retained files removed by the user — record dropped'
2462
+ : `${plural(rec.removed.length, 'entry')} dropped, ${rec.remaining} still retained`,
2463
+ );
2464
+ }
2465
+ } catch (err) {
2466
+ emitWarning(`retained-record reconcile skipped (${errMessage(err)})`);
2467
+ }
2468
+
2393
2469
  // ── 3h. Clear bootstrap sentinel if section-3 sync resolved it (#975) ───────
2394
2470
  // Section 3 above re-attempts the same file copies the bootstrap was supposed
2395
2471
  // to do, with the launcher's own retry logic. If after section 3 every file
@@ -8,7 +8,7 @@ import * as fs from 'fs';
8
8
  import * as path from 'path';
9
9
  import { errorDetail } from '../shared/utils/error-detail.js';
10
10
  import { findAncestorMofloRoot } from '../services/project-root.js';
11
- import { executeInit, executeUpgrade, executeUpgradeWithMissing, DEFAULT_INIT_OPTIONS, MINIMAL_INIT_OPTIONS, FULL_INIT_OPTIONS, } from '../init/index.js';
11
+ import { executeInit, executeUpgrade, executeUpgradeWithMissing, DEFAULT_INIT_OPTIONS, MINIMAL_INIT_OPTIONS, FULL_INIT_OPTIONS, SKILL_CATEGORIES, } from '../init/index.js';
12
12
  // Check if project is already initialized
13
13
  function isInitialized(cwd) {
14
14
  const claudePath = path.join(cwd, '.claude', 'settings.json');
@@ -366,17 +366,35 @@ const wizardCommand = {
366
366
  options.components.statusline = components.includes('statusline');
367
367
  options.components.mcp = components.includes('mcp');
368
368
  options.components.runtime = components.includes('runtime');
369
- // Skills selection
369
+ // Skills selection.
370
+ //
371
+ // #1308: this used to offer 'Core' and 'GitHub'. 'GitHub' mapped to
372
+ // `options.skills.github`, which has no matching SKILLS_MAP category —
373
+ // so ticking it did nothing at all, and the `memory` / `spells`
374
+ // categories were never offered. The options now mirror
375
+ // SKILL_CATEGORIES, so every choice here selects real skills.
370
376
  if (options.components.skills) {
371
377
  const skillSets = await multiSelect({
372
378
  message: 'Select skill sets:',
373
379
  options: [
374
- { value: 'core', label: 'Core', hint: 'Swarm, memory, SPARC, ReasoningBank skills', selected: true },
375
- { value: 'github', label: 'GitHub', hint: 'GitHub integration skills', selected: true },
380
+ { value: 'core', label: 'Core', hint: '/fl, /healer, /verify, /flo-simplify, /eldar …', selected: true },
381
+ { value: 'memory', label: 'Memory', hint: 'Memory patterns, vector search, team/worktree sharing', selected: true },
382
+ { value: 'spells', label: 'Spells', hint: 'Spell authoring, scheduling, connectors', selected: true },
376
383
  ],
377
384
  });
378
- options.skills.core = skillSets.includes('core');
379
- options.skills.github = skillSets.includes('github');
385
+ for (const category of SKILL_CATEGORIES) {
386
+ options.skills[category] = skillSets.includes(category);
387
+ }
388
+ // A selection made here governs what init COPIES, but the
389
+ // session-start launcher re-syncs shipped skills on every run and
390
+ // reads its own selection from moflo.yaml. Without that block the
391
+ // narrowing silently reverts on the next session — the #1308 trap.
392
+ // Say so rather than let the user discover it.
393
+ const narrowed = SKILL_CATEGORIES.filter((c) => !skillSets.includes(c));
394
+ if (narrowed.length > 0) {
395
+ output.printInfo(`To keep ${narrowed.join(' + ')} skills from returning on the next session, add to moflo.yaml:\n` +
396
+ ` skills:\n categories: [${skillSets.join(', ')}]`);
397
+ }
380
398
  }
381
399
  // Hooks selection
382
400
  if (options.components.settings) {
@@ -581,10 +599,13 @@ const checkCommand = {
581
599
  const skillsCommand = {
582
600
  name: 'skills',
583
601
  description: 'Initialize only skills',
602
+ // Flags mirror SKILL_CATEGORIES. `--github` used to be offered here and
603
+ // installed nothing — there is no GitHub skill category (#1308).
584
604
  options: [
585
- { name: 'all', description: 'Install all skills', type: 'boolean', default: false },
586
- { name: 'core', description: 'Install core skills', type: 'boolean', default: true },
587
- { name: 'github', description: 'Install GitHub skills', type: 'boolean', default: false },
605
+ { name: 'all', description: 'Install every skill category', type: 'boolean', default: false },
606
+ { name: 'core', description: 'Install core skills (/fl, /healer, /verify, …)', type: 'boolean', default: true },
607
+ { name: 'memory', description: 'Install memory skills (patterns, vector search, sharing)', type: 'boolean', default: false },
608
+ { name: 'spells', description: 'Install spell skills (authoring, scheduling, connectors)', type: 'boolean', default: false },
588
609
  ],
589
610
  action: async (ctx) => {
590
611
  const options = {
@@ -606,8 +627,8 @@ const skillsCommand = {
606
627
  skills: {
607
628
  all: ctx.flags.all,
608
629
  core: ctx.flags.core,
609
- github: ctx.flags.github,
610
- browser: false,
630
+ memory: ctx.flags.memory,
631
+ spells: ctx.flags.spells,
611
632
  },
612
633
  };
613
634
  const spinner = output.createSpinner({ text: 'Installing skills...' });
@@ -464,6 +464,21 @@ export class CLI {
464
464
  * Non-blocking — fires and forgets. Errors are silently swallowed.
465
465
  */
466
466
  async maybeAutoStartDaemon() {
467
+ // Test-only kill switch. A test that shells the CLI into a throwaway tmp
468
+ // project gets `daemon.auto_start: true` from DEFAULT_CONFIG (no moflo.yaml
469
+ // there to say otherwise), so EVERY such invocation spawned a detached,
470
+ // unref'd daemon. When the test then removed its tmp dir, the daemon
471
+ // survived holding a deleted cwd — no lockfile left to contend for, no
472
+ // project root to match, so nothing ever reclaimed it. One full suite run
473
+ // leaked several; they accumulated across runs into dozens of ~87MB
474
+ // processes that only `doctor --fix` could reap.
475
+ //
476
+ // Set in vitest.setup.ts alongside MOFLO_TEST_TRUST_DAEMON_PID and
477
+ // MOFLO_TEST_SKIP_ORPHAN_SCAN. Subprocesses inherit it, which is what makes
478
+ // it reach the spawned CLI. Production never sets it; a test that genuinely
479
+ // wants autostart can delete it in its own beforeEach, same as the others.
480
+ if (process.env.MOFLO_TEST_SKIP_DAEMON_AUTOSTART === '1')
481
+ return;
467
482
  const config = loadMofloConfig(process.cwd());
468
483
  if (!config.daemon.auto_start)
469
484
  return;
@@ -11,7 +11,7 @@ import { dirname } from 'path';
11
11
  // ESM-compatible __dirname
12
12
  const __filename = fileURLToPath(import.meta.url);
13
13
  const __dirname = dirname(__filename);
14
- import { detectPlatform, DEFAULT_INIT_OPTIONS } from './types.js';
14
+ import { detectPlatform, DEFAULT_INIT_OPTIONS, SKILL_CATEGORIES } from './types.js';
15
15
  import { generateSettingsJson, generateSettings } from './settings-generator.js';
16
16
  import { generateMCPJson } from './mcp-generator.js';
17
17
  import { generatePreCommitHook, generatePostCommitHook, generateAutoMemoryHook, generateGateScript, generateGateHookScript, generatePromptHookScript, generateHookHandlerScript, } from './helpers-generator.js';
@@ -31,6 +31,8 @@ import { readMofloEnv } from '../services/env-compat.js';
31
31
  // cruft). New additions MUST pass the same audit, otherwise the drift-guard
32
32
  // test (skills-classification-drift.test.ts) fails. See INTERNAL_SKILLS for
33
33
  // skills that ship in the tarball but are deliberately NOT installed.
34
+ // Typed by SkillCategory (not `string`) so this map and `SkillsConfig` cannot
35
+ // drift apart again — see SKILL_CATEGORIES in types.ts for what that drift cost.
34
36
  export const SKILLS_MAP = {
35
37
  core: [
36
38
  'commune',
@@ -915,9 +917,14 @@ async function copySkills(targetDir, options, result) {
915
917
  Object.values(SKILLS_MAP).forEach(skills => skillsToCopy.push(...skills));
916
918
  }
917
919
  else {
918
- for (const [key, skills] of Object.entries(SKILLS_MAP)) {
919
- if (skillsConfig[key])
920
- skillsToCopy.push(...skills);
920
+ // Iterate SKILL_CATEGORIES rather than Object.entries + a cast. The cast
921
+ // was what let `SKILLS_MAP`'s keys drift away from `SkillsConfig`'s without
922
+ // a type error, silently disabling the `memory` and `spells` categories
923
+ // (#1308). Indexing a typed Record by the category union needs no cast, so
924
+ // the compiler now enforces that every category is selectable.
925
+ for (const category of SKILL_CATEGORIES) {
926
+ if (skillsConfig[category])
927
+ skillsToCopy.push(...SKILLS_MAP[category]);
921
928
  }
922
929
  }
923
930
  // Find source skills directory
@@ -319,6 +319,12 @@ var mergeConf = loadMergeConfig();
319
319
  var command = process.argv[2];
320
320
 
321
321
  var EXEMPT = ['.claude/', '.claude\\\\', 'CLAUDE.md', 'MEMORY.md', 'workflow-state', 'node_modules', 'moflo.yaml'];
322
+
323
+ // Appended to every memory-first denial so a context audit doesn't read gate
324
+ // enforcement as permission misconfiguration (#1307 finding 5). SYNC: mirrors
325
+ // bin/gate.cjs GATE_ORIGIN_NOTE / GATE_DISABLE_NOTE.
326
+ var GATE_ORIGIN_NOTE = 'This is a moflo hook, not a Claude Code permission rule — allow-rules cannot override it.';
327
+ var GATE_DISABLE_NOTE = 'Disable per-gate via moflo.yaml: gates: memory_first: false';
322
328
  // #1294 Finding 3 — exempt ephemeral reads/scans under the OS temp dir
323
329
  // (background-task output, scratchpads) from the memory-first gate. Mirrors
324
330
  // bin/gate.cjs isEphemeralPath. Cross-platform via os.tmpdir(); normalizes a
@@ -628,7 +634,7 @@ switch (command) {
628
634
  var target = (process.env.TOOL_INPUT_pattern || '') + ' ' + (process.env.TOOL_INPUT_path || '');
629
635
  if (isEphemeralPath(process.env.TOOL_INPUT_path)) break;
630
636
  if (EXEMPT.some(function(p) { return target.indexOf(p) >= 0; })) break;
631
- process.stderr.write('BLOCKED: Search memory before exploring files. Use mcp__moflo__memory_search.\\n');
637
+ process.stderr.write('BLOCKED [moflo memory_first gate]: Search memory before exploring files. Use mcp__moflo__memory_search.\\n' + GATE_ORIGIN_NOTE + '\\n' + GATE_DISABLE_NOTE + '\\n');
632
638
  process.exit(2);
633
639
  }
634
640
  case 'check-before-read': {
@@ -638,7 +644,7 @@ switch (command) {
638
644
  var fp = process.env.TOOL_INPUT_file_path || '';
639
645
  if (isEphemeralPath(fp)) break;
640
646
  if (fp.indexOf('.claude/guidance/') < 0 && fp.indexOf('.claude\\\\guidance\\\\') < 0) break;
641
- process.stderr.write('BLOCKED: Search memory before reading guidance files. Use mcp__moflo__memory_search.\\n');
647
+ process.stderr.write('BLOCKED [moflo memory_first gate]: Search memory before reading guidance files. Use mcp__moflo__memory_search.\\n' + GATE_ORIGIN_NOTE + '\\n' + GATE_DISABLE_NOTE + '\\n');
642
648
  process.exit(2);
643
649
  }
644
650
  case 'record-task-created': {
@@ -670,11 +676,12 @@ switch (command) {
670
676
  // See bin/gate.cjs check-bash-memory for full rationale.
671
677
  var hint = s2.lastNamespaceHint || classifyBashNamespaceHint(cmd) || '';
672
678
  process.stderr.write(
673
- 'BLOCKED: Search memory before reading files via Bash.\\n' +
679
+ 'BLOCKED [moflo memory_first gate]: Search memory before reading files via Bash.\\n' +
674
680
  'Example: mcp__moflo__memory_search { query: "<topic>", namespace: "<one of: guidance | code-map | patterns | learnings | tests>" }\\n' +
675
681
  (hint ? hint + '\\n' : '') +
676
682
  'On chunk hits, traverse via mcp__moflo__memory_get_neighbors — see .claude/guidance/moflo-memory-protocol.md\\n' +
677
- 'Disable per-gate via moflo.yaml: gates: memory_first: false\\n'
683
+ GATE_ORIGIN_NOTE + '\\n' +
684
+ GATE_DISABLE_NOTE + '\\n'
678
685
  );
679
686
  process.exit(2);
680
687
  break;
@@ -3,7 +3,7 @@
3
3
  * Comprehensive initialization system for Claude Code integration
4
4
  */
5
5
  // Types
6
- export { DEFAULT_INIT_OPTIONS, MINIMAL_INIT_OPTIONS, FULL_INIT_OPTIONS, detectPlatform, } from './types.js';
6
+ export { SKILL_CATEGORIES, DEFAULT_INIT_OPTIONS, MINIMAL_INIT_OPTIONS, FULL_INIT_OPTIONS, detectPlatform, } from './types.js';
7
7
  // Generators
8
8
  export { generateSettings, generateSettingsJson, } from './settings-generator.js';
9
9
  export { generateMCPConfig, generateMCPJson, generateMCPCommands, } from './mcp-generator.js';
@@ -4,6 +4,21 @@
4
4
  */
5
5
  import os from 'os';
6
6
  import path from 'path';
7
+ /**
8
+ * The skill categories `SKILLS_MAP` partitions shipped skills into.
9
+ *
10
+ * This list is the single source of truth (#1308). `SkillsConfig` is DERIVED
11
+ * from it and `SKILLS_MAP` is TYPED by it, so the two can never drift again.
12
+ *
13
+ * They did drift, silently, and it disabled the whole selection mechanism:
14
+ * `SkillsConfig` declared `core`/`github`/`browser` while `SKILLS_MAP` keyed on
15
+ * `core`/`memory`/`spells`. `copySkills` joins them by key, so `memory` and
16
+ * `spells` resolved to `undefined` → falsy → never installed, while
17
+ * `github`/`browser` selected nothing at all. An `as keyof typeof` cast in the
18
+ * lookup suppressed the type error that would otherwise have caught it. Adding
19
+ * a category to one side without the other is now a compile error.
20
+ */
21
+ export const SKILL_CATEGORIES = ['core', 'memory', 'spells'];
7
22
  /**
8
23
  * Detect current platform
9
24
  */
@@ -69,10 +84,15 @@ export const DEFAULT_INIT_OPTIONS = {
69
84
  timeout: 5000,
70
85
  continueOnError: true,
71
86
  },
87
+ // Every category. This is not a behaviour change: the session-start launcher
88
+ // syncs all shipped skills on every run regardless of what init selected, so
89
+ // consumers already end up with all of them. Pre-#1308 this said
90
+ // `core/github/browser`, which resolved to core-only — meaning `flo init` and
91
+ // the launcher disagreed, and init's answer simply lost. Now they agree.
72
92
  skills: {
73
93
  core: true,
74
- github: true,
75
- browser: true,
94
+ memory: true,
95
+ spells: true,
76
96
  all: false,
77
97
  },
78
98
  commands: {
@@ -159,10 +179,11 @@ export const MINIMAL_INIT_OPTIONS = {
159
179
  stop: false,
160
180
  notification: false,
161
181
  },
182
+ // Minimal deliberately stays core-only — that is the point of the preset.
162
183
  skills: {
163
184
  core: true,
164
- github: false,
165
- browser: false,
185
+ memory: false,
186
+ spells: false,
166
187
  all: false,
167
188
  },
168
189
  agents: {
@@ -217,8 +238,8 @@ export const FULL_INIT_OPTIONS = {
217
238
  },
218
239
  skills: {
219
240
  core: true,
220
- github: true,
221
- browser: true,
241
+ memory: true,
242
+ spells: true,
222
243
  all: true,
223
244
  },
224
245
  commands: {
@@ -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.12.1';
5
+ export const VERSION = '4.12.2';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.12.1",
3
+ "version": "4.12.2",
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",
@@ -95,7 +95,7 @@
95
95
  "@typescript-eslint/eslint-plugin": "^7.18.0",
96
96
  "@typescript-eslint/parser": "^7.18.0",
97
97
  "eslint": "^8.0.0",
98
- "moflo": "^4.12.0",
98
+ "moflo": "^4.12.1",
99
99
  "tsx": "^4.21.0",
100
100
  "typescript": "^5.9.3",
101
101
  "vitest": "^4.0.0"