moflo 4.12.1 → 4.12.3-rc.1
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/hooks.mjs +5 -2
- package/bin/index-all.mjs +38 -0
- package/bin/lib/moflo-paths.mjs +37 -1
- package/bin/lib/process-manager.mjs +16 -1
- package/bin/lib/registry-cleanup.cjs +9 -1
- package/bin/lib/retired-files.mjs +125 -5
- package/bin/lib/skill-categories.mjs +158 -0
- package/bin/session-start-launcher.mjs +84 -5
- package/dist/src/cli/commands/agent.js +7 -2
- package/dist/src/cli/commands/benchmark.js +5 -2
- package/dist/src/cli/commands/daemon.js +81 -9
- package/dist/src/cli/commands/doctor-checks-config.js +55 -19
- package/dist/src/cli/commands/doctor-checks-memory.js +3 -2
- package/dist/src/cli/commands/doctor-checks-writers-audit.js +5 -1
- package/dist/src/cli/commands/doctor-embedding-hygiene.js +2 -1
- package/dist/src/cli/commands/doctor-fixes.js +20 -5
- package/dist/src/cli/commands/doctor-zombies.js +4 -3
- package/dist/src/cli/commands/embeddings.js +10 -5
- package/dist/src/cli/commands/hooks.js +11 -8
- package/dist/src/cli/commands/init.js +32 -11
- package/dist/src/cli/commands/swarm.js +2 -1
- package/dist/src/cli/index.js +33 -6
- 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/memory/bridge-core.js +8 -2
- package/dist/src/cli/memory/daemon-write-client.js +5 -5
- package/dist/src/cli/memory/database-provider.js +14 -5
- package/dist/src/cli/memory/entries-read.js +5 -4
- package/dist/src/cli/memory/entries-write.js +3 -2
- package/dist/src/cli/memory/hnsw-singleton.js +2 -1
- package/dist/src/cli/memory/init.js +14 -8
- package/dist/src/cli/memory/learnings-overview.js +2 -1
- package/dist/src/cli/runtime/headless.js +8 -3
- package/dist/src/cli/services/daemon-dashboard.js +4 -4
- package/dist/src/cli/services/embeddings-migration.js +2 -1
- package/dist/src/cli/services/ephemeral-namespace-purge.js +3 -2
- package/dist/src/cli/services/project-root.js +108 -1
- package/dist/src/cli/services/soft-delete-purge.js +2 -1
- package/dist/src/cli/services/worker-daemon.js +67 -1
- package/dist/src/cli/version.js +1 -1
- package/package.json +8 -7
|
@@ -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;
|
package/bin/hooks.mjs
CHANGED
|
@@ -26,7 +26,7 @@ import { fileURLToPath, pathToFileURL } from 'url';
|
|
|
26
26
|
import { createProcessManager } from './lib/process-manager.mjs';
|
|
27
27
|
import { shouldDaemonAutoStart } from './lib/daemon-config.mjs';
|
|
28
28
|
import { resolveMofloBin } from './lib/resolve-bin.mjs';
|
|
29
|
-
import {
|
|
29
|
+
import { resolveStateRoot } from './lib/moflo-paths.mjs';
|
|
30
30
|
|
|
31
31
|
const __filename = fileURLToPath(import.meta.url);
|
|
32
32
|
const __dirname = dirname(__filename);
|
|
@@ -35,7 +35,10 @@ const __dirname = dirname(__filename);
|
|
|
35
35
|
// in bin/ during development but gets synced to .claude/scripts/ in consumer
|
|
36
36
|
// projects, so __dirname-relative paths break. findProjectRoot() works
|
|
37
37
|
// everywhere and resolves identically to the TS bridge (see lib/moflo-paths.mjs).
|
|
38
|
-
|
|
38
|
+
// #1315 — resolveStateRoot, NOT findProjectRoot: this layer mkdirs
|
|
39
|
+
// `.moflo/` state, and findProjectRoot honors CLAUDE_PROJECT_DIR verbatim,
|
|
40
|
+
// so a sub-workspace-rooted session minted an island here every start.
|
|
41
|
+
const projectRoot = resolveStateRoot();
|
|
39
42
|
const logFile = resolve(projectRoot, '.moflo', 'logs', 'hooks.log');
|
|
40
43
|
try { mkdirSync(dirname(logFile), { recursive: true }); } catch { /* best effort */ }
|
|
41
44
|
const pm = createProcessManager(projectRoot);
|
package/bin/index-all.mjs
CHANGED
|
@@ -111,6 +111,41 @@ function killProcessTree(child) {
|
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
// The step currently in flight, so a termination signal can reap it (#1317).
|
|
115
|
+
let currentChild = null;
|
|
116
|
+
let shuttingDown = false;
|
|
117
|
+
|
|
118
|
+
// Reap on SIGTERM/SIGINT (#1317). hooks.mjs session-end calls pm.killAll(),
|
|
119
|
+
// which SIGTERMs THIS process. Without a handler Node's default action kills us
|
|
120
|
+
// instantly and the in-flight step is orphaned: it lives in its OWN process
|
|
121
|
+
// group (detached:true in runStep, below), so the signal aimed at us never
|
|
122
|
+
// reaches it, and no group-kill of our group can either. It reparents to init
|
|
123
|
+
// and keeps burning CPU and writing .moflo/moflo.db after the session is gone —
|
|
124
|
+
// build-embeddings most often, the same ~2 GB fastembed process as #744.
|
|
125
|
+
// killProcessTree is the teardown primitive #744 already built; it was simply
|
|
126
|
+
// never wired to the signal path.
|
|
127
|
+
function handleTerminationSignal(signal) {
|
|
128
|
+
if (shuttingDown) return; // second signal while tearing down — ignore
|
|
129
|
+
shuttingDown = true;
|
|
130
|
+
log(`SIGNAL ${signal} — reaping in-flight step before exit`);
|
|
131
|
+
// Synchronous on both platforms: process.kill delivers before returning,
|
|
132
|
+
// spawnSync('taskkill') blocks. Safe to process.exit() immediately after.
|
|
133
|
+
killProcessTree(currentChild);
|
|
134
|
+
currentChild = null;
|
|
135
|
+
process.exit(signal === 'SIGINT' ? 130 : 143);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 'SIGTERM' is never delivered on Windows — Node permits the listener but it
|
|
139
|
+
// cannot fire, and Windows is already correct via taskkill /T. Registering it
|
|
140
|
+
// unconditionally is therefore harmless, not a portability assumption.
|
|
141
|
+
// 'SIGBREAK' is the Windows-only console-termination signal.
|
|
142
|
+
for (const sig of ['SIGTERM', 'SIGINT']) {
|
|
143
|
+
try { process.on(sig, () => handleTerminationSignal(sig)); } catch { /* signal unsupported here */ }
|
|
144
|
+
}
|
|
145
|
+
if (platform() === 'win32') {
|
|
146
|
+
try { process.on('SIGBREAK', () => handleTerminationSignal('SIGBREAK')); } catch { /* ignore */ }
|
|
147
|
+
}
|
|
148
|
+
|
|
114
149
|
function runStep(label, cmd, args, timeoutMs = 120_000, extraEnv = null) {
|
|
115
150
|
return new Promise((resolveStep) => {
|
|
116
151
|
const start = Date.now();
|
|
@@ -122,6 +157,7 @@ function runStep(label, cmd, args, timeoutMs = 120_000, extraEnv = null) {
|
|
|
122
157
|
detached: platform() !== 'win32', // POSIX: own process group for tree-kill
|
|
123
158
|
env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
|
|
124
159
|
});
|
|
160
|
+
currentChild = child;
|
|
125
161
|
let timedOut = false;
|
|
126
162
|
const timer = setTimeout(() => {
|
|
127
163
|
timedOut = true;
|
|
@@ -129,6 +165,7 @@ function runStep(label, cmd, args, timeoutMs = 120_000, extraEnv = null) {
|
|
|
129
165
|
}, timeoutMs);
|
|
130
166
|
child.once('exit', (code, signal) => {
|
|
131
167
|
clearTimeout(timer);
|
|
168
|
+
if (currentChild === child) currentChild = null;
|
|
132
169
|
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
|
133
170
|
if (timedOut) {
|
|
134
171
|
log(`FAIL ${label} (${elapsed}s): timed out after ${timeoutMs}ms, child killed`);
|
|
@@ -143,6 +180,7 @@ function runStep(label, cmd, args, timeoutMs = 120_000, extraEnv = null) {
|
|
|
143
180
|
});
|
|
144
181
|
child.once('error', (err) => {
|
|
145
182
|
clearTimeout(timer);
|
|
183
|
+
if (currentChild === child) currentChild = null;
|
|
146
184
|
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
|
147
185
|
log(`FAIL ${label} (${elapsed}s): ${err.message?.split('\n')[0] || 'unknown'}`);
|
|
148
186
|
resolveStep(false);
|
package/bin/lib/moflo-paths.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* helpers no longer ship: the version-bump-gated cherry-pick lives entirely
|
|
13
13
|
* in the launcher and the TS service `cli/services/cherry-pick-learnings.ts`.
|
|
14
14
|
*/
|
|
15
|
-
import { existsSync } from 'node:fs';
|
|
15
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
16
16
|
import { basename, dirname, join, parse, resolve } from 'node:path';
|
|
17
17
|
|
|
18
18
|
export const MOFLO_DIR = '.moflo';
|
|
@@ -127,6 +127,42 @@ export function findAncestorMofloRoot(dir) {
|
|
|
127
127
|
* @param {{ cwd?: string; honorEnv?: boolean }} [opts]
|
|
128
128
|
* @returns {string} absolute project root
|
|
129
129
|
*/
|
|
130
|
+
/**
|
|
131
|
+
* Pure-JS twin of `src/cli/services/project-root.ts:resolveStateRoot()` (#1315).
|
|
132
|
+
*
|
|
133
|
+
* Use this — never `findProjectRoot()` — anywhere the bin/hook layer is about
|
|
134
|
+
* to CREATE `.moflo/` state. `findProjectRoot` returns `CLAUDE_PROJECT_DIR`
|
|
135
|
+
* verbatim, and Claude Code sets that to the SESSION directory; in a monorepo
|
|
136
|
+
* session rooted at a sub-workspace the launcher therefore mkdir'd a `.moflo/`
|
|
137
|
+
* island there every session. That made the healer's nested-island fix a
|
|
138
|
+
* perpetual loop: archive it, next session re-mints it.
|
|
139
|
+
*
|
|
140
|
+
* Semantics (kept in lockstep with the TS side): an EXISTING
|
|
141
|
+
* `CLAUDE_PROJECT_DIR` wins outright and is never climbed above — the marker
|
|
142
|
+
* walk has no upper bound, so climbing past an explicit anchor would let a
|
|
143
|
+
* stray ancestor `.moflo/moflo.db` capture every project beneath it. Otherwise
|
|
144
|
+
* walk up from cwd and take the topmost marker, which is what stops a
|
|
145
|
+
* sub-directory invocation from minting an island where it stands. A value
|
|
146
|
+
* naming a directory that does not exist is ignored rather than materialized.
|
|
147
|
+
*
|
|
148
|
+
* @param {{ cwd?: string }} [opts]
|
|
149
|
+
* @returns {string} absolute project root
|
|
150
|
+
*/
|
|
151
|
+
export function resolveStateRoot(opts) {
|
|
152
|
+
const envDir = process.env.CLAUDE_PROJECT_DIR;
|
|
153
|
+
const envAbs = envDir ? resolve(envDir) : undefined;
|
|
154
|
+
const resolved = envAbs && existsSync(envAbs)
|
|
155
|
+
? envAbs
|
|
156
|
+
: findProjectRoot({ honorEnv: false, cwd: opts?.cwd });
|
|
157
|
+
// Canonicalize so this compares equal to the realpath'd root the TS side
|
|
158
|
+
// derives (macOS /var → /private/var). Falls back when the path is absent.
|
|
159
|
+
try {
|
|
160
|
+
return realpathSync(resolve(resolved));
|
|
161
|
+
} catch {
|
|
162
|
+
return resolve(resolved);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
130
166
|
export function findProjectRoot(opts) {
|
|
131
167
|
const honorEnv = opts?.honorEnv !== false;
|
|
132
168
|
if (honorEnv && process.env.CLAUDE_PROJECT_DIR) {
|
|
@@ -219,7 +219,22 @@ export function createProcessManager(root) {
|
|
|
219
219
|
if (process.platform === 'win32') {
|
|
220
220
|
execFileSync('taskkill', ['/T', '/F', '/PID', String(entry.pid)], { windowsHide: true, timeout: 10000 });
|
|
221
221
|
} else {
|
|
222
|
-
|
|
222
|
+
// POSIX tree-kill (#1317). Windows walks the tree via taskkill /T;
|
|
223
|
+
// a bare SIGTERM here only hit the one PID, so anything the tracked
|
|
224
|
+
// process left in its group survived session-end. Everything spawn()
|
|
225
|
+
// launches is detached (setsid), so the tracked PID leads its own
|
|
226
|
+
// group and the negative PID reaches those children.
|
|
227
|
+
//
|
|
228
|
+
// NOTE this does NOT reach grandchildren that called setsid again —
|
|
229
|
+
// index-all's steps do exactly that, which is why index-all reaps
|
|
230
|
+
// them itself on SIGTERM. This is defence in depth, not the whole fix.
|
|
231
|
+
try {
|
|
232
|
+
process.kill(-entry.pid, 'SIGTERM');
|
|
233
|
+
} catch {
|
|
234
|
+
// No such process group — e.g. an entry registered by
|
|
235
|
+
// registerBackgroundPid() whose process doesn't lead one.
|
|
236
|
+
process.kill(entry.pid, 'SIGTERM');
|
|
237
|
+
}
|
|
223
238
|
}
|
|
224
239
|
killed++;
|
|
225
240
|
} catch {
|
|
@@ -32,7 +32,15 @@ function killTrackedSync(projectDir) {
|
|
|
32
32
|
if (process.platform === 'win32') {
|
|
33
33
|
childProcess.execFileSync('taskkill', ['/T', '/F', '/PID', String(entries[i].pid)], { windowsHide: true, timeout: 5000 });
|
|
34
34
|
} else {
|
|
35
|
-
process.
|
|
35
|
+
// POSIX tree-kill — mirrors process-manager.mjs killAll (#1317).
|
|
36
|
+
// This is the second reaper over the same registry (session-end via
|
|
37
|
+
// hook-handler.cjs), so it must not be left behind on the bare-PID
|
|
38
|
+
// kill or session-end orphans depend on which path ran.
|
|
39
|
+
try {
|
|
40
|
+
process.kill(-entries[i].pid, 'SIGTERM');
|
|
41
|
+
} catch (groupErr) {
|
|
42
|
+
process.kill(entries[i].pid, 'SIGTERM');
|
|
43
|
+
}
|
|
36
44
|
}
|
|
37
45
|
killed++;
|
|
38
46
|
} catch (e) { /* ok */ }
|
|
@@ -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
|
+
}
|