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.
- package/.claude/agents/base-template-generator.md +21 -1
- package/.claude/guidance/shipped/moflo-yaml-reference.md +40 -0
- package/.claude/helpers/gate.cjs +12 -4
- package/.claude/skills/distill/SKILL.md +1 -1
- package/.claude/skills/perf-audit/SKILL.md +1 -1
- package/.claude/skills/test-gaps/SKILL.md +1 -1
- package/bin/gate.cjs +12 -4
- 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/index.js +15 -0
- package/dist/src/cli/init/executor.js +11 -4
- package/dist/src/cli/init/helpers-generator.js +11 -4
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: base-template-generator
|
|
3
|
-
description:
|
|
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) |
|
package/.claude/helpers/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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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') {
|
|
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...' });
|
package/dist/src/cli/index.js
CHANGED
|
@@ -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
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
221
|
-
|
|
241
|
+
memory: true,
|
|
242
|
+
spells: true,
|
|
222
243
|
all: true,
|
|
223
244
|
},
|
|
224
245
|
commands: {
|
package/dist/src/cli/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "moflo",
|
|
3
|
-
"version": "4.12.
|
|
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.
|
|
98
|
+
"moflo": "^4.12.1",
|
|
99
99
|
"tsx": "^4.21.0",
|
|
100
100
|
"typescript": "^5.9.3",
|
|
101
101
|
"vitest": "^4.0.0"
|