klypix-mcp 1.53.0 → 1.55.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.
package/README.md CHANGED
@@ -193,6 +193,41 @@ excludes the owning IDE/chat application's RAM, redacts command-line secrets, an
193
193
  brain or terminates a process. Multiple processes under one host are reported as parallel sessions,
194
194
  not called duplicates without an authoritative logical-session receipt.
195
195
 
196
+ ### Project Map: current structure beside project understanding
197
+
198
+ If the project contains a compatible NetworkX node-link `graph.json`, agents can ask for bounded
199
+ code-structure evidence and current brain context in one read-only call:
200
+
201
+ ```jsonc
202
+ project_map_context {
203
+ "question": "what owns refresh-token rotation?",
204
+ "graph_path": "graphify-out/graph.json"
205
+ }
206
+ ```
207
+
208
+ Use `compare_to` with another project-relative graph artifact to add exact total node/edge deltas
209
+ and additions/removals from the two bounded query neighborhoods. Both paths are confined to the
210
+ declared project root; unsafe source paths are withheld; large or unsupported artifacts are
211
+ rejected. When a returned brain card names an exact mapped source path, the structured response
212
+ also includes a review-only evidence-link proposal. It never promotes similarity into truth and
213
+ never writes graph facts or links into `brain.klypix`.
214
+
215
+ Graphify is the first compatible producer. KLYPIX reads artifacts that users generate separately;
216
+ it does not bundle, install, or run Graphify and does not imply a partnership. A compatible generic
217
+ `graph.json` works through the same provider-neutral boundary.
218
+
219
+ For a reproducible map artifact on every pull request and main-branch push, install the shipped
220
+ read-only workflow into a Git checkout:
221
+
222
+ ```bash
223
+ npx klypix-project-map setup-github /path/to/project
224
+ ```
225
+
226
+ The command refuses to overwrite an existing workflow unless `--force` is explicit. The installed
227
+ workflow has `contents: read`, pins every action by commit SHA, pins `graphifyy==0.9.33`, validates
228
+ the graph contract, and uploads `graphify-out/` as a 14-day build artifact. This is opt-in CI code:
229
+ the local MCP tool still never installs or launches Graphify.
230
+
196
231
  ---
197
232
 
198
233
  ## Supported hosts and their integration level
@@ -398,7 +433,7 @@ The MCP verbs below are what agents call. These are what **you** call:
398
433
  | `brain_message` | Session-to-session coordination notes (24h TTL, never written into the brain) |
399
434
  | `brain_sync` | Context Gateway: task capsule, active-task peers, exact-file overlap, one-time alerts, timing |
400
435
  | `brain_connect` | Find and draw related-but-unlinked cards |
401
- | `project_map_context` | Read-only, bounded code-graph evidence beside correction-aware brain context; Graphify artifacts are supported but never installed or run |
436
+ | `project_map_context` | Read-only, bounded code-graph evidence beside correction-aware brain context, with exact-path review proposals; Graphify artifacts are supported but never installed or run locally |
402
437
  | `canvas_view` | Returns the board as a structured render spec plus a text summary, and declares an MCP Apps (SEP-1865) UI resource |
403
438
  | `read_canvas` | A canvas as markdown (cards, connection graph, `[[links]]`, `#tags`) |
404
439
  | `search_canvases` | Search across canvases by name and content |
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import { discoverProjectGraph } from '../src/project-graph.mjs';
6
+
7
+ const here = path.dirname(fileURLToPath(import.meta.url));
8
+ const template = path.resolve(here, '..', 'examples', 'github', 'klypix-project-map.yml');
9
+ const args = process.argv.slice(2);
10
+ const command = args[0] || 'status';
11
+ const force = args.includes('--force');
12
+ const rootArg = args.find((value, index) => index > 0 && value !== '--force') || process.cwd();
13
+
14
+ function projectRoot(value) {
15
+ const root = fs.realpathSync(path.resolve(value));
16
+ if (!fs.statSync(root).isDirectory()) throw new Error('Project root is not a directory.');
17
+ return root;
18
+ }
19
+
20
+ function usage() {
21
+ console.log([
22
+ 'KLYPIX Project Map',
23
+ '',
24
+ ' klypix-project-map status [project]',
25
+ ' klypix-project-map setup-github [project] [--force]',
26
+ '',
27
+ 'setup-github installs a read-only, pinned GitHub Actions workflow. It never installs or runs a provider on your computer.',
28
+ ].join('\n'));
29
+ }
30
+
31
+ try {
32
+ if (command === '--help' || command === '-h' || command === 'help') {
33
+ usage();
34
+ process.exit(0);
35
+ }
36
+ const root = projectRoot(rootArg);
37
+ if (command === 'status') {
38
+ const graph = discoverProjectGraph({ project: root });
39
+ const workflow = path.join(root, '.github', 'workflows', 'klypix-project-map.yml');
40
+ console.log(`Project: ${root}`);
41
+ console.log(`Graph artifact: ${graph.status === 'ready' ? `ready (${graph.artifact.graphJson})` : `missing (${graph.artifact.graphJson})`}`);
42
+ console.log(`GitHub workflow: ${fs.existsSync(workflow) ? 'installed' : 'not installed'}`);
43
+ process.exit(0);
44
+ }
45
+ if (command !== 'setup-github') {
46
+ usage();
47
+ process.exit(1);
48
+ }
49
+ if (!fs.existsSync(path.join(root, '.git'))) throw new Error('Run setup-github inside a Git checkout.');
50
+ const destination = path.join(root, '.github', 'workflows', 'klypix-project-map.yml');
51
+ const source = fs.readFileSync(template, 'utf8');
52
+ if (fs.existsSync(destination)) {
53
+ const current = fs.readFileSync(destination, 'utf8');
54
+ if (current === source) {
55
+ console.log(`Project Map workflow is already current: ${destination}`);
56
+ process.exit(0);
57
+ }
58
+ if (!force) throw new Error(`Workflow already exists with different contents: ${destination}\nReview it, then rerun with --force if replacement is intended.`);
59
+ }
60
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
61
+ fs.writeFileSync(destination, source, { encoding: 'utf8', flag: 'w' });
62
+ console.log(`Installed Project Map workflow: ${destination}`);
63
+ console.log('Commit the workflow when you are ready. Pull requests and main-branch pushes will produce a read-only Project Map artifact.');
64
+ } catch (error) {
65
+ console.error(`Project Map: ${error?.message || error}`);
66
+ process.exit(1);
67
+ }
@@ -30,7 +30,7 @@ import {
30
30
  opBrainInsights, opBrainConnect, opBrainReconcile, opBrainGarden, opCreateCanvas, opAddToCanvas, opBrainNote, opBrainMessage, opBrainAsk, opBrainChallenge, opCanvasView, opBrainLens,
31
31
  opBrainTaskContext,
32
32
  } from '../src/klypix-core.mjs';
33
- import { projectGraphContextMarkdown, queryProjectGraph } from '../src/project-graph.mjs';
33
+ import { compareProjectGraphResults, projectGraphContextMarkdown, queryProjectGraph, suggestProjectGraphBrainLinks } from '../src/project-graph.mjs';
34
34
  import { auditProject, compactAgentsBrief, linkProject, mcpServerEntry } from '../src/agent-rules.mjs';
35
35
  import { createMcpPresence, KLYPIX_MCP_INSTRUCTIONS } from '../src/mcp-presence.mjs';
36
36
  import {
@@ -256,12 +256,13 @@ server.registerTool('project_map_context', {
256
256
  question: z.string().min(1).describe('Question or code concept to ground in both current structure and project memory.'),
257
257
  project: z.string().optional().describe('Absolute project root. Defaults to this MCP connection\'s configured project/vault.'),
258
258
  graph_path: z.string().optional().describe('Optional project-relative graph JSON path. Defaults to graphify-out/graph.json and may not escape the project root.'),
259
+ compare_to: z.string().optional().describe('Optional project-relative prior graph JSON path. Adds exact total deltas plus bounded query-neighborhood changes; it may not escape the project root.'),
259
260
  depth: z.number().optional().describe('Relationship hops around the best code matches (0-3, default 1).'),
260
261
  max_nodes: z.number().optional().describe('Maximum code nodes returned (default 60, capped 200).'),
261
262
  k: z.number().optional().describe('Maximum brain cards returned (default 8; fast mode caps 8, deep history caps 20).'),
262
263
  deep_history: z.boolean().optional().describe('false (default) uses the sub-second lexical-fast correction-aware path; true opts into whole-brain semantic/history retrieval, which may cold-load the local model.'),
263
264
  },
264
- }, async ({ question, project, graph_path, depth, max_nodes, k, deep_history }) => {
265
+ }, async ({ question, project, graph_path, compare_to, depth, max_nodes, k, deep_history }) => {
265
266
  let graphResult;
266
267
  let graphMarkdown;
267
268
  try {
@@ -272,9 +273,19 @@ server.registerTool('project_map_context', {
272
273
  depth,
273
274
  maxNodes: max_nodes,
274
275
  });
276
+ if (compare_to) {
277
+ const previousGraphResult = queryProjectGraph({
278
+ project: project || mcpPresence.vault,
279
+ graphPath: compare_to,
280
+ query: question,
281
+ depth,
282
+ maxNodes: max_nodes,
283
+ });
284
+ graphResult.change = compareProjectGraphResults(graphResult, previousGraphResult);
285
+ }
275
286
  graphMarkdown = projectGraphContextMarkdown(graphResult);
276
287
  } catch (error) {
277
- graphResult = { schemaVersion: 1, status: 'invalid', error: error?.message || String(error) };
288
+ graphResult = { schemaVersion: 2, status: 'invalid', error: error?.message || String(error) };
278
289
  graphMarkdown = `# Project Map\n\nThe generated graph could not be read safely: ${graphResult.error}`;
279
290
  }
280
291
  const graphFiles = Array.isArray(graphResult?.nodes)
@@ -298,10 +309,14 @@ server.registerTool('project_map_context', {
298
309
  .filter(block => block.kind === 'text')
299
310
  .map(block => block.text)
300
311
  .join('\n\n');
312
+ const evidenceLinkProposals = suggestProjectGraphBrainLinks(graphResult, brainResult.context);
313
+ const proposalMarkdown = evidenceLinkProposals.length
314
+ ? `\n\n## Exact-path evidence link proposals\n\n${evidenceLinkProposals.slice(0, 12).map(proposal => `- Review brain card \`${proposal.brainCardId}\` beside **${proposal.nodeLabel}** in \`${proposal.sourceFile}\`.`).join('\n')}\n\n_These are review proposals based only on an exact source-path mention. Nothing was written to the brain._`
315
+ : '';
301
316
  return toContent({
302
- blocks: [{ kind: 'text', text: `${graphMarkdown}\n\n---\n\n# Project Brain - ${deep_history ? 'decisions and history' : 'current decisions and corrections'}\n\n${brainMarkdown}` }],
317
+ blocks: [{ kind: 'text', text: `${graphMarkdown}\n\n---\n\n# Project Brain - ${deep_history ? 'decisions and history' : 'current decisions and corrections'}\n\n${brainMarkdown}${proposalMarkdown}` }],
303
318
  isError: brainResult.isError,
304
- structured: { projectGraph: graphResult, brainContext: brainResult.context || null, deepHistory: deep_history === true },
319
+ structured: { projectGraph: graphResult, brainContext: brainResult.context || null, evidenceLinkProposals, deepHistory: deep_history === true },
305
320
  });
306
321
  });
307
322
 
@@ -0,0 +1,45 @@
1
+ name: KLYPIX Project Map
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ pull_request:
6
+ push:
7
+ branches: [main]
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ concurrency:
13
+ group: klypix-project-map-${{ github.workflow }}-${{ github.ref }}
14
+ cancel-in-progress: true
15
+
16
+ env:
17
+ GRAPHIFY_VERSION: "0.9.33"
18
+
19
+ jobs:
20
+ project-map:
21
+ name: Generate bounded map artifact
22
+ runs-on: ubuntu-latest
23
+ timeout-minutes: 20
24
+ steps:
25
+ - name: Check out repository
26
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
27
+
28
+ - name: Set up uv
29
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
30
+ with:
31
+ enable-cache: true
32
+
33
+ - name: Generate code-only graph
34
+ run: uvx --from "graphifyy==${GRAPHIFY_VERSION}" graphify extract . --code-only
35
+
36
+ - name: Validate the generated contract
37
+ run: node -e "const fs=require('fs'); const p='graphify-out/graph.json'; const g=JSON.parse(fs.readFileSync(p,'utf8')); if(!Array.isArray(g.nodes)||!(Array.isArray(g.edges)||Array.isArray(g.links))) throw Error('unsupported graph contract'); console.log('Project Map:',g.nodes.length,'nodes');"
38
+
39
+ - name: Upload Project Map artifact
40
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
41
+ with:
42
+ name: klypix-project-map-${{ github.sha }}
43
+ path: graphify-out/
44
+ if-no-files-found: error
45
+ retention-days: 14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.53.0",
3
+ "version": "1.55.0",
4
4
  "description": "Shared project brain and MCP coordination server for multi-agent coding.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -43,7 +43,8 @@
43
43
  "klypix-write": "bin/klypix-write.mjs",
44
44
  "klypix-append": "bin/klypix-append.mjs",
45
45
  "klypix-install": "bin/klypix-install.mjs",
46
- "klypix-uninstall": "bin/klypix-uninstall.mjs"
46
+ "klypix-uninstall": "bin/klypix-uninstall.mjs",
47
+ "klypix-project-map": "bin/klypix-project-map.mjs"
47
48
  },
48
49
  "main": "index.mjs",
49
50
  "exports": {
@@ -75,7 +76,7 @@
75
76
  },
76
77
  "scripts": {
77
78
  "test:project-graph": "node test/project-graph.mjs",
78
- "test": "node test/project-graph.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/agent-presence.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/context-gateway.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/a2a-smoke.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
79
+ "test": "node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/agent-presence.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/context-gateway.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/a2a-smoke.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
79
80
  "test:memory": "node test/memory-runtime.mjs",
80
81
  "test:memory:soak": "node --expose-gc test/memory-soak.mjs",
81
82
  "runtime": "node bin/klypix-runtime.mjs"
@@ -7,7 +7,7 @@
7
7
  import fs from 'fs';
8
8
  import path from 'path';
9
9
 
10
- export const PROJECT_GRAPH_SCHEMA_VERSION = 1;
10
+ export const PROJECT_GRAPH_SCHEMA_VERSION = 2;
11
11
  export const DEFAULT_PROJECT_GRAPH = 'graphify-out/graph.json';
12
12
  export const DEFAULT_PROJECT_GRAPH_HTML = 'graphify-out/graph.html';
13
13
  export const DEFAULT_PROJECT_GRAPH_REPORT = 'graphify-out/GRAPH_REPORT.md';
@@ -148,6 +148,11 @@ function normalizeGraph(raw, projectRoot, artifact) {
148
148
  return {
149
149
  schemaVersion: PROJECT_GRAPH_SCHEMA_VERSION,
150
150
  provider: 'graphify',
151
+ format: {
152
+ family: 'networkx-node-link',
153
+ sourceSchemaVersion: shortString(raw.schema_version ?? raw.schemaVersion, 80) || null,
154
+ providerVersion: shortString(raw?.metadata?.version ?? raw?.meta?.version ?? raw.generator_version, 80) || null,
155
+ },
151
156
  artifact,
152
157
  nodes,
153
158
  edges,
@@ -294,6 +299,7 @@ export function queryProjectGraph({ project, graphPath, query = '', depth = 1, m
294
299
  return {
295
300
  schemaVersion: PROJECT_GRAPH_SCHEMA_VERSION,
296
301
  provider: graph.provider,
302
+ format: graph.format,
297
303
  status: 'ready',
298
304
  query: String(query || ''),
299
305
  depth: hopLimit,
@@ -311,23 +317,93 @@ export function queryProjectGraph({ project, graphPath, query = '', depth = 1, m
311
317
  };
312
318
  }
313
319
 
320
+ function edgeIdentity(edge) {
321
+ return `${edge.source}\u0000${edge.relation}\u0000${edge.target}\u0000${edge.confidence}`;
322
+ }
323
+
324
+ /**
325
+ * Compare two already-bounded query results. Artifact totals are exact; named
326
+ * node/edge changes describe only the returned neighborhoods and say so.
327
+ */
328
+ export function compareProjectGraphResults(current, previous) {
329
+ if (!current || !previous || current.status !== 'ready' || previous.status !== 'ready') return null;
330
+ const oldNodes = new Map(previous.nodes.map(node => [node.id, node]));
331
+ const newNodes = new Map(current.nodes.map(node => [node.id, node]));
332
+ const oldEdges = new Map(previous.edges.map(edge => [edgeIdentity(edge), edge]));
333
+ const newEdges = new Map(current.edges.map(edge => [edgeIdentity(edge), edge]));
334
+ return {
335
+ graphNodesDelta: current.counts.graphNodes - previous.counts.graphNodes,
336
+ graphEdgesDelta: current.counts.graphEdges - previous.counts.graphEdges,
337
+ addedNodes: current.nodes.filter(node => !oldNodes.has(node.id)).slice(0, 80),
338
+ removedNodes: previous.nodes.filter(node => !newNodes.has(node.id)).slice(0, 80),
339
+ addedEdges: current.edges.filter(edge => !oldEdges.has(edgeIdentity(edge))).slice(0, 160),
340
+ removedEdges: previous.edges.filter(edge => !newEdges.has(edgeIdentity(edge))).slice(0, 160),
341
+ coverage: 'bounded-query-neighborhoods',
342
+ comparedArtifact: previous.artifact?.graphJson || null,
343
+ };
344
+ }
345
+
346
+ function textContainsExactSourcePath(text, sourceFile) {
347
+ const haystack = String(text || '').replace(/\\/g, '/').toLocaleLowerCase('en-US');
348
+ const needle = String(sourceFile || '').replace(/\\/g, '/').toLocaleLowerCase('en-US');
349
+ if (!needle || !haystack.includes(needle)) return false;
350
+ const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
351
+ return new RegExp(`(^|[\\s\`'"([{<])${escaped}($|[\\s\`'"\\])}>.,;:#])`, 'i').test(haystack);
352
+ }
353
+
354
+ /**
355
+ * Deterministic proposals only. These are never persisted and never imply
356
+ * causality: an exact project-relative source path must appear in the brain
357
+ * card text before KLYPIX will suggest reviewing the pair.
358
+ */
359
+ export function suggestProjectGraphBrainLinks(graphResult, brainContext, limit = 40) {
360
+ if (graphResult?.status !== 'ready' || !Array.isArray(graphResult.nodes)) return [];
361
+ const hits = Array.isArray(brainContext?.hits) ? brainContext.hits : [];
362
+ const proposals = [];
363
+ const seen = new Set();
364
+ for (const node of graphResult.nodes) {
365
+ if (!node?.sourceFile) continue;
366
+ for (const hit of hits) {
367
+ const texts = [hit?.text, hit?.correctedBy].filter(Boolean);
368
+ if (!texts.some(text => textContainsExactSourcePath(text, node.sourceFile))) continue;
369
+ const key = `${hit.id}\u0000${node.id}\u0000${node.sourceFile}`;
370
+ if (seen.has(key)) continue;
371
+ seen.add(key);
372
+ proposals.push({
373
+ brainCardId: hit.id,
374
+ brainArea: hit.area || 'Notes',
375
+ nodeId: node.id,
376
+ nodeLabel: node.label || node.id,
377
+ sourceFile: node.sourceFile,
378
+ basis: 'exact-source-path',
379
+ status: 'review-proposal',
380
+ });
381
+ if (proposals.length >= Math.max(1, Math.min(100, Number(limit) || 40))) return proposals;
382
+ }
383
+ }
384
+ return proposals;
385
+ }
386
+
314
387
  export function projectGraphContextMarkdown(result) {
315
388
  if (result.status === 'missing') {
316
389
  return `# Project Map\n\nNo supported project graph was found at \`${result.artifact.graphJson}\`. KLYPIX did not install or run a provider. Generate the artifact with Graphify, then retry.`;
317
390
  }
318
391
  const lines = result.nodes.map(node => {
319
- const where = node.sourceFile ? ` — \`${node.sourceFile}${node.sourceLocation ? `:${node.sourceLocation}` : ''}\`` : '';
320
- return `- **${node.label || node.id}** (${node.kind || 'symbol'})${where} · id \`${node.id}\``;
392
+ const where = node.sourceFile ? ` in \`${node.sourceFile}${node.sourceLocation ? `:${node.sourceLocation}` : ''}\`` : '';
393
+ return `- **${node.label || node.id}** (${node.kind || 'symbol'})${where}; id \`${node.id}\``;
321
394
  });
322
395
  const edgeLines = result.edges.slice(0, 80).map(edge =>
323
- `- \`${edge.source}\` —${edge.relation}→ \`${edge.target}\` [${edge.confidence}]`);
396
+ `- \`${edge.source}\` --${edge.relation}--> \`${edge.target}\` [${edge.confidence}]`);
324
397
  const warnings = [];
325
398
  if (result.diagnostics?.unsafeSourcePaths) warnings.push(`${result.diagnostics.unsafeSourcePaths} unsafe/out-of-root source path(s) were withheld`);
326
399
  if (result.diagnostics?.danglingEdges) warnings.push(`${result.diagnostics.danglingEdges} dangling edge(s) were ignored`);
327
400
  return [
328
- '# Project Map — code evidence',
329
- `Provider artifact: \`${result.artifact.graphJson}\` · ${result.counts.graphNodes.toLocaleString()} nodes · ${result.counts.graphEdges.toLocaleString()} edges · query \`${result.query || '(overview)'}\`.`,
401
+ '# Project Map: code evidence',
402
+ `Provider artifact: \`${result.artifact.graphJson}\`; ${result.counts.graphNodes.toLocaleString()} nodes; ${result.counts.graphEdges.toLocaleString()} edges; query \`${result.query || '(overview)'}\`.`,
330
403
  warnings.length ? `Safety note: ${warnings.join('; ')}.` : '',
404
+ result.change ? `## Change from \`${result.change.comparedArtifact || 'comparison artifact'}\`\nExact total deltas: ${result.change.graphNodesDelta >= 0 ? '+' : ''}${result.change.graphNodesDelta} nodes; ${result.change.graphEdgesDelta >= 0 ? '+' : ''}${result.change.graphEdgesDelta} edges. Named additions and removals are limited to the two bounded query neighborhoods.` : '',
405
+ result.change?.addedNodes?.length ? `Added in bounded result: ${result.change.addedNodes.slice(0, 20).map(node => `\`${node.label || node.id}\``).join(', ')}.` : '',
406
+ result.change?.removedNodes?.length ? `Removed from bounded result: ${result.change.removedNodes.slice(0, 20).map(node => `\`${node.label || node.id}\``).join(', ')}.` : '',
331
407
  '## Relevant code nodes',
332
408
  lines.join('\n') || '_No matching code nodes._',
333
409
  edgeLines.length ? '## Relationships\n' + edgeLines.join('\n') : '',