devsmind-mcp 2.2.0 → 2.3.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 (51) hide show
  1. package/README.md +234 -527
  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 +76 -0
  6. package/dist/cli/index.js.map +1 -1
  7. package/dist/cli/integrations/memory.d.ts +11 -0
  8. package/dist/cli/integrations/memory.js +156 -0
  9. package/dist/cli/integrations/memory.js.map +1 -0
  10. package/dist/cli/integrations/prompt.d.ts +5 -1
  11. package/dist/cli/integrations/prompt.js +29 -7
  12. package/dist/cli/integrations/prompt.js.map +1 -1
  13. package/dist/cli/integrations/registry.d.ts +35 -0
  14. package/dist/cli/integrations/registry.js +59 -0
  15. package/dist/cli/integrations/registry.js.map +1 -1
  16. package/dist/cli/rule.js +37 -46
  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 +163 -0
  26. package/dist/db/analyze.js.map +1 -0
  27. package/dist/db/database.d.ts +166 -3
  28. package/dist/db/database.js +700 -64
  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 +9 -0
  40. package/dist/mcp/server.js +417 -27
  41. package/dist/mcp/server.js.map +1 -1
  42. package/dist/utils/config.d.ts +2 -0
  43. package/dist/utils/config.js +11 -0
  44. package/dist/utils/config.js.map +1 -1
  45. package/dist/utils/git.d.ts +14 -0
  46. package/dist/utils/git.js +43 -0
  47. package/dist/utils/git.js.map +1 -0
  48. package/dist/utils/scanner.d.ts +7 -0
  49. package/dist/utils/scanner.js +9 -3
  50. package/dist/utils/scanner.js.map +1 -1
  51. package/package.json +1 -1
@@ -114,6 +114,9 @@ class DevMindDatabase {
114
114
  )
115
115
  `);
116
116
  }
117
+ getContext() {
118
+ return this.context;
119
+ }
117
120
  getSystemMeta(key) {
118
121
  try {
119
122
  const stmt = this.db.prepare('SELECT value FROM system_meta WHERE key = ?');
@@ -183,6 +186,9 @@ class DevMindDatabase {
183
186
  }
184
187
  fs.mkdirSync(p, { recursive: true });
185
188
  }
189
+ // NOTE: 'workflows/' is intentionally NOT wiped — workflow data is long-lived
190
+ // cross-session state that survives a node/history reindex. It will be
191
+ // restored from workflows/*/workflow.json on the next syncFromDisk().
186
192
  this.vacuum();
187
193
  }
188
194
  /**
@@ -232,11 +238,12 @@ class DevMindDatabase {
232
238
  }
233
239
  // --- Node Operations ---
234
240
  upsertNode(node) {
241
+ const canonicalFp = (0, config_1.canonicalizePath)(node.file_path);
235
242
  const existing = this.getNode(node.id);
236
243
  if (existing) {
237
244
  let finalPath = existing.file_path;
238
245
  const paths = existing.file_path.split(',').map(p => p.trim()).filter(Boolean);
239
- const incoming = node.file_path.trim();
246
+ const incoming = canonicalFp.trim();
240
247
  if (!paths.includes(incoming)) {
241
248
  paths.push(incoming);
242
249
  finalPath = paths.join(', ');
@@ -257,9 +264,9 @@ class DevMindDatabase {
257
264
  INSERT INTO nodes (id, type, name, file_path, signature)
258
265
  VALUES (?, ?, ?, ?, ?)
259
266
  `);
260
- stmt.run(node.id, node.type, node.name, node.file_path, node.signature || null);
267
+ stmt.run(node.id, node.type, node.name, canonicalFp, node.signature || null);
261
268
  }
262
- this.writeGraphToDisk(node.file_path);
269
+ this.writeGraphToDisk(canonicalFp);
263
270
  }
264
271
  getNode(id) {
265
272
  const stmt = this.db.prepare('SELECT * FROM nodes WHERE id = ?');
@@ -267,8 +274,8 @@ class DevMindDatabase {
267
274
  if (direct)
268
275
  return direct;
269
276
  if (!id.includes('#')) {
270
- const suffixStmt = this.db.prepare('SELECT * FROM nodes WHERE id LIKE ? AND deprecated = 0');
271
- const matches = suffixStmt.all(`%#${id}`);
277
+ const suffixStmt = this.db.prepare("SELECT * FROM nodes WHERE id LIKE ? ESCAPE '\\' AND deprecated = 0");
278
+ const matches = suffixStmt.all(`%#${this.likeEscape(id)}`);
272
279
  if (matches.length === 1) {
273
280
  return matches[0];
274
281
  }
@@ -314,20 +321,22 @@ class DevMindDatabase {
314
321
  this.writeGraphToDisk(p);
315
322
  }
316
323
  }
317
- renameNode(oldId, newId, newName) {
324
+ /** `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. */
325
+ renameNode(oldId, newId, newName, newFilePath) {
318
326
  const node = this.getNode(oldId);
319
327
  if (!node) {
320
328
  throw new Error(`Node not found: ${oldId}`);
321
329
  }
322
330
  const name = newName || (node.name === oldId ? newId : node.name);
331
+ const filePath = newFilePath || node.file_path;
323
332
  this.db.pragma('foreign_keys = OFF');
324
333
  try {
325
334
  const runTx = this.db.transaction(() => {
326
335
  const insertStmt = this.db.prepare(`
327
- INSERT INTO nodes (id, type, name, file_path, signature, created_at)
328
- VALUES (?, ?, ?, ?, ?, ?)
336
+ INSERT INTO nodes (id, type, name, file_path, signature, created_at, deprecated)
337
+ VALUES (?, ?, ?, ?, ?, ?, ?)
329
338
  `);
330
- insertStmt.run(newId, node.type, name, node.file_path, node.signature, node.created_at);
339
+ insertStmt.run(newId, node.type, name, filePath, node.signature, node.created_at, node.deprecated ? 1 : 0);
331
340
  const updateSourceStmt = this.db.prepare(`
332
341
  UPDATE node_connections SET source_node_id = ? WHERE source_node_id = ?
333
342
  `);
@@ -345,8 +354,13 @@ class DevMindDatabase {
345
354
  });
346
355
  runTx();
347
356
  if (node.file_path) {
357
+ // Rewrite the OLD file's graph JSON too when the file itself moved, so the
358
+ // stale node entry doesn't linger under the old path's JSON on disk.
348
359
  this.writeGraphToDisk(node.file_path);
349
360
  }
361
+ if (filePath && filePath !== node.file_path) {
362
+ this.writeGraphToDisk(filePath);
363
+ }
350
364
  // Edges pointing INTO the renamed node live in the SOURCE nodes' files' graph JSONs
351
365
  // (which still reference oldId on disk). The DB was already repointed to newId above,
352
366
  // so rewrite each such file — otherwise syncFromDisk reloads the stale oldId edge and
@@ -357,7 +371,7 @@ class DevMindDatabase {
357
371
  // longer exists in the DB) and re-insert it right back, undoing the rename.
358
372
  const historyIds = this.db.prepare('SELECT id FROM history WHERE node_id = ?').all(newId);
359
373
  for (const row of historyIds) {
360
- this.patchHistoryDiskIdentity(row.id, newId, name, node.type, node.file_path, node.signature);
374
+ this.patchHistoryDiskIdentity(row.id, newId, name, node.type, filePath, node.signature);
361
375
  }
362
376
  }
363
377
  finally {
@@ -741,7 +755,17 @@ class DevMindDatabase {
741
755
  return result;
742
756
  }
743
757
  updateHistory(params) {
744
- const { node_id, code_snapshot, reasoning } = params;
758
+ const { node_id, code_snapshot } = params;
759
+ let reasoning = params.reasoning;
760
+ // The calling AI has no reliable way to know who the human running this
761
+ // machine actually is -- it can only guess ("Claude Code", "AI Assistant",
762
+ // etc). Whenever this project has a configured developer identity (from
763
+ // .env's DEVELOPER_NAME, set by `devsmind init`), that's authoritative and
764
+ // always overrides whatever the agent supplied, so history is attributed
765
+ // to the real developer regardless of what the agent wrote in this field.
766
+ if (typeof reasoning === 'object' && this.context?.developer?.name) {
767
+ reasoning = { ...reasoning, developer: this.context.developer.name };
768
+ }
745
769
  const node = this.getNode(node_id);
746
770
  const resolvedId = node ? node.id : node_id;
747
771
  const formattedReasoning = formatReasoning(reasoning);
@@ -753,20 +777,30 @@ class DevMindDatabase {
753
777
  const lastUpdate = new Date(latest.updated_at).getTime();
754
778
  const nowTime = new Date(nowStr).getTime();
755
779
  const diffMs = nowTime - lastUpdate;
756
- // If updated < 1 hour ago, update same record
780
+ // If updated < 1 hour ago, update the same record IN PLACE (no new row — this is what
781
+ // keeps db/graph/history from bloating with one entry per commit during an active editing
782
+ // session). code_snapshot is always the latest state (git already owns version history for
783
+ // code). reasoning is APPENDED, not overwritten — an earlier commit's "why" in this same
784
+ // session is still real and still worth keeping; losing it silently is worse than a few
785
+ // extra lines in one file. This also keeps any workflow step whose history_ids point at
786
+ // this row valid: it never loses what it originally linked to, only gains more below it.
757
787
  if (diffMs < 3600000) {
788
+ const previousReasoning = typeof latest.reasoning === 'string' ? latest.reasoning : '';
789
+ const mergedReasoning = previousReasoning.trim().length > 0
790
+ ? `${previousReasoning}\n\n── Update @ ${nowStr} ──\n${formattedReasoning}`
791
+ : formattedReasoning;
758
792
  const updateStmt = this.db.prepare(`
759
793
  UPDATE history
760
794
  SET code_snapshot = '', reasoning = ?, updated_at = ?
761
795
  WHERE id = ?
762
796
  `);
763
- updateStmt.run(formattedReasoning, nowStr, latest.id);
797
+ updateStmt.run(mergedReasoning, nowStr, latest.id);
764
798
  // Write/Update on disk
765
- this.writeHistoryToDisk(latest.id, resolvedId, latest.session_id, latest.created_at, nowStr, code_snapshot, formattedReasoning);
799
+ this.writeHistoryToDisk(latest.id, resolvedId, latest.session_id, latest.created_at, nowStr, code_snapshot, mergedReasoning);
766
800
  return {
767
801
  ...latest,
768
802
  code_snapshot,
769
- reasoning: formattedReasoning,
803
+ reasoning: mergedReasoning,
770
804
  updated_at: nowStr
771
805
  };
772
806
  }
@@ -792,7 +826,14 @@ class DevMindDatabase {
792
826
  };
793
827
  }
794
828
  // --- Search Operations ---
795
- searchNodes(query) {
829
+ /**
830
+ * Search for nodes by name/id/reasoning first (cheap, SQL-only). If that finds
831
+ * nothing, transparently fall back to a code-content search (same engine as
832
+ * {@link searchCode}) so a query like "alipay" still succeeds even when no
833
+ * node's name/id/reasoning mentions it but the code itself does. Every result
834
+ * is tagged `matched_via` so the caller knows which path found it.
835
+ */
836
+ searchNodes(query, opts = {}) {
796
837
  const stmt = this.db.prepare(`
797
838
  SELECT DISTINCT n.* FROM nodes n
798
839
  LEFT JOIN history h ON n.id = h.node_id
@@ -800,7 +841,16 @@ class DevMindDatabase {
800
841
  LIMIT 50
801
842
  `);
802
843
  const wildcard = `%${query}%`;
803
- return stmt.all(wildcard, wildcard, wildcard);
844
+ const identifierMatches = stmt.all(wildcard, wildcard, wildcard);
845
+ if (identifierMatches.length > 0) {
846
+ return identifierMatches.map(n => ({ ...n, matched_via: 'identifier' }));
847
+ }
848
+ const codeMatches = this.searchCode({
849
+ query,
850
+ is_regex: opts.is_regex,
851
+ case_insensitive: opts.case_insensitive
852
+ });
853
+ return codeMatches.map(m => ({ ...m, matched_via: 'code' }));
804
854
  }
805
855
  getRecentChanges(hours = 24, analyzeImpact = true) {
806
856
  const stmt = this.db.prepare(`
@@ -842,11 +892,11 @@ class DevMindDatabase {
842
892
  SELECT h.node_id, n.name as node_name, h.updated_at, h.reasoning
843
893
  FROM history h
844
894
  JOIN nodes n ON h.node_id = n.id
845
- WHERE h.reasoning LIKE ?
895
+ WHERE h.reasoning LIKE ? ESCAPE '\\'
846
896
  ORDER BY h.updated_at DESC
847
897
  LIMIT ?
848
898
  `);
849
- const query = `%Developer: %${developer}%`;
899
+ const query = `%Developer: %${this.likeEscape(developer)}%`;
850
900
  return stmt.all(query, limit);
851
901
  }
852
902
  getChangesByRequirement(requirementId) {
@@ -854,10 +904,10 @@ class DevMindDatabase {
854
904
  SELECT h.node_id, n.name as node_name, h.updated_at, h.reasoning
855
905
  FROM history h
856
906
  JOIN nodes n ON h.node_id = n.id
857
- WHERE h.reasoning LIKE ?
907
+ WHERE h.reasoning LIKE ? ESCAPE '\\'
858
908
  ORDER BY h.updated_at DESC
859
909
  `);
860
- const query = `%Requirement: %${requirementId}%`;
910
+ const query = `%Requirement: %${this.likeEscape(requirementId)}%`;
861
911
  return stmt.all(query);
862
912
  }
863
913
  searchDecisions(query) {
@@ -865,10 +915,10 @@ class DevMindDatabase {
865
915
  SELECT h.node_id, n.name as node_name, h.updated_at, h.reasoning
866
916
  FROM history h
867
917
  JOIN nodes n ON h.node_id = n.id
868
- WHERE h.reasoning LIKE ?
918
+ WHERE h.reasoning LIKE ? ESCAPE '\\'
869
919
  ORDER BY h.updated_at DESC
870
920
  `);
871
- const wildcard = `%Decision: %${query}%`;
921
+ const wildcard = `%Decision: %${this.likeEscape(query)}%`;
872
922
  return stmt.all(wildcard);
873
923
  }
874
924
  searchCode(params) {
@@ -941,7 +991,8 @@ class DevMindDatabase {
941
991
  getOrphanedNodes() {
942
992
  const stmt = this.db.prepare(`
943
993
  SELECT * FROM nodes
944
- WHERE id NOT IN (SELECT DISTINCT source_node_id FROM node_connections)
994
+ WHERE deprecated = 0
995
+ AND id NOT IN (SELECT DISTINCT source_node_id FROM node_connections)
945
996
  AND id NOT IN (SELECT DISTINCT target_node_id FROM node_connections)
946
997
  `);
947
998
  return stmt.all();
@@ -976,48 +1027,469 @@ class DevMindDatabase {
976
1027
  const rows = stmt.all();
977
1028
  return rows.map(row => this.populateHistoryFromDisk(row));
978
1029
  }
979
- pruneSpuriousNodes(workspaceRoot) {
980
- const spuriousNames = new Set([
981
- 'promise', 'map', 'set', 'json', 'console', 'error', 'object', 'function', 'array', 'string', 'number', 'boolean', 'regexp', 'date', 'math',
982
- 'any', 'void', 'unknown', 'never', 'null', 'undefined', 'dict', 'list',
983
- 'data', 'useeffect', 'val', 'temp', 'result', 'item', 'key', 'value', 'err', 'req', 'res', 'args', 'params', 'response', 'request'
1030
+ // ─── `devsmind analyze` read-only health checks ────────────────────────────
1031
+ // All pure queries/graph traversal, no mutation, no LLM calls.
1032
+ /** Nodes whose total (in + out) connection degree meets/exceeds `threshold` architectural bottleneck candidates. */
1033
+ getGodEntities(threshold = 15) {
1034
+ const stmt = this.db.prepare(`
1035
+ SELECT * FROM (
1036
+ SELECT n.id, n.name, n.file_path, (
1037
+ (SELECT COUNT(*) FROM node_connections c WHERE c.source_node_id = n.id) +
1038
+ (SELECT COUNT(*) FROM node_connections c WHERE c.target_node_id = n.id)
1039
+ ) AS degree
1040
+ FROM nodes n
1041
+ WHERE n.deprecated = 0
1042
+ )
1043
+ WHERE degree >= ?
1044
+ ORDER BY degree DESC
1045
+ `);
1046
+ return stmt.all(threshold);
1047
+ }
1048
+ /** DFS cycle detection over the connection graph, capped at `maxCycles` reported paths. */
1049
+ getCircularDependencies(maxCycles = 50) {
1050
+ const edges = this.getAllConnections();
1051
+ const adjacency = new Map();
1052
+ for (const e of edges) {
1053
+ if (!adjacency.has(e.source_node_id))
1054
+ adjacency.set(e.source_node_id, []);
1055
+ adjacency.get(e.source_node_id).push(e.target_node_id);
1056
+ }
1057
+ const cycles = [];
1058
+ const visited = new Set();
1059
+ const stack = [];
1060
+ const onStack = new Set();
1061
+ const dfs = (node) => {
1062
+ if (cycles.length >= maxCycles)
1063
+ return;
1064
+ if (onStack.has(node)) {
1065
+ const start = stack.indexOf(node);
1066
+ cycles.push([...stack.slice(start), node]);
1067
+ return;
1068
+ }
1069
+ if (visited.has(node))
1070
+ return;
1071
+ visited.add(node);
1072
+ stack.push(node);
1073
+ onStack.add(node);
1074
+ for (const next of adjacency.get(node) || []) {
1075
+ if (cycles.length >= maxCycles)
1076
+ break;
1077
+ dfs(next);
1078
+ }
1079
+ stack.pop();
1080
+ onStack.delete(node);
1081
+ };
1082
+ for (const node of adjacency.keys()) {
1083
+ if (cycles.length >= maxCycles)
1084
+ break;
1085
+ if (!visited.has(node))
1086
+ dfs(node);
1087
+ }
1088
+ return cycles;
1089
+ }
1090
+ /** node_connections rows whose source or target no longer exists in `nodes` (broken by a non-transactional delete, or a sync race). */
1091
+ getDanglingEdges() {
1092
+ const stmt = this.db.prepare(`
1093
+ SELECT * FROM node_connections
1094
+ WHERE source_node_id NOT IN (SELECT id FROM nodes)
1095
+ OR target_node_id NOT IN (SELECT id FROM nodes)
1096
+ `);
1097
+ return stmt.all();
1098
+ }
1099
+ /** Deletes a single dangling `node_connections` row. The edge itself is invalid data — no history/graph JSON to rewrite. */
1100
+ deleteDanglingEdge(sourceId, targetId) {
1101
+ this.db.prepare('DELETE FROM node_connections WHERE source_node_id = ? AND target_node_id = ?').run(sourceId, targetId);
1102
+ }
1103
+ /** Node ids that differ only by case — a real collision risk on Windows's case-insensitive filesystem. */
1104
+ getDuplicateNodeIds() {
1105
+ const stmt = this.db.prepare(`
1106
+ SELECT LOWER(id) AS lower_id, GROUP_CONCAT(id, '|') AS ids
1107
+ FROM nodes
1108
+ WHERE deprecated = 0
1109
+ GROUP BY lower_id
1110
+ HAVING COUNT(*) > 1
1111
+ `);
1112
+ const rows = stmt.all();
1113
+ return rows.map(r => ({ lowerId: r.lower_id, ids: r.ids.split('|') }));
1114
+ }
1115
+ /** History rows whose flattened `reasoning` text has no non-empty `Developer:` line — can't be attributed to anyone. */
1116
+ getHistoryMissingDeveloper() {
1117
+ const stmt = this.db.prepare('SELECT id, node_id, updated_at, reasoning FROM history');
1118
+ const rows = stmt.all();
1119
+ return rows
1120
+ .filter(r => {
1121
+ // [ \t]* (not \s*) so the match can't swallow the newline and bleed into the
1122
+ // next "Model:" line when Developer is blank, which would wrongly capture
1123
+ // "Model: <value>" as if it were the developer's name.
1124
+ const match = /Developer:[ \t]*([^\n]*)/i.exec(r.reasoning || '');
1125
+ return !match || !match[1].trim();
1126
+ })
1127
+ .map(({ id, node_id, updated_at }) => ({ id, node_id, updated_at }));
1128
+ }
1129
+ /**
1130
+ * History rows with a blank code snapshot — usually a silent AST extraction failure.
1131
+ * The `history.code_snapshot` DB column is always written as `''` (the real content
1132
+ * lives only in the per-row JSON on disk, see `populateHistoryFromDisk`), so this
1133
+ * must read through the populated rows rather than querying the column directly.
1134
+ */
1135
+ getEmptyCodeSnapshots() {
1136
+ return this.getAllHistory()
1137
+ .filter(h => !h.code_snapshot || h.code_snapshot.trim() === '')
1138
+ .map(({ id, node_id, updated_at }) => ({ id, node_id, updated_at }));
1139
+ }
1140
+ // ─── Workflow Context Vault ─────────────────────────────────────────────
1141
+ // Persistent, cross-session feature memory. Steps link to existing `history`
1142
+ // rows rather than duplicating code/reasoning; artifacts are plain files on
1143
+ // disk under `.devmind/workflows/<id>/`, with only the path stored in the DB.
1144
+ workflowsDir() {
1145
+ return path.join(path.dirname(this.dbPath), 'workflows');
1146
+ }
1147
+ /** Serializes the workflow + its steps + artifact index to disk so teammates can sync it via git. */
1148
+ writeWorkflowToDisk(workflowId) {
1149
+ try {
1150
+ const workflow = this.db.prepare('SELECT * FROM workflows WHERE id = ?').get(workflowId);
1151
+ if (!workflow)
1152
+ return;
1153
+ const steps = this.db.prepare('SELECT * FROM workflow_steps WHERE workflow_id = ? ORDER BY step_index ASC').all(workflowId);
1154
+ const artifacts = this.db.prepare('SELECT * FROM workflow_artifacts WHERE workflow_id = ? ORDER BY created_at ASC').all(workflowId);
1155
+ const activeId = this.getSystemMeta('active_workflow_id');
1156
+ const data = {
1157
+ id: workflow.id,
1158
+ name: workflow.name,
1159
+ description: workflow.description,
1160
+ status: workflow.status,
1161
+ created_at: workflow.created_at,
1162
+ updated_at: workflow.updated_at,
1163
+ is_active: activeId === workflowId,
1164
+ steps: steps.map(s => ({
1165
+ id: s.id,
1166
+ step_index: s.step_index,
1167
+ summary: s.summary,
1168
+ pending_tasks: s.pending_tasks,
1169
+ history_ids: s.history_ids,
1170
+ session_id: s.session_id,
1171
+ created_at: s.created_at
1172
+ })),
1173
+ artifact_index: artifacts.map(a => ({
1174
+ id: a.id,
1175
+ step_id: a.step_id,
1176
+ type: a.type,
1177
+ source_name: a.source_name,
1178
+ file_path: a.file_path,
1179
+ created_at: a.created_at
1180
+ }))
1181
+ };
1182
+ const dir = path.join(this.workflowsDir(), workflowId);
1183
+ fs.mkdirSync(dir, { recursive: true });
1184
+ fs.writeFileSync(path.join(dir, 'workflow.json'), JSON.stringify(data, null, 2), 'utf-8');
1185
+ }
1186
+ catch (err) {
1187
+ console.warn('⚠️ DevsMind: Failed to write workflow JSON to disk:', err);
1188
+ }
1189
+ }
1190
+ createWorkflow(name, description) {
1191
+ const id = `wf_${crypto.randomUUID()}`;
1192
+ const now = new Date().toISOString();
1193
+ this.db.prepare(`
1194
+ INSERT INTO workflows (id, name, description, status, created_at, updated_at)
1195
+ VALUES (?, ?, ?, 'active', ?, ?)
1196
+ `).run(id, name, description, now, now);
1197
+ this.setSystemMeta('active_workflow_id', id);
1198
+ this.writeWorkflowToDisk(id);
1199
+ return { id, name, description, status: 'active', created_at: now, updated_at: now };
1200
+ }
1201
+ getWorkflow(id) {
1202
+ const row = this.db.prepare('SELECT * FROM workflows WHERE id = ?').get(id);
1203
+ return row || null;
1204
+ }
1205
+ getActiveWorkflow() {
1206
+ const id = this.getSystemMeta('active_workflow_id');
1207
+ return id ? this.getWorkflow(id) : null;
1208
+ }
1209
+ listWorkflows(status) {
1210
+ if (status) {
1211
+ return this.db.prepare('SELECT * FROM workflows WHERE status = ? ORDER BY updated_at DESC').all(status);
1212
+ }
1213
+ return this.db.prepare('SELECT * FROM workflows ORDER BY updated_at DESC').all();
1214
+ }
1215
+ /** Pauses the currently active workflow (if any) and clears the active pointer. */
1216
+ pauseWorkflow() {
1217
+ const active = this.getActiveWorkflow();
1218
+ if (!active)
1219
+ return null;
1220
+ const now = new Date().toISOString();
1221
+ this.db.prepare(`UPDATE workflows SET status = 'paused', updated_at = ? WHERE id = ?`).run(now, active.id);
1222
+ this.setSystemMeta('active_workflow_id', '');
1223
+ this.writeWorkflowToDisk(active.id);
1224
+ return { ...active, status: 'paused', updated_at: now };
1225
+ }
1226
+ /** Resumes `id`, auto-pausing whatever was previously active (only one workflow is active at a time). */
1227
+ resumeWorkflow(id) {
1228
+ const workflow = this.getWorkflow(id);
1229
+ if (!workflow)
1230
+ throw new Error(`Workflow not found: ${id}`);
1231
+ const currentActive = this.getActiveWorkflow();
1232
+ if (currentActive && currentActive.id !== id)
1233
+ this.pauseWorkflow();
1234
+ const now = new Date().toISOString();
1235
+ this.db.prepare(`UPDATE workflows SET status = 'active', updated_at = ? WHERE id = ?`).run(now, id);
1236
+ this.setSystemMeta('active_workflow_id', id);
1237
+ this.writeWorkflowToDisk(id);
1238
+ return { ...workflow, status: 'active', updated_at: now };
1239
+ }
1240
+ completeWorkflow(id) {
1241
+ const workflow = this.getWorkflow(id);
1242
+ if (!workflow)
1243
+ throw new Error(`Workflow not found: ${id}`);
1244
+ const now = new Date().toISOString();
1245
+ this.db.prepare(`UPDATE workflows SET status = 'completed', updated_at = ? WHERE id = ?`).run(now, id);
1246
+ if (this.getSystemMeta('active_workflow_id') === id)
1247
+ this.setSystemMeta('active_workflow_id', '');
1248
+ this.writeWorkflowToDisk(id);
1249
+ return { ...workflow, status: 'completed', updated_at: now };
1250
+ }
1251
+ addWorkflowStep(workflowId, opts) {
1252
+ if (!this.getWorkflow(workflowId))
1253
+ throw new Error(`Workflow not found: ${workflowId}`);
1254
+ const id = crypto.randomUUID();
1255
+ const now = new Date().toISOString();
1256
+ const nextIndex = (this.db.prepare('SELECT MAX(step_index) AS m FROM workflow_steps WHERE workflow_id = ?').get(workflowId).m ?? 0) + 1;
1257
+ const historyIdsJson = opts.historyIds && opts.historyIds.length ? JSON.stringify(opts.historyIds) : null;
1258
+ this.db.prepare(`
1259
+ INSERT INTO workflow_steps (id, workflow_id, step_index, summary, pending_tasks, history_ids, session_id, created_at)
1260
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1261
+ `).run(id, workflowId, nextIndex, opts.summary, opts.pendingTasks || null, historyIdsJson, opts.sessionId || null, now);
1262
+ this.db.prepare(`UPDATE workflows SET updated_at = ? WHERE id = ?`).run(now, workflowId);
1263
+ this.writeWorkflowToDisk(workflowId);
1264
+ 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 };
1265
+ }
1266
+ /** Writes `content` to `.devmind/workflows/<workflowId>/<artifactId>_<sourceName>` and records the DB row. */
1267
+ addWorkflowArtifact(workflowId, opts) {
1268
+ if (!this.getWorkflow(workflowId))
1269
+ throw new Error(`Workflow not found: ${workflowId}`);
1270
+ const id = crypto.randomUUID();
1271
+ const now = new Date().toISOString();
1272
+ const safeName = opts.sourceName.replace(/[^a-zA-Z0-9._-]/g, '_') || 'artifact.md';
1273
+ const dir = path.join(this.workflowsDir(), workflowId);
1274
+ fs.mkdirSync(dir, { recursive: true });
1275
+ const filePath = path.join(dir, `${id}_${safeName}`);
1276
+ fs.writeFileSync(filePath, opts.content, 'utf-8');
1277
+ this.db.prepare(`
1278
+ INSERT INTO workflow_artifacts (id, workflow_id, step_id, type, source_name, file_path, created_at)
1279
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1280
+ `).run(id, workflowId, opts.stepId || null, opts.type, opts.sourceName, filePath, now);
1281
+ this.db.prepare(`UPDATE workflows SET updated_at = ? WHERE id = ?`).run(now, workflowId);
1282
+ this.writeWorkflowToDisk(workflowId);
1283
+ return { id, workflow_id: workflowId, step_id: opts.stepId || null, type: opts.type, source_name: opts.sourceName, file_path: filePath, created_at: now };
1284
+ }
1285
+ getWorkflowContext(id, opts) {
1286
+ const workflow = this.getWorkflow(id);
1287
+ if (!workflow)
1288
+ throw new Error(`Workflow not found: ${id}`);
1289
+ const steps = this.db.prepare('SELECT * FROM workflow_steps WHERE workflow_id = ? ORDER BY step_index ASC').all(id);
1290
+ const artifactRows = this.db.prepare('SELECT * FROM workflow_artifacts WHERE workflow_id = ? ORDER BY created_at ASC').all(id);
1291
+ const artifacts = artifactRows.map(a => {
1292
+ if (!opts?.includeArtifactContent)
1293
+ return a;
1294
+ try {
1295
+ const content = fs.existsSync(a.file_path) ? fs.readFileSync(a.file_path, 'utf-8') : undefined;
1296
+ return { ...a, content };
1297
+ }
1298
+ catch {
1299
+ return a;
1300
+ }
1301
+ });
1302
+ return { workflow, steps, artifacts };
1303
+ }
1304
+ /**
1305
+ * Returns steps for a workflow with optional pagination.
1306
+ * Use `last_n` to get only the most recent N steps (tail), or `limit`/`offset` for
1307
+ * arbitrary pagination. Without any option, all steps are returned.
1308
+ */
1309
+ getWorkflowSteps(workflowId, opts) {
1310
+ if (!this.getWorkflow(workflowId))
1311
+ throw new Error(`Workflow not found: ${workflowId}`);
1312
+ if (opts?.last_n && opts.last_n > 0) {
1313
+ // Fetch the last N steps by descending step_index, then reverse to chronological
1314
+ const rows = this.db.prepare('SELECT * FROM workflow_steps WHERE workflow_id = ? ORDER BY step_index DESC LIMIT ?').all(workflowId, opts.last_n);
1315
+ return rows.reverse();
1316
+ }
1317
+ if (opts?.limit) {
1318
+ 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);
1319
+ }
1320
+ return this.db.prepare('SELECT * FROM workflow_steps WHERE workflow_id = ? ORDER BY step_index ASC').all(workflowId);
1321
+ }
1322
+ /**
1323
+ * Reads a single workflow artifact's file content from disk.
1324
+ * Accepts either an artifact_id or a source_name (first match used).
1325
+ */
1326
+ readWorkflowArtifact(workflowId, artifactId) {
1327
+ if (!this.getWorkflow(workflowId))
1328
+ throw new Error(`Workflow not found: ${workflowId}`);
1329
+ const row = this.db.prepare('SELECT * FROM workflow_artifacts WHERE workflow_id = ? AND id = ?').get(workflowId, artifactId);
1330
+ if (!row)
1331
+ throw new Error(`Artifact not found: ${artifactId} in workflow ${workflowId}`);
1332
+ if (!fs.existsSync(row.file_path))
1333
+ throw new Error(`Artifact file missing on disk: ${row.file_path}`);
1334
+ const content = fs.readFileSync(row.file_path, 'utf-8');
1335
+ return { artifact: row, content };
1336
+ }
1337
+ /**
1338
+ * Full-text keyword search across all workflows' step summaries, pending_tasks,
1339
+ * and artifact source names. Optionally also searches artifact file content.
1340
+ * Returns a list of matches grouped by workflow.
1341
+ */
1342
+ searchWorkflows(query, opts) {
1343
+ const lq = `%${query.toLowerCase()}%`;
1344
+ // Find matching steps
1345
+ const matchedStepRows = this.db.prepare(`
1346
+ SELECT ws.* FROM workflow_steps ws
1347
+ JOIN workflows w ON w.id = ws.workflow_id
1348
+ WHERE (LOWER(ws.summary) LIKE ? OR LOWER(IFNULL(ws.pending_tasks,'')) LIKE ?)
1349
+ ${opts?.status ? 'AND w.status = ?' : ''}
1350
+ ORDER BY ws.workflow_id, ws.step_index ASC
1351
+ `).all(...(opts?.status ? [lq, lq, opts.status] : [lq, lq]));
1352
+ // Find matching artifacts by source_name
1353
+ const matchedArtifactRows = this.db.prepare(`
1354
+ SELECT wa.* FROM workflow_artifacts wa
1355
+ JOIN workflows w ON w.id = wa.workflow_id
1356
+ WHERE LOWER(wa.source_name) LIKE ?
1357
+ ${opts?.status ? 'AND w.status = ?' : ''}
1358
+ ORDER BY wa.workflow_id, wa.created_at ASC
1359
+ `).all(...(opts?.status ? [lq, opts.status] : [lq]));
1360
+ // If content search requested, also scan artifact files
1361
+ const contentMatchedArtifactIds = new Set();
1362
+ const artifactContentSnippets = new Map();
1363
+ if (opts?.include_artifact_content) {
1364
+ 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] : []));
1365
+ const lqPlain = query.toLowerCase();
1366
+ for (const a of allArtifacts) {
1367
+ if (contentMatchedArtifactIds.has(a.id))
1368
+ continue;
1369
+ try {
1370
+ if (fs.existsSync(a.file_path)) {
1371
+ const text = fs.readFileSync(a.file_path, 'utf-8');
1372
+ const idx = text.toLowerCase().indexOf(lqPlain);
1373
+ if (idx !== -1) {
1374
+ contentMatchedArtifactIds.add(a.id);
1375
+ const start = Math.max(0, idx - 80);
1376
+ const end = Math.min(text.length, idx + query.length + 80);
1377
+ artifactContentSnippets.set(a.id, (start > 0 ? '…' : '') + text.slice(start, end) + (end < text.length ? '…' : ''));
1378
+ }
1379
+ }
1380
+ }
1381
+ catch { /* skip unreadable */ }
1382
+ }
1383
+ }
1384
+ // Collect all relevant workflow IDs
1385
+ const workflowIdSet = new Set([
1386
+ ...matchedStepRows.map(s => s.workflow_id),
1387
+ ...matchedArtifactRows.map(a => a.workflow_id),
1388
+ ...Array.from(contentMatchedArtifactIds).map(id => {
1389
+ const r = this.db.prepare('SELECT workflow_id FROM workflow_artifacts WHERE id = ?').get(id);
1390
+ return r?.workflow_id || '';
1391
+ }).filter(Boolean)
984
1392
  ]);
985
- // Get all active nodes (including those with history) to check for missing files or spurious names
1393
+ const results = [];
1394
+ for (const wid of workflowIdSet) {
1395
+ const workflow = this.getWorkflow(wid);
1396
+ if (!workflow)
1397
+ continue;
1398
+ const steps = matchedStepRows.filter(s => s.workflow_id === wid);
1399
+ const artByName = matchedArtifactRows.filter(a => a.workflow_id === wid);
1400
+ const artByContent = opts?.include_artifact_content
1401
+ ? 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))
1402
+ : [];
1403
+ const allArtifacts = [
1404
+ ...artByName.map(a => ({ ...a, content_snippet: artifactContentSnippets.get(a.id) })),
1405
+ ...artByContent.map(a => ({ ...a, content_snippet: artifactContentSnippets.get(a.id) }))
1406
+ ];
1407
+ results.push({ workflow, matched_steps: steps, matched_artifacts: allArtifacts });
1408
+ }
1409
+ // Sort by most recently updated workflow first
1410
+ results.sort((a, b) => b.workflow.updated_at.localeCompare(a.workflow.updated_at));
1411
+ return results;
1412
+ }
1413
+ /**
1414
+ * Imports an existing flow/architecture doc as a paused workflow (not active — importing
1415
+ * a doc isn't the same as declaring active work). Idempotent on `name`: re-importing the
1416
+ * same doc overwrites its existing `imported_doc` artifact file in place instead of
1417
+ * creating a duplicate workflow every time the source docs are re-imported.
1418
+ */
1419
+ importWorkflowDoc(name, description, content, sourceFileName) {
1420
+ const existing = this.db.prepare('SELECT * FROM workflows WHERE name = ?').get(name);
1421
+ const now = new Date().toISOString();
1422
+ if (existing) {
1423
+ this.db.prepare(`UPDATE workflows SET description = ?, updated_at = ? WHERE id = ?`).run(description, now, existing.id);
1424
+ 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);
1425
+ if (existingArtifact) {
1426
+ fs.writeFileSync(existingArtifact.file_path, content, 'utf-8');
1427
+ }
1428
+ else {
1429
+ this.addWorkflowArtifact(existing.id, { type: 'imported_doc', sourceName: sourceFileName, content });
1430
+ }
1431
+ this.writeWorkflowToDisk(existing.id);
1432
+ return { workflow: { ...existing, description, updated_at: now }, created: false };
1433
+ }
1434
+ const id = `wf_${crypto.randomUUID()}`;
1435
+ this.db.prepare(`
1436
+ INSERT INTO workflows (id, name, description, status, created_at, updated_at)
1437
+ VALUES (?, ?, ?, 'paused', ?, ?)
1438
+ `).run(id, name, description, now, now);
1439
+ this.addWorkflowStep(id, { summary: `Imported existing flow documentation: ${sourceFileName}` });
1440
+ this.addWorkflowArtifact(id, { type: 'imported_doc', sourceName: sourceFileName, content });
1441
+ // writeWorkflowToDisk is already called inside addWorkflowArtifact/addWorkflowStep above
1442
+ return { workflow: { id, name, description, status: 'paused', created_at: now, updated_at: now }, created: true };
1443
+ }
1444
+ static SPURIOUS_NODE_NAMES = new Set([
1445
+ 'promise', 'map', 'set', 'json', 'console', 'error', 'object', 'function', 'array', 'string', 'number', 'boolean', 'regexp', 'date', 'math',
1446
+ 'any', 'void', 'unknown', 'never', 'null', 'undefined', 'dict', 'list',
1447
+ 'data', 'useeffect', 'val', 'temp', 'result', 'item', 'key', 'value', 'err', 'req', 'res', 'args', 'params', 'response', 'request'
1448
+ ]);
1449
+ /**
1450
+ * Read-only detection shared by `pruneSpuriousNodes` (which acts on it) and `devsmind
1451
+ * analyze`'s dry-run report (which just lists it). Never mutates the DB.
1452
+ */
1453
+ findSpuriousAndMissingFileNodes(workspaceRoot) {
986
1454
  const stmt = this.db.prepare(`
987
1455
  SELECT id, name, file_path FROM nodes
988
1456
  WHERE deprecated = 0
989
1457
  `);
990
1458
  const candidates = stmt.all();
991
- const idsToDelete = [];
992
- const namesDeleted = [];
993
- const affectedFilePaths = new Set();
1459
+ const spurious = [];
1460
+ const missingFile = [];
994
1461
  for (const node of candidates) {
995
1462
  const lowerName = node.name.toLowerCase();
996
- // 1. Check if name is in the spurious list
997
- const isSpurious = spuriousNames.has(lowerName);
998
- // 2. Check if file path does not exist on disk
999
- let fileMissing = false;
1463
+ if (DevMindDatabase.SPURIOUS_NODE_NAMES.has(lowerName)) {
1464
+ spurious.push(node);
1465
+ continue;
1466
+ }
1000
1467
  if (node.file_path) {
1001
1468
  const paths = node.file_path.split(',').map(p => p.trim()).filter(Boolean);
1002
1469
  if (paths.length > 0) {
1003
1470
  const allMissing = paths.every(p => {
1004
- const resolvedPath = path.isAbsolute(p)
1005
- ? p
1006
- : path.resolve(workspaceRoot, p);
1471
+ const resolvedPath = path.isAbsolute(p) ? p : path.resolve(workspaceRoot, p);
1007
1472
  return !fs.existsSync(resolvedPath);
1008
1473
  });
1009
- if (allMissing) {
1010
- fileMissing = true;
1011
- }
1474
+ if (allMissing)
1475
+ missingFile.push(node);
1012
1476
  }
1013
1477
  }
1014
- if (isSpurious || fileMissing) {
1015
- idsToDelete.push(node.id);
1016
- namesDeleted.push(`${node.name} (${node.id})`);
1017
- if (node.file_path) {
1018
- for (const p of node.file_path.split(',').map(s => s.trim()).filter(Boolean)) {
1019
- affectedFilePaths.add(p);
1020
- }
1478
+ }
1479
+ return { spurious, missingFile };
1480
+ }
1481
+ pruneSpuriousNodes(workspaceRoot) {
1482
+ const { spurious, missingFile } = this.findSpuriousAndMissingFileNodes(workspaceRoot);
1483
+ const candidates = [...spurious, ...missingFile];
1484
+ const idsToDelete = [];
1485
+ const namesDeleted = [];
1486
+ const affectedFilePaths = new Set();
1487
+ for (const node of candidates) {
1488
+ idsToDelete.push(node.id);
1489
+ namesDeleted.push(`${node.name} (${node.id})`);
1490
+ if (node.file_path) {
1491
+ for (const p of node.file_path.split(',').map(s => s.trim()).filter(Boolean)) {
1492
+ affectedFilePaths.add(p);
1021
1493
  }
1022
1494
  }
1023
1495
  }
@@ -1110,41 +1582,120 @@ class DevMindDatabase {
1110
1582
  toRepoRelativePath(absolutePath) {
1111
1583
  if (!absolutePath || !this.context)
1112
1584
  return absolutePath;
1113
- const abs = path.resolve(absolutePath).replace(/\\/g, '/');
1585
+ const abs = (0, config_1.canonicalizePath)(absolutePath).replace(/\\/g, '/');
1586
+ const absLower = abs.toLowerCase();
1114
1587
  for (const repo of this.context.config.repos) {
1115
1588
  const repoPath = (0, config_1.resolveRepoPath)(this.context, repo.name);
1116
1589
  if (repoPath) {
1117
- const normalizedRepoPath = path.resolve(repoPath).replace(/\\/g, '/');
1118
- if (abs === normalizedRepoPath || abs.startsWith(normalizedRepoPath + '/')) {
1590
+ const normalizedRepoPath = (0, config_1.canonicalizePath)(repoPath).replace(/\\/g, '/');
1591
+ const normalizedRepoPathLower = normalizedRepoPath.toLowerCase();
1592
+ if (absLower === normalizedRepoPathLower || absLower.startsWith(normalizedRepoPathLower + '/')) {
1119
1593
  const relative = path.relative(normalizedRepoPath, abs).replace(/\\/g, '/');
1120
1594
  return `{${repo.name}}/${relative}`;
1121
1595
  }
1122
1596
  }
1123
1597
  }
1124
1598
  // Fallback: resolve relative to workspace root
1125
- const workspaceRoot = path.dirname(this.dbPath);
1599
+ const workspaceRoot = (0, config_1.canonicalizePath)(path.dirname(this.dbPath));
1126
1600
  return path.relative(workspaceRoot, absolutePath).replace(/\\/g, '/');
1127
1601
  }
1602
+ /**
1603
+ * Rejects a resolved path that escapes its expected root (e.g. via a stored
1604
+ * `{repo}/../../..` path traveling outside the repo) by clamping it back to
1605
+ * the root itself. node_id/file_path values flow in from AI-supplied tool
1606
+ * calls, so a resolve must never be trusted to stay inside root on its own.
1607
+ */
1608
+ clampToRoot(root, resolved) {
1609
+ const normalizedRoot = (0, config_1.canonicalizePath)(root);
1610
+ const normalizedResolved = (0, config_1.canonicalizePath)(resolved);
1611
+ const rootLower = normalizedRoot.toLowerCase();
1612
+ const resolvedLower = normalizedResolved.toLowerCase();
1613
+ if (resolvedLower === rootLower || resolvedLower.startsWith(rootLower + path.sep)) {
1614
+ return normalizedResolved;
1615
+ }
1616
+ console.warn(`⚠️ Path traversal blocked: "${resolved}" escapes root "${root}"`);
1617
+ return normalizedRoot;
1618
+ }
1619
+ /**
1620
+ * True if `absPath` sits inside a configured repo root or the workspace root itself.
1621
+ * Used to reject `stage_change`/`update_history` file paths that would otherwise let a
1622
+ * tool call read/write any file on disk (absolute path, or a `../` escape) instead of
1623
+ * just repo source — nothing upstream of this validates that the AI-supplied path is
1624
+ * actually inside the project.
1625
+ */
1626
+ isPathAllowed(absPath) {
1627
+ const abs = (0, config_1.canonicalizePath)(absPath);
1628
+ const absLower = abs.toLowerCase();
1629
+ const workspaceRoot = (0, config_1.canonicalizePath)(path.dirname(this.dbPath));
1630
+ const workspaceRootLower = workspaceRoot.toLowerCase();
1631
+ if (absLower === workspaceRootLower || absLower.startsWith(workspaceRootLower + path.sep))
1632
+ return true;
1633
+ if (this.context) {
1634
+ for (const repo of this.context.config.repos) {
1635
+ const repoPath = (0, config_1.resolveRepoPath)(this.context, repo.name);
1636
+ if (repoPath) {
1637
+ const normalizedRepoPath = (0, config_1.canonicalizePath)(repoPath);
1638
+ const normalizedRepoPathLower = normalizedRepoPath.toLowerCase();
1639
+ if (absLower === normalizedRepoPathLower || absLower.startsWith(normalizedRepoPathLower + path.sep))
1640
+ return true;
1641
+ }
1642
+ }
1643
+ }
1644
+ return false;
1645
+ }
1128
1646
  toAbsolutePath(repoRelativePath) {
1129
1647
  if (!repoRelativePath)
1130
1648
  return repoRelativePath;
1131
- const workspaceRoot = path.dirname(this.dbPath);
1649
+ const workspaceRoot = (0, config_1.canonicalizePath)(path.dirname(this.dbPath));
1132
1650
  const match = repoRelativePath.match(/^\{([^}]+)\}\/(.*)$/);
1133
1651
  if (match && this.context) {
1134
1652
  const repoName = match[1];
1135
1653
  const relativePath = match[2];
1136
1654
  const repoPath = (0, config_1.resolveRepoPath)(this.context, repoName);
1137
1655
  if (repoPath) {
1138
- return path.resolve(repoPath, relativePath);
1656
+ return (0, config_1.canonicalizePath)(this.clampToRoot(repoPath, path.resolve(repoPath, relativePath)));
1657
+ }
1658
+ }
1659
+ // Heuristic: if it doesn't start with {repoName} but contains a configured repo name in the path
1660
+ if (this.context) {
1661
+ for (const repo of this.context.config.repos) {
1662
+ // Find if repo.name appears as a folder in the path, e.g. "harrir-express-backend/tests/..."
1663
+ const escapedRepoName = repo.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1664
+ const regex = new RegExp('(?:^|/|\\\\)' + escapedRepoName + '(?:/|\\\\)(.*)$', 'i');
1665
+ const m = repoRelativePath.match(regex);
1666
+ if (m) {
1667
+ const repoPath = (0, config_1.resolveRepoPath)(this.context, repo.name);
1668
+ if (repoPath) {
1669
+ const relativePath = m[1];
1670
+ return (0, config_1.canonicalizePath)(path.resolve(repoPath, relativePath));
1671
+ }
1672
+ }
1139
1673
  }
1140
1674
  }
1141
1675
  // Fallback: resolve relative to workspace root
1142
- return path.resolve(workspaceRoot, repoRelativePath);
1676
+ return (0, config_1.canonicalizePath)(this.clampToRoot(workspaceRoot, path.resolve(workspaceRoot, repoRelativePath)));
1143
1677
  }
1144
1678
  syncFromDisk() {
1145
1679
  this.db.pragma('foreign_keys = OFF');
1146
1680
  try {
1147
1681
  const workspaceRoot = path.dirname(this.dbPath);
1682
+ // 0. Auto-heal any legacy relative path records in SQLite
1683
+ try {
1684
+ const legacyNodes = this.db.prepare("SELECT id, file_path FROM nodes WHERE file_path NOT LIKE 'c:%' AND file_path NOT LIKE 'C:%' AND file_path NOT LIKE '/%'").all();
1685
+ if (legacyNodes.length > 0) {
1686
+ const updateStmt = this.db.prepare('UPDATE nodes SET file_path = ? WHERE id = ?');
1687
+ const healTx = this.db.transaction(() => {
1688
+ for (const n of legacyNodes) {
1689
+ const abs = this.toAbsolutePath(n.file_path);
1690
+ updateStmt.run(abs, n.id);
1691
+ }
1692
+ });
1693
+ healTx();
1694
+ }
1695
+ }
1696
+ catch (err) {
1697
+ // ignore legacy errors
1698
+ }
1148
1699
  // 1. Sync History JSONs
1149
1700
  const historyDir = path.join(workspaceRoot, 'history');
1150
1701
  if (fs.existsSync(historyDir)) {
@@ -1249,6 +1800,65 @@ class DevMindDatabase {
1249
1800
  syncGraphTx();
1250
1801
  }
1251
1802
  }
1803
+ // 3. Sync Workflow JSONs
1804
+ const workflowsDir = this.workflowsDir();
1805
+ if (fs.existsSync(workflowsDir)) {
1806
+ const upsertWorkflow = this.db.prepare(`
1807
+ INSERT INTO workflows (id, name, description, status, created_at, updated_at)
1808
+ VALUES (?, ?, ?, ?, ?, ?)
1809
+ ON CONFLICT(id) DO UPDATE SET
1810
+ name = excluded.name,
1811
+ description = excluded.description,
1812
+ status = excluded.status,
1813
+ updated_at = excluded.updated_at
1814
+ `);
1815
+ const upsertStep = this.db.prepare(`
1816
+ INSERT OR IGNORE INTO workflow_steps (id, workflow_id, step_index, summary, pending_tasks, history_ids, session_id, created_at)
1817
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1818
+ `);
1819
+ const upsertArtifact = this.db.prepare(`
1820
+ INSERT OR IGNORE INTO workflow_artifacts (id, workflow_id, step_id, type, source_name, file_path, created_at)
1821
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1822
+ `);
1823
+ // Track which workflow.json has is_active:true with the latest updated_at
1824
+ let bestActiveId = null;
1825
+ let bestActiveUpdatedAt = '';
1826
+ const syncWorkflowsTx = this.db.transaction(() => {
1827
+ const subdirs = fs.readdirSync(workflowsDir);
1828
+ for (const subdir of subdirs) {
1829
+ const jsonPath = path.join(workflowsDir, subdir, 'workflow.json');
1830
+ if (!fs.existsSync(jsonPath))
1831
+ continue;
1832
+ try {
1833
+ const data = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
1834
+ if (!data.id || !data.name)
1835
+ continue;
1836
+ upsertWorkflow.run(data.id, data.name, data.description || '', data.status || 'paused', data.created_at || new Date().toISOString(), data.updated_at || new Date().toISOString());
1837
+ for (const s of (data.steps || [])) {
1838
+ if (!s.id)
1839
+ continue;
1840
+ 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());
1841
+ }
1842
+ for (const a of (data.artifact_index || [])) {
1843
+ if (!a.id)
1844
+ continue;
1845
+ 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());
1846
+ }
1847
+ // Track which workflow declared itself active most recently
1848
+ if (data.is_active && data.updated_at > bestActiveUpdatedAt) {
1849
+ bestActiveId = data.id;
1850
+ bestActiveUpdatedAt = data.updated_at;
1851
+ }
1852
+ }
1853
+ catch { /* skip malformed */ }
1854
+ }
1855
+ });
1856
+ syncWorkflowsTx();
1857
+ // Restore active_workflow_id if not already set and a JSON claims active status
1858
+ if (bestActiveId && !this.getSystemMeta('active_workflow_id')) {
1859
+ this.setSystemMeta('active_workflow_id', bestActiveId);
1860
+ }
1861
+ }
1252
1862
  }
1253
1863
  catch (err) {
1254
1864
  console.warn('⚠️ SQLite warning: Failed to sync from disk:', err);
@@ -1265,9 +1875,9 @@ class DevMindDatabase {
1265
1875
  try {
1266
1876
  if (!filePath)
1267
1877
  return;
1268
- const workspaceRoot = path.dirname(this.dbPath);
1878
+ const workspaceRoot = (0, config_1.canonicalizePath)(path.dirname(this.dbPath));
1269
1879
  // Clean/resolve the file path
1270
- const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(workspaceRoot, filePath);
1880
+ const absPath = (0, config_1.canonicalizePath)(filePath);
1271
1881
  const repoRelPath = this.toRepoRelativePath(absPath);
1272
1882
  // E.g., "{harrir-web}/app/page.tsx" -> "graph/harrir-web/app/page.json"
1273
1883
  const diskRelPath = repoRelPath.replace(/^\{([^}]+)\}/, '$1').replace(/\.[^/.]+$/, '.json');
@@ -1281,16 +1891,18 @@ class DevMindDatabase {
1281
1891
  // is durable across a syncFromDisk() restart and propagates to teammates via git —
1282
1892
  // otherwise the node's history JSON would resurrect it as active on the next start.
1283
1893
  const absEsc = this.likeEscape(absPath);
1894
+ const absLower = absPath.toLowerCase();
1895
+ const absEscLower = absEsc.toLowerCase();
1284
1896
  const stmtNodes = this.db.prepare(`
1285
1897
  SELECT * FROM nodes
1286
1898
  WHERE (
1287
- file_path = ? OR
1288
- file_path LIKE ? ESCAPE '\\' OR
1289
- file_path LIKE ? ESCAPE '\\' OR
1290
- file_path LIKE ? ESCAPE '\\'
1899
+ LOWER(file_path) = ? OR
1900
+ LOWER(file_path) LIKE ? ESCAPE '\\' OR
1901
+ LOWER(file_path) LIKE ? ESCAPE '\\' OR
1902
+ LOWER(file_path) LIKE ? ESCAPE '\\'
1291
1903
  )
1292
1904
  `);
1293
- const nodes = stmtNodes.all(absPath, `${absEsc}, %`, `%, ${absEsc}`, `%, ${absEsc}, %`);
1905
+ const nodes = stmtNodes.all(absLower, `${absEscLower}, %`, `%, ${absEscLower}`, `%, ${absEscLower}, %`);
1294
1906
  if (nodes.length === 0) {
1295
1907
  // If no nodes left, delete the JSON file if it exists
1296
1908
  if (fs.existsSync(graphJsonPath)) {
@@ -1333,6 +1945,30 @@ class DevMindDatabase {
1333
1945
  console.warn('⚠️ SQLite warning: Failed to write graph JSON to disk:', err);
1334
1946
  }
1335
1947
  }
1948
+ /** Force-syncs all database nodes and workflows to disk JSON files. */
1949
+ syncToDisk() {
1950
+ try {
1951
+ const rows = this.db.prepare('SELECT DISTINCT file_path FROM nodes').all();
1952
+ const filePaths = new Set();
1953
+ for (const row of rows) {
1954
+ if (row.file_path) {
1955
+ for (const p of row.file_path.split(',').map(s => s.trim()).filter(Boolean)) {
1956
+ filePaths.add(p);
1957
+ }
1958
+ }
1959
+ }
1960
+ for (const filePath of filePaths) {
1961
+ this.writeGraphToDisk(filePath);
1962
+ }
1963
+ const workflowRows = this.db.prepare('SELECT id FROM workflows').all();
1964
+ for (const row of workflowRows) {
1965
+ this.writeWorkflowToDisk(row.id);
1966
+ }
1967
+ }
1968
+ catch (err) {
1969
+ console.warn('⚠️ DevsMind: Failed to sync database to disk:', err);
1970
+ }
1971
+ }
1336
1972
  }
1337
1973
  exports.DevMindDatabase = DevMindDatabase;
1338
1974
  //# sourceMappingURL=database.js.map