monomind 2.7.7 → 2.7.8

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 (54) hide show
  1. package/package.json +2 -1
  2. package/packages/@monomind/cli/.claude/agents/core/coder.md +9 -0
  3. package/packages/@monomind/cli/.claude/agents/core/coordinator.md +62 -0
  4. package/packages/@monomind/cli/.claude/agents/core/planner.md +9 -0
  5. package/packages/@monomind/cli/.claude/agents/core/reviewer.md +9 -0
  6. package/packages/@monomind/cli/.claude/agents/core/tester.md +8 -0
  7. package/packages/@monomind/cli/.claude/helpers/handlers/gates-handler.cjs +180 -47
  8. package/packages/@monomind/cli/.claude/helpers/handlers/route-handler.cjs +32 -3
  9. package/packages/@monomind/cli/.claude/helpers/hook-handler.cjs +55 -3
  10. package/packages/@monomind/cli/.claude/helpers/intelligence.cjs +3 -1
  11. package/packages/@monomind/cli/.claude/helpers/statusline.cjs +27 -3
  12. package/packages/@monomind/cli/.claude/helpers/utils/monograph.cjs +104 -18
  13. package/packages/@monomind/cli/.claude/settings.json +1 -1
  14. package/packages/@monomind/cli/dist/src/browser/dashboard/server.js +6 -1
  15. package/packages/@monomind/cli/dist/src/capabilities/index.d.ts +0 -1
  16. package/packages/@monomind/cli/dist/src/capabilities/index.js +8 -1
  17. package/packages/@monomind/cli/dist/src/commands/agent-lifecycle.js +5 -1
  18. package/packages/@monomind/cli/dist/src/commands/doctor-project-checks.js +8 -1
  19. package/packages/@monomind/cli/dist/src/commands/guidance.js +8 -2
  20. package/packages/@monomind/cli/dist/src/commands/init.js +18 -4
  21. package/packages/@monomind/cli/dist/src/commands/memory-crud.js +7 -1
  22. package/packages/@monomind/cli/dist/src/commands/org-observe.js +17 -7
  23. package/packages/@monomind/cli/dist/src/commands/org.d.ts +11 -0
  24. package/packages/@monomind/cli/dist/src/commands/org.js +162 -15
  25. package/packages/@monomind/cli/dist/src/commands/security-scan.d.ts +30 -1
  26. package/packages/@monomind/cli/dist/src/commands/security-scan.js +182 -69
  27. package/packages/@monomind/cli/dist/src/commands/swarm.js +41 -14
  28. package/packages/@monomind/cli/dist/src/consensus/audit-writer.js +11 -10
  29. package/packages/@monomind/cli/dist/src/init/executor.js +138 -22
  30. package/packages/@monomind/cli/dist/src/init/settings-generator.js +4 -1
  31. package/packages/@monomind/cli/dist/src/knowledge/document-pipeline.d.ts +17 -0
  32. package/packages/@monomind/cli/dist/src/knowledge/document-pipeline.js +62 -3
  33. package/packages/@monomind/cli/dist/src/mcp-tools/embeddings-tools.js +16 -8
  34. package/packages/@monomind/cli/dist/src/mcp-tools/knowledge-tools.js +27 -13
  35. package/packages/@monomind/cli/dist/src/memory/memory-bridge.d.ts +1 -1
  36. package/packages/@monomind/cli/dist/src/memory/memory-bridge.js +5 -1
  37. package/packages/@monomind/cli/dist/src/memory/memory-read.d.ts +7 -2
  38. package/packages/@monomind/cli/dist/src/memory/memory-read.js +10 -2
  39. package/packages/@monomind/cli/dist/src/monovector/diff-classifier.js +25 -6
  40. package/packages/@monomind/cli/dist/src/orgrt/daemon.d.ts +21 -1
  41. package/packages/@monomind/cli/dist/src/orgrt/daemon.js +82 -11
  42. package/packages/@monomind/cli/dist/src/orgrt/inbox.js +53 -20
  43. package/packages/@monomind/cli/dist/src/parser.d.ts +4 -2
  44. package/packages/@monomind/cli/dist/src/parser.js +61 -25
  45. package/packages/@monomind/cli/dist/src/services/config-file-manager.d.ts +10 -2
  46. package/packages/@monomind/cli/dist/src/services/config-file-manager.js +10 -2
  47. package/packages/@monomind/cli/dist/src/ui/collector.mjs +43 -3
  48. package/packages/@monomind/cli/dist/src/ui/dashboard.html +27 -8
  49. package/packages/@monomind/cli/dist/src/ui/server.mjs +144 -14
  50. package/packages/@monomind/cli/package.json +3 -3
  51. package/packages/@monomind/cli/dist/src/capabilities/watcher.d.ts +0 -18
  52. package/packages/@monomind/cli/dist/src/capabilities/watcher.js +0 -107
  53. package/packages/@monomind/cli/dist/src/config-adapter.d.ts +0 -16
  54. package/packages/@monomind/cli/dist/src/config-adapter.js +0 -220
@@ -8,19 +8,45 @@ import { callMCPTool, MCPClientError } from '../mcp-client.js';
8
8
  import * as fs from 'fs';
9
9
  import * as path from 'path';
10
10
  import { writeJsonFileAtomic } from '../utils/json-file.js';
11
- // Canonical paths shared with MCP tools so CLI and MCP see the same state
12
- const SWARM_STATE_DIR = '.monomind/swarm';
11
+ import { getMonomindDataRoot, getProjectCwd, migrateLegacyStoreFile } from '../mcp-tools/types.js';
12
+ // Canonical paths — resolved through getMonomindDataRoot() so the CLI and the MCP
13
+ // tools (agent-tools.ts / swarm-tools.ts / task-tools.ts) read and write the SAME
14
+ // physical files. These used to be `path.join(process.cwd(), '.monomind/...')`,
15
+ // which only coincides with the canonical root when there is no `.git` directory;
16
+ // inside a real repo the canonical root is `<repo>/.git/monomind`, so the CLI read
17
+ // an empty parallel store ("0 agents" with agents on disk) and `swarm init` wrote a
18
+ // second, divergent swarm-state.json no MCP tool ever read.
19
+ const SWARM_STATE_SUBDIR = 'swarm';
13
20
  const SWARM_STATE_FILE = 'swarm-state.json';
14
- const AGENT_STORE_PATH = '.monomind/agents/store.json';
21
+ const AGENT_STORE_SUBDIR = 'agents';
22
+ const AGENT_STORE_FILE = 'store.json';
23
+ function getSwarmDir() {
24
+ return path.join(getMonomindDataRoot(), SWARM_STATE_SUBDIR);
25
+ }
26
+ // Canonical swarm-state.json path. Also pulls a pre-existing legacy
27
+ // `<cwd>/.monomind/swarm/swarm-state.json` into place (copy, never move; no-op once
28
+ // the canonical file exists or when the two paths coincide) so state written by an
29
+ // older CLI isn't silently orphaned — same helper the MCP tools use.
30
+ function getSwarmStateFile() {
31
+ const file = path.join(getSwarmDir(), SWARM_STATE_FILE);
32
+ migrateLegacyStoreFile(file, path.join(SWARM_STATE_SUBDIR, SWARM_STATE_FILE));
33
+ return file;
34
+ }
35
+ function getAgentStoreFile() {
36
+ const file = path.join(getMonomindDataRoot(), AGENT_STORE_SUBDIR, AGENT_STORE_FILE);
37
+ migrateLegacyStoreFile(file, path.join(AGENT_STORE_SUBDIR, AGENT_STORE_FILE));
38
+ return file;
39
+ }
15
40
  // Get dynamic swarm status from MCP-canonical state files
16
41
  function getSwarmStatus(swarmId) {
17
- const sessionDir = path.join(process.cwd(), '.claude', 'sessions');
42
+ const projectCwd = getProjectCwd();
43
+ const sessionDir = path.join(projectCwd, '.claude', 'sessions');
18
44
  const memoryPaths = [
19
- path.join(process.cwd(), '.monomind', 'memory.db'),
20
- path.join(process.cwd(), '.claude', 'memory.db'),
45
+ path.join(projectCwd, '.monomind', 'memory.db'),
46
+ path.join(projectCwd, '.claude', 'memory.db'),
21
47
  ];
22
- // Read swarm state from MCP-canonical path (.monomind/swarm/swarm-state.json)
23
- const swarmStateFile = path.join(process.cwd(), SWARM_STATE_DIR, SWARM_STATE_FILE);
48
+ // Read swarm state from the MCP-canonical path
49
+ const swarmStateFile = getSwarmStateFile();
24
50
  let swarmState = null;
25
51
  if (fs.existsSync(swarmStateFile)) {
26
52
  try {
@@ -52,10 +78,11 @@ function getSwarmStatus(swarmId) {
52
78
  // Ignore parse errors
53
79
  }
54
80
  }
55
- // Count agents from MCP-canonical agent store (.monomind/agents/store.json)
81
+ // Count agents from the MCP-canonical agent store (the same physical file
82
+ // agent_spawn writes to — see getAgentStoreFile()).
56
83
  let activeAgents = 0;
57
84
  let totalAgents = 0;
58
- const agentStoreFile = path.join(process.cwd(), AGENT_STORE_PATH);
85
+ const agentStoreFile = getAgentStoreFile();
59
86
  if (fs.existsSync(agentStoreFile)) {
60
87
  try {
61
88
  const agentSz = fs.statSync(agentStoreFile).size;
@@ -102,7 +129,7 @@ function getSwarmStatus(swarmId) {
102
129
  let completedTasks = 0;
103
130
  let inProgressTasks = 0;
104
131
  let pendingTasks = 0;
105
- const tasksDir = path.join(process.cwd(), SWARM_STATE_DIR, 'tasks');
132
+ const tasksDir = path.join(getSwarmDir(), 'tasks');
106
133
  if (fs.existsSync(tasksDir)) {
107
134
  try {
108
135
  const taskFiles = fs.readdirSync(tasksDir).filter(f => f.endsWith('.json'));
@@ -311,7 +338,7 @@ const initCommand = {
311
338
  output.printSuccess('Swarm initialized successfully');
312
339
  // Save swarm state to MCP-canonical path so CLI and MCP share state
313
340
  try {
314
- const swarmDir2 = path.join(process.cwd(), SWARM_STATE_DIR);
341
+ const swarmDir2 = getSwarmDir();
315
342
  if (!fs.existsSync(swarmDir2)) {
316
343
  fs.mkdirSync(swarmDir2, { recursive: true });
317
344
  }
@@ -466,7 +493,7 @@ const startCommand = {
466
493
  output.writeln(output.dim(' Run with -v/--verbose for more detail, or `monomind doctor` to check config/permission issues.'));
467
494
  }
468
495
  // Persist swarm state to MCP-canonical path so CLI and MCP share state
469
- const swarmDir = path.join(process.cwd(), SWARM_STATE_DIR);
496
+ const swarmDir = getSwarmDir();
470
497
  if (!fs.existsSync(swarmDir))
471
498
  fs.mkdirSync(swarmDir, { recursive: true });
472
499
  // Read existing store to preserve other swarms
@@ -627,7 +654,7 @@ const stopCommand = {
627
654
  }
628
655
  // Only update persisted swarm state if the MCP call succeeded (#1423)
629
656
  if (mcpStopped) {
630
- const stopStateFile = path.join(process.cwd(), SWARM_STATE_DIR, SWARM_STATE_FILE);
657
+ const stopStateFile = getSwarmStateFile();
631
658
  if (fs.existsSync(stopStateFile)) {
632
659
  try {
633
660
  const stopStatSz = fs.statSync(stopStateFile).size;
@@ -177,19 +177,20 @@ export class AuditWriter {
177
177
  appendFileSync(filePath, JSON.stringify(data) + '\n', 'utf-8');
178
178
  }
179
179
  readLines(filePath) {
180
+ // A missing log is a genuine empty trail. Everything else — an oversized
181
+ // log, a permissions error, an I/O failure — is an *unreadable* trail, and
182
+ // must not be reported as an empty one: this file is the tamper-evidence
183
+ // record, so "0 records" and "cannot read" have opposite meanings.
184
+ // Callers (hive-mind_audit_list / _verify) already surface a throw as
185
+ // `success: false` with the message.
180
186
  if (!existsSync(filePath))
181
187
  return [];
182
- try {
183
- const MAX_BYTES = 50 * 1024 * 1024;
184
- if (statSync(filePath).size > MAX_BYTES) {
185
- throw new Error(`Audit log ${filePath} exceeds 50MB — run rotation/cleanup`);
186
- }
187
- const content = readFileSync(filePath, 'utf-8');
188
- return parseJsonl(content);
189
- }
190
- catch {
191
- return [];
188
+ const MAX_BYTES = 50 * 1024 * 1024;
189
+ if (statSync(filePath).size > MAX_BYTES) {
190
+ throw new Error(`Audit log ${filePath} exceeds 50MB — run rotation/cleanup`);
192
191
  }
192
+ const content = readFileSync(filePath, 'utf-8');
193
+ return parseJsonl(content);
193
194
  }
194
195
  }
195
196
  //# sourceMappingURL=audit-writer.js.map
@@ -410,17 +410,37 @@ try { await buildAsync(${JSON.stringify(targetDir)}); } finally {
410
410
  * Non-fatal: best-effort health check and auto-install.
411
411
  */
412
412
  async function runDoctorFix(targetDir, result) {
413
+ // Run the doctor THIS binary ships, in-process.
414
+ //
415
+ // This used to be `execSync('npx monomind@latest doctor --install')`, which
416
+ // was wrong in three ways at once: it downloaded and ran a DIFFERENT version
417
+ // than the one the user deliberately invoked (so `monomind@2.7.0 init` was
418
+ // finished by whatever `latest` happened to be), it silently required network
419
+ // — on a machine without it, the 120s timeout elapsed and init reported
420
+ // "skipped" with no explanation — and `stdio: 'ignore'` discarded everything
421
+ // it said, so a failed health check looked identical to a passing one.
413
422
  try {
414
- const { execSync } = await import('child_process');
415
- execSync('npx monomind@latest doctor --install', {
423
+ const { doctorCommand } = await import('../commands/doctor.js');
424
+ if (!doctorCommand.action) {
425
+ result.skipped.push('doctor: auto-fix unavailable (run: monomind doctor --install)');
426
+ return;
427
+ }
428
+ const res = await doctorCommand.action({
429
+ args: [],
430
+ flags: { install: true },
416
431
  cwd: targetDir,
417
- stdio: 'ignore',
418
- timeout: 120000,
419
432
  });
420
- result.created.files.push('doctor --install (health check + auto-fix)');
433
+ // Report what actually happened rather than asserting success either way.
434
+ if (res && res.success === false) {
435
+ result.skipped.push('doctor: reported issues (run: monomind doctor for details)');
436
+ }
437
+ else {
438
+ result.created.files.push('doctor --install (health check + auto-fix)');
439
+ }
421
440
  }
422
- catch {
423
- result.skipped.push('doctor: auto-fix skipped (run: monomind doctor --install)');
441
+ catch (err) {
442
+ const detail = err instanceof Error ? err.message : String(err);
443
+ result.skipped.push(`doctor: auto-fix failed (${detail}) — run: monomind doctor --install`);
424
444
  }
425
445
  }
426
446
  /**
@@ -953,6 +973,73 @@ async function writeMCPConfig(targetDir, options, result) {
953
973
  atomicWriteFile(mcpPath, content);
954
974
  result.created.files.push('.mcp.json');
955
975
  }
976
+ /**
977
+ * Provenance manifest for generated .claude content.
978
+ *
979
+ * init used to "clean stale" entries by deleting every name under
980
+ * .claude/{skills,commands,agents} that was absent from the current version's
981
+ * SKILLS_MAP/COMMANDS_MAP/AGENTS_MAP. Every user-authored command and skill is
982
+ * absent from those maps, so that pass deleted user content on the very first
983
+ * run — unrecoverable data loss.
984
+ *
985
+ * The manifest records exactly which entries *this tool* wrote, so the stale
986
+ * sweep can be restricted to those. Anything init did not write is never
987
+ * removed. Projects initialised by an older version have no manifest, so their
988
+ * first run under the fix deletes nothing and seeds the manifest instead;
989
+ * stale generated content may survive one extra run, which is the correct
990
+ * trade (preserving stale generated content is recoverable, deleting user
991
+ * content is not).
992
+ */
993
+ const INIT_MANIFEST_REL = path.join('.monomind', 'init-manifest.json');
994
+ /**
995
+ * Read the provenance manifest. Returns null when absent or unreadable —
996
+ * callers must treat that as "provenance unknown", i.e. delete nothing.
997
+ */
998
+ function readInitManifest(targetDir) {
999
+ const manifestPath = path.join(targetDir, INIT_MANIFEST_REL);
1000
+ try {
1001
+ if (!fs.existsSync(manifestPath))
1002
+ return null;
1003
+ const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
1004
+ if (!parsed || typeof parsed !== 'object')
1005
+ return null;
1006
+ return {
1007
+ version: typeof parsed.version === 'number' ? parsed.version : 1,
1008
+ skills: Array.isArray(parsed.skills) ? parsed.skills.filter((s) => typeof s === 'string') : [],
1009
+ commands: Array.isArray(parsed.commands) ? parsed.commands.filter((s) => typeof s === 'string') : [],
1010
+ agents: Array.isArray(parsed.agents) ? parsed.agents.filter((s) => typeof s === 'string') : [],
1011
+ };
1012
+ }
1013
+ catch {
1014
+ return null;
1015
+ }
1016
+ }
1017
+ /**
1018
+ * Names init previously generated in one section. Empty set when no manifest
1019
+ * exists — which makes the stale sweep a no-op rather than a delete-everything.
1020
+ */
1021
+ function previouslyGenerated(targetDir, section) {
1022
+ return new Set(readInitManifest(targetDir)?.[section] ?? []);
1023
+ }
1024
+ /**
1025
+ * Record the entries init just wrote for one section, merging into any
1026
+ * existing manifest so a partial run (e.g. --only-claude, or a section whose
1027
+ * source dir was missing) never drops provenance for the other sections.
1028
+ */
1029
+ function recordGenerated(targetDir, section, entries) {
1030
+ const manifestPath = path.join(targetDir, INIT_MANIFEST_REL);
1031
+ const existing = readInitManifest(targetDir);
1032
+ const manifest = existing ?? { version: 1, skills: [], commands: [], agents: [] };
1033
+ manifest.version = 1;
1034
+ manifest[section] = [...new Set(entries)].sort();
1035
+ try {
1036
+ fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
1037
+ atomicWriteFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
1038
+ }
1039
+ catch {
1040
+ // Non-fatal: without a manifest the next run simply deletes nothing.
1041
+ }
1042
+ }
956
1043
  /**
957
1044
  * Copy skills from source
958
1045
  */
@@ -992,11 +1079,14 @@ async function copySkills(targetDir, options, result) {
992
1079
  skillsToCopy.push(...fs.readdirSync(sourceSkillsDir).filter(n => n.startsWith(prefix) &&
993
1080
  fs.existsSync(path.join(sourceSkillsDir, n, 'SKILL.md'))));
994
1081
  }
995
- // Remove stale skill directories no longer in the current version's map
1082
+ // Remove stale skill directories that a PREVIOUS init generated and this
1083
+ // version no longer ships. Entries init never wrote (user-authored skills,
1084
+ // skills installed by other tools) are left untouched — see readInitManifest.
996
1085
  const knownSkills = new Set([...new Set(skillsToCopy)]);
1086
+ const priorSkills = previouslyGenerated(targetDir, 'skills');
997
1087
  if (fs.existsSync(targetSkillsDir)) {
998
1088
  for (const existing of fs.readdirSync(targetSkillsDir)) {
999
- if (!knownSkills.has(existing)) {
1089
+ if (!knownSkills.has(existing) && priorSkills.has(existing)) {
1000
1090
  const stalePath = path.join(targetSkillsDir, existing);
1001
1091
  fs.rmSync(stalePath, { recursive: true, force: true });
1002
1092
  result.created.files.push(`[cleaned] .claude/skills/${existing} (stale)`);
@@ -1004,14 +1094,19 @@ async function copySkills(targetDir, options, result) {
1004
1094
  }
1005
1095
  }
1006
1096
  // Always copy/overwrite skills (never skip — ensures new version content lands)
1097
+ const writtenSkills = [];
1007
1098
  for (const skillName of knownSkills) {
1008
1099
  const sourcePath = path.join(sourceSkillsDir, skillName);
1009
1100
  const targetPath = path.join(targetSkillsDir, skillName);
1010
1101
  if (fs.existsSync(sourcePath)) {
1011
- if (fs.existsSync(targetPath)) {
1012
- fs.rmSync(targetPath, { recursive: true, force: true });
1013
- }
1102
+ // Deliberately NOT rmSync'd first. copyDirRecursive overwrites every
1103
+ // file it ships, so wiping the directory adds nothing except destroying
1104
+ // anything the user put inside it — notes beside a shipped skill, an
1105
+ // extra command in a shipped folder. `init --force` did exactly that.
1106
+ // The cost of not wiping is that a file removed from a newer version
1107
+ // lingers; the cost of wiping is silent data loss, which is worse.
1014
1108
  copyDirRecursive(sourcePath, targetPath);
1109
+ writtenSkills.push(skillName);
1015
1110
  result.created.files.push(`.claude/skills/${skillName}`);
1016
1111
  result.summary.skillsCount++;
1017
1112
  }
@@ -1022,6 +1117,12 @@ async function copySkills(targetDir, options, result) {
1022
1117
  result.errors.push(`Skill '${skillName}' listed in SKILLS_MAP has no source directory at ${sourcePath} — skipped`);
1023
1118
  }
1024
1119
  }
1120
+ // Record provenance so the next run can distinguish "we wrote this" from
1121
+ // "the user wrote this". Keep entries this run did not re-write but that a
1122
+ // previous run generated and that still exist, so provenance is not lost
1123
+ // when a section is partially skipped.
1124
+ const retainedSkills = [...priorSkills].filter(n => !writtenSkills.includes(n) && fs.existsSync(path.join(targetSkillsDir, n)));
1125
+ recordGenerated(targetDir, 'skills', [...writtenSkills, ...retainedSkills]);
1025
1126
  }
1026
1127
  /**
1027
1128
  * Copy commands from source
@@ -1084,11 +1185,13 @@ async function copyCommands(targetDir, options, result) {
1084
1185
  result.errors.push('Could not find source commands directory');
1085
1186
  return;
1086
1187
  }
1087
- // Remove stale command files/directories no longer in the current version's map
1188
+ // Remove stale command files/directories that a PREVIOUS init generated and
1189
+ // this version no longer ships. User-authored commands are never touched.
1088
1190
  const knownCommands = new Set([...new Set(commandsToCopy)]);
1191
+ const priorCommands = previouslyGenerated(targetDir, 'commands');
1089
1192
  if (fs.existsSync(targetCommandsDir)) {
1090
1193
  for (const existing of fs.readdirSync(targetCommandsDir)) {
1091
- if (!knownCommands.has(existing)) {
1194
+ if (!knownCommands.has(existing) && priorCommands.has(existing)) {
1092
1195
  const stalePath = path.join(targetCommandsDir, existing);
1093
1196
  fs.rmSync(stalePath, { recursive: true, force: true });
1094
1197
  result.created.files.push(`[cleaned] .claude/commands/${existing} (stale)`);
@@ -1096,23 +1199,27 @@ async function copyCommands(targetDir, options, result) {
1096
1199
  }
1097
1200
  }
1098
1201
  // Always copy/overwrite commands (never skip — ensures new version content lands)
1202
+ const writtenCommands = [];
1099
1203
  for (const cmdName of knownCommands) {
1100
1204
  const sourcePath = path.join(sourceCommandsDir, cmdName);
1101
1205
  const targetPath = path.join(targetCommandsDir, cmdName);
1102
1206
  if (fs.existsSync(sourcePath)) {
1103
- if (fs.existsSync(targetPath)) {
1104
- fs.rmSync(targetPath, { recursive: true, force: true });
1105
- }
1207
+ // No pre-copy rmSync — see the note in copySkills. Both branches below
1208
+ // overwrite what they ship, so wiping first only destroys files the user
1209
+ // added inside a shipped command directory.
1106
1210
  if (fs.statSync(sourcePath).isDirectory()) {
1107
1211
  copyDirRecursive(sourcePath, targetPath);
1108
1212
  }
1109
1213
  else {
1110
1214
  fs.copyFileSync(sourcePath, targetPath);
1111
1215
  }
1216
+ writtenCommands.push(cmdName);
1112
1217
  result.created.files.push(`.claude/commands/${cmdName}`);
1113
1218
  result.summary.commandsCount++;
1114
1219
  }
1115
1220
  }
1221
+ const retainedCommands = [...priorCommands].filter(n => !writtenCommands.includes(n) && fs.existsSync(path.join(targetCommandsDir, n)));
1222
+ recordGenerated(targetDir, 'commands', [...writtenCommands, ...retainedCommands]);
1116
1223
  }
1117
1224
  /**
1118
1225
  * Copy agents from source
@@ -1147,11 +1254,13 @@ async function copyAgents(targetDir, options, result) {
1147
1254
  result.errors.push('Could not find source agents directory');
1148
1255
  return;
1149
1256
  }
1150
- // Remove stale agent category directories no longer in the current version's map
1257
+ // Remove stale agent category directories that a PREVIOUS init generated and
1258
+ // this version no longer ships. User-authored agent dirs are never touched.
1151
1259
  const knownAgents = new Set([...new Set(agentsToCopy)]);
1260
+ const priorAgents = previouslyGenerated(targetDir, 'agents');
1152
1261
  if (fs.existsSync(targetAgentsDir)) {
1153
1262
  for (const existing of fs.readdirSync(targetAgentsDir)) {
1154
- if (!knownAgents.has(existing)) {
1263
+ if (!knownAgents.has(existing) && priorAgents.has(existing)) {
1155
1264
  const stalePath = path.join(targetAgentsDir, existing);
1156
1265
  fs.rmSync(stalePath, { recursive: true, force: true });
1157
1266
  result.created.files.push(`[cleaned] .claude/agents/${existing} (stale)`);
@@ -1159,20 +1268,27 @@ async function copyAgents(targetDir, options, result) {
1159
1268
  }
1160
1269
  }
1161
1270
  // Always copy/overwrite agents (never skip — ensures new version content lands)
1271
+ const writtenAgents = [];
1162
1272
  for (const agentCategory of knownAgents) {
1163
1273
  const sourcePath = path.join(sourceAgentsDir, agentCategory);
1164
1274
  const targetPath = path.join(targetAgentsDir, agentCategory);
1165
1275
  if (fs.existsSync(sourcePath)) {
1166
- if (fs.existsSync(targetPath)) {
1167
- fs.rmSync(targetPath, { recursive: true, force: true });
1168
- }
1276
+ // Deliberately NOT rmSync'd first. copyDirRecursive overwrites every
1277
+ // file it ships, so wiping the directory adds nothing except destroying
1278
+ // anything the user put inside it — notes beside a shipped skill, an
1279
+ // extra command in a shipped folder. `init --force` did exactly that.
1280
+ // The cost of not wiping is that a file removed from a newer version
1281
+ // lingers; the cost of wiping is silent data loss, which is worse.
1169
1282
  copyDirRecursive(sourcePath, targetPath);
1170
1283
  // Count agent files (.md only — .yaml agents were migrated to .md)
1171
1284
  const mdFiles = countFiles(sourcePath, '.md');
1172
1285
  result.summary.agentsCount += mdFiles;
1286
+ writtenAgents.push(agentCategory);
1173
1287
  result.created.files.push(`.claude/agents/${agentCategory}`);
1174
1288
  }
1175
1289
  }
1290
+ const retainedAgents = [...priorAgents].filter(n => !writtenAgents.includes(n) && fs.existsSync(path.join(targetAgentsDir, n)));
1291
+ recordGenerated(targetDir, 'agents', [...writtenAgents, ...retainedAgents]);
1176
1292
  }
1177
1293
  /**
1178
1294
  * Find source helpers directory.
@@ -226,7 +226,10 @@ function generateHooksConfig(config, graphify = true) {
226
226
  ],
227
227
  },
228
228
  {
229
- matcher: 'Write|Edit|MultiEdit',
229
+ // NotebookEdit is listed explicitly: its content field (`new_source`)
230
+ // is scanned by the same secrets gate as Write/Edit/MultiEdit, so it
231
+ // must not depend on `Edit` happening to substring-match.
232
+ matcher: 'Write|Edit|MultiEdit|NotebookEdit',
230
233
  hooks: [
231
234
  {
232
235
  // Was 'pre-edit' — not a registered hook-handler.cjs dispatch
@@ -26,6 +26,10 @@ export interface KnowledgeExcerpt {
26
26
  similarity: number;
27
27
  chunkIndex: number;
28
28
  scope: string;
29
+ /** True when this chunk belongs to a document version that has since been
30
+ * re-ingested (its contentHash is no longer the file's current one). Only
31
+ * ever set when the caller opted into `includeSuperseded`. */
32
+ superseded?: boolean;
29
33
  }
30
34
  export interface DocumentMeta {
31
35
  filePath: string;
@@ -40,6 +44,16 @@ export declare function ingestDirectory(dirPath: string, scope?: string, opts?:
40
44
  rootDir?: string;
41
45
  onProgress?: (file: string, done: number, total: number) => void;
42
46
  }): Promise<BatchIngestResult>;
47
+ /** Content hashes of the documents currently indexed under `rootDir`. */
48
+ export declare function liveContentHashes(rootDir: string): Set<string>;
49
+ /**
50
+ * True when `key` is a document chunk whose version is no longer current.
51
+ * Non-`doc:` keys are never superseded, and an empty `live` set means the
52
+ * metadata log is missing/unreadable — in that case nothing is filtered,
53
+ * because "no metadata" must not read as "everything is stale".
54
+ */
55
+ export declare function isSupersededKey(key: string, live: Set<string>): boolean;
56
+ export declare function supersededOverfetchLimit(limit: number, live: Set<string>): number;
43
57
  export declare function searchKnowledge(query: string, opts?: {
44
58
  scope?: string;
45
59
  limit?: number;
@@ -47,6 +61,9 @@ export declare function searchKnowledge(query: string, opts?: {
47
61
  rootDir?: string;
48
62
  /** which store(s): project-only, global-only, or both (default). */
49
63
  store?: 'project' | 'global' | 'all';
64
+ /** Return chunks from superseded document versions too, flagged
65
+ * `superseded: true`. Default false — see the note above `liveContentHashes`. */
66
+ includeSuperseded?: boolean;
50
67
  }): Promise<KnowledgeExcerpt[]>;
51
68
  export declare function listDocuments(rootDir?: string, scope?: string): DocumentMeta[];
52
69
  export declare function removeDocument(filePath: string, scope?: string, rootDir?: string): Promise<void>;
@@ -358,6 +358,52 @@ export async function ingestDirectory(dirPath, scope = 'shared', opts) {
358
358
  /** Small additive boost so project knowledge wins ties against the global
359
359
  * brain — local context is more likely to be what the user means. */
360
360
  const PROJECT_SCOPE_BOOST = 0.05;
361
+ // ── Superseded-version filtering ───────────────────────────────────
362
+ //
363
+ // Chunk keys are `doc:<contentHash>:<chunkIndex>`. Re-ingesting a changed file
364
+ // produces a NEW contentHash, so its chunks land under new keys — the previous
365
+ // version's rows are never touched (`removeDocument` only tombstones metadata;
366
+ // the bridge exposes no delete-by-prefix). The store therefore accumulates every
367
+ // version a document has ever had, and all of them stay searchable.
368
+ //
369
+ // Measured on this repo's own store (2026-07-26): 9,067 `doc:`-keyed rows in
370
+ // `knowledge:shared` spanning 798 distinct content hashes, of which only 139
371
+ // are current — 8,542 rows (94.2%) are orphaned older versions.
372
+ //
373
+ // Nothing is deleted here. The current-hash set from doc-metadata.jsonl is used
374
+ // to decide what search RETURNS; `includeSuperseded` puts the old versions back
375
+ // (flagged `superseded: true`) for anyone who wants document history.
376
+ /** Content hashes of the documents currently indexed under `rootDir`. */
377
+ export function liveContentHashes(rootDir) {
378
+ const live = new Set();
379
+ for (const m of readMetadata(rootDir))
380
+ if (m.contentHash)
381
+ live.add(m.contentHash);
382
+ return live;
383
+ }
384
+ /**
385
+ * True when `key` is a document chunk whose version is no longer current.
386
+ * Non-`doc:` keys are never superseded, and an empty `live` set means the
387
+ * metadata log is missing/unreadable — in that case nothing is filtered,
388
+ * because "no metadata" must not read as "everything is stale".
389
+ */
390
+ export function isSupersededKey(key, live) {
391
+ if (!key || !key.startsWith('doc:'))
392
+ return false;
393
+ if (live.size === 0)
394
+ return false;
395
+ return !live.has(key.split(':')[1] ?? '');
396
+ }
397
+ /** How many rows to ask the backend for per requested result when superseded
398
+ * filtering is active — most rows in a long-lived store are old versions, so
399
+ * a 1:1 fetch would return an almost-empty page. */
400
+ const SUPERSEDED_OVERFETCH = 20;
401
+ const SUPERSEDED_OVERFETCH_CAP = 300;
402
+ export function supersededOverfetchLimit(limit, live) {
403
+ if (live.size === 0)
404
+ return limit;
405
+ return Math.min(Math.max(limit * SUPERSEDED_OVERFETCH, limit), SUPERSEDED_OVERFETCH_CAP);
406
+ }
361
407
  export async function searchKnowledge(query, opts) {
362
408
  const bridge = await getBridge();
363
409
  if (!bridge)
@@ -373,17 +419,28 @@ export async function searchKnowledge(query, opts) {
373
419
  if (store !== 'project') {
374
420
  targets.push({ ns: namespace('global'), dbPath: GLOBAL_BRAIN_SENTINEL, root: globalBrainRoot(), label: 'global', boost: 0 });
375
421
  }
422
+ const includeSuperseded = opts?.includeSuperseded === true;
376
423
  const perTarget = await Promise.all(targets.map(async (t) => {
424
+ const meta = readMetadata(t.root);
425
+ const live = new Set();
426
+ for (const m of meta)
427
+ if (m.contentHash)
428
+ live.add(m.contentHash);
429
+ // Old versions dominate a long-lived store, so a 1:1 fetch would come back
430
+ // nearly empty once they are filtered out. Over-fetch, then trim.
431
+ const fetchLimit = includeSuperseded ? limit : supersededOverfetchLimit(limit, live);
377
432
  const result = await bridge.bridgeSearchEntries({
378
- query, namespace: t.ns, limit, threshold: minScore, dbPath: t.dbPath,
433
+ query, namespace: t.ns, limit: fetchLimit, threshold: minScore, dbPath: t.dbPath,
379
434
  }).catch(() => null);
380
435
  if (!result?.success || !result.results.length)
381
436
  return [];
382
- const meta = readMetadata(t.root);
383
437
  const hashToFile = new Map();
384
438
  for (const m of meta)
385
439
  hashToFile.set(m.contentHash, m.filePath);
386
- return result.results.map((r) => {
440
+ const kept = includeSuperseded
441
+ ? result.results
442
+ : result.results.filter((r) => !isSupersededKey(String(r.key ?? ''), live));
443
+ return kept.slice(0, limit).map((r) => {
387
444
  const parts = r.key.startsWith('doc:') ? r.key.split(':') : [];
388
445
  const hash = parts[1] ?? '';
389
446
  const idx = parseInt(parts[2] ?? '0', 10);
@@ -391,6 +448,7 @@ export async function searchKnowledge(query, opts) {
391
448
  // hash→file map can misattribute when two documents share identical
392
449
  // content, and goes empty when a re-ingested file's hash changed.
393
450
  const srcTag = (r.tags ?? []).find((tag) => tag.startsWith('src:'));
451
+ const superseded = includeSuperseded && isSupersededKey(String(r.key ?? ''), live);
394
452
  return {
395
453
  id: r.id,
396
454
  filePath: srcTag ? srcTag.slice(4) : hashToFile.get(hash) ?? '',
@@ -398,6 +456,7 @@ export async function searchKnowledge(query, opts) {
398
456
  similarity: r.score + t.boost,
399
457
  chunkIndex: isNaN(idx) ? 0 : idx,
400
458
  scope: t.label,
459
+ ...(superseded ? { superseded: true } : {}),
401
460
  };
402
461
  });
403
462
  }));
@@ -544,9 +544,13 @@ export const allEmbeddingsTools = [
544
544
  message: 'Embedding substrate initialized',
545
545
  };
546
546
  case 'drift':
547
- // Get real drift metrics if available
547
+ // Get real drift metrics if available.
548
+ // initializeIntelligence() MUST run first: getIntelligenceStats()
549
+ // reads module singletons (sonaCoordinator / reasoningBank) that are
550
+ // null until init, so a populated store otherwise reports 0 patterns.
548
551
  try {
549
- const { getIntelligenceStats } = await import('../memory/intelligence.js');
552
+ const { getIntelligenceStats, initializeIntelligence } = await import('../memory/intelligence.js');
553
+ await initializeIntelligence();
550
554
  const stats = getIntelligenceStats();
551
555
  return {
552
556
  success: true,
@@ -564,17 +568,20 @@ export const allEmbeddingsTools = [
564
568
  : 'No patterns stored yet - drift detection inactive',
565
569
  };
566
570
  }
567
- catch {
571
+ catch (e) {
572
+ // Failing to read the store is NOT a drift report of zero.
568
573
  return {
569
- success: true,
574
+ success: false,
570
575
  action: 'drift',
576
+ error: `Intelligence store unavailable — drift status unknown: ${e.message}`,
571
577
  status: { semanticDrift: { enabled: false, reason: 'Intelligence module unavailable' } },
572
578
  };
573
579
  }
574
580
  case 'consolidate':
575
- // Get real consolidation metrics
581
+ // Get real consolidation metrics — same init requirement as 'drift'.
576
582
  try {
577
- const { getIntelligenceStats } = await import('../memory/intelligence.js');
583
+ const { getIntelligenceStats, initializeIntelligence } = await import('../memory/intelligence.js');
584
+ await initializeIntelligence();
578
585
  const stats = getIntelligenceStats();
579
586
  return {
580
587
  success: true,
@@ -590,10 +597,11 @@ export const allEmbeddingsTools = [
590
597
  message: `ReasoningBank: ${stats.reasoningBankSize} patterns, ${stats.trajectoriesRecorded} trajectories`,
591
598
  };
592
599
  }
593
- catch {
600
+ catch (e) {
594
601
  return {
595
- success: true,
602
+ success: false,
596
603
  action: 'consolidate',
604
+ error: `Intelligence store unavailable — consolidation status unknown: ${e.message}`,
597
605
  status: { memoryPhysics: { enabled: false, reason: 'Intelligence module unavailable' } },
598
606
  };
599
607
  }