devsmind-mcp 2.2.2 → 2.4.0

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/README.md +234 -606
  2. package/dist/cli/analyze.d.ts +13 -0
  3. package/dist/cli/analyze.js +143 -0
  4. package/dist/cli/analyze.js.map +1 -0
  5. package/dist/cli/index.js +62 -0
  6. package/dist/cli/index.js.map +1 -1
  7. package/dist/cli/init.js +9 -0
  8. package/dist/cli/init.js.map +1 -1
  9. package/dist/cli/integrations/memory.js +9 -3
  10. package/dist/cli/integrations/memory.js.map +1 -1
  11. package/dist/cli/integrations/prompt.d.ts +2 -0
  12. package/dist/cli/integrations/prompt.js +21 -7
  13. package/dist/cli/integrations/prompt.js.map +1 -1
  14. package/dist/cli/prune.js +4 -3
  15. package/dist/cli/prune.js.map +1 -1
  16. package/dist/cli/rule.js +43 -83
  17. package/dist/cli/rule.js.map +1 -1
  18. package/dist/cli/sync.d.ts +7 -0
  19. package/dist/cli/sync.js +40 -7
  20. package/dist/cli/sync.js.map +1 -1
  21. package/dist/cli/workflow.d.ts +8 -0
  22. package/dist/cli/workflow.js +156 -0
  23. package/dist/cli/workflow.js.map +1 -0
  24. package/dist/db/analyze.d.ts +67 -0
  25. package/dist/db/analyze.js +167 -0
  26. package/dist/db/analyze.js.map +1 -0
  27. package/dist/db/database.d.ts +195 -2
  28. package/dist/db/database.js +870 -74
  29. package/dist/db/database.js.map +1 -1
  30. package/dist/db/schema.d.ts +28 -1
  31. package/dist/db/schema.js +34 -0
  32. package/dist/db/schema.js.map +1 -1
  33. package/dist/db/staging.d.ts +4 -0
  34. package/dist/db/staging.js +16 -2
  35. package/dist/db/staging.js.map +1 -1
  36. package/dist/db/workflow-import.d.ts +22 -0
  37. package/dist/db/workflow-import.js +116 -0
  38. package/dist/db/workflow-import.js.map +1 -0
  39. package/dist/mcp/server.d.ts +1 -1
  40. package/dist/mcp/server.js +641 -48
  41. package/dist/mcp/server.js.map +1 -1
  42. package/dist/utils/ast.d.ts +98 -0
  43. package/dist/utils/ast.js +262 -10
  44. package/dist/utils/ast.js.map +1 -1
  45. package/dist/utils/config.d.ts +2 -0
  46. package/dist/utils/config.js +11 -0
  47. package/dist/utils/config.js.map +1 -1
  48. package/dist/utils/edit.d.ts +41 -0
  49. package/dist/utils/edit.js +163 -0
  50. package/dist/utils/edit.js.map +1 -0
  51. package/dist/utils/git.d.ts +14 -0
  52. package/dist/utils/git.js +43 -0
  53. package/dist/utils/git.js.map +1 -0
  54. package/package.json +1 -1
@@ -38,6 +38,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.DevMindDatabase = void 0;
40
40
  exports.formatReasoning = formatReasoning;
41
+ exports.parseReasoningBlocks = parseReasoningBlocks;
41
42
  const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
42
43
  const crypto = __importStar(require("crypto"));
43
44
  const fs = __importStar(require("fs"));
@@ -76,6 +77,47 @@ function formatReasoning(r) {
76
77
  ];
77
78
  return lines.join('\n');
78
79
  }
80
+ /**
81
+ * Inverse of `formatReasoning`. A single history row accumulates every later update appended
82
+ * under a `── Update @ … ──` separator, so one stored blob can hold several changes — this
83
+ * splits them back apart and returns them NEWEST FIRST.
84
+ *
85
+ * Reasoning written before the structured format (or by a caller passing a bare string) has no
86
+ * labels to read; rather than drop it, the whole chunk is surfaced as `what_changed`.
87
+ */
88
+ function parseReasoningBlocks(raw) {
89
+ if (!raw || typeof raw !== 'string')
90
+ return [];
91
+ const chunks = raw
92
+ .split(/\n*── Update @ [^\n]*──\n/g)
93
+ .map(c => c.trim())
94
+ .filter(Boolean);
95
+ const parsed = chunks.map(chunk => {
96
+ const field = (label) => {
97
+ const m = chunk.match(new RegExp(`^${label}:[ \\t]*(.*)$`, 'm'));
98
+ const v = m?.[1]?.trim();
99
+ return v ? v : undefined;
100
+ };
101
+ const what = field('What changed');
102
+ const why = field('Why');
103
+ const goal = field('Goal');
104
+ // No recognised labels → free-text reasoning; keep it rather than return an empty shell.
105
+ if (!what && !why && !goal) {
106
+ return { what_changed: chunk, why: '', goal: '' };
107
+ }
108
+ return {
109
+ what_changed: what || '',
110
+ why: why || '',
111
+ goal: goal || '',
112
+ requirement: field('Requirement'),
113
+ previous_state: field('Previous state'),
114
+ decision: field('Decision'),
115
+ developer: field('Developer'),
116
+ model: field('Model')
117
+ };
118
+ });
119
+ return parsed.reverse();
120
+ }
79
121
  class DevMindDatabase {
80
122
  db;
81
123
  dbPath;
@@ -114,6 +156,9 @@ class DevMindDatabase {
114
156
  )
115
157
  `);
116
158
  }
159
+ getContext() {
160
+ return this.context;
161
+ }
117
162
  getSystemMeta(key) {
118
163
  try {
119
164
  const stmt = this.db.prepare('SELECT value FROM system_meta WHERE key = ?');
@@ -132,9 +177,16 @@ class DevMindDatabase {
132
177
  `);
133
178
  stmt.run(key, value, value);
134
179
  }
180
+ /**
181
+ * Nodes declared in one file. Both sides are folded to a canonical form before comparing:
182
+ * a stored `c:\x\y.ts` and a caller's `C:/x/y.ts` are the same file on Windows, and a raw
183
+ * `=` match silently returns nothing — which reads as "this file has no nodes" rather than
184
+ * as an error. There is no index on file_path, so this was already a full scan; normalizing
185
+ * in SQL costs nothing extra.
186
+ */
135
187
  getNodesByFilePath(filePath) {
136
- const stmt = this.db.prepare('SELECT * FROM nodes WHERE file_path = ? AND deprecated = 0');
137
- return stmt.all(filePath);
188
+ const stmt = this.db.prepare(`SELECT * FROM nodes WHERE deprecated = 0 AND REPLACE(LOWER(file_path), '\\', '/') = ?`);
189
+ return stmt.all((0, ast_1.normalizeFsPath)(filePath));
138
190
  }
139
191
  close() {
140
192
  this.db.close();
@@ -183,6 +235,9 @@ class DevMindDatabase {
183
235
  }
184
236
  fs.mkdirSync(p, { recursive: true });
185
237
  }
238
+ // NOTE: 'workflows/' is intentionally NOT wiped — workflow data is long-lived
239
+ // cross-session state that survives a node/history reindex. It will be
240
+ // restored from workflows/*/workflow.json on the next syncFromDisk().
186
241
  this.vacuum();
187
242
  }
188
243
  /**
@@ -232,11 +287,12 @@ class DevMindDatabase {
232
287
  }
233
288
  // --- Node Operations ---
234
289
  upsertNode(node) {
290
+ const canonicalFp = (0, config_1.canonicalizePath)(node.file_path);
235
291
  const existing = this.getNode(node.id);
236
292
  if (existing) {
237
293
  let finalPath = existing.file_path;
238
294
  const paths = existing.file_path.split(',').map(p => p.trim()).filter(Boolean);
239
- const incoming = node.file_path.trim();
295
+ const incoming = canonicalFp.trim();
240
296
  if (!paths.includes(incoming)) {
241
297
  paths.push(incoming);
242
298
  finalPath = paths.join(', ');
@@ -257,9 +313,9 @@ class DevMindDatabase {
257
313
  INSERT INTO nodes (id, type, name, file_path, signature)
258
314
  VALUES (?, ?, ?, ?, ?)
259
315
  `);
260
- stmt.run(node.id, node.type, node.name, node.file_path, node.signature || null);
316
+ stmt.run(node.id, node.type, node.name, canonicalFp, node.signature || null);
261
317
  }
262
- this.writeGraphToDisk(node.file_path);
318
+ this.writeGraphToDisk(canonicalFp);
263
319
  }
264
320
  getNode(id) {
265
321
  const stmt = this.db.prepare('SELECT * FROM nodes WHERE id = ?');
@@ -267,8 +323,8 @@ class DevMindDatabase {
267
323
  if (direct)
268
324
  return direct;
269
325
  if (!id.includes('#')) {
270
- const suffixStmt = this.db.prepare('SELECT * FROM nodes WHERE id LIKE ? AND deprecated = 0');
271
- const matches = suffixStmt.all(`%#${id}`);
326
+ const suffixStmt = this.db.prepare("SELECT * FROM nodes WHERE id LIKE ? ESCAPE '\\' AND deprecated = 0");
327
+ const matches = suffixStmt.all(`%#${this.likeEscape(id)}`);
272
328
  if (matches.length === 1) {
273
329
  return matches[0];
274
330
  }
@@ -314,39 +370,53 @@ class DevMindDatabase {
314
370
  this.writeGraphToDisk(p);
315
371
  }
316
372
  }
317
- renameNode(oldId, newId, newName) {
373
+ /** `newFilePath`: pass when the rename is a file move (analyze's rename migration), leave undefined for a pure symbol-id rename where the file itself is unchanged. */
374
+ renameNode(oldId, newId, newName, newFilePath) {
318
375
  const node = this.getNode(oldId);
319
376
  if (!node) {
320
377
  throw new Error(`Node not found: ${oldId}`);
321
378
  }
322
- const name = newName || (node.name === oldId ? newId : node.name);
379
+ // getNode() resolves a bare/unqualified id (e.g. "createCart") to the node's fully-qualified
380
+ // one via a suffix match — but node_connections/history are keyed by the FULLY-QUALIFIED id
381
+ // only. Every statement below must use node.id, not the raw oldId parameter: using oldId
382
+ // directly makes each UPDATE a silent no-op whenever the caller passed a bare id (matching
383
+ // no rows, throwing no error), leaving the new id's row empty/disconnected while the old
384
+ // node's history and edges stay put under the id that was supposedly just renamed away.
385
+ const resolvedOldId = node.id;
386
+ const name = newName || (node.name === resolvedOldId ? newId : node.name);
387
+ const filePath = newFilePath || node.file_path;
323
388
  this.db.pragma('foreign_keys = OFF');
324
389
  try {
325
390
  const runTx = this.db.transaction(() => {
326
391
  const insertStmt = this.db.prepare(`
327
- INSERT INTO nodes (id, type, name, file_path, signature, created_at)
328
- VALUES (?, ?, ?, ?, ?, ?)
392
+ INSERT INTO nodes (id, type, name, file_path, signature, created_at, deprecated)
393
+ VALUES (?, ?, ?, ?, ?, ?, ?)
329
394
  `);
330
- insertStmt.run(newId, node.type, name, node.file_path, node.signature, node.created_at);
395
+ insertStmt.run(newId, node.type, name, filePath, node.signature, node.created_at, node.deprecated ? 1 : 0);
331
396
  const updateSourceStmt = this.db.prepare(`
332
397
  UPDATE node_connections SET source_node_id = ? WHERE source_node_id = ?
333
398
  `);
334
- updateSourceStmt.run(newId, oldId);
399
+ updateSourceStmt.run(newId, resolvedOldId);
335
400
  const updateTargetStmt = this.db.prepare(`
336
401
  UPDATE node_connections SET target_node_id = ? WHERE target_node_id = ?
337
402
  `);
338
- updateTargetStmt.run(newId, oldId);
403
+ updateTargetStmt.run(newId, resolvedOldId);
339
404
  const updateHistoryStmt = this.db.prepare(`
340
405
  UPDATE history SET node_id = ? WHERE node_id = ?
341
406
  `);
342
- updateHistoryStmt.run(newId, oldId);
407
+ updateHistoryStmt.run(newId, resolvedOldId);
343
408
  const deleteOldStmt = this.db.prepare('DELETE FROM nodes WHERE id = ?');
344
- deleteOldStmt.run(oldId);
409
+ deleteOldStmt.run(resolvedOldId);
345
410
  });
346
411
  runTx();
347
412
  if (node.file_path) {
413
+ // Rewrite the OLD file's graph JSON too when the file itself moved, so the
414
+ // stale node entry doesn't linger under the old path's JSON on disk.
348
415
  this.writeGraphToDisk(node.file_path);
349
416
  }
417
+ if (filePath && filePath !== node.file_path) {
418
+ this.writeGraphToDisk(filePath);
419
+ }
350
420
  // Edges pointing INTO the renamed node live in the SOURCE nodes' files' graph JSONs
351
421
  // (which still reference oldId on disk). The DB was already repointed to newId above,
352
422
  // so rewrite each such file — otherwise syncFromDisk reloads the stale oldId edge and
@@ -357,7 +427,7 @@ class DevMindDatabase {
357
427
  // longer exists in the DB) and re-insert it right back, undoing the rename.
358
428
  const historyIds = this.db.prepare('SELECT id FROM history WHERE node_id = ?').all(newId);
359
429
  for (const row of historyIds) {
360
- this.patchHistoryDiskIdentity(row.id, newId, name, node.type, node.file_path, node.signature);
430
+ this.patchHistoryDiskIdentity(row.id, newId, name, node.type, filePath, node.signature);
361
431
  }
362
432
  }
363
433
  finally {
@@ -741,7 +811,17 @@ class DevMindDatabase {
741
811
  return result;
742
812
  }
743
813
  updateHistory(params) {
744
- const { node_id, code_snapshot, reasoning } = params;
814
+ const { node_id, code_snapshot } = params;
815
+ let reasoning = params.reasoning;
816
+ // The calling AI has no reliable way to know who the human running this
817
+ // machine actually is -- it can only guess ("Claude Code", "AI Assistant",
818
+ // etc). Whenever this project has a configured developer identity (from
819
+ // .env's DEVELOPER_NAME, set by `devsmind init`), that's authoritative and
820
+ // always overrides whatever the agent supplied, so history is attributed
821
+ // to the real developer regardless of what the agent wrote in this field.
822
+ if (typeof reasoning === 'object' && this.context?.developer?.name) {
823
+ reasoning = { ...reasoning, developer: this.context.developer.name };
824
+ }
745
825
  const node = this.getNode(node_id);
746
826
  const resolvedId = node ? node.id : node_id;
747
827
  const formattedReasoning = formatReasoning(reasoning);
@@ -753,20 +833,30 @@ class DevMindDatabase {
753
833
  const lastUpdate = new Date(latest.updated_at).getTime();
754
834
  const nowTime = new Date(nowStr).getTime();
755
835
  const diffMs = nowTime - lastUpdate;
756
- // If updated < 1 hour ago, update same record
836
+ // If updated < 1 hour ago, update the same record IN PLACE (no new row — this is what
837
+ // keeps db/graph/history from bloating with one entry per commit during an active editing
838
+ // session). code_snapshot is always the latest state (git already owns version history for
839
+ // code). reasoning is APPENDED, not overwritten — an earlier commit's "why" in this same
840
+ // session is still real and still worth keeping; losing it silently is worse than a few
841
+ // extra lines in one file. This also keeps any workflow step whose history_ids point at
842
+ // this row valid: it never loses what it originally linked to, only gains more below it.
757
843
  if (diffMs < 3600000) {
844
+ const previousReasoning = typeof latest.reasoning === 'string' ? latest.reasoning : '';
845
+ const mergedReasoning = previousReasoning.trim().length > 0
846
+ ? `${previousReasoning}\n\n── Update @ ${nowStr} ──\n${formattedReasoning}`
847
+ : formattedReasoning;
758
848
  const updateStmt = this.db.prepare(`
759
849
  UPDATE history
760
850
  SET code_snapshot = '', reasoning = ?, updated_at = ?
761
851
  WHERE id = ?
762
852
  `);
763
- updateStmt.run(formattedReasoning, nowStr, latest.id);
853
+ updateStmt.run(mergedReasoning, nowStr, latest.id);
764
854
  // Write/Update on disk
765
- this.writeHistoryToDisk(latest.id, resolvedId, latest.session_id, latest.created_at, nowStr, code_snapshot, formattedReasoning);
855
+ this.writeHistoryToDisk(latest.id, resolvedId, latest.session_id, latest.created_at, nowStr, code_snapshot, mergedReasoning);
766
856
  return {
767
857
  ...latest,
768
858
  code_snapshot,
769
- reasoning: formattedReasoning,
859
+ reasoning: mergedReasoning,
770
860
  updated_at: nowStr
771
861
  };
772
862
  }
@@ -803,10 +893,14 @@ class DevMindDatabase {
803
893
  const stmt = this.db.prepare(`
804
894
  SELECT DISTINCT n.* FROM nodes n
805
895
  LEFT JOIN history h ON n.id = h.node_id
806
- WHERE n.name LIKE ? OR n.id LIKE ? OR h.reasoning LIKE ?
896
+ WHERE n.name LIKE ? ESCAPE '\\' OR n.id LIKE ? ESCAPE '\\' OR h.reasoning LIKE ? ESCAPE '\\'
807
897
  LIMIT 50
808
898
  `);
809
- const wildcard = `%${query}%`;
899
+ // Escaped so a query containing '%'/'_' (a real identifier fragment like "CartService_addItem"
900
+ // matches the literal underscore, not "any single character") searches for those characters
901
+ // rather than acting as SQL LIKE wildcards — sibling methods (getDeveloperActivity,
902
+ // searchDecisions) already do this; this one didn't.
903
+ const wildcard = `%${this.likeEscape(query)}%`;
810
904
  const identifierMatches = stmt.all(wildcard, wildcard, wildcard);
811
905
  if (identifierMatches.length > 0) {
812
906
  return identifierMatches.map(n => ({ ...n, matched_via: 'identifier' }));
@@ -816,7 +910,97 @@ class DevMindDatabase {
816
910
  is_regex: opts.is_regex,
817
911
  case_insensitive: opts.case_insensitive
818
912
  });
819
- return codeMatches.map(m => ({ ...m, matched_via: 'code' }));
913
+ if (codeMatches.length > 0) {
914
+ return codeMatches.map(m => ({ ...m, matched_via: 'code' }));
915
+ }
916
+ const tokens = this.tokenizeQuery(query);
917
+ if (tokens.length === 0) {
918
+ return [];
919
+ }
920
+ return this.fuzzySearchNodes(tokens);
921
+ }
922
+ /**
923
+ * Splits a query string into lowercase word tokens for the fuzzy fallback
924
+ * stage of {@link searchNodes}. This is request-scoped tokenization only —
925
+ * nothing is persisted or indexed; the result is discarded after the call.
926
+ */
927
+ tokenizeQuery(query) {
928
+ const seen = new Set();
929
+ for (const raw of query.toLowerCase().split(/[^a-z0-9]+/i)) {
930
+ if (raw.length >= 2)
931
+ seen.add(raw);
932
+ }
933
+ return Array.from(seen);
934
+ }
935
+ /**
936
+ * Word-split relevance-ranked fallback for {@link searchNodes}. Runs only
937
+ * when the exact identifier and code stages both return nothing. Scores
938
+ * every non-deprecated node by how many distinct query tokens appear as a
939
+ * substring of its file_path/name/id (highest signal), latest reasoning,
940
+ * or code content (lowest signal, one point per matching line). No new
941
+ * data is written or synced — this is a plain in-memory scan reusing the
942
+ * same node/history sources searchCode already reads.
943
+ */
944
+ fuzzySearchNodes(tokens) {
945
+ const historyDir = path.join(path.dirname(this.dbPath), 'history');
946
+ const stmt = this.db.prepare(`
947
+ SELECT n.*, h.id AS latest_history_id, h.reasoning AS reasoning
948
+ FROM nodes n
949
+ LEFT JOIN history h ON h.id = (
950
+ SELECT id FROM history WHERE node_id = n.id ORDER BY updated_at DESC LIMIT 1
951
+ )
952
+ WHERE n.deprecated = 0
953
+ `);
954
+ const rows = stmt.all();
955
+ const FIELD_WEIGHT = { path: 3, identifier: 3, reasoning: 2, code: 1 };
956
+ const scored = [];
957
+ for (const row of rows) {
958
+ const { latest_history_id, reasoning, ...node } = row;
959
+ const matchedTerms = new Set();
960
+ let score = 0;
961
+ const filePathLower = (node.file_path || '').toLowerCase();
962
+ const nameLower = (node.name || '').toLowerCase();
963
+ const idLower = (node.id || '').toLowerCase();
964
+ const reasoningLower = (reasoning || '').toLowerCase();
965
+ let code = '';
966
+ if (latest_history_id) {
967
+ const historyFile = path.join(historyDir, `${latest_history_id}.json`);
968
+ if (fs.existsSync(historyFile)) {
969
+ try {
970
+ const data = JSON.parse(fs.readFileSync(historyFile, 'utf-8'));
971
+ code = (data.code_snapshot || '').toLowerCase();
972
+ }
973
+ catch {
974
+ // Skip corrupted or unreadable history files
975
+ }
976
+ }
977
+ }
978
+ for (const token of tokens) {
979
+ let tokenMatched = false;
980
+ if (filePathLower.includes(token)) {
981
+ score += FIELD_WEIGHT.path;
982
+ tokenMatched = true;
983
+ }
984
+ if (nameLower.includes(token) || idLower.includes(token)) {
985
+ score += FIELD_WEIGHT.identifier;
986
+ tokenMatched = true;
987
+ }
988
+ if (reasoningLower.includes(token)) {
989
+ score += FIELD_WEIGHT.reasoning;
990
+ tokenMatched = true;
991
+ }
992
+ if (code && code.includes(token)) {
993
+ score += FIELD_WEIGHT.code;
994
+ tokenMatched = true;
995
+ }
996
+ if (tokenMatched)
997
+ matchedTerms.add(token);
998
+ }
999
+ if (score > 0) {
1000
+ scored.push({ ...node, matched_via: 'fuzzy', matched_terms: Array.from(matchedTerms), score });
1001
+ }
1002
+ }
1003
+ return scored.sort((a, b) => b.score - a.score).slice(0, 20);
820
1004
  }
821
1005
  getRecentChanges(hours = 24, analyzeImpact = true) {
822
1006
  const stmt = this.db.prepare(`
@@ -858,11 +1042,11 @@ class DevMindDatabase {
858
1042
  SELECT h.node_id, n.name as node_name, h.updated_at, h.reasoning
859
1043
  FROM history h
860
1044
  JOIN nodes n ON h.node_id = n.id
861
- WHERE h.reasoning LIKE ?
1045
+ WHERE h.reasoning LIKE ? ESCAPE '\\'
862
1046
  ORDER BY h.updated_at DESC
863
1047
  LIMIT ?
864
1048
  `);
865
- const query = `%Developer: %${developer}%`;
1049
+ const query = `%Developer: %${this.likeEscape(developer)}%`;
866
1050
  return stmt.all(query, limit);
867
1051
  }
868
1052
  getChangesByRequirement(requirementId) {
@@ -870,10 +1054,10 @@ class DevMindDatabase {
870
1054
  SELECT h.node_id, n.name as node_name, h.updated_at, h.reasoning
871
1055
  FROM history h
872
1056
  JOIN nodes n ON h.node_id = n.id
873
- WHERE h.reasoning LIKE ?
1057
+ WHERE h.reasoning LIKE ? ESCAPE '\\'
874
1058
  ORDER BY h.updated_at DESC
875
1059
  `);
876
- const query = `%Requirement: %${requirementId}%`;
1060
+ const query = `%Requirement: %${this.likeEscape(requirementId)}%`;
877
1061
  return stmt.all(query);
878
1062
  }
879
1063
  searchDecisions(query) {
@@ -881,10 +1065,10 @@ class DevMindDatabase {
881
1065
  SELECT h.node_id, n.name as node_name, h.updated_at, h.reasoning
882
1066
  FROM history h
883
1067
  JOIN nodes n ON h.node_id = n.id
884
- WHERE h.reasoning LIKE ?
1068
+ WHERE h.reasoning LIKE ? ESCAPE '\\'
885
1069
  ORDER BY h.updated_at DESC
886
1070
  `);
887
- const wildcard = `%Decision: %${query}%`;
1071
+ const wildcard = `%Decision: %${this.likeEscape(query)}%`;
888
1072
  return stmt.all(wildcard);
889
1073
  }
890
1074
  searchCode(params) {
@@ -957,7 +1141,8 @@ class DevMindDatabase {
957
1141
  getOrphanedNodes() {
958
1142
  const stmt = this.db.prepare(`
959
1143
  SELECT * FROM nodes
960
- WHERE id NOT IN (SELECT DISTINCT source_node_id FROM node_connections)
1144
+ WHERE deprecated = 0
1145
+ AND id NOT IN (SELECT DISTINCT source_node_id FROM node_connections)
961
1146
  AND id NOT IN (SELECT DISTINCT target_node_id FROM node_connections)
962
1147
  `);
963
1148
  return stmt.all();
@@ -974,8 +1159,15 @@ class DevMindDatabase {
974
1159
  params.push(filter.type);
975
1160
  }
976
1161
  if (filter?.file_path) {
977
- sql += ' AND file_path LIKE ?';
978
- params.push(`%${filter.file_path}%`);
1162
+ // file_path is stored with OS-native separators (backslashes on Windows), but the tool's
1163
+ // own schema example is forward-slash ("src/components") — a raw LIKE against the
1164
+ // unmodified column means that exact example returns nothing on Windows unless the
1165
+ // caller happens to pass backslashes instead. Normalize both sides to forward slashes
1166
+ // (getNodesByFilePath a few hundred lines up already does the equivalent for exact
1167
+ // matches; this just extends the same fix to the substring-filter path) and escape LIKE
1168
+ // metacharacters so a literal '%' or '_' in a path segment can't be misread as a wildcard.
1169
+ sql += " AND REPLACE(file_path, '\\', '/') LIKE ? ESCAPE '\\'";
1170
+ params.push(`%${this.likeEscape(filter.file_path.replace(/\\/g, '/'))}%`);
979
1171
  }
980
1172
  if (!filter?.include_deprecated) {
981
1173
  sql += ' AND deprecated = 0';
@@ -992,48 +1184,471 @@ class DevMindDatabase {
992
1184
  const rows = stmt.all();
993
1185
  return rows.map(row => this.populateHistoryFromDisk(row));
994
1186
  }
995
- pruneSpuriousNodes(workspaceRoot) {
996
- const spuriousNames = new Set([
997
- 'promise', 'map', 'set', 'json', 'console', 'error', 'object', 'function', 'array', 'string', 'number', 'boolean', 'regexp', 'date', 'math',
998
- 'any', 'void', 'unknown', 'never', 'null', 'undefined', 'dict', 'list',
999
- 'data', 'useeffect', 'val', 'temp', 'result', 'item', 'key', 'value', 'err', 'req', 'res', 'args', 'params', 'response', 'request'
1187
+ // ─── `devsmind analyze` read-only health checks ────────────────────────────
1188
+ // All pure queries/graph traversal, no mutation, no LLM calls.
1189
+ /** Nodes whose total (in + out) connection degree meets/exceeds `threshold` architectural bottleneck candidates. */
1190
+ getGodEntities(threshold = 15) {
1191
+ const stmt = this.db.prepare(`
1192
+ SELECT * FROM (
1193
+ SELECT n.id, n.name, n.file_path, (
1194
+ (SELECT COUNT(*) FROM node_connections c WHERE c.source_node_id = n.id) +
1195
+ (SELECT COUNT(*) FROM node_connections c WHERE c.target_node_id = n.id)
1196
+ ) AS degree
1197
+ FROM nodes n
1198
+ WHERE n.deprecated = 0
1199
+ )
1200
+ WHERE degree >= ?
1201
+ ORDER BY degree DESC
1202
+ `);
1203
+ return stmt.all(threshold);
1204
+ }
1205
+ /** DFS cycle detection over the connection graph, capped at `maxCycles` reported paths. */
1206
+ getCircularDependencies(maxCycles = 50) {
1207
+ const edges = this.getAllConnections();
1208
+ const adjacency = new Map();
1209
+ for (const e of edges) {
1210
+ if (!adjacency.has(e.source_node_id))
1211
+ adjacency.set(e.source_node_id, []);
1212
+ adjacency.get(e.source_node_id).push(e.target_node_id);
1213
+ }
1214
+ const cycles = [];
1215
+ const visited = new Set();
1216
+ const stack = [];
1217
+ const onStack = new Set();
1218
+ const dfs = (node) => {
1219
+ if (cycles.length >= maxCycles)
1220
+ return;
1221
+ if (onStack.has(node)) {
1222
+ const start = stack.indexOf(node);
1223
+ cycles.push([...stack.slice(start), node]);
1224
+ return;
1225
+ }
1226
+ if (visited.has(node))
1227
+ return;
1228
+ visited.add(node);
1229
+ stack.push(node);
1230
+ onStack.add(node);
1231
+ for (const next of adjacency.get(node) || []) {
1232
+ if (cycles.length >= maxCycles)
1233
+ break;
1234
+ dfs(next);
1235
+ }
1236
+ stack.pop();
1237
+ onStack.delete(node);
1238
+ };
1239
+ for (const node of adjacency.keys()) {
1240
+ if (cycles.length >= maxCycles)
1241
+ break;
1242
+ if (!visited.has(node))
1243
+ dfs(node);
1244
+ }
1245
+ return cycles;
1246
+ }
1247
+ /** node_connections rows whose source or target no longer exists in `nodes` (broken by a non-transactional delete, or a sync race). */
1248
+ getDanglingEdges() {
1249
+ const stmt = this.db.prepare(`
1250
+ SELECT * FROM node_connections
1251
+ WHERE source_node_id NOT IN (SELECT id FROM nodes)
1252
+ OR target_node_id NOT IN (SELECT id FROM nodes)
1253
+ `);
1254
+ return stmt.all();
1255
+ }
1256
+ /** Deletes a single dangling `node_connections` row. The edge itself is invalid data — no history/graph JSON to rewrite. */
1257
+ deleteDanglingEdge(sourceId, targetId) {
1258
+ this.db.prepare('DELETE FROM node_connections WHERE source_node_id = ? AND target_node_id = ?').run(sourceId, targetId);
1259
+ }
1260
+ /** Node ids that differ only by case — a real collision risk on Windows's case-insensitive filesystem. */
1261
+ getDuplicateNodeIds() {
1262
+ const stmt = this.db.prepare(`
1263
+ SELECT LOWER(id) AS lower_id, GROUP_CONCAT(id, '|') AS ids
1264
+ FROM nodes
1265
+ WHERE deprecated = 0
1266
+ GROUP BY lower_id
1267
+ HAVING COUNT(*) > 1
1268
+ `);
1269
+ const rows = stmt.all();
1270
+ return rows.map(r => ({ lowerId: r.lower_id, ids: r.ids.split('|') }));
1271
+ }
1272
+ /** History rows whose flattened `reasoning` text has no non-empty `Developer:` line — can't be attributed to anyone. */
1273
+ getHistoryMissingDeveloper() {
1274
+ const stmt = this.db.prepare('SELECT id, node_id, updated_at, reasoning FROM history');
1275
+ const rows = stmt.all();
1276
+ return rows
1277
+ .filter(r => {
1278
+ // [ \t]* (not \s*) so the match can't swallow the newline and bleed into the
1279
+ // next "Model:" line when Developer is blank, which would wrongly capture
1280
+ // "Model: <value>" as if it were the developer's name.
1281
+ const match = /Developer:[ \t]*([^\n]*)/i.exec(r.reasoning || '');
1282
+ return !match || !match[1].trim();
1283
+ })
1284
+ .map(({ id, node_id, updated_at }) => ({ id, node_id, updated_at }));
1285
+ }
1286
+ /**
1287
+ * History rows with a blank code snapshot — usually a silent AST extraction failure.
1288
+ * The `history.code_snapshot` DB column is always written as `''` (the real content
1289
+ * lives only in the per-row JSON on disk, see `populateHistoryFromDisk`), so this
1290
+ * must read through the populated rows rather than querying the column directly.
1291
+ */
1292
+ getEmptyCodeSnapshots() {
1293
+ return this.getAllHistory()
1294
+ .filter(h => !h.code_snapshot || h.code_snapshot.trim() === '')
1295
+ .map(({ id, node_id, updated_at }) => ({ id, node_id, updated_at }));
1296
+ }
1297
+ // ─── Workflow Context Vault ─────────────────────────────────────────────
1298
+ // Persistent, cross-session feature memory. Steps link to existing `history`
1299
+ // rows rather than duplicating code/reasoning; artifacts are plain files on
1300
+ // disk under `.devmind/workflows/<id>/`, with only the path stored in the DB.
1301
+ workflowsDir() {
1302
+ return path.join(path.dirname(this.dbPath), 'workflows');
1303
+ }
1304
+ /** Serializes the workflow + its steps + artifact index to disk so teammates can sync it via git. */
1305
+ writeWorkflowToDisk(workflowId) {
1306
+ try {
1307
+ const workflow = this.db.prepare('SELECT * FROM workflows WHERE id = ?').get(workflowId);
1308
+ if (!workflow)
1309
+ return;
1310
+ const steps = this.db.prepare('SELECT * FROM workflow_steps WHERE workflow_id = ? ORDER BY step_index ASC').all(workflowId);
1311
+ const artifacts = this.db.prepare('SELECT * FROM workflow_artifacts WHERE workflow_id = ? ORDER BY created_at ASC').all(workflowId);
1312
+ const activeId = this.getSystemMeta('active_workflow_id');
1313
+ const data = {
1314
+ id: workflow.id,
1315
+ name: workflow.name,
1316
+ description: workflow.description,
1317
+ status: workflow.status,
1318
+ created_at: workflow.created_at,
1319
+ updated_at: workflow.updated_at,
1320
+ is_active: activeId === workflowId,
1321
+ steps: steps.map(s => ({
1322
+ id: s.id,
1323
+ step_index: s.step_index,
1324
+ summary: s.summary,
1325
+ pending_tasks: s.pending_tasks,
1326
+ history_ids: s.history_ids,
1327
+ session_id: s.session_id,
1328
+ created_at: s.created_at
1329
+ })),
1330
+ artifact_index: artifacts.map(a => ({
1331
+ id: a.id,
1332
+ step_id: a.step_id,
1333
+ type: a.type,
1334
+ source_name: a.source_name,
1335
+ file_path: a.file_path,
1336
+ created_at: a.created_at
1337
+ }))
1338
+ };
1339
+ const dir = path.join(this.workflowsDir(), workflowId);
1340
+ fs.mkdirSync(dir, { recursive: true });
1341
+ fs.writeFileSync(path.join(dir, 'workflow.json'), JSON.stringify(data, null, 2), 'utf-8');
1342
+ }
1343
+ catch (err) {
1344
+ console.warn('⚠️ DevsMind: Failed to write workflow JSON to disk:', err);
1345
+ }
1346
+ }
1347
+ createWorkflow(name, description) {
1348
+ const id = `wf_${crypto.randomUUID()}`;
1349
+ const now = new Date().toISOString();
1350
+ this.db.prepare(`
1351
+ INSERT INTO workflows (id, name, description, status, created_at, updated_at)
1352
+ VALUES (?, ?, ?, 'active', ?, ?)
1353
+ `).run(id, name, description, now, now);
1354
+ this.setSystemMeta('active_workflow_id', id);
1355
+ this.writeWorkflowToDisk(id);
1356
+ return { id, name, description, status: 'active', created_at: now, updated_at: now };
1357
+ }
1358
+ getWorkflow(id) {
1359
+ const row = this.db.prepare('SELECT * FROM workflows WHERE id = ?').get(id);
1360
+ return row || null;
1361
+ }
1362
+ getActiveWorkflow() {
1363
+ const id = this.getSystemMeta('active_workflow_id');
1364
+ return id ? this.getWorkflow(id) : null;
1365
+ }
1366
+ listWorkflows(status) {
1367
+ if (status) {
1368
+ return this.db.prepare('SELECT * FROM workflows WHERE status = ? ORDER BY updated_at DESC').all(status);
1369
+ }
1370
+ return this.db.prepare('SELECT * FROM workflows ORDER BY updated_at DESC').all();
1371
+ }
1372
+ /** Pauses the currently active workflow (if any) and clears the active pointer. */
1373
+ pauseWorkflow() {
1374
+ const active = this.getActiveWorkflow();
1375
+ if (!active)
1376
+ return null;
1377
+ const now = new Date().toISOString();
1378
+ this.db.prepare(`UPDATE workflows SET status = 'paused', updated_at = ? WHERE id = ?`).run(now, active.id);
1379
+ this.setSystemMeta('active_workflow_id', '');
1380
+ this.writeWorkflowToDisk(active.id);
1381
+ return { ...active, status: 'paused', updated_at: now };
1382
+ }
1383
+ /** Resumes `id`, auto-pausing whatever was previously active (only one workflow is active at a time). */
1384
+ resumeWorkflow(id) {
1385
+ const workflow = this.getWorkflow(id);
1386
+ if (!workflow)
1387
+ throw new Error(`Workflow not found: ${id}`);
1388
+ const currentActive = this.getActiveWorkflow();
1389
+ if (currentActive && currentActive.id !== id)
1390
+ this.pauseWorkflow();
1391
+ const now = new Date().toISOString();
1392
+ this.db.prepare(`UPDATE workflows SET status = 'active', updated_at = ? WHERE id = ?`).run(now, id);
1393
+ this.setSystemMeta('active_workflow_id', id);
1394
+ this.writeWorkflowToDisk(id);
1395
+ return { ...workflow, status: 'active', updated_at: now };
1396
+ }
1397
+ completeWorkflow(id) {
1398
+ const workflow = this.getWorkflow(id);
1399
+ if (!workflow)
1400
+ throw new Error(`Workflow not found: ${id}`);
1401
+ const now = new Date().toISOString();
1402
+ this.db.prepare(`UPDATE workflows SET status = 'completed', updated_at = ? WHERE id = ?`).run(now, id);
1403
+ if (this.getSystemMeta('active_workflow_id') === id)
1404
+ this.setSystemMeta('active_workflow_id', '');
1405
+ this.writeWorkflowToDisk(id);
1406
+ return { ...workflow, status: 'completed', updated_at: now };
1407
+ }
1408
+ addWorkflowStep(workflowId, opts) {
1409
+ if (!this.getWorkflow(workflowId))
1410
+ throw new Error(`Workflow not found: ${workflowId}`);
1411
+ const id = crypto.randomUUID();
1412
+ const now = new Date().toISOString();
1413
+ const nextIndex = (this.db.prepare('SELECT MAX(step_index) AS m FROM workflow_steps WHERE workflow_id = ?').get(workflowId).m ?? 0) + 1;
1414
+ const historyIdsJson = opts.historyIds && opts.historyIds.length ? JSON.stringify(opts.historyIds) : null;
1415
+ this.db.prepare(`
1416
+ INSERT INTO workflow_steps (id, workflow_id, step_index, summary, pending_tasks, history_ids, session_id, created_at)
1417
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1418
+ `).run(id, workflowId, nextIndex, opts.summary, opts.pendingTasks || null, historyIdsJson, opts.sessionId || null, now);
1419
+ this.db.prepare(`UPDATE workflows SET updated_at = ? WHERE id = ?`).run(now, workflowId);
1420
+ this.writeWorkflowToDisk(workflowId);
1421
+ return { id, workflow_id: workflowId, step_index: nextIndex, summary: opts.summary, pending_tasks: opts.pendingTasks || null, history_ids: historyIdsJson, session_id: opts.sessionId || null, created_at: now };
1422
+ }
1423
+ /** Writes `content` to `.devmind/workflows/<workflowId>/<artifactId>_<sourceName>` and records the DB row. */
1424
+ addWorkflowArtifact(workflowId, opts) {
1425
+ if (!this.getWorkflow(workflowId))
1426
+ throw new Error(`Workflow not found: ${workflowId}`);
1427
+ const id = crypto.randomUUID();
1428
+ const now = new Date().toISOString();
1429
+ const safeName = opts.sourceName.replace(/[^a-zA-Z0-9._-]/g, '_') || 'artifact.md';
1430
+ const dir = path.join(this.workflowsDir(), workflowId);
1431
+ fs.mkdirSync(dir, { recursive: true });
1432
+ const filePath = path.join(dir, `${id}_${safeName}`);
1433
+ fs.writeFileSync(filePath, opts.content, 'utf-8');
1434
+ this.db.prepare(`
1435
+ INSERT INTO workflow_artifacts (id, workflow_id, step_id, type, source_name, file_path, created_at)
1436
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1437
+ `).run(id, workflowId, opts.stepId || null, opts.type, opts.sourceName, filePath, now);
1438
+ this.db.prepare(`UPDATE workflows SET updated_at = ? WHERE id = ?`).run(now, workflowId);
1439
+ this.writeWorkflowToDisk(workflowId);
1440
+ return { id, workflow_id: workflowId, step_id: opts.stepId || null, type: opts.type, source_name: opts.sourceName, file_path: filePath, created_at: now };
1441
+ }
1442
+ getWorkflowContext(id, opts) {
1443
+ const workflow = this.getWorkflow(id);
1444
+ if (!workflow)
1445
+ throw new Error(`Workflow not found: ${id}`);
1446
+ const steps = this.db.prepare('SELECT * FROM workflow_steps WHERE workflow_id = ? ORDER BY step_index ASC').all(id);
1447
+ const artifactRows = this.db.prepare('SELECT * FROM workflow_artifacts WHERE workflow_id = ? ORDER BY created_at ASC').all(id);
1448
+ const artifacts = artifactRows.map(a => {
1449
+ if (!opts?.includeArtifactContent)
1450
+ return a;
1451
+ try {
1452
+ const content = fs.existsSync(a.file_path) ? fs.readFileSync(a.file_path, 'utf-8') : undefined;
1453
+ return { ...a, content };
1454
+ }
1455
+ catch {
1456
+ return a;
1457
+ }
1458
+ });
1459
+ return { workflow, steps, artifacts };
1460
+ }
1461
+ /**
1462
+ * Returns steps for a workflow with optional pagination.
1463
+ * Use `last_n` to get only the most recent N steps (tail), or `limit`/`offset` for
1464
+ * arbitrary pagination. Without any option, all steps are returned.
1465
+ */
1466
+ getWorkflowSteps(workflowId, opts) {
1467
+ if (!this.getWorkflow(workflowId))
1468
+ throw new Error(`Workflow not found: ${workflowId}`);
1469
+ if (opts?.last_n && opts.last_n > 0) {
1470
+ // Fetch the last N steps by descending step_index, then reverse to chronological
1471
+ const rows = this.db.prepare('SELECT * FROM workflow_steps WHERE workflow_id = ? ORDER BY step_index DESC LIMIT ?').all(workflowId, opts.last_n);
1472
+ return rows.reverse();
1473
+ }
1474
+ if (opts?.limit) {
1475
+ return this.db.prepare('SELECT * FROM workflow_steps WHERE workflow_id = ? ORDER BY step_index ASC LIMIT ? OFFSET ?').all(workflowId, opts.limit, opts.offset ?? 0);
1476
+ }
1477
+ return this.db.prepare('SELECT * FROM workflow_steps WHERE workflow_id = ? ORDER BY step_index ASC').all(workflowId);
1478
+ }
1479
+ /**
1480
+ * Reads a single workflow artifact's file content from disk.
1481
+ * Accepts either an artifact_id or a source_name (first match used).
1482
+ */
1483
+ readWorkflowArtifact(workflowId, artifactId) {
1484
+ if (!this.getWorkflow(workflowId))
1485
+ throw new Error(`Workflow not found: ${workflowId}`);
1486
+ const row = this.db.prepare('SELECT * FROM workflow_artifacts WHERE workflow_id = ? AND id = ?').get(workflowId, artifactId);
1487
+ if (!row)
1488
+ throw new Error(`Artifact not found: ${artifactId} in workflow ${workflowId}`);
1489
+ if (!fs.existsSync(row.file_path))
1490
+ throw new Error(`Artifact file missing on disk: ${row.file_path}`);
1491
+ const content = fs.readFileSync(row.file_path, 'utf-8');
1492
+ return { artifact: row, content };
1493
+ }
1494
+ /**
1495
+ * Full-text keyword search across all workflows' step summaries, pending_tasks,
1496
+ * and artifact source names. Optionally also searches artifact file content.
1497
+ * Returns a list of matches grouped by workflow.
1498
+ */
1499
+ searchWorkflows(query, opts) {
1500
+ // Escaped so a query containing '%' or '_' matches those characters literally instead of
1501
+ // acting as SQL LIKE wildcards — otherwise `query: "%"` matches every row in the project.
1502
+ const lq = `%${this.likeEscape(query.toLowerCase())}%`;
1503
+ // Find matching steps
1504
+ const matchedStepRows = this.db.prepare(`
1505
+ SELECT ws.* FROM workflow_steps ws
1506
+ JOIN workflows w ON w.id = ws.workflow_id
1507
+ WHERE (LOWER(ws.summary) LIKE ? ESCAPE '\\' OR LOWER(IFNULL(ws.pending_tasks,'')) LIKE ? ESCAPE '\\')
1508
+ ${opts?.status ? 'AND w.status = ?' : ''}
1509
+ ORDER BY ws.workflow_id, ws.step_index ASC
1510
+ `).all(...(opts?.status ? [lq, lq, opts.status] : [lq, lq]));
1511
+ // Find matching artifacts by source_name
1512
+ const matchedArtifactRows = this.db.prepare(`
1513
+ SELECT wa.* FROM workflow_artifacts wa
1514
+ JOIN workflows w ON w.id = wa.workflow_id
1515
+ WHERE LOWER(wa.source_name) LIKE ? ESCAPE '\\'
1516
+ ${opts?.status ? 'AND w.status = ?' : ''}
1517
+ ORDER BY wa.workflow_id, wa.created_at ASC
1518
+ `).all(...(opts?.status ? [lq, opts.status] : [lq]));
1519
+ // If content search requested, also scan artifact files
1520
+ const contentMatchedArtifactIds = new Set();
1521
+ const artifactContentSnippets = new Map();
1522
+ if (opts?.include_artifact_content) {
1523
+ const allArtifacts = this.db.prepare(`SELECT wa.* FROM workflow_artifacts wa JOIN workflows w ON w.id = wa.workflow_id${opts.status ? ' WHERE w.status = ?' : ''}`).all(...(opts.status ? [opts.status] : []));
1524
+ const lqPlain = query.toLowerCase();
1525
+ for (const a of allArtifacts) {
1526
+ if (contentMatchedArtifactIds.has(a.id))
1527
+ continue;
1528
+ try {
1529
+ if (fs.existsSync(a.file_path)) {
1530
+ const text = fs.readFileSync(a.file_path, 'utf-8');
1531
+ const idx = text.toLowerCase().indexOf(lqPlain);
1532
+ if (idx !== -1) {
1533
+ contentMatchedArtifactIds.add(a.id);
1534
+ const start = Math.max(0, idx - 80);
1535
+ const end = Math.min(text.length, idx + query.length + 80);
1536
+ artifactContentSnippets.set(a.id, (start > 0 ? '…' : '') + text.slice(start, end) + (end < text.length ? '…' : ''));
1537
+ }
1538
+ }
1539
+ }
1540
+ catch { /* skip unreadable */ }
1541
+ }
1542
+ }
1543
+ // Collect all relevant workflow IDs
1544
+ const workflowIdSet = new Set([
1545
+ ...matchedStepRows.map(s => s.workflow_id),
1546
+ ...matchedArtifactRows.map(a => a.workflow_id),
1547
+ ...Array.from(contentMatchedArtifactIds).map(id => {
1548
+ const r = this.db.prepare('SELECT workflow_id FROM workflow_artifacts WHERE id = ?').get(id);
1549
+ return r?.workflow_id || '';
1550
+ }).filter(Boolean)
1000
1551
  ]);
1001
- // Get all active nodes (including those with history) to check for missing files or spurious names
1552
+ const results = [];
1553
+ for (const wid of workflowIdSet) {
1554
+ const workflow = this.getWorkflow(wid);
1555
+ if (!workflow)
1556
+ continue;
1557
+ const steps = matchedStepRows.filter(s => s.workflow_id === wid);
1558
+ const artByName = matchedArtifactRows.filter(a => a.workflow_id === wid);
1559
+ const artByContent = opts?.include_artifact_content
1560
+ ? this.db.prepare('SELECT * FROM workflow_artifacts WHERE workflow_id = ?').all(wid).filter(a => contentMatchedArtifactIds.has(a.id) && !artByName.find(x => x.id === a.id))
1561
+ : [];
1562
+ const allArtifacts = [
1563
+ ...artByName.map(a => ({ ...a, content_snippet: artifactContentSnippets.get(a.id) })),
1564
+ ...artByContent.map(a => ({ ...a, content_snippet: artifactContentSnippets.get(a.id) }))
1565
+ ];
1566
+ results.push({ workflow, matched_steps: steps, matched_artifacts: allArtifacts });
1567
+ }
1568
+ // Sort by most recently updated workflow first
1569
+ results.sort((a, b) => b.workflow.updated_at.localeCompare(a.workflow.updated_at));
1570
+ return results;
1571
+ }
1572
+ /**
1573
+ * Imports an existing flow/architecture doc as a paused workflow (not active — importing
1574
+ * a doc isn't the same as declaring active work). Idempotent on `name`: re-importing the
1575
+ * same doc overwrites its existing `imported_doc` artifact file in place instead of
1576
+ * creating a duplicate workflow every time the source docs are re-imported.
1577
+ */
1578
+ importWorkflowDoc(name, description, content, sourceFileName) {
1579
+ const existing = this.db.prepare('SELECT * FROM workflows WHERE name = ?').get(name);
1580
+ const now = new Date().toISOString();
1581
+ if (existing) {
1582
+ this.db.prepare(`UPDATE workflows SET description = ?, updated_at = ? WHERE id = ?`).run(description, now, existing.id);
1583
+ const existingArtifact = this.db.prepare(`SELECT * FROM workflow_artifacts WHERE workflow_id = ? AND type = 'imported_doc' ORDER BY created_at ASC LIMIT 1`).get(existing.id);
1584
+ if (existingArtifact) {
1585
+ fs.writeFileSync(existingArtifact.file_path, content, 'utf-8');
1586
+ }
1587
+ else {
1588
+ this.addWorkflowArtifact(existing.id, { type: 'imported_doc', sourceName: sourceFileName, content });
1589
+ }
1590
+ this.writeWorkflowToDisk(existing.id);
1591
+ return { workflow: { ...existing, description, updated_at: now }, created: false };
1592
+ }
1593
+ const id = `wf_${crypto.randomUUID()}`;
1594
+ this.db.prepare(`
1595
+ INSERT INTO workflows (id, name, description, status, created_at, updated_at)
1596
+ VALUES (?, ?, ?, 'paused', ?, ?)
1597
+ `).run(id, name, description, now, now);
1598
+ this.addWorkflowStep(id, { summary: `Imported existing flow documentation: ${sourceFileName}` });
1599
+ this.addWorkflowArtifact(id, { type: 'imported_doc', sourceName: sourceFileName, content });
1600
+ // writeWorkflowToDisk is already called inside addWorkflowArtifact/addWorkflowStep above
1601
+ return { workflow: { id, name, description, status: 'paused', created_at: now, updated_at: now }, created: true };
1602
+ }
1603
+ static SPURIOUS_NODE_NAMES = new Set([
1604
+ 'promise', 'map', 'set', 'json', 'console', 'error', 'object', 'function', 'array', 'string', 'number', 'boolean', 'regexp', 'date', 'math',
1605
+ 'any', 'void', 'unknown', 'never', 'null', 'undefined', 'dict', 'list',
1606
+ 'data', 'useeffect', 'val', 'temp', 'result', 'item', 'key', 'value', 'err', 'req', 'res', 'args', 'params', 'response', 'request'
1607
+ ]);
1608
+ /**
1609
+ * Read-only detection shared by `pruneSpuriousNodes` (which acts on it) and `devsmind
1610
+ * analyze`'s dry-run report (which just lists it). Never mutates the DB.
1611
+ */
1612
+ findSpuriousAndMissingFileNodes(workspaceRoot) {
1002
1613
  const stmt = this.db.prepare(`
1003
1614
  SELECT id, name, file_path FROM nodes
1004
1615
  WHERE deprecated = 0
1005
1616
  `);
1006
1617
  const candidates = stmt.all();
1007
- const idsToDelete = [];
1008
- const namesDeleted = [];
1009
- const affectedFilePaths = new Set();
1618
+ const spurious = [];
1619
+ const missingFile = [];
1010
1620
  for (const node of candidates) {
1011
1621
  const lowerName = node.name.toLowerCase();
1012
- // 1. Check if name is in the spurious list
1013
- const isSpurious = spuriousNames.has(lowerName);
1014
- // 2. Check if file path does not exist on disk
1015
- let fileMissing = false;
1622
+ if (DevMindDatabase.SPURIOUS_NODE_NAMES.has(lowerName)) {
1623
+ spurious.push(node);
1624
+ continue;
1625
+ }
1016
1626
  if (node.file_path) {
1017
1627
  const paths = node.file_path.split(',').map(p => p.trim()).filter(Boolean);
1018
1628
  if (paths.length > 0) {
1019
1629
  const allMissing = paths.every(p => {
1020
- const resolvedPath = path.isAbsolute(p)
1021
- ? p
1022
- : path.resolve(workspaceRoot, p);
1630
+ const resolvedPath = path.isAbsolute(p) ? p : path.resolve(workspaceRoot, p);
1023
1631
  return !fs.existsSync(resolvedPath);
1024
1632
  });
1025
- if (allMissing) {
1026
- fileMissing = true;
1027
- }
1633
+ if (allMissing)
1634
+ missingFile.push(node);
1028
1635
  }
1029
1636
  }
1030
- if (isSpurious || fileMissing) {
1031
- idsToDelete.push(node.id);
1032
- namesDeleted.push(`${node.name} (${node.id})`);
1033
- if (node.file_path) {
1034
- for (const p of node.file_path.split(',').map(s => s.trim()).filter(Boolean)) {
1035
- affectedFilePaths.add(p);
1036
- }
1637
+ }
1638
+ return { spurious, missingFile };
1639
+ }
1640
+ pruneSpuriousNodes(workspaceRoot) {
1641
+ const { spurious, missingFile } = this.findSpuriousAndMissingFileNodes(workspaceRoot);
1642
+ const candidates = [...spurious, ...missingFile];
1643
+ const idsToDelete = [];
1644
+ const namesDeleted = [];
1645
+ const affectedFilePaths = new Set();
1646
+ for (const node of candidates) {
1647
+ idsToDelete.push(node.id);
1648
+ namesDeleted.push(`${node.name} (${node.id})`);
1649
+ if (node.file_path) {
1650
+ for (const p of node.file_path.split(',').map(s => s.trim()).filter(Boolean)) {
1651
+ affectedFilePaths.add(p);
1037
1652
  }
1038
1653
  }
1039
1654
  }
@@ -1126,41 +1741,137 @@ class DevMindDatabase {
1126
1741
  toRepoRelativePath(absolutePath) {
1127
1742
  if (!absolutePath || !this.context)
1128
1743
  return absolutePath;
1129
- const abs = path.resolve(absolutePath).replace(/\\/g, '/');
1744
+ const abs = (0, config_1.canonicalizePath)(absolutePath).replace(/\\/g, '/');
1745
+ const absLower = abs.toLowerCase();
1130
1746
  for (const repo of this.context.config.repos) {
1131
1747
  const repoPath = (0, config_1.resolveRepoPath)(this.context, repo.name);
1132
1748
  if (repoPath) {
1133
- const normalizedRepoPath = path.resolve(repoPath).replace(/\\/g, '/');
1134
- if (abs === normalizedRepoPath || abs.startsWith(normalizedRepoPath + '/')) {
1749
+ const normalizedRepoPath = (0, config_1.canonicalizePath)(repoPath).replace(/\\/g, '/');
1750
+ const normalizedRepoPathLower = normalizedRepoPath.toLowerCase();
1751
+ if (absLower === normalizedRepoPathLower || absLower.startsWith(normalizedRepoPathLower + '/')) {
1135
1752
  const relative = path.relative(normalizedRepoPath, abs).replace(/\\/g, '/');
1136
1753
  return `{${repo.name}}/${relative}`;
1137
1754
  }
1138
1755
  }
1139
1756
  }
1140
1757
  // Fallback: resolve relative to workspace root
1141
- const workspaceRoot = path.dirname(this.dbPath);
1758
+ const workspaceRoot = (0, config_1.canonicalizePath)(path.dirname(this.dbPath));
1142
1759
  return path.relative(workspaceRoot, absolutePath).replace(/\\/g, '/');
1143
1760
  }
1761
+ /**
1762
+ * Rejects a resolved path that escapes its expected root (e.g. via a stored
1763
+ * `{repo}/../../..` path traveling outside the repo) by clamping it back to
1764
+ * the root itself. node_id/file_path values flow in from AI-supplied tool
1765
+ * calls, so a resolve must never be trusted to stay inside root on its own.
1766
+ */
1767
+ clampToRoot(root, resolved) {
1768
+ const normalizedRoot = (0, config_1.canonicalizePath)(root);
1769
+ const normalizedResolved = (0, config_1.canonicalizePath)(resolved);
1770
+ const rootLower = normalizedRoot.toLowerCase();
1771
+ const resolvedLower = normalizedResolved.toLowerCase();
1772
+ if (resolvedLower === rootLower || resolvedLower.startsWith(rootLower + path.sep)) {
1773
+ return normalizedResolved;
1774
+ }
1775
+ console.warn(`⚠️ Path traversal blocked: "${resolved}" escapes root "${root}"`);
1776
+ return normalizedRoot;
1777
+ }
1778
+ /**
1779
+ * True if `absPath` sits inside a configured repo root or the workspace root itself.
1780
+ * Used to reject `stage_change`/`update_history` file paths that would otherwise let a
1781
+ * tool call read/write any file on disk (absolute path, or a `../` escape) instead of
1782
+ * just repo source — nothing upstream of this validates that the AI-supplied path is
1783
+ * actually inside the project.
1784
+ */
1785
+ /**
1786
+ * Gate for every AI-facing write (edit_node, stage_change, the legacy update_history):
1787
+ * true only for paths inside a configured repo. `.devmind` itself — this project's OWN
1788
+ * config, brain.db, and cached graph JSON — is never writable through these tools, even
1789
+ * though it sits next to (and, before this check, was indistinguishable from) real source:
1790
+ * without this, a write tool built to "never refuse a file type" would just as happily
1791
+ * rewrite devsmind's own config.json as it would application source.
1792
+ */
1793
+ isPathAllowed(absPath) {
1794
+ const abs = (0, config_1.canonicalizePath)(absPath);
1795
+ const absLower = abs.toLowerCase();
1796
+ const devmindDirLower = (0, config_1.canonicalizePath)(path.dirname(this.dbPath)).toLowerCase();
1797
+ if (absLower === devmindDirLower || absLower.startsWith(devmindDirLower + path.sep))
1798
+ return false;
1799
+ if (this.context) {
1800
+ for (const repo of this.context.config.repos) {
1801
+ const repoPath = (0, config_1.resolveRepoPath)(this.context, repo.name);
1802
+ if (repoPath) {
1803
+ const normalizedRepoPath = (0, config_1.canonicalizePath)(repoPath);
1804
+ const normalizedRepoPathLower = normalizedRepoPath.toLowerCase();
1805
+ if (absLower === normalizedRepoPathLower || absLower.startsWith(normalizedRepoPathLower + path.sep))
1806
+ return true;
1807
+ }
1808
+ }
1809
+ }
1810
+ return false;
1811
+ }
1144
1812
  toAbsolutePath(repoRelativePath) {
1145
1813
  if (!repoRelativePath)
1146
1814
  return repoRelativePath;
1147
- const workspaceRoot = path.dirname(this.dbPath);
1815
+ const workspaceRoot = (0, config_1.canonicalizePath)(path.dirname(this.dbPath));
1148
1816
  const match = repoRelativePath.match(/^\{([^}]+)\}\/(.*)$/);
1149
1817
  if (match && this.context) {
1150
1818
  const repoName = match[1];
1151
1819
  const relativePath = match[2];
1152
1820
  const repoPath = (0, config_1.resolveRepoPath)(this.context, repoName);
1153
1821
  if (repoPath) {
1154
- return path.resolve(repoPath, relativePath);
1822
+ return (0, config_1.canonicalizePath)(this.clampToRoot(repoPath, path.resolve(repoPath, relativePath)));
1823
+ }
1824
+ }
1825
+ // Heuristic: if it doesn't start with {repoName} but contains a configured repo name in the path
1826
+ if (this.context) {
1827
+ for (const repo of this.context.config.repos) {
1828
+ // Find if repo.name appears as a folder in the path, e.g. "harrir-express-backend/tests/..."
1829
+ const escapedRepoName = repo.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1830
+ const regex = new RegExp('(?:^|/|\\\\)' + escapedRepoName + '(?:/|\\\\)(.*)$', 'i');
1831
+ const m = repoRelativePath.match(regex);
1832
+ if (m) {
1833
+ const repoPath = (0, config_1.resolveRepoPath)(this.context, repo.name);
1834
+ if (repoPath) {
1835
+ const relativePath = m[1];
1836
+ return (0, config_1.canonicalizePath)(path.resolve(repoPath, relativePath));
1837
+ }
1838
+ }
1155
1839
  }
1156
1840
  }
1157
1841
  // Fallback: resolve relative to workspace root
1158
- return path.resolve(workspaceRoot, repoRelativePath);
1842
+ return (0, config_1.canonicalizePath)(this.clampToRoot(workspaceRoot, path.resolve(workspaceRoot, repoRelativePath)));
1159
1843
  }
1160
1844
  syncFromDisk() {
1161
1845
  this.db.pragma('foreign_keys = OFF');
1162
1846
  try {
1163
1847
  const workspaceRoot = path.dirname(this.dbPath);
1848
+ // 0. Auto-heal any legacy relative path records in SQLite.
1849
+ //
1850
+ // Runs on every server start, so getting "already absolute" wrong is not a one-time
1851
+ // migration slip — it recurs forever. The original check only recognized the C: drive
1852
+ // and POSIX roots ('c:%'/'C:%'/'/%'); SQL LIKE has no character-range syntax, so it could
1853
+ // not express "any drive letter" or a UNC path (\\server\share\...) in one pattern. Every
1854
+ // node on a D:, E:, ... drive or a UNC path was misclassified as relative, run through
1855
+ // toAbsolutePath() -> clampToRoot(), and silently rewritten to the workspace root — i.e.
1856
+ // real file_paths for an entire class of valid Windows paths got destroyed on restart.
1857
+ // path.isAbsolute() classifies all of these correctly in one call.
1858
+ try {
1859
+ const legacyNodes = this.db.prepare('SELECT id, file_path FROM nodes').all()
1860
+ .filter(n => n.file_path && !path.isAbsolute(n.file_path));
1861
+ if (legacyNodes.length > 0) {
1862
+ const updateStmt = this.db.prepare('UPDATE nodes SET file_path = ? WHERE id = ?');
1863
+ const healTx = this.db.transaction(() => {
1864
+ for (const n of legacyNodes) {
1865
+ const abs = this.toAbsolutePath(n.file_path);
1866
+ updateStmt.run(abs, n.id);
1867
+ }
1868
+ });
1869
+ healTx();
1870
+ }
1871
+ }
1872
+ catch (err) {
1873
+ // ignore legacy errors
1874
+ }
1164
1875
  // 1. Sync History JSONs
1165
1876
  const historyDir = path.join(workspaceRoot, 'history');
1166
1877
  if (fs.existsSync(historyDir)) {
@@ -1265,6 +1976,65 @@ class DevMindDatabase {
1265
1976
  syncGraphTx();
1266
1977
  }
1267
1978
  }
1979
+ // 3. Sync Workflow JSONs
1980
+ const workflowsDir = this.workflowsDir();
1981
+ if (fs.existsSync(workflowsDir)) {
1982
+ const upsertWorkflow = this.db.prepare(`
1983
+ INSERT INTO workflows (id, name, description, status, created_at, updated_at)
1984
+ VALUES (?, ?, ?, ?, ?, ?)
1985
+ ON CONFLICT(id) DO UPDATE SET
1986
+ name = excluded.name,
1987
+ description = excluded.description,
1988
+ status = excluded.status,
1989
+ updated_at = excluded.updated_at
1990
+ `);
1991
+ const upsertStep = this.db.prepare(`
1992
+ INSERT OR IGNORE INTO workflow_steps (id, workflow_id, step_index, summary, pending_tasks, history_ids, session_id, created_at)
1993
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1994
+ `);
1995
+ const upsertArtifact = this.db.prepare(`
1996
+ INSERT OR IGNORE INTO workflow_artifacts (id, workflow_id, step_id, type, source_name, file_path, created_at)
1997
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1998
+ `);
1999
+ // Track which workflow.json has is_active:true with the latest updated_at
2000
+ let bestActiveId = null;
2001
+ let bestActiveUpdatedAt = '';
2002
+ const syncWorkflowsTx = this.db.transaction(() => {
2003
+ const subdirs = fs.readdirSync(workflowsDir);
2004
+ for (const subdir of subdirs) {
2005
+ const jsonPath = path.join(workflowsDir, subdir, 'workflow.json');
2006
+ if (!fs.existsSync(jsonPath))
2007
+ continue;
2008
+ try {
2009
+ const data = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
2010
+ if (!data.id || !data.name)
2011
+ continue;
2012
+ upsertWorkflow.run(data.id, data.name, data.description || '', data.status || 'paused', data.created_at || new Date().toISOString(), data.updated_at || new Date().toISOString());
2013
+ for (const s of (data.steps || [])) {
2014
+ if (!s.id)
2015
+ continue;
2016
+ upsertStep.run(s.id, data.id, s.step_index, s.summary || '', s.pending_tasks || null, s.history_ids || null, s.session_id || null, s.created_at || new Date().toISOString());
2017
+ }
2018
+ for (const a of (data.artifact_index || [])) {
2019
+ if (!a.id)
2020
+ continue;
2021
+ upsertArtifact.run(a.id, data.id, a.step_id || null, a.type || 'unknown', a.source_name || '', a.file_path || '', a.created_at || new Date().toISOString());
2022
+ }
2023
+ // Track which workflow declared itself active most recently
2024
+ if (data.is_active && data.updated_at > bestActiveUpdatedAt) {
2025
+ bestActiveId = data.id;
2026
+ bestActiveUpdatedAt = data.updated_at;
2027
+ }
2028
+ }
2029
+ catch { /* skip malformed */ }
2030
+ }
2031
+ });
2032
+ syncWorkflowsTx();
2033
+ // Restore active_workflow_id if not already set and a JSON claims active status
2034
+ if (bestActiveId && !this.getSystemMeta('active_workflow_id')) {
2035
+ this.setSystemMeta('active_workflow_id', bestActiveId);
2036
+ }
2037
+ }
1268
2038
  }
1269
2039
  catch (err) {
1270
2040
  console.warn('⚠️ SQLite warning: Failed to sync from disk:', err);
@@ -1281,9 +2051,9 @@ class DevMindDatabase {
1281
2051
  try {
1282
2052
  if (!filePath)
1283
2053
  return;
1284
- const workspaceRoot = path.dirname(this.dbPath);
2054
+ const workspaceRoot = (0, config_1.canonicalizePath)(path.dirname(this.dbPath));
1285
2055
  // Clean/resolve the file path
1286
- const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(workspaceRoot, filePath);
2056
+ const absPath = (0, config_1.canonicalizePath)(filePath);
1287
2057
  const repoRelPath = this.toRepoRelativePath(absPath);
1288
2058
  // E.g., "{harrir-web}/app/page.tsx" -> "graph/harrir-web/app/page.json"
1289
2059
  const diskRelPath = repoRelPath.replace(/^\{([^}]+)\}/, '$1').replace(/\.[^/.]+$/, '.json');
@@ -1297,16 +2067,18 @@ class DevMindDatabase {
1297
2067
  // is durable across a syncFromDisk() restart and propagates to teammates via git —
1298
2068
  // otherwise the node's history JSON would resurrect it as active on the next start.
1299
2069
  const absEsc = this.likeEscape(absPath);
2070
+ const absLower = absPath.toLowerCase();
2071
+ const absEscLower = absEsc.toLowerCase();
1300
2072
  const stmtNodes = this.db.prepare(`
1301
2073
  SELECT * FROM nodes
1302
2074
  WHERE (
1303
- file_path = ? OR
1304
- file_path LIKE ? ESCAPE '\\' OR
1305
- file_path LIKE ? ESCAPE '\\' OR
1306
- file_path LIKE ? ESCAPE '\\'
2075
+ LOWER(file_path) = ? OR
2076
+ LOWER(file_path) LIKE ? ESCAPE '\\' OR
2077
+ LOWER(file_path) LIKE ? ESCAPE '\\' OR
2078
+ LOWER(file_path) LIKE ? ESCAPE '\\'
1307
2079
  )
1308
2080
  `);
1309
- const nodes = stmtNodes.all(absPath, `${absEsc}, %`, `%, ${absEsc}`, `%, ${absEsc}, %`);
2081
+ const nodes = stmtNodes.all(absLower, `${absEscLower}, %`, `%, ${absEscLower}`, `%, ${absEscLower}, %`);
1310
2082
  if (nodes.length === 0) {
1311
2083
  // If no nodes left, delete the JSON file if it exists
1312
2084
  if (fs.existsSync(graphJsonPath)) {
@@ -1349,6 +2121,30 @@ class DevMindDatabase {
1349
2121
  console.warn('⚠️ SQLite warning: Failed to write graph JSON to disk:', err);
1350
2122
  }
1351
2123
  }
2124
+ /** Force-syncs all database nodes and workflows to disk JSON files. */
2125
+ syncToDisk() {
2126
+ try {
2127
+ const rows = this.db.prepare('SELECT DISTINCT file_path FROM nodes').all();
2128
+ const filePaths = new Set();
2129
+ for (const row of rows) {
2130
+ if (row.file_path) {
2131
+ for (const p of row.file_path.split(',').map(s => s.trim()).filter(Boolean)) {
2132
+ filePaths.add(p);
2133
+ }
2134
+ }
2135
+ }
2136
+ for (const filePath of filePaths) {
2137
+ this.writeGraphToDisk(filePath);
2138
+ }
2139
+ const workflowRows = this.db.prepare('SELECT id FROM workflows').all();
2140
+ for (const row of workflowRows) {
2141
+ this.writeWorkflowToDisk(row.id);
2142
+ }
2143
+ }
2144
+ catch (err) {
2145
+ console.warn('⚠️ DevsMind: Failed to sync database to disk:', err);
2146
+ }
2147
+ }
1352
2148
  }
1353
2149
  exports.DevMindDatabase = DevMindDatabase;
1354
2150
  //# sourceMappingURL=database.js.map