moflo 4.12.0 → 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.
@@ -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...' });