moflo 4.10.20 → 4.10.21

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 (46) hide show
  1. package/.claude/guidance/shipped/moflo-cli-reference.md +1 -0
  2. package/.claude/guidance/shipped/moflo-core-guidance.md +1 -0
  3. package/.claude/guidance/shipped/moflo-skills-reference.md +108 -0
  4. package/.claude/guidance/shipped/moflo-yaml-reference.md +13 -0
  5. package/.claude/skills/commune/SKILL.md +140 -0
  6. package/.claude/skills/divine/SKILL.md +130 -0
  7. package/.claude/skills/meditate/SKILL.md +122 -0
  8. package/README.md +39 -3
  9. package/bin/index-all.mjs +2 -1
  10. package/bin/index-reference.mjs +221 -0
  11. package/bin/lib/file-sync.mjs +50 -1
  12. package/bin/lib/hook-io.mjs +63 -0
  13. package/bin/lib/index-fingerprint.mjs +0 -0
  14. package/bin/lib/internal-skills.mjs +16 -0
  15. package/bin/lib/meditate.mjs +497 -0
  16. package/bin/lib/pii-scrub.mjs +119 -0
  17. package/bin/lib/reference-docs.mjs +218 -0
  18. package/bin/lib/session-continuity.mjs +372 -0
  19. package/bin/lib/shipped-scripts.json +36 -0
  20. package/bin/lib/shipped-scripts.mjs +33 -0
  21. package/bin/lib/yaml-upgrader.mjs +62 -0
  22. package/bin/meditate-capture.mjs +123 -0
  23. package/bin/meditate-distill.mjs +121 -0
  24. package/bin/session-continuity.mjs +206 -0
  25. package/bin/session-start-launcher.mjs +140 -60
  26. package/dist/src/cli/config/moflo-config.js +18 -0
  27. package/dist/src/cli/init/executor.js +11 -17
  28. package/dist/src/cli/init/moflo-init.js +21 -19
  29. package/dist/src/cli/init/moflo-yaml-template.js +21 -0
  30. package/dist/src/cli/init/settings-generator.js +23 -1
  31. package/dist/src/cli/init/shipped-scripts.js +39 -0
  32. package/dist/src/cli/memory/bridge-core.js +20 -0
  33. package/dist/src/cli/memory/bridge-entries.js +8 -2
  34. package/dist/src/cli/memory/memory-bridge.js +6 -2
  35. package/dist/src/cli/memory/memory-initializer.js +6 -2
  36. package/dist/src/cli/services/hook-block-hash.js +9 -1
  37. package/dist/src/cli/services/hook-wiring.js +38 -0
  38. package/dist/src/cli/transfer/anonymization/index.js +146 -40
  39. package/dist/src/cli/transfer/deploy-seraphine.js +1 -1
  40. package/dist/src/cli/transfer/export.js +2 -2
  41. package/dist/src/cli/transfer/store/publish.js +1 -1
  42. package/dist/src/cli/version.js +1 -1
  43. package/package.json +2 -2
  44. package/scripts/post-install-bootstrap.mjs +22 -42
  45. package/dist/src/cli/hooks/llm/index.js +0 -11
  46. package/dist/src/cli/hooks/llm/llm-hooks.js +0 -382
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * @module v3/cli/bridge-entries
9
9
  */
10
- import { cosineSim, execRows, generateId, logBridgeError, persistBridgeDb, refreshVectorStatsCache, withDb } from './bridge-core.js';
10
+ import { cosineSim, execRows, generateId, logBridgeError, persistBridgeDb, refreshVectorStatsCache, searchCandidateCap, withDb } from './bridge-core.js';
11
11
  import { embeddingResponseFrom, getBridgeEmbedder, resolveBridgeEmbedding } from './bridge-embedder.js';
12
12
  import { errorDetail } from '../shared/utils/error-detail.js';
13
13
  /**
@@ -461,11 +461,17 @@ export async function bridgeSearchEntries(options) {
461
461
  const nsFilter = namespace !== 'all' ? `AND namespace = ?` : '';
462
462
  let rows;
463
463
  try {
464
+ // #1201 — ORDER BY created_at DESC before the cap. A bare `LIMIT 1000`
465
+ // (no ORDER BY) truncated the candidate pool by rowid, so on a populated
466
+ // DB the first 1000 rows were all bulk-indexed code-map and a
467
+ // no-namespace search never scored learnings/patterns/etc. Recency
468
+ // ordering keeps recent curated entries in the pool when truncation hits.
464
469
  const sql = `
465
470
  SELECT id, key, namespace, content, metadata, embedding
466
471
  FROM memory_entries
467
472
  WHERE status = 'active' ${nsFilter}
468
- LIMIT 1000
473
+ ORDER BY created_at DESC
474
+ LIMIT ${searchCandidateCap()}
469
475
  `;
470
476
  rows = namespace !== 'all' ? execRows(ctx.db, sql, [namespace]) : execRows(ctx.db, sql);
471
477
  }
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * @module v3/cli/memory-bridge
11
11
  */
12
- import { cosineSim, execRows, generateId, getRegistry, persistBridgeDb, withDb, } from './bridge-core.js';
12
+ import { cosineSim, execRows, generateId, getRegistry, persistBridgeDb, searchCandidateCap, withDb, } from './bridge-core.js';
13
13
  import { bridgeSearchEntries, bridgeStoreEntry, } from './bridge-entries.js';
14
14
  import { BRIDGE_EMBEDDING_MODEL, getBridgeEmbedder, } from './bridge-embedder.js';
15
15
  // ===== Re-exports: primitives =====
@@ -73,11 +73,15 @@ export async function bridgeSearchHNSW(queryEmbedding, options, dbPath) {
73
73
  : '';
74
74
  let rows;
75
75
  try {
76
+ // #1201 — recency-ordered candidate cap; see searchCandidateCap. A bare
77
+ // LIMIT truncated by rowid, hiding recent non-code-map namespaces from a
78
+ // no-namespace search.
76
79
  const sql = `
77
80
  SELECT id, key, namespace, content, embedding
78
81
  FROM memory_entries
79
82
  WHERE status = 'active' AND embedding IS NOT NULL ${nsFilter}
80
- LIMIT 10000
83
+ ORDER BY created_at DESC
84
+ LIMIT ${searchCandidateCap()}
81
85
  `;
82
86
  rows = nsFilter ? execRows(ctx.db, sql, [options.namespace]) : execRows(ctx.db, sql);
83
87
  }
@@ -22,7 +22,7 @@ import { HnswLite } from './hnsw-lite.js';
22
22
  import { tryLoadHnswSidecar } from './hnsw-persistence.js';
23
23
  import { EMBEDDING_MODEL_OPT_OUT, getBridgeEmbedder, isEphemeralNamespace } from './bridge-embedder.js';
24
24
  import { parseEmbeddingJson, toFloat32 } from './controllers/_shared.js';
25
- import { writeVectorStatsJson } from './bridge-core.js';
25
+ import { searchCandidateCap, writeVectorStatsJson } from './bridge-core.js';
26
26
  import { serialiseMetadata } from './bridge-entries.js';
27
27
  import { errorDetail } from '../shared/utils/error-detail.js';
28
28
  import { MOFLO_DIR, hnswIndexPath, legacyMemoryDbPath, memoryDbPath, } from '../services/moflo-paths.js';
@@ -1836,12 +1836,16 @@ export async function searchEntries(options) {
1836
1836
  // Fall back to brute-force SQLite search via the unified factory.
1837
1837
  const db = openDaemonDatabase(dbPath);
1838
1838
  // Get entries with embeddings
1839
+ // #1201 — recency-ordered candidate cap (see searchCandidateCap). A bare
1840
+ // LIMIT truncated by rowid, hiding recent non-code-map namespaces from a
1841
+ // no-namespace search.
1839
1842
  const entries = db.exec(`
1840
1843
  SELECT id, key, namespace, content, metadata, embedding
1841
1844
  FROM memory_entries
1842
1845
  WHERE status = 'active'
1843
1846
  ${namespace !== 'all' ? `AND namespace = '${namespace.replace(/'/g, "''")}'` : ''}
1844
- LIMIT 1000
1847
+ ORDER BY created_at DESC
1848
+ LIMIT ${searchCandidateCap()}
1845
1849
  `);
1846
1850
  const results = [];
1847
1851
  if (entries[0]?.values) {
@@ -37,6 +37,12 @@ const scriptHook = (file, timeout) => ({
37
37
  command: `node "${SCRIPTS_PREFIX}/${file}"`,
38
38
  timeout,
39
39
  });
40
+ /** Build a `node "<scripts/file>" <subcommand>` hook entry. */
41
+ const scriptHookSub = (file, sub, timeout) => ({
42
+ type: 'command',
43
+ command: `node "${SCRIPTS_PREFIX}/${file}"${sub ? ` ${sub}` : ''}`,
44
+ timeout,
45
+ });
40
46
  const gateHook = (sub, timeout) => helperHook('gate-hook.mjs', sub, timeout);
41
47
  const gateCjs = (sub, timeout) => helperHook('gate.cjs', sub, timeout);
42
48
  const handler = (sub, timeout) => helperHook('hook-handler.cjs', sub, timeout);
@@ -97,6 +103,8 @@ export function getReferenceHookBlock() {
97
103
  { hooks: [helperHook('prompt-hook.mjs', '', 3000)] },
98
104
  // #931 — Defensive safety-net hook. State reset only, no emission.
99
105
  { hooks: [gateHook('prompt-state-reset', 3000)] },
106
+ // #1198 — auto-meditate capture (detect). Default-ON (opt-out in-script).
107
+ { hooks: [scriptHookSub('meditate-capture.mjs', 'meditate-detect', 3000)] },
100
108
  ],
101
109
  SubagentStart: [
102
110
  { hooks: [helperHook('subagent-start.cjs', '', 2000)] },
@@ -107,7 +115,7 @@ export function getReferenceHookBlock() {
107
115
  },
108
116
  ],
109
117
  Stop: [
110
- { hooks: [handler('session-end', 5000), autoMemory('sync', 10000)] },
118
+ { hooks: [handler('session-end', 5000), autoMemory('sync', 10000), scriptHookSub('session-continuity.mjs', 'capture', 5000), scriptHookSub('meditate-capture.mjs', 'meditate-scrape', 5000)] },
111
119
  ],
112
120
  PreCompact: [
113
121
  { hooks: [gateCjs('compact-guidance', 3000)] },
@@ -37,6 +37,19 @@ export const REQUIRED_HOOK_WIRING = [
37
37
  // the duplicate `prompt-reminder` wiring that was emitting the TaskCreate
38
38
  // REMINDER twice per prompt (#931).
39
39
  { event: 'UserPromptSubmit', pattern: 'prompt-state-reset' },
40
+ // #1185 — passive session-continuity capture on the Stop hook. Self-heals
41
+ // into existing consumers on upgrade so the feature reaches everyone, not
42
+ // just fresh `flo init`. Capture is default-on (silent); injection is
43
+ // relevance-gated at session-start.
44
+ { event: 'Stop', pattern: 'session-continuity.mjs' },
45
+ // #1198 — auto-meditate capture (default-ON; opt out via
46
+ // auto_meditate.enabled: false). `meditate-detect` (UserPromptSubmit) injects the
47
+ // answer-first directive on a strong signal; `meditate-scrape` (Stop) harvests
48
+ // <meditate-capture> tags into the ledger. Both share meditate-capture.mjs, so
49
+ // the unique subcommand token (not the shared filename) is the presence
50
+ // discriminator — same convention as gate-hook subcommands like record-test-run.
51
+ { event: 'UserPromptSubmit', pattern: 'meditate-detect' },
52
+ { event: 'Stop', pattern: 'meditate-scrape' },
40
53
  ];
41
54
  /**
42
55
  * Map gate pattern → hook entry to add when missing from settings.json.
@@ -78,6 +91,15 @@ export const HOOK_ENTRY_MAP = {
78
91
  // Defensive safety-net — runs gate.cjs `prompt-state-reset` if prompt-hook.mjs
79
92
  // throws before completing the per-prompt state reset. State-only, no emission.
80
93
  'prompt-state-reset': { event: 'UserPromptSubmit', matcher: '', hook: { type: 'command', command: 'node "$CLAUDE_PROJECT_DIR/.claude/helpers/gate-hook.mjs" prompt-state-reset', timeout: 3000 } },
94
+ // #1185 — passive session-continuity capture (Stop hook). Bare block (no
95
+ // matcher), like the UserPromptSubmit entries; Claude Code fires every Stop
96
+ // block, so a separate block alongside session-end/sync is fine.
97
+ 'session-continuity.mjs': { event: 'Stop', matcher: '', hook: { type: 'command', command: 'node "$CLAUDE_PROJECT_DIR/.claude/scripts/session-continuity.mjs" capture', timeout: 5000 } },
98
+ // #1198 — auto-meditate capture. Default-ON (the script no-ops only when
99
+ // auto_meditate.enabled is false). Bare blocks (no matcher), like the other
100
+ // UserPromptSubmit/Stop entries.
101
+ 'meditate-detect': { event: 'UserPromptSubmit', matcher: '', hook: { type: 'command', command: 'node "$CLAUDE_PROJECT_DIR/.claude/scripts/meditate-capture.mjs" meditate-detect', timeout: 3000 } },
102
+ 'meditate-scrape': { event: 'Stop', matcher: '', hook: { type: 'command', command: 'node "$CLAUDE_PROJECT_DIR/.claude/scripts/meditate-capture.mjs" meditate-scrape', timeout: 5000 } },
81
103
  };
82
104
  /**
83
105
  * Inspect a parsed settings.json object for missing required hook wirings
@@ -156,6 +178,22 @@ export const HOOK_REWRITE_RULES = [
156
178
  from: 'node "$CLAUDE_PROJECT_DIR/.claude/helpers/gate.cjs" check-before-agent',
157
179
  to: 'node "$CLAUDE_PROJECT_DIR/.claude/helpers/gate-hook.mjs" check-before-agent',
158
180
  },
181
+ // Auto-meditate rebrand — the auto-reflect capture script + subcommands were
182
+ // renamed (reflect-capture.mjs → meditate-capture.mjs; reflect-detect/scrape →
183
+ // meditate-detect/scrape). Existing consumers self-heal here: the stale hook
184
+ // command is rewritten in place on session-start, so no dead hook is left
185
+ // pointing at the pruned reflect-capture.mjs. Idempotent (a command already at
186
+ // `to` won't match `from`).
187
+ {
188
+ name: 'auto-meditate rebrand: reflect-capture detect → meditate-capture detect',
189
+ from: 'node "$CLAUDE_PROJECT_DIR/.claude/scripts/reflect-capture.mjs" reflect-detect',
190
+ to: 'node "$CLAUDE_PROJECT_DIR/.claude/scripts/meditate-capture.mjs" meditate-detect',
191
+ },
192
+ {
193
+ name: 'auto-meditate rebrand: reflect-capture scrape → meditate-capture scrape',
194
+ from: 'node "$CLAUDE_PROJECT_DIR/.claude/scripts/reflect-capture.mjs" reflect-scrape',
195
+ to: 'node "$CLAUDE_PROJECT_DIR/.claude/scripts/meditate-capture.mjs" meditate-scrape',
196
+ },
159
197
  ];
160
198
  export const MATCHER_REWRITE_RULES = [
161
199
  // Issue #929 — Claude Code anchors hook matchers (`^…$` semantics), so a
@@ -1,34 +1,102 @@
1
1
  /**
2
2
  * Anonymization Pipeline
3
- * PII detection and redaction for pattern export
3
+ * PII detection and redaction before a CFP is shared **externally**.
4
+ *
5
+ * ── Secret-pattern sourcing (#1193) ─────────────────────────────────────────
6
+ * The secret/credential *detection shapes* (Anthropic/OpenAI `sk-`/`sk-ant-`
7
+ * keys, GitHub `ghp_`/`github_pat_`, AWS `AKIA…`, Slack `xox…`, Google `AIza…`,
8
+ * JWTs, PEM private-key blocks, bearer / url / assigned secrets) are
9
+ * SINGLE-SOURCED from the session-continuity scrubber `bin/lib/pii-scrub.mjs`
10
+ * (its exported `SECRET_PATTERNS` + `scrubSecrets`).
11
+ *
12
+ * Before #1193 this module carried its own narrow `apiKey` regex
13
+ * (`/\b(sk-|pk-|api[_-]?key[_-]?)[a-zA-Z0-9]{20,}\b/gi`). For `sk-ant-api03-…`
14
+ * the hyphen after `sk-ant` broke the `[a-zA-Z0-9]{20,}` run, so a *live*
15
+ * Anthropic key — plus GitHub/AWS/Slack tokens — survived anonymization and
16
+ * could leak in a CFP published externally. Rather than re-type the broader
17
+ * shapes here (which would drift from the scrubber), we load the canonical set.
18
+ *
19
+ * The two modules keep DIFFERENT threat models, so only the SECRET shapes are
20
+ * shared, not the policies:
21
+ * - `pii-scrub.mjs` protects the user's OWN local disk → deliberately KEEPS
22
+ * benign context (file paths, IPs) and only nukes literal secrets.
23
+ * - this module pseudonymises for EXTERNAL publication → additionally strips
24
+ * emails / phones / IPs / home paths. Those non-secret PII patterns and
25
+ * their replacement policy stay defined locally below.
26
+ *
27
+ * ── Cross-boundary loading (CLAUDE.md Rule #1 + dogfooding) ──────────────────
28
+ * `bin/lib/pii-scrub.mjs` lives outside this module's TypeScript compile unit.
29
+ * A static `../../../../bin/lib/pii-scrub.mjs` import is the banned depth-mismatch
30
+ * anti-pattern (the path differs between `src/cli/**` and `dist/src/cli/**`), so
31
+ * the scrubber is reached the sanctioned way: `locateMofloRootPath()` (the
32
+ * shared moflo-package anchor, which also existence-checks) + `pathToFileURL()`
33
+ * + dynamic `import()`. `pathToFileURL` is what makes the ESM
34
+ * `import()` work on Windows; the join is platform-agnostic. That dynamic load
35
+ * is why `detectPII` / `redactPII` / `anonymizeCFP` / `scanCFPForPII` are async.
36
+ *
37
+ * The loader is fail-CLOSED: if the scrubber can't be resolved we throw rather
38
+ * than silently export un-redacted content — a credential leak is the worse
39
+ * outcome for an external-share path.
4
40
  */
5
41
  import * as crypto from 'crypto';
42
+ import { join } from 'path';
43
+ import { pathToFileURL } from 'url';
44
+ import { locateMofloRootPath } from '../../services/moflo-require.js';
6
45
  /**
7
- * PII detection patterns
46
+ * Non-secret PII detection patterns owned by THIS module's external-share
47
+ * policy. Secret shapes are NOT here — they come from `bin/lib/pii-scrub.mjs`
48
+ * (see file header) so they only have to be maintained in one place.
8
49
  */
9
50
  const PII_PATTERNS = {
10
51
  email: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g,
11
52
  phone: /\b(\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g,
12
53
  ipv4: /\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b/g,
13
54
  ipv6: /\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b/g,
14
- apiKey: /\b(sk-|pk-|api[_-]?key[_-]?)[a-zA-Z0-9]{20,}\b/gi,
15
- jwt: /\beyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*\b/g,
16
55
  homePath: /\/(Users|home|Documents)\/[a-zA-Z0-9_.-]+/g,
17
56
  windowsPath: /[A-Z]:\\Users\\[a-zA-Z0-9_.-]+/g,
18
57
  };
19
58
  /**
20
- * Redaction replacements
59
+ * Replacement policy for the local non-secret PII patterns above. Secret
60
+ * replacements are applied by `scrubSecrets` from the shared module.
21
61
  */
22
62
  const REDACTIONS = {
23
63
  email: (match) => `user_${hash(match).slice(0, 8)}@example.com`,
24
64
  phone: '[REDACTED_PHONE]',
25
65
  ipv4: '0.0.0.0',
26
66
  ipv6: '::1',
27
- apiKey: '[REDACTED_API_KEY]',
28
- jwt: '[REDACTED_JWT]',
29
67
  homePath: '/user/anonymous',
30
68
  windowsPath: 'C:\\Users\\anonymous',
31
69
  };
70
+ /**
71
+ * Lazily import the shared scrubber once per process and cache the promise.
72
+ * Fail-closed: a missing/garbled scrubber throws so the export aborts instead
73
+ * of shipping un-redacted credentials.
74
+ */
75
+ let piiScrubPromise = null;
76
+ function loadPiiScrub() {
77
+ if (!piiScrubPromise) {
78
+ piiScrubPromise = (async () => {
79
+ const scrubPath = locateMofloRootPath(join('bin', 'lib', 'pii-scrub.mjs'));
80
+ if (!scrubPath) {
81
+ throw new Error('[anonymization] bin/lib/pii-scrub.mjs not found under the moflo package root — ' +
82
+ 'refusing to anonymize without the shared secret scrubber (would risk leaking ' +
83
+ 'credentials in an external share).');
84
+ }
85
+ const mod = (await import(pathToFileURL(scrubPath).href));
86
+ if (!Array.isArray(mod.SECRET_PATTERNS) || typeof mod.scrubSecrets !== 'function') {
87
+ throw new Error('[anonymization] bin/lib/pii-scrub.mjs is missing SECRET_PATTERNS/scrubSecrets — broken moflo install.');
88
+ }
89
+ return mod;
90
+ })().catch((err) => {
91
+ // Never cache a rejected promise — a transient/early failure must not
92
+ // poison every later anonymize call. Clear the singleton so a subsequent
93
+ // call re-attempts the load (still fail-closed: it re-throws here).
94
+ piiScrubPromise = null;
95
+ throw err;
96
+ });
97
+ }
98
+ return piiScrubPromise;
99
+ }
32
100
  /**
33
101
  * Hash a string for consistent pseudonymization
34
102
  */
@@ -36,41 +104,51 @@ function hash(input) {
36
104
  return crypto.createHash('sha256').update(input).digest('hex');
37
105
  }
38
106
  /**
39
- * Detect PII in a string
107
+ * Detect PII in a string. Combines the shared secret shapes (always `critical`)
108
+ * with this module's local non-secret PII patterns.
40
109
  */
41
- export function detectPII(content) {
110
+ export async function detectPII(content) {
42
111
  const result = {
43
112
  found: false,
44
113
  count: 0,
45
114
  types: {},
46
115
  locations: [],
47
116
  };
48
- for (const [type, pattern] of Object.entries(PII_PATTERNS)) {
117
+ const record = (type, pattern, severity) => {
118
+ // Reset lastIndex defensively — these regexes are module-shared and carry
119
+ // /g, so a prior .test()/.exec() elsewhere must not leak state.
120
+ pattern.lastIndex = 0;
49
121
  const matches = content.match(pattern);
50
- if (matches) {
51
- result.found = true;
52
- result.count += matches.length;
53
- result.types[type] = matches.length;
54
- for (const match of matches.slice(0, 5)) { // Limit to first 5 samples
55
- result.locations.push({
56
- type,
57
- path: 'content',
58
- sample: match.slice(0, 20) + (match.length > 20 ? '...' : ''),
59
- severity: getSeverity(type),
60
- });
61
- }
122
+ if (!matches)
123
+ return;
124
+ result.found = true;
125
+ result.count += matches.length;
126
+ result.types[type] = (result.types[type] ?? 0) + matches.length;
127
+ for (const match of matches.slice(0, 5)) {
128
+ // Limit to first 5 samples
129
+ result.locations.push({
130
+ type,
131
+ path: 'content',
132
+ sample: match.slice(0, 20) + (match.length > 20 ? '...' : ''),
133
+ severity,
134
+ });
62
135
  }
136
+ };
137
+ const { SECRET_PATTERNS } = await loadPiiScrub();
138
+ for (const { name, pattern } of SECRET_PATTERNS) {
139
+ record(name, pattern, 'critical');
140
+ }
141
+ for (const [type, pattern] of Object.entries(PII_PATTERNS)) {
142
+ record(type, pattern, getSeverity(type));
63
143
  }
64
144
  return result;
65
145
  }
66
146
  /**
67
- * Get severity for PII type
147
+ * Get severity for a local (non-secret) PII type. Secret shapes are always
148
+ * reported as `critical` by `detectPII`, so they never reach this helper.
68
149
  */
69
150
  function getSeverity(type) {
70
151
  switch (type) {
71
- case 'apiKey':
72
- case 'jwt':
73
- return 'critical';
74
152
  case 'email':
75
153
  case 'phone':
76
154
  return 'high';
@@ -82,25 +160,30 @@ function getSeverity(type) {
82
160
  }
83
161
  }
84
162
  /**
85
- * Redact PII from a string
163
+ * Redact PII from a string. Secrets are removed first via the shared scrubber
164
+ * (broad vendor-token coverage), then this module's external-share PII policy
165
+ * pseudonymises emails / phones / IPs / home paths.
86
166
  */
87
- export function redactPII(content) {
88
- let result = content;
167
+ export async function redactPII(content) {
168
+ const { scrubSecrets } = await loadPiiScrub();
169
+ let result = scrubSecrets(content);
89
170
  for (const [type, pattern] of Object.entries(PII_PATTERNS)) {
171
+ pattern.lastIndex = 0;
90
172
  const replacement = REDACTIONS[type];
91
- if (typeof replacement === 'function') {
92
- result = result.replace(pattern, replacement);
93
- }
94
- else {
95
- result = result.replace(pattern, replacement);
96
- }
173
+ // Narrow the union for `String.replace`'s overloads: email is a function
174
+ // replacer, the rest are literal strings. A blanket `as string` cast would
175
+ // mis-describe (and risk silently breaking) the function case.
176
+ result =
177
+ typeof replacement === 'function'
178
+ ? result.replace(pattern, replacement)
179
+ : result.replace(pattern, replacement);
97
180
  }
98
181
  return result;
99
182
  }
100
183
  /**
101
184
  * Apply anonymization to CFP document
102
185
  */
103
- export function anonymizeCFP(cfp, level) {
186
+ export async function anonymizeCFP(cfp, level) {
104
187
  const transforms = [];
105
188
  const anonymized = JSON.parse(JSON.stringify(cfp));
106
189
  // Level: Minimal
@@ -113,10 +196,24 @@ export function anonymizeCFP(cfp, level) {
113
196
  }
114
197
  // Level: Standard
115
198
  if (['standard', 'strict', 'paranoid'].includes(level)) {
116
- // Redact PII from all string fields
199
+ // Redact secrets/PII from the patterns body...
117
200
  const jsonStr = JSON.stringify(anonymized.patterns);
118
- const redacted = redactPII(jsonStr);
201
+ const redacted = await redactPII(jsonStr);
119
202
  anonymized.patterns = JSON.parse(redacted);
203
+ // ...and from free-text metadata (name / description / tags), which is the
204
+ // SAME external-share leak vector (#1193): a secret pasted into a CFP's
205
+ // description or a tag would otherwise survive at every level. The rest of
206
+ // metadata is structured (ids, timestamps, license); author displayName was
207
+ // already dropped at the minimal level above.
208
+ if (anonymized.metadata.name) {
209
+ anonymized.metadata.name = await redactPII(anonymized.metadata.name);
210
+ }
211
+ if (anonymized.metadata.description) {
212
+ anonymized.metadata.description = await redactPII(anonymized.metadata.description);
213
+ }
214
+ if (Array.isArray(anonymized.metadata.tags) && anonymized.metadata.tags.length > 0) {
215
+ anonymized.metadata.tags = await Promise.all(anonymized.metadata.tags.map((tag) => redactPII(tag)));
216
+ }
120
217
  transforms.push('pii-redacted');
121
218
  // Generalize timestamps
122
219
  anonymized.anonymization.timestampsGeneralized = true;
@@ -168,8 +265,17 @@ export function anonymizeCFP(cfp, level) {
168
265
  /**
169
266
  * Scan CFP for PII without modification
170
267
  */
171
- export function scanCFPForPII(cfp) {
172
- const content = JSON.stringify(cfp.patterns);
268
+ export async function scanCFPForPII(cfp) {
269
+ // Scan the same surface anonymizeCFP redacts: the patterns body plus the
270
+ // free-text metadata fields (name / description / tags) — so the scan can't
271
+ // report "clean" on a CFP whose description carries a secret (#1193).
272
+ const meta = cfp.metadata;
273
+ const content = JSON.stringify({
274
+ patterns: cfp.patterns,
275
+ name: meta?.name,
276
+ description: meta?.description,
277
+ tags: meta?.tags,
278
+ });
173
279
  return detectPII(content);
174
280
  }
175
281
  //# sourceMappingURL=index.js.map
@@ -136,7 +136,7 @@ async function deploy() {
136
136
  }
137
137
  // Step 3: Scan for PII
138
138
  console.log('🔍 Scanning for PII...');
139
- const piiScan = scanCFPForPII(genesis);
139
+ const piiScan = await scanCFPForPII(genesis);
140
140
  if (piiScan.found) {
141
141
  console.log(` Found ${piiScan.count} PII items:`);
142
142
  for (const [type, count] of Object.entries(piiScan.types)) {
@@ -13,12 +13,12 @@ import { uploadToIPFS } from './ipfs/upload.js';
13
13
  export async function exportPatterns(cfp, options = {}) {
14
14
  const { output, format = 'json', anonymize = 'standard', redactPii = true, stripPaths = false, toIpfs = false, pin = true, gateway = 'https://w3s.link', } = options;
15
15
  // Step 1: Scan for PII
16
- const piiScan = scanCFPForPII(cfp);
16
+ const piiScan = await scanCFPForPII(cfp);
17
17
  if (piiScan.found && redactPii) {
18
18
  console.log(`Found ${piiScan.count} PII items, will be redacted`);
19
19
  }
20
20
  // Step 2: Apply anonymization
21
- const { cfp: anonymizedCfp, transforms } = anonymizeCFP(cfp, anonymize);
21
+ const { cfp: anonymizedCfp, transforms } = await anonymizeCFP(cfp, anonymize);
22
22
  console.log(`Applied ${transforms.length} anonymization transforms: ${transforms.join(', ')}`);
23
23
  // Step 3: Serialize
24
24
  const serialized = format === 'json'
@@ -23,7 +23,7 @@ export class PatternPublisher {
23
23
  console.log(`[Publish] Starting publish: ${options.name}`);
24
24
  try {
25
25
  // Step 1: Anonymize if needed
26
- const anonymized = anonymizeCFP(cfp, options.anonymize);
26
+ const anonymized = await anonymizeCFP(cfp, options.anonymize);
27
27
  console.log(`[Publish] Anonymization level: ${options.anonymize}`);
28
28
  // Step 2: Serialize content
29
29
  const content = JSON.stringify(anonymized, null, 2);
@@ -2,5 +2,5 @@
2
2
  * Auto-generated by build. Do not edit manually.
3
3
  * Source of truth: root package.json → scripts/sync-version.mjs
4
4
  */
5
- export const VERSION = '4.10.20';
5
+ export const VERSION = '4.10.21';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.10.20",
3
+ "version": "4.10.21",
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.10.19",
98
+ "moflo": "^4.10.21-rc.1",
99
99
  "tsx": "^4.21.0",
100
100
  "typescript": "^5.9.3",
101
101
  "vitest": "^4.0.0"
@@ -24,10 +24,9 @@
24
24
  *
25
25
  * The bootstrap only has to do enough to break the deadlock.
26
26
  *
27
- * The lists below MUST stay aligned with bin/session-start-launcher.mjs
28
- * section 3 (the launcher's own sync). A unit test asserts list parity
29
- * (mcp-tools-drift-guard pattern). See SCRIPT_FILES / BIN_HELPER_FILES /
30
- * SOURCE_HELPER_FILES exports.
27
+ * The script + helper lists are read at runtime from the canonical manifest
28
+ * bin/lib/shipped-scripts.json (#1191) the SAME source the launcher §3 reads
29
+ * so the npm-install path and the session-start path can no longer drift.
31
30
  *
32
31
  * Failure posture:
33
32
  * - Surface per-file failures on stderr with `flo doctor --fix` advice
@@ -47,46 +46,16 @@ import {
47
46
  import { dirname, join, resolve } from 'node:path';
48
47
  import { fileURLToPath, pathToFileURL } from 'node:url';
49
48
  import { errMessage, makeSyncer } from '../bin/lib/file-sync.mjs';
49
+ import { loadShippedScripts } from '../bin/lib/shipped-scripts.mjs';
50
50
 
51
51
  const SCRIPT_PATH = fileURLToPath(import.meta.url);
52
52
  const MOFLO_ROOT = resolve(dirname(SCRIPT_PATH), '..');
53
53
 
54
- // ── Sync lists — keep in lockstep with bin/session-start-launcher.mjs §3 ─────
54
+ // ── Sync lists ───────────────────────────────────────────────────────────────
55
55
  //
56
- // Drift guard: tests/unit/post-install-bootstrap-drift.test.ts asserts these
57
- // arrays match the launcher's section-3 sync lists by parsing both files.
58
-
59
- export const SCRIPT_FILES = [
60
- 'hooks.mjs',
61
- 'session-start-launcher.mjs',
62
- 'index-guidance.mjs',
63
- 'build-embeddings.mjs',
64
- 'generate-code-map.mjs',
65
- 'semantic-search.mjs',
66
- 'index-tests.mjs',
67
- 'index-patterns.mjs',
68
- 'index-all.mjs',
69
- 'setup-project.mjs',
70
- 'run-migrations.mjs',
71
- ];
72
-
73
- export const BIN_HELPER_FILES = [
74
- 'gate.cjs',
75
- 'gate-hook.mjs',
76
- 'prompt-hook.mjs',
77
- 'hook-handler.cjs',
78
- 'simplify-classify.cjs',
79
- ];
80
-
81
- export const SOURCE_HELPER_FILES = [
82
- 'auto-memory-hook.mjs',
83
- 'statusline.cjs',
84
- 'intelligence.cjs',
85
- 'subagent-start.cjs',
86
- 'subagent-bootstrap.json',
87
- 'pre-commit',
88
- 'post-commit',
89
- ];
56
+ // The script + helper lists are read at runtime from the canonical manifest
57
+ // bin/lib/shipped-scripts.json (#1191) the SAME source the launcher §3 reads
58
+ // so the npm-install path and the session-start path can no longer drift.
90
59
 
91
60
  // ── Retry + atomic copy + circuit breaker (#854 / #975) ─────────────────────
92
61
  //
@@ -148,13 +117,24 @@ export async function runBootstrap({
148
117
  return { ran: false, reason: 'no-bin-dir' };
149
118
  }
150
119
 
120
+ // Canonical sync lists (#1191) — read from the freshly-installed package's
121
+ // bin/lib so the bootstrap and the launcher §3 can't drift. A broken manifest
122
+ // aborts gracefully (postinstall must never throw / block npm install).
123
+ let scriptFiles, binHelperFiles, sourceHelperFiles;
124
+ try {
125
+ ({ scriptFiles, binHelperFiles, sourceHelperFiles } = loadShippedScripts(resolve(binDir, 'lib')));
126
+ } catch (err) {
127
+ log(`bootstrap: shipped-scripts manifest unreadable (${errMessage(err)}) — skipping`);
128
+ return { ran: false, reason: 'no-manifest' };
129
+ }
130
+
151
131
  const { syncFile, failures } = makeSyncer();
152
132
  let synced = 0;
153
133
 
154
134
  // 1. Top-level scripts → .claude/scripts/
155
135
  const scriptsDir = resolve(claudeDir, 'scripts');
156
136
  if (!existsSync(scriptsDir)) mkdirSync(scriptsDir, { recursive: true });
157
- for (const file of SCRIPT_FILES) {
137
+ for (const file of scriptFiles) {
158
138
  const result = await syncFile(
159
139
  resolve(binDir, file),
160
140
  resolve(scriptsDir, file),
@@ -210,7 +190,7 @@ export async function runBootstrap({
210
190
  // 4. bin/ helpers → .claude/helpers/
211
191
  const helpersDir = resolve(claudeDir, 'helpers');
212
192
  if (!existsSync(helpersDir)) mkdirSync(helpersDir, { recursive: true });
213
- for (const file of BIN_HELPER_FILES) {
193
+ for (const file of binHelperFiles) {
214
194
  const result = await syncFile(
215
195
  resolve(binDir, file),
216
196
  resolve(helpersDir, file),
@@ -223,7 +203,7 @@ export async function runBootstrap({
223
203
  // (these never lived in bin/ — they're shipped via .claude/helpers/** in files[])
224
204
  const sourceHelpersDir = resolve(mofloRoot, '.claude/helpers');
225
205
  if (existsSync(sourceHelpersDir)) {
226
- for (const file of SOURCE_HELPER_FILES) {
206
+ for (const file of sourceHelperFiles) {
227
207
  const src = resolve(sourceHelpersDir, file);
228
208
  if (!existsSync(src)) continue;
229
209
  const result = await syncFile(src, resolve(helpersDir, file), `.claude/helpers/${file}`);
@@ -1,11 +0,0 @@
1
- /**
2
- * V3 LLM Hooks Module
3
- *
4
- * Exports LLM-specific hooks for request caching,
5
- * optimization, cost tracking, and pattern learning.
6
- *
7
- * @module moflo/cli/hooks/llm
8
- */
9
- export * from './llm-hooks.js';
10
- export { llmHooks as default } from './llm-hooks.js';
11
- //# sourceMappingURL=index.js.map