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.
- package/.claude/agents/base-template-generator.md +21 -1
- package/.claude/guidance/shipped/moflo-yaml-reference.md +40 -0
- package/.claude/helpers/gate.cjs +117 -13
- package/.claude/skills/distill/SKILL.md +1 -1
- package/.claude/skills/fl/SKILL.md +35 -14
- package/.claude/skills/fl/sdd.md +4 -2
- package/.claude/skills/perf-audit/SKILL.md +1 -1
- package/.claude/skills/test-gaps/SKILL.md +1 -1
- package/bin/gate.cjs +117 -13
- package/bin/lib/retired-files.mjs +125 -5
- package/bin/lib/skill-categories.mjs +158 -0
- package/bin/session-start-launcher.mjs +79 -3
- package/dist/src/cli/commands/init.js +32 -11
- package/dist/src/cli/commands/sdd.js +119 -1
- package/dist/src/cli/index.js +15 -0
- package/dist/src/cli/init/executor.js +11 -4
- package/dist/src/cli/init/helpers-generator.js +99 -10
- package/dist/src/cli/init/index.js +1 -1
- package/dist/src/cli/init/types.js +27 -6
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
|
@@ -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') {
|
|
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:
|
|
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: '
|
|
375
|
-
{ value: '
|
|
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
|
-
|
|
379
|
-
|
|
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
|
|
586
|
-
{ name: 'core', description: 'Install core skills', type: 'boolean', default: true },
|
|
587
|
-
{ name: '
|
|
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
|
-
|
|
610
|
-
|
|
630
|
+
memory: ctx.flags.memory,
|
|
631
|
+
spells: ctx.flags.spells,
|
|
611
632
|
},
|
|
612
633
|
};
|
|
613
634
|
const spinner = output.createSpinner({ text: 'Installing skills...' });
|