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.
Files changed (50) hide show
  1. package/.claude/agents/base-template-generator.md +21 -1
  2. package/.claude/guidance/shipped/moflo-yaml-reference.md +40 -0
  3. package/.claude/helpers/gate.cjs +12 -4
  4. package/.claude/skills/distill/SKILL.md +1 -1
  5. package/.claude/skills/perf-audit/SKILL.md +1 -1
  6. package/.claude/skills/test-gaps/SKILL.md +1 -1
  7. package/bin/gate.cjs +12 -4
  8. package/bin/hooks.mjs +5 -2
  9. package/bin/index-all.mjs +38 -0
  10. package/bin/lib/moflo-paths.mjs +37 -1
  11. package/bin/lib/process-manager.mjs +16 -1
  12. package/bin/lib/registry-cleanup.cjs +9 -1
  13. package/bin/lib/retired-files.mjs +125 -5
  14. package/bin/lib/skill-categories.mjs +158 -0
  15. package/bin/session-start-launcher.mjs +84 -5
  16. package/dist/src/cli/commands/agent.js +7 -2
  17. package/dist/src/cli/commands/benchmark.js +5 -2
  18. package/dist/src/cli/commands/daemon.js +81 -9
  19. package/dist/src/cli/commands/doctor-checks-config.js +55 -19
  20. package/dist/src/cli/commands/doctor-checks-memory.js +3 -2
  21. package/dist/src/cli/commands/doctor-checks-writers-audit.js +5 -1
  22. package/dist/src/cli/commands/doctor-embedding-hygiene.js +2 -1
  23. package/dist/src/cli/commands/doctor-fixes.js +20 -5
  24. package/dist/src/cli/commands/doctor-zombies.js +4 -3
  25. package/dist/src/cli/commands/embeddings.js +10 -5
  26. package/dist/src/cli/commands/hooks.js +11 -8
  27. package/dist/src/cli/commands/init.js +32 -11
  28. package/dist/src/cli/commands/swarm.js +2 -1
  29. package/dist/src/cli/index.js +33 -6
  30. package/dist/src/cli/init/executor.js +11 -4
  31. package/dist/src/cli/init/helpers-generator.js +11 -4
  32. package/dist/src/cli/init/index.js +1 -1
  33. package/dist/src/cli/init/types.js +27 -6
  34. package/dist/src/cli/memory/bridge-core.js +8 -2
  35. package/dist/src/cli/memory/daemon-write-client.js +5 -5
  36. package/dist/src/cli/memory/database-provider.js +14 -5
  37. package/dist/src/cli/memory/entries-read.js +5 -4
  38. package/dist/src/cli/memory/entries-write.js +3 -2
  39. package/dist/src/cli/memory/hnsw-singleton.js +2 -1
  40. package/dist/src/cli/memory/init.js +14 -8
  41. package/dist/src/cli/memory/learnings-overview.js +2 -1
  42. package/dist/src/cli/runtime/headless.js +8 -3
  43. package/dist/src/cli/services/daemon-dashboard.js +4 -4
  44. package/dist/src/cli/services/embeddings-migration.js +2 -1
  45. package/dist/src/cli/services/ephemeral-namespace-purge.js +3 -2
  46. package/dist/src/cli/services/project-root.js +108 -1
  47. package/dist/src/cli/services/soft-delete-purge.js +2 -1
  48. package/dist/src/cli/services/worker-daemon.js +67 -1
  49. package/dist/src/cli/version.js +1 -1
  50. package/package.json +8 -7
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: base-template-generator
3
- description: Use this agent when you need to create foundational templates, boilerplate code, or starter configurations for new projects, components, or features. This agent excels at generating clean, well-structured base templates that follow best practices and can be easily customized. Examples: <example>Context: User needs to start a new React component and wants a solid foundation. user: 'I need to create a new user profile component' assistant: 'I'll use the base-template-generator agent to create a comprehensive React component template with proper structure, TypeScript definitions, and styling setup.' <commentary>Since the user needs a foundational template for a new component, use the base-template-generator agent to create a well-structured starting point.</commentary></example> <example>Context: User is setting up a new API endpoint and needs a template. user: 'Can you help me set up a new REST API endpoint for user management?' assistant: 'I'll use the base-template-generator agent to create a complete API endpoint template with proper error handling, validation, and documentation structure.' <commentary>The user needs a foundational template for an API endpoint, so use the base-template-generator agent to provide a comprehensive starting point.</commentary></example>
3
+ description: Create foundational templates, boilerplate code, and starter configurations for new projects, components, or features. Use for scaffolding a new component, API endpoint, model, config, or test suite from scratch.
4
4
  color: orange
5
5
  ---
6
6
 
@@ -19,6 +19,26 @@ On chunk hits where `navigation` is non-null, traverse via `mcp__moflo__memory_g
19
19
 
20
20
  You are a Base Template Generator, an expert architect specializing in creating clean, well-structured foundational templates and boilerplate code. Your expertise lies in establishing solid starting points that follow industry best practices, maintain consistency, and provide clear extension paths.
21
21
 
22
+ ## When you get dispatched
23
+
24
+ Worked examples of the requests that route here — these live in the body, not the
25
+ frontmatter `description`, because the description is always-resident routing
26
+ context while this body only loads on dispatch.
27
+
28
+ <example>
29
+ Context: User needs to start a new React component and wants a solid foundation.
30
+ user: 'I need to create a new user profile component'
31
+ assistant: 'I'll use the base-template-generator agent to create a comprehensive React component template with proper structure, TypeScript definitions, and styling setup.'
32
+ <commentary>Since the user needs a foundational template for a new component, use the base-template-generator agent to create a well-structured starting point.</commentary>
33
+ </example>
34
+
35
+ <example>
36
+ Context: User is setting up a new API endpoint and needs a template.
37
+ user: 'Can you help me set up a new REST API endpoint for user management?'
38
+ assistant: 'I'll use the base-template-generator agent to create a complete API endpoint template with proper error handling, validation, and documentation structure.'
39
+ <commentary>The user needs a foundational template for an API endpoint, so use the base-template-generator agent to provide a comprehensive starting point.</commentary>
40
+ </example>
41
+
22
42
  Your core responsibilities:
23
43
  - Generate comprehensive base templates for components, modules, APIs, configurations, and project structures
24
44
  - Ensure all templates follow established coding standards and best practices from the project's CLAUDE.md guidelines
@@ -123,14 +123,54 @@ auto_update:
123
123
  helpers: true # Sync .claude/helpers/ from moflo source
124
124
  hook_block_drift: regenerate # warn | regenerate | off (default: regenerate since #1227)
125
125
  claudemd_injection_drift: regenerate # warn | regenerate | off
126
+
127
+ # Which skill categories to install (OPTIONAL — omit to get every skill)
128
+ skills:
129
+ categories: [core, memory, spells] # core | memory | spells
126
130
  ```
127
131
 
128
132
  If your `moflo.yaml` predates the `sandbox:` or `auto_update:` blocks, they are auto-appended on the next session start — you never need to re-run `moflo init` after a version bump.
129
133
 
134
+ ### Narrowing Installed Skills with `skills.categories`
135
+
136
+ **Omit the `skills:` block entirely to receive every moflo skill — that is the default and the recommended setting.** Add `skills.categories` only when you want a smaller always-resident skill listing, and expect to lose the commands in the categories you drop.
137
+
138
+ Every installed skill's name and description sit in Claude's context in every session, so a project that will never author a spell can reclaim that space. The cost is real but small — the full set is roughly 2,000 tokens.
139
+
140
+ | Category | Skills | Drop it when |
141
+ |----------|--------|--------------|
142
+ | `core` | `/fl`, `/healer`, `/verify`, `/flo-simplify`, `/eldar`, `/quicken`, `/ward`, `/divine`, `/meditate`, `/guidance`, `/commune`, `/luminarium` | Never — these are the everyday commands |
143
+ | `memory` | `/memory-patterns`, `/vector-search`, `/memory-optimization`, `/memory-team`, `/memory-worktree` | You are not building on moflo's memory stack and have finished any team/worktree sharing setup |
144
+ | `spells` | `/spell-builder`, `/spell-schedule`, `/connector-builder` | You do not author or schedule spells in this project |
145
+
146
+ Both YAML list styles work:
147
+
148
+ ```yaml
149
+ skills:
150
+ categories: [core, memory]
151
+ ```
152
+
153
+ ```yaml
154
+ skills:
155
+ categories:
156
+ - core
157
+ - memory
158
+ ```
159
+
160
+ **Key behaviors:**
161
+
162
+ - **Omitting `skills:` means no restriction.** Every shipped skill syncs on every session start, exactly as before this option existed.
163
+ - **`/flo` and `/fl` are never gated.** The ticket spell is the primary entry point and installs regardless of selection.
164
+ - **An unknown category name is ignored, not fatal.** A typo narrows nothing rather than silently stripping your skills.
165
+ - **Narrowing stops future syncing; it does not delete.** Skills already on disk stay until you remove them — moflo will not re-add them once excluded, so delete the directories under `.claude/skills/` to reclaim the context.
166
+ - **Re-widening is instant.** Add the category back and the next session start restores its skills.
167
+
130
168
  ### Key Behaviors
131
169
 
132
170
  | Config | Effect |
133
171
  |--------|--------|
172
+ | `skills.categories` omitted | Install every skill (default) |
173
+ | `skills.categories: [core]` | Install only core skills; `memory` + `spells` stop syncing |
134
174
  | `auto_index.guidance: false` | Skip guidance indexing on session start |
135
175
  | `auto_index.code_map: false` | Skip code map generation on session start |
136
176
  | `auto_meditate.enabled: false` | Opt out of automatic session-lesson distillation (`/meditate` still works manually) |
@@ -134,6 +134,13 @@ var command = process.argv[2];
134
134
 
135
135
  var EXEMPT = ['.claude/', '.claude\\', 'CLAUDE.md', 'MEMORY.md', 'workflow-state', 'node_modules', 'moflo.yaml'];
136
136
 
137
+ // Appended to every memory-first denial. Without it, a context audit of Claude
138
+ // Code's denial telemetry reads moflo's gate enforcements as permission-setup
139
+ // failures and "fixes" them with allow rules — which cannot override a hook
140
+ // block at all (#1307 finding 5). Naming the gate makes the cause self-evident.
141
+ var GATE_ORIGIN_NOTE = 'This is a moflo hook, not a Claude Code permission rule — allow-rules cannot override it.';
142
+ var GATE_DISABLE_NOTE = 'Disable per-gate via moflo.yaml: gates: memory_first: false';
143
+
137
144
  // #1294 Finding 3 — reads/scans of EPHEMERAL files under the OS temp dir
138
145
  // (background-task output/transcripts, agent scratchpads) are transient tool
139
146
  // I/O and never carry indexable project knowledge, so they must not trip the
@@ -713,7 +720,7 @@ switch (command) {
713
720
  var target = (process.env.TOOL_INPUT_pattern || '') + ' ' + (process.env.TOOL_INPUT_path || '');
714
721
  if (isEphemeralPath(process.env.TOOL_INPUT_path)) break;
715
722
  if (EXEMPT.some(function(p) { return target.indexOf(p) >= 0; })) break;
716
- process.stderr.write('BLOCKED: Search memory before exploring files. Use mcp__moflo__memory_search. On chunk hits, traverse via mcp__moflo__memory_get_neighbors — see .claude/guidance/moflo-memory-protocol.md\n');
723
+ process.stderr.write('BLOCKED [moflo memory_first gate]: Search memory before exploring files. Use mcp__moflo__memory_search. On chunk hits, traverse via mcp__moflo__memory_get_neighbors — see .claude/guidance/moflo-memory-protocol.md\n' + GATE_ORIGIN_NOTE + '\n' + GATE_DISABLE_NOTE + '\n');
717
724
  process.exit(2);
718
725
  }
719
726
  case 'check-before-read': {
@@ -726,7 +733,7 @@ switch (command) {
726
733
  if (isEphemeralPath(fp)) break;
727
734
  var isGuidance = fp.indexOf('.claude/guidance/') >= 0 || fp.indexOf('.claude\\guidance\\') >= 0;
728
735
  if (!isGuidance && EXEMPT.some(function(p) { return fp.indexOf(p) >= 0; })) break;
729
- process.stderr.write('BLOCKED: Search memory before reading files. Use mcp__moflo__memory_search. On chunk hits, traverse via mcp__moflo__memory_get_neighbors — see .claude/guidance/moflo-memory-protocol.md\n');
736
+ process.stderr.write('BLOCKED [moflo memory_first gate]: Search memory before reading files. Use mcp__moflo__memory_search. On chunk hits, traverse via mcp__moflo__memory_get_neighbors — see .claude/guidance/moflo-memory-protocol.md\n' + GATE_ORIGIN_NOTE + '\n' + GATE_DISABLE_NOTE + '\n');
730
737
  process.exit(2);
731
738
  }
732
739
  case 'record-task-created': {
@@ -775,11 +782,12 @@ switch (command) {
775
782
  // "Memory namespace hint: ..." sentence so the BLOCK message stays uniform.
776
783
  var hint = s2.lastNamespaceHint || classifyBashNamespaceHint(cmd) || '';
777
784
  process.stderr.write(
778
- 'BLOCKED: Search memory before reading files via Bash.\n' +
785
+ 'BLOCKED [moflo memory_first gate]: Search memory before reading files via Bash.\n' +
779
786
  'Example: mcp__moflo__memory_search { query: "<topic>", namespace: "<one of: guidance | code-map | patterns | learnings | tests>" }\n' +
780
787
  (hint ? hint + '\n' : '') +
781
788
  'On chunk hits, traverse via mcp__moflo__memory_get_neighbors — see .claude/guidance/moflo-memory-protocol.md\n' +
782
- 'Disable per-gate via moflo.yaml: gates: memory_first: false\n'
789
+ GATE_ORIGIN_NOTE + '\n' +
790
+ GATE_DISABLE_NOTE + '\n'
783
791
  );
784
792
  process.exit(2);
785
793
  break;
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: distill
3
- description: Alias for /flo-simplify. Review changed code for reuse, quality, and efficiency, then fix any issues found effort scales to the diff size.
3
+ description: Alias for /flo-simplify — see that skill's description.
4
4
  ---
5
5
 
6
6
  # /distill is an alias for /flo-simplify
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: perf-audit
3
- description: Alias for /quicken. Ad-hoc performance audit reveals N+1 queries, needless re-renders, missing caching, memory leaks, and redundant computation in your diff (or a named path), and prescribes the fix inline.
3
+ description: Alias for /quicken — see that skill's description.
4
4
  arguments: "[path or area | --all]"
5
5
  ---
6
6
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: test-gaps
3
- description: Alias for /ward. Ad-hoc test-gap audit reveals untested functions, uncovered edge cases, and missing error-handling/integration tests in your diff (or a named path), and conjures ready-to-paste test skeletons inline.
3
+ description: Alias for /ward — see that skill's description.
4
4
  arguments: "[path or area | --all]"
5
5
  ---
6
6
 
package/bin/gate.cjs CHANGED
@@ -134,6 +134,13 @@ var command = process.argv[2];
134
134
 
135
135
  var EXEMPT = ['.claude/', '.claude\\', 'CLAUDE.md', 'MEMORY.md', 'workflow-state', 'node_modules', 'moflo.yaml'];
136
136
 
137
+ // Appended to every memory-first denial. Without it, a context audit of Claude
138
+ // Code's denial telemetry reads moflo's gate enforcements as permission-setup
139
+ // failures and "fixes" them with allow rules — which cannot override a hook
140
+ // block at all (#1307 finding 5). Naming the gate makes the cause self-evident.
141
+ var GATE_ORIGIN_NOTE = 'This is a moflo hook, not a Claude Code permission rule — allow-rules cannot override it.';
142
+ var GATE_DISABLE_NOTE = 'Disable per-gate via moflo.yaml: gates: memory_first: false';
143
+
137
144
  // #1294 Finding 3 — reads/scans of EPHEMERAL files under the OS temp dir
138
145
  // (background-task output/transcripts, agent scratchpads) are transient tool
139
146
  // I/O and never carry indexable project knowledge, so they must not trip the
@@ -713,7 +720,7 @@ switch (command) {
713
720
  var target = (process.env.TOOL_INPUT_pattern || '') + ' ' + (process.env.TOOL_INPUT_path || '');
714
721
  if (isEphemeralPath(process.env.TOOL_INPUT_path)) break;
715
722
  if (EXEMPT.some(function(p) { return target.indexOf(p) >= 0; })) break;
716
- process.stderr.write('BLOCKED: Search memory before exploring files. Use mcp__moflo__memory_search. On chunk hits, traverse via mcp__moflo__memory_get_neighbors — see .claude/guidance/moflo-memory-protocol.md\n');
723
+ process.stderr.write('BLOCKED [moflo memory_first gate]: Search memory before exploring files. Use mcp__moflo__memory_search. On chunk hits, traverse via mcp__moflo__memory_get_neighbors — see .claude/guidance/moflo-memory-protocol.md\n' + GATE_ORIGIN_NOTE + '\n' + GATE_DISABLE_NOTE + '\n');
717
724
  process.exit(2);
718
725
  }
719
726
  case 'check-before-read': {
@@ -726,7 +733,7 @@ switch (command) {
726
733
  if (isEphemeralPath(fp)) break;
727
734
  var isGuidance = fp.indexOf('.claude/guidance/') >= 0 || fp.indexOf('.claude\\guidance\\') >= 0;
728
735
  if (!isGuidance && EXEMPT.some(function(p) { return fp.indexOf(p) >= 0; })) break;
729
- process.stderr.write('BLOCKED: Search memory before reading files. Use mcp__moflo__memory_search. On chunk hits, traverse via mcp__moflo__memory_get_neighbors — see .claude/guidance/moflo-memory-protocol.md\n');
736
+ process.stderr.write('BLOCKED [moflo memory_first gate]: Search memory before reading files. Use mcp__moflo__memory_search. On chunk hits, traverse via mcp__moflo__memory_get_neighbors — see .claude/guidance/moflo-memory-protocol.md\n' + GATE_ORIGIN_NOTE + '\n' + GATE_DISABLE_NOTE + '\n');
730
737
  process.exit(2);
731
738
  }
732
739
  case 'record-task-created': {
@@ -775,11 +782,12 @@ switch (command) {
775
782
  // "Memory namespace hint: ..." sentence so the BLOCK message stays uniform.
776
783
  var hint = s2.lastNamespaceHint || classifyBashNamespaceHint(cmd) || '';
777
784
  process.stderr.write(
778
- 'BLOCKED: Search memory before reading files via Bash.\n' +
785
+ 'BLOCKED [moflo memory_first gate]: Search memory before reading files via Bash.\n' +
779
786
  'Example: mcp__moflo__memory_search { query: "<topic>", namespace: "<one of: guidance | code-map | patterns | learnings | tests>" }\n' +
780
787
  (hint ? hint + '\n' : '') +
781
788
  'On chunk hits, traverse via mcp__moflo__memory_get_neighbors — see .claude/guidance/moflo-memory-protocol.md\n' +
782
- 'Disable per-gate via moflo.yaml: gates: memory_first: false\n'
789
+ GATE_ORIGIN_NOTE + '\n' +
790
+ GATE_DISABLE_NOTE + '\n'
783
791
  );
784
792
  process.exit(2);
785
793
  break;
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 { findProjectRoot } from './lib/moflo-paths.mjs';
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
- const projectRoot = findProjectRoot();
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);
@@ -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
- process.kill(entry.pid, 'SIGTERM');
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.kill(entries[i].pid, 'SIGTERM');
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') { report.preserved.push(entry.path); continue; }
180
+ if (action === 'preserve') {
181
+ report.preserved.push(entry.path);
182
+ report.preservedDetails.push({
183
+ path: entry.path,
184
+ ...(entry.retiredIn ? { retiredIn: entry.retiredIn } : {}),
185
+ ...(entry.retiredBy ? { retiredBy: entry.retiredBy } : {}),
186
+ });
187
+ continue;
188
+ }
170
189
  if (action === 'unknown') { report.unknown.push(entry.path); continue; }
171
190
  // action === 'prune'
172
191
  try {
@@ -181,3 +200,104 @@ export function applyRetiredPrune(projectRoot, manifestPath) {
181
200
  }
182
201
  return report;
183
202
  }
203
+
204
+ /**
205
+ * Reconcile an existing retained record against what is actually on disk.
206
+ *
207
+ * `writeRetainedRecord` only runs inside the launcher's UPGRADE branch (that
208
+ * is where `applyRetiredPrune` lives), so on its own it cannot keep the record
209
+ * honest: a user who reads the record and deletes the files would keep a
210
+ * record naming those files until their next moflo upgrade — the record would
211
+ * outlive what it describes, which is the same "not actionable" complaint
212
+ * #1307 raised about the truncated banner.
213
+ *
214
+ * This runs on EVERY session start instead, and is written to cost nothing in
215
+ * the common case: one `existsSync` when no record is present (overwhelmingly
216
+ * the norm), and only then a stat per retained entry.
217
+ *
218
+ * Never throws — advisory bookkeeping must not break session start.
219
+ *
220
+ * @param {string} projectRoot
221
+ * @returns {{ changed: boolean, removed: string[], remaining: number }}
222
+ */
223
+ export function reconcileRetainedRecord(projectRoot) {
224
+ const result = { changed: false, removed: [], remaining: 0 };
225
+ const abs = resolve(projectRoot, RETAINED_RECORD_REL);
226
+ try {
227
+ if (!existsSync(abs)) return result; // fast path — no record
228
+ // Corrupt/hand-edited record: drop it rather than reason about it. Parsed
229
+ // in its own try so a JSON error lands here and the bad file is actually
230
+ // removed, instead of falling to the outer catch and lingering forever.
231
+ let parsed = null;
232
+ try {
233
+ parsed = JSON.parse(readFileSync(abs, 'utf-8'));
234
+ } catch { /* handled below */ }
235
+ if (!parsed || !Array.isArray(parsed.retained)) {
236
+ unlinkSync(abs);
237
+ result.changed = true;
238
+ return result;
239
+ }
240
+ const stillPresent = [];
241
+ for (const entry of parsed.retained) {
242
+ if (entry && typeof entry.path === 'string' && existsSync(resolve(projectRoot, entry.path))) {
243
+ stillPresent.push(entry);
244
+ } else if (entry && typeof entry.path === 'string') {
245
+ result.removed.push(entry.path);
246
+ }
247
+ }
248
+ result.remaining = stillPresent.length;
249
+ if (result.removed.length === 0) return result; // nothing to do
250
+ result.changed = true;
251
+ if (stillPresent.length === 0) {
252
+ unlinkSync(abs);
253
+ return result;
254
+ }
255
+ writeFileSync(abs, JSON.stringify({ ...parsed, retained: stillPresent }, null, 2) + '\n', 'utf-8');
256
+ return result;
257
+ } catch {
258
+ return result;
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Persist the retained (customized-and-therefore-preserved) retired files to
264
+ * `.moflo/retired-retained.json` (#1307 finding 3).
265
+ *
266
+ * Before this, the only place the retained set appeared was a launcher stdout
267
+ * banner truncated to 5 entries — so "delete manually if unwanted" was not
268
+ * actionable for the remainder, and nothing on disk let you reconstruct the
269
+ * set (`installed-files.json` doesn't track pre-#948 agents/skills).
270
+ *
271
+ * The record is advisory state, not a manifest: it is rewritten from scratch
272
+ * on every launcher run, and deleted when nothing is retained so a stale file
273
+ * never claims paths the consumer has since removed.
274
+ *
275
+ * Never throws — a failure to write an advisory record must not break session
276
+ * start on any consumer. Returns the absolute path written, or null.
277
+ *
278
+ * @param {string} projectRoot
279
+ * @param {Array<{path:string, retiredIn?:string, retiredBy?:string}>} preservedDetails
280
+ * @param {string} [mofloVersion] - version that produced this record, if known
281
+ * @returns {string|null} absolute path written, or null if nothing was written
282
+ */
283
+ export function writeRetainedRecord(projectRoot, preservedDetails, mofloVersion) {
284
+ const abs = resolve(projectRoot, RETAINED_RECORD_REL);
285
+ try {
286
+ if (!Array.isArray(preservedDetails) || preservedDetails.length === 0) {
287
+ // Nothing retained — drop any record from a previous run.
288
+ if (existsSync(abs)) unlinkSync(abs);
289
+ return null;
290
+ }
291
+ mkdirSync(dirname(abs), { recursive: true });
292
+ const payload = {
293
+ version: 1,
294
+ note: 'Retired moflo files preserved because they were customized locally. Safe to delete manually if unwanted; moflo will not remove them.',
295
+ ...(mofloVersion ? { mofloVersion } : {}),
296
+ retained: preservedDetails,
297
+ };
298
+ writeFileSync(abs, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
299
+ return abs;
300
+ } catch {
301
+ return null;
302
+ }
303
+ }