@stackmemoryai/stackmemory 1.8.0 → 1.9.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.
@@ -659,8 +659,15 @@ OUTPUT THE IMPROVED TEMPLATE:`;
659
659
  }
660
660
  }
661
661
  function createConductorCommands() {
662
+ const CONDUCTOR_VERSION = "0.2.0";
662
663
  const cmd = new Command("conductor");
663
- cmd.description("Conductor \u2014 autonomous agent orchestration via Linear");
664
+ cmd.description("Conductor \u2014 autonomous agent orchestration via Linear").option("--version", "Print conductor adapter version").action((options) => {
665
+ if (options.version) {
666
+ console.log(`symphony-adapter ${CONDUCTOR_VERSION}`);
667
+ return;
668
+ }
669
+ cmd.help();
670
+ });
664
671
  cmd.command("capture").description("Capture workspace context after an agent run").requiredOption("--issue <id>", "Issue identifier (e.g., STA-476)").option("--workspace <path>", "Workspace directory", process.cwd()).option("--attempt <n>", "Attempt number", "1").action(async (options) => {
665
672
  const workspace = options.workspace;
666
673
  const issueId = options.issue;
@@ -627,6 +627,72 @@ class LinearClient {
627
627
  };
628
628
  }
629
629
  }
630
+ // --- Comment CRUD ---
631
+ async createComment(issueId, body) {
632
+ const mutation = `
633
+ mutation CreateComment($input: CommentCreateInput!) {
634
+ commentCreate(input: $input) {
635
+ success
636
+ comment {
637
+ id
638
+ body
639
+ createdAt
640
+ user { id name }
641
+ }
642
+ }
643
+ }
644
+ `;
645
+ const result = await this.graphql(mutation, { input: { issueId, body } });
646
+ if (!result.commentCreate.success) {
647
+ throw new IntegrationError(
648
+ "Failed to create comment",
649
+ ErrorCode.LINEAR_API_ERROR,
650
+ { issueId }
651
+ );
652
+ }
653
+ return result.commentCreate.comment;
654
+ }
655
+ async updateComment(commentId, body) {
656
+ const mutation = `
657
+ mutation UpdateComment($id: String!, $input: CommentUpdateInput!) {
658
+ commentUpdate(id: $id, input: $input) {
659
+ success
660
+ comment {
661
+ id
662
+ body
663
+ updatedAt
664
+ }
665
+ }
666
+ }
667
+ `;
668
+ const result = await this.graphql(mutation, { id: commentId, input: { body } });
669
+ if (!result.commentUpdate.success) {
670
+ throw new IntegrationError(
671
+ "Failed to update comment",
672
+ ErrorCode.LINEAR_API_ERROR,
673
+ { commentId }
674
+ );
675
+ }
676
+ return result.commentUpdate.comment;
677
+ }
678
+ async getComments(issueId) {
679
+ const query = `
680
+ query GetComments($issueId: String!) {
681
+ issue(id: $issueId) {
682
+ comments(first: 100) {
683
+ nodes {
684
+ id
685
+ body
686
+ createdAt
687
+ user { name }
688
+ }
689
+ }
690
+ }
691
+ }
692
+ `;
693
+ const result = await this.graphql(query, { issueId });
694
+ return result.issue.comments.nodes;
695
+ }
630
696
  }
631
697
  export {
632
698
  LinearClient
@@ -20,6 +20,9 @@ import {
20
20
  } from "./provider-handlers.js";
21
21
  import { TeamHandlers } from "./team-handlers.js";
22
22
  import { CordHandlers } from "./cord-handlers.js";
23
+ import {
24
+ ProvenantHandlers
25
+ } from "./provenant-handlers.js";
23
26
  import {
24
27
  ContextHandlers as ContextHandlers2
25
28
  } from "./context-handlers.js";
@@ -31,6 +34,7 @@ import { TraceHandlers as TraceHandlers2 } from "./trace-handlers.js";
31
34
  import { ProviderHandlers as ProviderHandlers2 } from "./provider-handlers.js";
32
35
  import { TeamHandlers as TeamHandlers2 } from "./team-handlers.js";
33
36
  import { CordHandlers as CordHandlers2 } from "./cord-handlers.js";
37
+ import { ProvenantHandlers as ProvenantHandlers2 } from "./provenant-handlers.js";
34
38
  class MCPHandlerFactory {
35
39
  contextHandlers;
36
40
  taskHandlers;
@@ -39,6 +43,7 @@ class MCPHandlerFactory {
39
43
  providerHandlers;
40
44
  teamHandlers;
41
45
  cordHandlers;
46
+ provenantHandlers;
42
47
  constructor(deps) {
43
48
  this.contextHandlers = new ContextHandlers2({
44
49
  frameManager: deps.frameManager
@@ -67,6 +72,11 @@ class MCPHandlerFactory {
67
72
  dbAdapter: deps.dbAdapter
68
73
  });
69
74
  }
75
+ if (deps.projectDir) {
76
+ this.provenantHandlers = new ProvenantHandlers2({
77
+ projectDir: deps.projectDir
78
+ });
79
+ }
70
80
  }
71
81
  /**
72
82
  * Get handler for a specific tool
@@ -166,6 +176,31 @@ class MCPHandlerFactory {
166
176
  case "cord_tree":
167
177
  if (!this.cordHandlers) throw new Error("Cord tools require dbAdapter");
168
178
  return this.cordHandlers.handleCordTree.bind(this.cordHandlers);
179
+ // Provenant decision graph handlers
180
+ case "provenant_search":
181
+ if (!this.provenantHandlers)
182
+ throw new Error("Provenant tools require projectDir");
183
+ return this.provenantHandlers.handleSearch.bind(this.provenantHandlers);
184
+ case "provenant_log":
185
+ if (!this.provenantHandlers)
186
+ throw new Error("Provenant tools require projectDir");
187
+ return this.provenantHandlers.handleLog.bind(this.provenantHandlers);
188
+ case "provenant_status":
189
+ if (!this.provenantHandlers)
190
+ throw new Error("Provenant tools require projectDir");
191
+ return this.provenantHandlers.handleStatus.bind(this.provenantHandlers);
192
+ case "provenant_contradictions":
193
+ if (!this.provenantHandlers)
194
+ throw new Error("Provenant tools require projectDir");
195
+ return this.provenantHandlers.handleContradictions.bind(
196
+ this.provenantHandlers
197
+ );
198
+ case "provenant_resolve":
199
+ if (!this.provenantHandlers)
200
+ throw new Error("Provenant tools require projectDir");
201
+ return this.provenantHandlers.handleResolve.bind(
202
+ this.provenantHandlers
203
+ );
169
204
  default:
170
205
  throw new Error(`Unknown tool: ${toolName}`);
171
206
  }
@@ -211,7 +246,13 @@ class MCPHandlerFactory {
211
246
  "cord_fork",
212
247
  "cord_complete",
213
248
  "cord_ask",
214
- "cord_tree"
249
+ "cord_tree",
250
+ // Provenant decision graph tools
251
+ "provenant_search",
252
+ "provenant_log",
253
+ "provenant_status",
254
+ "provenant_contradictions",
255
+ "provenant_resolve"
215
256
  ];
216
257
  }
217
258
  /**
@@ -227,6 +268,7 @@ export {
227
268
  DiscoveryHandlers,
228
269
  LinearHandlers,
229
270
  MCPHandlerFactory,
271
+ ProvenantHandlers,
230
272
  ProviderHandlers,
231
273
  TaskHandlers,
232
274
  TeamHandlers,
@@ -218,6 +218,108 @@ ${issueLines.join("\n")}` : "No issues found matching filters.";
218
218
  };
219
219
  }
220
220
  }
221
+ /**
222
+ * Create a comment on a Linear issue
223
+ */
224
+ async handleLinearCreateComment(args) {
225
+ try {
226
+ const { issue_id, body } = args;
227
+ if (!issue_id || !body) {
228
+ throw new Error("issue_id and body are required");
229
+ }
230
+ const client = await this.getClient();
231
+ const comment = await client.createComment(issue_id, body);
232
+ return {
233
+ content: [
234
+ {
235
+ type: "text",
236
+ text: `Comment created on ${issue_id}
237
+ ID: ${comment.id}
238
+ Preview: ${body.slice(0, 100)}${body.length > 100 ? "..." : ""}`
239
+ }
240
+ ],
241
+ metadata: {
242
+ id: comment.id,
243
+ issueId: issue_id,
244
+ createdAt: comment.createdAt
245
+ }
246
+ };
247
+ } catch (error) {
248
+ logger.error(
249
+ "Error creating Linear comment",
250
+ error instanceof Error ? error : new Error(String(error))
251
+ );
252
+ throw error;
253
+ }
254
+ }
255
+ /**
256
+ * Update an existing comment on a Linear issue
257
+ */
258
+ async handleLinearUpdateComment(args) {
259
+ try {
260
+ const { comment_id, body } = args;
261
+ if (!comment_id || !body) {
262
+ throw new Error("comment_id and body are required");
263
+ }
264
+ const client = await this.getClient();
265
+ const comment = await client.updateComment(comment_id, body);
266
+ return {
267
+ content: [
268
+ {
269
+ type: "text",
270
+ text: `Comment ${comment_id} updated
271
+ Preview: ${body.slice(0, 100)}${body.length > 100 ? "..." : ""}`
272
+ }
273
+ ],
274
+ metadata: {
275
+ id: comment.id,
276
+ updatedAt: comment.updatedAt
277
+ }
278
+ };
279
+ } catch (error) {
280
+ logger.error(
281
+ "Error updating Linear comment",
282
+ error instanceof Error ? error : new Error(String(error))
283
+ );
284
+ throw error;
285
+ }
286
+ }
287
+ /**
288
+ * List comments on a Linear issue
289
+ */
290
+ async handleLinearListComments(args) {
291
+ try {
292
+ const { issue_id } = args;
293
+ if (!issue_id) {
294
+ throw new Error("issue_id is required");
295
+ }
296
+ const client = await this.getClient();
297
+ const comments = await client.getComments(issue_id);
298
+ const lines = comments.map(
299
+ (c) => `${c.id.slice(0, 8)} | ${c.user?.name ?? "unknown"} | ${new Date(c.createdAt).toISOString().slice(0, 10)} | ${c.body.slice(0, 60).replace(/\n/g, " ")}${c.body.length > 60 ? "..." : ""}`
300
+ );
301
+ const text = comments.length > 0 ? `${comments.length} comments on ${issue_id}:
302
+ ${lines.join("\n")}` : `No comments on ${issue_id}`;
303
+ return {
304
+ content: [{ type: "text", text }],
305
+ metadata: {
306
+ count: comments.length,
307
+ comments: comments.map((c) => ({
308
+ id: c.id,
309
+ author: c.user?.name,
310
+ createdAt: c.createdAt,
311
+ bodyPreview: c.body.slice(0, 200)
312
+ }))
313
+ }
314
+ };
315
+ } catch (error) {
316
+ logger.error(
317
+ "Error listing Linear comments",
318
+ error instanceof Error ? error : new Error(String(error))
319
+ );
320
+ throw error;
321
+ }
322
+ }
221
323
  }
222
324
  export {
223
325
  LinearHandlers
@@ -0,0 +1,398 @@
1
+ import { fileURLToPath as __fileURLToPath } from 'url';
2
+ import { dirname as __pathDirname } from 'path';
3
+ const __filename = __fileURLToPath(import.meta.url);
4
+ const __dirname = __pathDirname(__filename);
5
+ import { existsSync, mkdirSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ import { randomUUID, createHash } from "node:crypto";
8
+ import BetterSqlite3 from "better-sqlite3";
9
+ import { logger } from "../../../core/monitoring/logger.js";
10
+ const SCHEMA_SQL = `
11
+ CREATE TABLE IF NOT EXISTS nodes (
12
+ id TEXT PRIMARY KEY, type TEXT NOT NULL, content TEXT NOT NULL,
13
+ embedding BLOB, actor TEXT, confidence REAL NOT NULL DEFAULT 0.0,
14
+ version INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
15
+ );
16
+ CREATE TABLE IF NOT EXISTS edges (
17
+ id TEXT PRIMARY KEY, from_node TEXT NOT NULL, to_node TEXT NOT NULL,
18
+ rel_type TEXT NOT NULL, confidence REAL NOT NULL DEFAULT 1.0,
19
+ version INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL
20
+ );
21
+ CREATE TABLE IF NOT EXISTS sources (
22
+ id TEXT PRIMARY KEY, system TEXT NOT NULL, external_id TEXT NOT NULL,
23
+ raw_payload TEXT NOT NULL, hash TEXT NOT NULL, fetched_at INTEGER NOT NULL
24
+ );
25
+ CREATE TABLE IF NOT EXISTS source_edges (
26
+ id TEXT PRIMARY KEY, node_id TEXT NOT NULL, source_id TEXT NOT NULL,
27
+ system TEXT NOT NULL, external_id TEXT NOT NULL
28
+ );
29
+ CREATE TABLE IF NOT EXISTS contradictions (
30
+ id TEXT PRIMARY KEY, node_a TEXT NOT NULL, node_b TEXT NOT NULL,
31
+ conflict_score REAL NOT NULL, status TEXT NOT NULL DEFAULT 'pending',
32
+ resolved_by TEXT, resolved_at INTEGER
33
+ );
34
+ CREATE TABLE IF NOT EXISTS stale_flags (
35
+ id TEXT PRIMARY KEY, node_id TEXT NOT NULL, triggered_by_source TEXT NOT NULL,
36
+ flagged_at INTEGER NOT NULL, resolved_at INTEGER
37
+ );
38
+ CREATE TABLE IF NOT EXISTS rejection_log (
39
+ id TEXT PRIMARY KEY, suggestion_node TEXT NOT NULL, override_node TEXT,
40
+ reasoning TEXT, reasoning_resolved INTEGER NOT NULL DEFAULT 0,
41
+ actor TEXT, created_at INTEGER NOT NULL
42
+ );
43
+ CREATE TABLE IF NOT EXISTS review_queue (
44
+ id TEXT PRIMARY KEY, source_id TEXT NOT NULL, candidate_content TEXT NOT NULL,
45
+ confidence REAL NOT NULL, queue_reason TEXT NOT NULL,
46
+ created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, resolved_at INTEGER
47
+ );
48
+ CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL);
49
+ CREATE INDEX IF NOT EXISTS idx_nodes_type ON nodes(type);
50
+ CREATE INDEX IF NOT EXISTS idx_nodes_created ON nodes(created_at DESC);
51
+ CREATE INDEX IF NOT EXISTS idx_contradictions_status ON contradictions(status);
52
+ INSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (1, unixepoch() * 1000);
53
+ `;
54
+ class ProvenantHandlers {
55
+ constructor(deps) {
56
+ this.deps = deps;
57
+ this.dbPath = join(deps.projectDir, ".provenant", "graph.db");
58
+ }
59
+ dbPath;
60
+ openDb() {
61
+ mkdirSync(dirname(this.dbPath), { recursive: true });
62
+ const db = new BetterSqlite3(this.dbPath);
63
+ db.pragma("journal_mode = WAL");
64
+ db.pragma("foreign_keys = ON");
65
+ try {
66
+ db.prepare("SELECT 1 FROM schema_version").get();
67
+ } catch {
68
+ db.exec(SCHEMA_SQL);
69
+ }
70
+ return db;
71
+ }
72
+ async handleSearch(args) {
73
+ try {
74
+ const { query, actor, since, limit = 10 } = args;
75
+ if (!query) throw new Error("query is required");
76
+ const db = this.openDb();
77
+ try {
78
+ const stopwords = /* @__PURE__ */ new Set([
79
+ "the",
80
+ "a",
81
+ "an",
82
+ "is",
83
+ "was",
84
+ "were",
85
+ "are",
86
+ "we",
87
+ "our",
88
+ "this",
89
+ "that",
90
+ "it",
91
+ "to",
92
+ "of",
93
+ "in",
94
+ "for",
95
+ "on",
96
+ "with",
97
+ "did",
98
+ "do",
99
+ "not",
100
+ "why",
101
+ "how",
102
+ "what",
103
+ "when",
104
+ "and",
105
+ "or",
106
+ "but"
107
+ ]);
108
+ const keywords = query.toLowerCase().replace(/[^\w\s]/g, "").split(/\s+/).filter((w) => w.length > 2 && !stopwords.has(w));
109
+ let sql;
110
+ const params = [];
111
+ if (keywords.length === 0) {
112
+ sql = "SELECT * FROM nodes ORDER BY created_at DESC LIMIT ?";
113
+ params.push(limit);
114
+ } else {
115
+ const scoreParts = keywords.map(
116
+ () => "(CASE WHEN LOWER(content) LIKE ? THEN 1 ELSE 0 END)"
117
+ );
118
+ for (const kw of keywords) params.push(`%${kw}%`);
119
+ sql = `SELECT *, (${scoreParts.join(" + ")}) as match_score FROM nodes WHERE (${scoreParts.join(" + ")}) > 0`;
120
+ for (const kw of keywords) params.push(`%${kw}%`);
121
+ if (actor) {
122
+ sql += " AND actor = ?";
123
+ params.push(actor);
124
+ }
125
+ if (since) {
126
+ sql += " AND created_at >= ?";
127
+ params.push(new Date(since).getTime());
128
+ }
129
+ sql += " ORDER BY match_score DESC, created_at DESC LIMIT ?";
130
+ params.push(limit);
131
+ }
132
+ const nodes = db.prepare(sql).all(...params);
133
+ const results = nodes.map((n) => ({
134
+ id: n.id.slice(0, 8),
135
+ type: n.type,
136
+ content: n.content.slice(0, 200),
137
+ actor: n.actor,
138
+ confidence: n.confidence,
139
+ created: new Date(n.created_at).toISOString()
140
+ }));
141
+ logger.info("Provenant search", { query, resultCount: results.length });
142
+ return {
143
+ content: [
144
+ {
145
+ type: "text",
146
+ text: results.length === 0 ? `No decisions found for: "${query}"` : results.map(
147
+ (r) => `[${r.id}] (${r.confidence.toFixed(2)}) ${r.content}${r.actor ? ` \u2014 ${r.actor}` : ""}`
148
+ ).join("\n\n")
149
+ }
150
+ ],
151
+ metadata: { query, results }
152
+ };
153
+ } finally {
154
+ db.close();
155
+ }
156
+ } catch (error) {
157
+ logger.error(
158
+ "Provenant search error",
159
+ error instanceof Error ? error : new Error(String(error))
160
+ );
161
+ throw error;
162
+ }
163
+ }
164
+ async handleLog(args) {
165
+ try {
166
+ const { content, actor, reasoning } = args;
167
+ if (!content) throw new Error("content is required");
168
+ const db = this.openDb();
169
+ try {
170
+ const now = Date.now();
171
+ const hash = createHash("sha256").update(content).digest("hex");
172
+ const externalId = `manual-${now}`;
173
+ let confidence = 0.6;
174
+ if (reasoning) confidence += 0.15;
175
+ if (actor) confidence += 0.1;
176
+ const sourceId = randomUUID();
177
+ db.prepare(
178
+ "INSERT INTO sources (id, system, external_id, raw_payload, hash, fetched_at) VALUES (?, ?, ?, ?, ?, ?)"
179
+ ).run(
180
+ sourceId,
181
+ "manual",
182
+ externalId,
183
+ JSON.stringify({ content, actor, reasoning }),
184
+ hash,
185
+ now
186
+ );
187
+ const nodeId = randomUUID();
188
+ db.prepare(
189
+ "INSERT INTO nodes (id, type, content, embedding, actor, confidence, version, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
190
+ ).run(
191
+ nodeId,
192
+ "decision",
193
+ content,
194
+ null,
195
+ actor ?? null,
196
+ confidence,
197
+ 1,
198
+ now,
199
+ now
200
+ );
201
+ db.prepare(
202
+ "INSERT INTO source_edges (id, node_id, source_id, system, external_id) VALUES (?, ?, ?, ?, ?)"
203
+ ).run(randomUUID(), nodeId, sourceId, "manual", externalId);
204
+ logger.info("Provenant decision logged", { nodeId, confidence });
205
+ return {
206
+ content: [
207
+ {
208
+ type: "text",
209
+ text: `Logged decision: ${nodeId.slice(0, 8)} (confidence: ${confidence.toFixed(2)})`
210
+ }
211
+ ],
212
+ metadata: { nodeId, confidence }
213
+ };
214
+ } finally {
215
+ db.close();
216
+ }
217
+ } catch (error) {
218
+ logger.error(
219
+ "Provenant log error",
220
+ error instanceof Error ? error : new Error(String(error))
221
+ );
222
+ throw error;
223
+ }
224
+ }
225
+ async handleStatus(_args) {
226
+ try {
227
+ if (!existsSync(this.dbPath)) {
228
+ return {
229
+ content: [
230
+ {
231
+ type: "text",
232
+ text: "No Provenant database found. Use provenant_log to create one."
233
+ }
234
+ ]
235
+ };
236
+ }
237
+ const db = this.openDb();
238
+ try {
239
+ const count = (sql) => db.prepare(sql).get().c;
240
+ const s = {
241
+ nodeCount: count("SELECT COUNT(*) as c FROM nodes"),
242
+ edgeCount: count("SELECT COUNT(*) as c FROM edges"),
243
+ pendingQueue: count(
244
+ "SELECT COUNT(*) as c FROM review_queue WHERE resolved_at IS NULL"
245
+ ),
246
+ unresolvedContradictions: count(
247
+ "SELECT COUNT(*) as c FROM contradictions WHERE status = 'pending'"
248
+ ),
249
+ unresolvedStaleFlags: count(
250
+ "SELECT COUNT(*) as c FROM stale_flags WHERE resolved_at IS NULL"
251
+ ),
252
+ unresolvedRejections: count(
253
+ "SELECT COUNT(*) as c FROM rejection_log WHERE reasoning_resolved = 0"
254
+ )
255
+ };
256
+ return {
257
+ content: [
258
+ {
259
+ type: "text",
260
+ text: `Nodes: ${s.nodeCount}
261
+ Edges: ${s.edgeCount}
262
+ Review queue: ${s.pendingQueue}
263
+ Contradictions: ${s.unresolvedContradictions}
264
+ Stale flags: ${s.unresolvedStaleFlags}
265
+ Rejections needing reasoning: ${s.unresolvedRejections}`
266
+ }
267
+ ],
268
+ metadata: s
269
+ };
270
+ } finally {
271
+ db.close();
272
+ }
273
+ } catch (error) {
274
+ logger.error(
275
+ "Provenant status error",
276
+ error instanceof Error ? error : new Error(String(error))
277
+ );
278
+ throw error;
279
+ }
280
+ }
281
+ async handleContradictions(_args) {
282
+ try {
283
+ if (!existsSync(this.dbPath)) {
284
+ return { content: [{ type: "text", text: "No Provenant database." }] };
285
+ }
286
+ const db = this.openDb();
287
+ try {
288
+ const contras = db.prepare("SELECT * FROM contradictions WHERE status = 'pending'").all();
289
+ if (contras.length === 0) {
290
+ return {
291
+ content: [{ type: "text", text: "No open contradictions." }]
292
+ };
293
+ }
294
+ const lines = contras.map((c) => {
295
+ const a = db.prepare("SELECT content FROM nodes WHERE id = ?").get(c.node_a);
296
+ const b = db.prepare("SELECT content FROM nodes WHERE id = ?").get(c.node_b);
297
+ return `${c.node_a.slice(0, 8)} \u2194 ${c.node_b.slice(0, 8)} (score: ${c.conflict_score.toFixed(2)})
298
+ A: ${a?.content?.slice(0, 100) ?? "?"}
299
+ B: ${b?.content?.slice(0, 100) ?? "?"}`;
300
+ });
301
+ return {
302
+ content: [
303
+ {
304
+ type: "text",
305
+ text: `${contras.length} open contradictions:
306
+
307
+ ${lines.join("\n\n")}`
308
+ }
309
+ ],
310
+ metadata: { count: contras.length }
311
+ };
312
+ } finally {
313
+ db.close();
314
+ }
315
+ } catch (error) {
316
+ logger.error(
317
+ "Provenant contradictions error",
318
+ error instanceof Error ? error : new Error(String(error))
319
+ );
320
+ throw error;
321
+ }
322
+ }
323
+ async handleResolve(args) {
324
+ try {
325
+ const { node_a, node_b, winner, dismiss } = args;
326
+ if (!node_a || !node_b) throw new Error("node_a and node_b are required");
327
+ if (!winner && !dismiss) throw new Error("Specify winner or dismiss");
328
+ const db = this.openDb();
329
+ try {
330
+ const contras = db.prepare("SELECT * FROM contradictions WHERE status = 'pending'").all();
331
+ const contradiction = contras.find(
332
+ (c) => c.node_a.startsWith(node_a) && c.node_b.startsWith(node_b) || c.node_a.startsWith(node_b) && c.node_b.startsWith(node_a)
333
+ );
334
+ if (!contradiction) {
335
+ throw new Error(
336
+ `No pending contradiction between ${node_a} and ${node_b}`
337
+ );
338
+ }
339
+ if (dismiss) {
340
+ db.prepare(
341
+ "UPDATE contradictions SET status = ?, resolved_by = ?, resolved_at = ? WHERE id = ?"
342
+ ).run("dismissed", "human", Date.now(), contradiction.id);
343
+ return {
344
+ content: [
345
+ {
346
+ type: "text",
347
+ text: `Dismissed contradiction ${contradiction.id.slice(0, 8)}`
348
+ }
349
+ ]
350
+ };
351
+ }
352
+ const winnerNode = [contradiction.node_a, contradiction.node_b].find(
353
+ (id) => id.startsWith(winner)
354
+ );
355
+ if (!winnerNode) {
356
+ throw new Error(
357
+ `Winner must match one of: ${contradiction.node_a.slice(0, 8)}, ${contradiction.node_b.slice(0, 8)}`
358
+ );
359
+ }
360
+ const loserNode = winnerNode === contradiction.node_a ? contradiction.node_b : contradiction.node_a;
361
+ db.prepare(
362
+ "UPDATE contradictions SET status = ?, resolved_by = ?, resolved_at = ? WHERE id = ?"
363
+ ).run("resolved", winnerNode, Date.now(), contradiction.id);
364
+ db.prepare(
365
+ "INSERT INTO edges (id, from_node, to_node, rel_type, confidence, version, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
366
+ ).run(
367
+ randomUUID(),
368
+ winnerNode,
369
+ loserNode,
370
+ "supersedes",
371
+ 1,
372
+ 1,
373
+ Date.now()
374
+ );
375
+ return {
376
+ content: [
377
+ {
378
+ type: "text",
379
+ text: `Resolved: ${winnerNode.slice(0, 8)} supersedes ${loserNode.slice(0, 8)}`
380
+ }
381
+ ],
382
+ metadata: { winner: winnerNode, loser: loserNode }
383
+ };
384
+ } finally {
385
+ db.close();
386
+ }
387
+ } catch (error) {
388
+ logger.error(
389
+ "Provenant resolve error",
390
+ error instanceof Error ? error : new Error(String(error))
391
+ );
392
+ throw error;
393
+ }
394
+ }
395
+ }
396
+ export {
397
+ ProvenantHandlers
398
+ };
@@ -1353,6 +1353,15 @@ ${summary}...`, 0.8);
1353
1353
  case "linear_status":
1354
1354
  result = await this.handleLinearStatus(args);
1355
1355
  break;
1356
+ case "linear_create_comment":
1357
+ result = await this.handleLinearCreateComment(args);
1358
+ break;
1359
+ case "linear_update_comment":
1360
+ result = await this.handleLinearUpdateComment(args);
1361
+ break;
1362
+ case "linear_list_comments":
1363
+ result = await this.handleLinearListComments(args);
1364
+ break;
1356
1365
  case "get_traces":
1357
1366
  result = await this.handleGetTraces(args);
1358
1367
  break;
@@ -2665,6 +2674,114 @@ Tokens: Valid`
2665
2674
  };
2666
2675
  }
2667
2676
  }
2677
+ async getLinearClient() {
2678
+ const { LinearClient } = await import("../linear/client.js");
2679
+ const tokens = this.linearAuthManager.loadTokens();
2680
+ if (!tokens) {
2681
+ throw new Error("Linear not configured. Run: stackmemory linear setup");
2682
+ }
2683
+ return new LinearClient({
2684
+ apiKey: tokens.accessToken,
2685
+ useBearer: true,
2686
+ onUnauthorized: async () => {
2687
+ const refreshed = await this.linearAuthManager.refreshAccessToken();
2688
+ return refreshed.accessToken;
2689
+ }
2690
+ });
2691
+ }
2692
+ async handleLinearCreateComment(args) {
2693
+ const { issue_id, body } = args;
2694
+ if (!issue_id || !body) {
2695
+ return {
2696
+ content: [
2697
+ { type: "text", text: "Error: issue_id and body are required" }
2698
+ ]
2699
+ };
2700
+ }
2701
+ try {
2702
+ const client = await this.getLinearClient();
2703
+ const comment = await client.createComment(issue_id, body);
2704
+ return {
2705
+ content: [
2706
+ {
2707
+ type: "text",
2708
+ text: `Comment created on ${issue_id}
2709
+ ID: ${comment.id}
2710
+ Preview: ${body.slice(0, 100)}${body.length > 100 ? "..." : ""}`
2711
+ }
2712
+ ],
2713
+ metadata: { id: comment.id, issueId: issue_id }
2714
+ };
2715
+ } catch (error) {
2716
+ const msg = error instanceof Error ? error.message : String(error);
2717
+ return {
2718
+ content: [{ type: "text", text: `Error creating comment: ${msg}` }]
2719
+ };
2720
+ }
2721
+ }
2722
+ async handleLinearUpdateComment(args) {
2723
+ const { comment_id, body } = args;
2724
+ if (!comment_id || !body) {
2725
+ return {
2726
+ content: [
2727
+ { type: "text", text: "Error: comment_id and body are required" }
2728
+ ]
2729
+ };
2730
+ }
2731
+ try {
2732
+ const client = await this.getLinearClient();
2733
+ const comment = await client.updateComment(comment_id, body);
2734
+ return {
2735
+ content: [
2736
+ {
2737
+ type: "text",
2738
+ text: `Comment ${comment_id} updated
2739
+ Preview: ${body.slice(0, 100)}${body.length > 100 ? "..." : ""}`
2740
+ }
2741
+ ],
2742
+ metadata: { id: comment.id, updatedAt: comment.updatedAt }
2743
+ };
2744
+ } catch (error) {
2745
+ const msg = error instanceof Error ? error.message : String(error);
2746
+ return {
2747
+ content: [{ type: "text", text: `Error updating comment: ${msg}` }]
2748
+ };
2749
+ }
2750
+ }
2751
+ async handleLinearListComments(args) {
2752
+ const { issue_id } = args;
2753
+ if (!issue_id) {
2754
+ return {
2755
+ content: [{ type: "text", text: "Error: issue_id is required" }]
2756
+ };
2757
+ }
2758
+ try {
2759
+ const client = await this.getLinearClient();
2760
+ const comments = await client.getComments(issue_id);
2761
+ const lines = comments.map(
2762
+ (c) => `${c.id.slice(0, 8)} | ${c.user?.name ?? "unknown"} | ${new Date(c.createdAt).toISOString().slice(0, 10)} | ${c.body.slice(0, 60).replace(/\n/g, " ")}${c.body.length > 60 ? "..." : ""}`
2763
+ );
2764
+ const text = comments.length > 0 ? `${comments.length} comments on ${issue_id}:
2765
+ ${lines.join("\n")}` : `No comments on ${issue_id}`;
2766
+ return {
2767
+ content: [{ type: "text", text }],
2768
+ metadata: {
2769
+ count: comments.length,
2770
+ comments: comments.map((c) => ({
2771
+ id: c.id,
2772
+ author: c.user?.name,
2773
+ createdAt: c.createdAt,
2774
+ bodyPreview: c.body.slice(0, 200)
2775
+ }))
2776
+ }
2777
+ };
2778
+ } catch (error) {
2779
+ const msg = error instanceof Error ? error.message : String(error);
2780
+ return {
2781
+ content: [{ type: "text", text: `Error listing comments: ${msg}` }]
2782
+ };
2783
+ }
2784
+ }
2668
2785
  async handleGetTraces(args) {
2669
2786
  const { type, minScore, limit = 20 } = args;
2670
2787
  this.traceDetector.flush();
@@ -24,7 +24,8 @@ class MCPToolDefinitions {
24
24
  ...this.getTeamTools(),
25
25
  ...this.getCordTools(),
26
26
  ...this.getDigestTools(),
27
- ...this.getDesirePathTools()
27
+ ...this.getDesirePathTools(),
28
+ ...this.getProvenantTools()
28
29
  ];
29
30
  }
30
31
  /**
@@ -391,6 +392,56 @@ class MCPToolDefinitions {
391
392
  type: "object",
392
393
  properties: {}
393
394
  }
395
+ },
396
+ {
397
+ name: "linear_create_comment",
398
+ description: "Create a comment on a Linear issue (useful for workpad updates)",
399
+ inputSchema: {
400
+ type: "object",
401
+ properties: {
402
+ issue_id: {
403
+ type: "string",
404
+ description: "Linear issue ID or identifier (e.g. STA-123 or UUID)"
405
+ },
406
+ body: {
407
+ type: "string",
408
+ description: "Comment body (supports markdown)"
409
+ }
410
+ },
411
+ required: ["issue_id", "body"]
412
+ }
413
+ },
414
+ {
415
+ name: "linear_update_comment",
416
+ description: "Update an existing comment on a Linear issue",
417
+ inputSchema: {
418
+ type: "object",
419
+ properties: {
420
+ comment_id: {
421
+ type: "string",
422
+ description: "Comment ID to update"
423
+ },
424
+ body: {
425
+ type: "string",
426
+ description: "New comment body (supports markdown)"
427
+ }
428
+ },
429
+ required: ["comment_id", "body"]
430
+ }
431
+ },
432
+ {
433
+ name: "linear_list_comments",
434
+ description: "List comments on a Linear issue (useful for finding workpad)",
435
+ inputSchema: {
436
+ type: "object",
437
+ properties: {
438
+ issue_id: {
439
+ type: "string",
440
+ description: "Linear issue ID or identifier (e.g. STA-123 or UUID)"
441
+ }
442
+ },
443
+ required: ["issue_id"]
444
+ }
394
445
  }
395
446
  ];
396
447
  }
@@ -1421,6 +1472,104 @@ class MCPToolDefinitions {
1421
1472
  }
1422
1473
  ];
1423
1474
  }
1475
+ /**
1476
+ * Provenant decision graph tools
1477
+ */
1478
+ getProvenantTools() {
1479
+ return [
1480
+ {
1481
+ name: "provenant_search",
1482
+ description: "Search the decision graph for past decisions, patterns, and context by meaning",
1483
+ inputSchema: {
1484
+ type: "object",
1485
+ properties: {
1486
+ query: {
1487
+ type: "string",
1488
+ description: "Natural language search query"
1489
+ },
1490
+ actor: {
1491
+ type: "string",
1492
+ description: "Filter by decision maker"
1493
+ },
1494
+ since: {
1495
+ type: "string",
1496
+ description: "ISO date \u2014 only include decisions after this date"
1497
+ },
1498
+ limit: {
1499
+ type: "number",
1500
+ description: "Max results to return",
1501
+ default: 10
1502
+ }
1503
+ },
1504
+ required: ["query"]
1505
+ }
1506
+ },
1507
+ {
1508
+ name: "provenant_log",
1509
+ description: "Log a decision to the graph. Use when a product or technical decision is made during a session.",
1510
+ inputSchema: {
1511
+ type: "object",
1512
+ properties: {
1513
+ content: {
1514
+ type: "string",
1515
+ description: "The decision content"
1516
+ },
1517
+ actor: {
1518
+ type: "string",
1519
+ description: "Who made this decision"
1520
+ },
1521
+ reasoning: {
1522
+ type: "string",
1523
+ description: "Why this decision was made"
1524
+ }
1525
+ },
1526
+ required: ["content"]
1527
+ }
1528
+ },
1529
+ {
1530
+ name: "provenant_status",
1531
+ description: "Get decision graph overview: node count, edges, open contradictions, review queue depth",
1532
+ inputSchema: {
1533
+ type: "object",
1534
+ properties: {}
1535
+ }
1536
+ },
1537
+ {
1538
+ name: "provenant_contradictions",
1539
+ description: "List open contradictions in the decision graph \u2014 conflicting beliefs that need human resolution",
1540
+ inputSchema: {
1541
+ type: "object",
1542
+ properties: {}
1543
+ }
1544
+ },
1545
+ {
1546
+ name: "provenant_resolve",
1547
+ description: "Resolve a contradiction between two nodes by picking a winner or dismissing",
1548
+ inputSchema: {
1549
+ type: "object",
1550
+ properties: {
1551
+ node_a: {
1552
+ type: "string",
1553
+ description: "First node ID or prefix"
1554
+ },
1555
+ node_b: {
1556
+ type: "string",
1557
+ description: "Second node ID or prefix"
1558
+ },
1559
+ winner: {
1560
+ type: "string",
1561
+ description: "Winning node ID or prefix"
1562
+ },
1563
+ dismiss: {
1564
+ type: "boolean",
1565
+ description: "Dismiss as noise instead of resolving"
1566
+ }
1567
+ },
1568
+ required: ["node_a", "node_b"]
1569
+ }
1570
+ }
1571
+ ];
1572
+ }
1424
1573
  /**
1425
1574
  * Get tool definition by name
1426
1575
  */
@@ -1466,6 +1615,8 @@ class MCPToolDefinitions {
1466
1615
  return this.getDigestTools();
1467
1616
  case "desire_paths":
1468
1617
  return this.getDesirePathTools();
1618
+ case "provenant":
1619
+ return this.getProvenantTools();
1469
1620
  default:
1470
1621
  return [];
1471
1622
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackmemoryai/stackmemory",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Lossless, project-scoped memory for AI coding tools. Durable context across sessions with 56 MCP tools, FTS5 search, conductor orchestrator, loop/watch monitoring, snapshot capture, pre-flight overlap checks, Claude/Codex/OpenCode wrappers, Linear sync, and automatic hooks.",
5
5
  "engines": {
6
6
  "node": ">=20.0.0",