backlog-mcp 0.28.0 → 0.29.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 (76) hide show
  1. package/README.md +38 -6
  2. package/dist/index.d.mts +1 -1
  3. package/dist/index.mjs +1 -1
  4. package/dist/operations/index.d.mts +6 -0
  5. package/dist/operations/index.mjs +7 -0
  6. package/dist/operations/logger.d.mts +23 -0
  7. package/dist/operations/logger.mjs +53 -0
  8. package/dist/operations/logger.mjs.map +1 -0
  9. package/dist/operations/middleware.d.mts +11 -0
  10. package/dist/operations/middleware.mjs +23 -0
  11. package/dist/operations/middleware.mjs.map +1 -0
  12. package/dist/operations/resource-id.d.mts +12 -0
  13. package/dist/operations/resource-id.mjs +24 -0
  14. package/dist/operations/resource-id.mjs.map +1 -0
  15. package/dist/operations/storage.d.mts +26 -0
  16. package/dist/operations/storage.mjs +64 -0
  17. package/dist/operations/storage.mjs.map +1 -0
  18. package/dist/operations/types.d.mts +27 -0
  19. package/dist/operations/types.mjs +11 -0
  20. package/dist/operations/types.mjs.map +1 -0
  21. package/dist/resources/manager.d.mts +7 -0
  22. package/dist/resources/manager.mjs +38 -2
  23. package/dist/resources/manager.mjs.map +1 -1
  24. package/dist/search/embedding-service.d.mts +27 -0
  25. package/dist/search/embedding-service.mjs +47 -0
  26. package/dist/search/embedding-service.mjs.map +1 -0
  27. package/dist/search/index.d.mts +4 -0
  28. package/dist/search/index.mjs +4 -0
  29. package/dist/search/orama-search-service.d.mts +76 -0
  30. package/dist/search/orama-search-service.mjs +526 -0
  31. package/dist/search/orama-search-service.mjs.map +1 -0
  32. package/dist/search/types.d.mts +81 -0
  33. package/dist/search/types.mjs +1 -0
  34. package/dist/server/mcp-handler.mjs +4 -2
  35. package/dist/server/mcp-handler.mjs.map +1 -1
  36. package/dist/server/viewer-routes.mjs +29 -4
  37. package/dist/server/viewer-routes.mjs.map +1 -1
  38. package/dist/storage/backlog-service.d.mts +49 -0
  39. package/dist/storage/backlog-service.mjs +99 -0
  40. package/dist/storage/backlog-service.mjs.map +1 -0
  41. package/dist/storage/{backlog.d.mts → task-storage.d.mts} +8 -8
  42. package/dist/storage/{backlog.mjs → task-storage.mjs} +8 -11
  43. package/dist/storage/task-storage.mjs.map +1 -0
  44. package/dist/tools/backlog-create.mjs +1 -1
  45. package/dist/tools/backlog-create.mjs.map +1 -1
  46. package/dist/tools/backlog-delete.mjs +1 -1
  47. package/dist/tools/backlog-delete.mjs.map +1 -1
  48. package/dist/tools/backlog-get.mjs +1 -1
  49. package/dist/tools/backlog-get.mjs.map +1 -1
  50. package/dist/tools/backlog-list.mjs +6 -4
  51. package/dist/tools/backlog-list.mjs.map +1 -1
  52. package/dist/tools/backlog-update.mjs +2 -3
  53. package/dist/tools/backlog-update.mjs.map +1 -1
  54. package/dist/utils/logger.mjs +1 -1
  55. package/dist/utils/paths.mjs +1 -1
  56. package/dist/viewer/activity-COWPF622.svg +4 -0
  57. package/dist/viewer/index.html +12 -1
  58. package/dist/viewer/main.css +1292 -39
  59. package/dist/viewer/main.js +3803 -270
  60. package/package.json +6 -1
  61. package/viewer/components/activity-panel.ts +326 -0
  62. package/viewer/components/spotlight-search.ts +429 -0
  63. package/viewer/components/task-badge.ts +2 -2
  64. package/viewer/components/task-detail.ts +33 -6
  65. package/viewer/components/task-filter-bar.ts +13 -1
  66. package/viewer/components/task-item.ts +1 -1
  67. package/viewer/components/task-list.ts +10 -2
  68. package/viewer/icons/activity.svg +4 -0
  69. package/viewer/icons/index.ts +2 -0
  70. package/viewer/index.html +12 -1
  71. package/viewer/main.ts +36 -16
  72. package/viewer/styles.css +633 -0
  73. package/viewer/utils/api.ts +9 -2
  74. package/viewer/utils/split-pane.ts +113 -8
  75. package/viewer/utils/url-state.ts +2 -0
  76. package/dist/storage/backlog.mjs.map +0 -1
package/README.md CHANGED
@@ -63,6 +63,7 @@ backlog_list # List active tasks (open, in_progress
63
63
  backlog_list status=["done"] # Show completed tasks
64
64
  backlog_list type="epic" # List only epics
65
65
  backlog_list epic_id="EPIC-0002" # Tasks in specific epic
66
+ backlog_list query="authentication" # Search across all fields
66
67
  backlog_list counts=true # Get counts by status
67
68
  backlog_list limit=10 # Limit results
68
69
 
@@ -77,20 +78,51 @@ backlog_update id="TASK-0001" status="done" # Mark done
77
78
  backlog_update id="TASK-0001" status="blocked" blocked_reason="Waiting on API"
78
79
  backlog_update id="TASK-0001" evidence=["Fixed in CR-12345"] # Add completion proof
79
80
 
81
+ # To update task content, use write_resource:
82
+ write_resource uri="mcp://backlog/tasks/TASK-0001.md" operation={type: "str_replace", old_str: "old text", new_str: "new text"}
83
+
80
84
  backlog_delete id="TASK-0001" # Permanently delete
81
85
  ```
82
86
 
87
+ ### Search
88
+
89
+ Search across all task fields (title, description, evidence, references, blocked_reason, epic_id):
90
+
91
+ ```
92
+ backlog_list query="oauth" # Find tasks mentioning OAuth
93
+ backlog_list query="bug" status=["open"] # Search within open tasks
94
+ ```
95
+
96
+ Search is case-insensitive substring matching. Works with all other filters.
97
+
83
98
  ### Resources (MCP Resources Protocol)
84
99
 
85
100
  Access tasks and resources via MCP resource URIs:
86
101
 
87
102
  ```
88
103
  mcp://backlog/tasks/TASK-0001.md # Task markdown file
89
- mcp://backlog/resources/TASK-0001/adr.md # Task-attached resource
90
- mcp://backlog/resources/investigation.md # Standalone resource
104
+ mcp://backlog/resources/path/to/file.md # Standalone resource
105
+ ```
106
+
107
+ ### write_resource Tool
108
+
109
+ Create and edit files on the MCP server. Operations mirror `fs_write`:
110
+
111
+ ```
112
+ write_resource uri="mcp://backlog/resources/notes.md" operation={type: "create", file_text: "# Notes\n\nContent here"}
113
+
114
+ write_resource uri="mcp://backlog/resources/notes.md" operation={type: "str_replace", old_str: "old text", new_str: "new text"}
115
+
116
+ write_resource uri="mcp://backlog/resources/notes.md" operation={type: "insert", insert_line: 5, new_str: "inserted line"}
117
+
118
+ write_resource uri="mcp://backlog/resources/notes.md" operation={type: "append", new_str: "appended content"}
91
119
  ```
92
120
 
93
- Modify resources via `write_resource` tool with operations like `str_replace`, `append`, `insert`.
121
+ **Operations:**
122
+ - `create` - Create file or override existing (file_text required)
123
+ - `str_replace` - Replace exact string match (must be unique in file)
124
+ - `insert` - Insert after specified line number
125
+ - `append` - Add to end of file (auto-adds newline if needed)
94
126
 
95
127
  ## Installation
96
128
 
@@ -122,7 +154,7 @@ pnpm start
122
154
  backlog-mcp # Run as stdio MCP server (default)
123
155
  backlog-mcp serve # Run HTTP server with viewer
124
156
  backlog-mcp version # Show version
125
- backlog-mcp status # Check if server is running
157
+ backlog-mcp status # Check server status (port, version, task count, uptime)
126
158
  backlog-mcp stop # Stop the server
127
159
  backlog-mcp --help # Show help
128
160
  ```
@@ -151,9 +183,9 @@ BACKLOG_VIEWER_PORT=3030 # HTTP server port
151
183
 
152
184
  ## Storage
153
185
 
154
- - Default: `data/tasks/` and `data/archive/` (local to project)
186
+ - Default: `data/tasks/` (local to project)
155
187
  - Global: Set `BACKLOG_DATA_DIR=~/.backlog` for cross-project persistence
156
- - Completed/cancelled tasks auto-archive to `archive/`
188
+ - All tasks stored in single `tasks/` directory regardless of status (see [ADR 0003](docs/adr/0003-remove-archive-directory.md))
157
189
 
158
190
  ## License
159
191
 
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
1
  import { CreateTaskInput, STATUSES, Status, Task, createTask, formatTaskId, isValidTaskId, nextTaskId, parseTaskId } from "./storage/schema.mjs";
2
- import { storage } from "./storage/backlog.mjs";
2
+ import { storage } from "./storage/backlog-service.mjs";
3
3
  import { startHttpServer } from "./server/fastify-server.mjs";
4
4
  export { type CreateTaskInput, STATUSES, type Status, type Task, createTask, formatTaskId, isValidTaskId, nextTaskId, parseTaskId, startHttpServer, storage };
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { STATUSES, createTask, formatTaskId, isValidTaskId, nextTaskId, parseTaskId } from "./storage/schema.mjs";
2
- import { storage } from "./storage/backlog.mjs";
2
+ import { storage } from "./storage/backlog-service.mjs";
3
3
  import { startHttpServer } from "./server/fastify-server.mjs";
4
4
 
5
5
  export { STATUSES, createTask, formatTaskId, isValidTaskId, nextTaskId, parseTaskId, startHttpServer, storage };
@@ -0,0 +1,6 @@
1
+ import { Actor, OperationEntry, OperationFilter, ToolName, WRITE_TOOLS } from "./types.mjs";
2
+ import { operationLogger } from "./logger.mjs";
3
+ import { withOperationLogging } from "./middleware.mjs";
4
+ import { OperationStorage } from "./storage.mjs";
5
+ import { extractResourceId } from "./resource-id.mjs";
6
+ export { type Actor, type OperationEntry, type OperationFilter, OperationStorage, type ToolName, WRITE_TOOLS, extractResourceId, operationLogger, withOperationLogging };
@@ -0,0 +1,7 @@
1
+ import { OperationStorage } from "./storage.mjs";
2
+ import { extractResourceId } from "./resource-id.mjs";
3
+ import { WRITE_TOOLS } from "./types.mjs";
4
+ import { operationLogger } from "./logger.mjs";
5
+ import { withOperationLogging } from "./middleware.mjs";
6
+
7
+ export { OperationStorage, WRITE_TOOLS, extractResourceId, operationLogger, withOperationLogging };
@@ -0,0 +1,23 @@
1
+ import { Actor, OperationEntry, OperationFilter } from "./types.mjs";
2
+
3
+ //#region src/operations/logger.d.ts
4
+ declare class OperationLogger {
5
+ private storage;
6
+ constructor();
7
+ /**
8
+ * Log a tool operation. Only logs write operations.
9
+ */
10
+ log(tool: string, params: Record<string, unknown>, result: unknown): void;
11
+ /**
12
+ * Read recent operations, optionally filtered by task ID.
13
+ */
14
+ read(options?: OperationFilter): OperationEntry[];
15
+ /**
16
+ * Count operations for a specific task (for badge display).
17
+ */
18
+ countForTask(taskId: string): number;
19
+ }
20
+ declare const operationLogger: OperationLogger;
21
+ //#endregion
22
+ export { type Actor, type OperationEntry, type OperationFilter, operationLogger };
23
+ //# sourceMappingURL=logger.d.mts.map
@@ -0,0 +1,53 @@
1
+ import { OperationStorage } from "./storage.mjs";
2
+ import { extractResourceId } from "./resource-id.mjs";
3
+ import { WRITE_TOOLS } from "./types.mjs";
4
+
5
+ //#region src/operations/logger.ts
6
+ /**
7
+ * Operation logger - thin orchestration layer.
8
+ * Coordinates storage, resource ID extraction, and actor info.
9
+ */
10
+ const actor = {
11
+ type: process.env.BACKLOG_ACTOR_TYPE || "user",
12
+ name: process.env.BACKLOG_ACTOR_NAME || process.env.USER || "unknown",
13
+ delegatedBy: process.env.BACKLOG_DELEGATED_BY,
14
+ taskContext: process.env.BACKLOG_TASK_CONTEXT
15
+ };
16
+ var OperationLogger = class {
17
+ storage;
18
+ constructor() {
19
+ this.storage = new OperationStorage();
20
+ }
21
+ /**
22
+ * Log a tool operation. Only logs write operations.
23
+ */
24
+ log(tool, params, result) {
25
+ if (!WRITE_TOOLS.includes(tool)) return;
26
+ const entry = {
27
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
28
+ tool,
29
+ params,
30
+ result,
31
+ resourceId: extractResourceId(tool, params, result),
32
+ actor
33
+ };
34
+ this.storage.append(entry);
35
+ }
36
+ /**
37
+ * Read recent operations, optionally filtered by task ID.
38
+ */
39
+ read(options = {}) {
40
+ return this.storage.query(options);
41
+ }
42
+ /**
43
+ * Count operations for a specific task (for badge display).
44
+ */
45
+ countForTask(taskId) {
46
+ return this.storage.countForTask(taskId);
47
+ }
48
+ };
49
+ const operationLogger = new OperationLogger();
50
+
51
+ //#endregion
52
+ export { operationLogger };
53
+ //# sourceMappingURL=logger.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"logger.mjs","names":[],"sources":["../../src/operations/logger.ts"],"sourcesContent":["/**\n * Operation logger - thin orchestration layer.\n * Coordinates storage, resource ID extraction, and actor info.\n */\n\nimport { OperationStorage } from './storage.js';\nimport { extractResourceId } from './resource-id.js';\nimport type { Actor, OperationEntry, OperationFilter } from './types.js';\nimport { WRITE_TOOLS } from './types.js';\n\n// Read actor info from environment at module load\nconst actor: Actor = {\n type: (process.env.BACKLOG_ACTOR_TYPE as 'user' | 'agent') || 'user',\n name: process.env.BACKLOG_ACTOR_NAME || process.env.USER || 'unknown',\n delegatedBy: process.env.BACKLOG_DELEGATED_BY,\n taskContext: process.env.BACKLOG_TASK_CONTEXT,\n};\n\nclass OperationLogger {\n private storage: OperationStorage;\n\n constructor() {\n this.storage = new OperationStorage();\n }\n\n /**\n * Log a tool operation. Only logs write operations.\n */\n log(tool: string, params: Record<string, unknown>, result: unknown): void {\n if (!WRITE_TOOLS.includes(tool as any)) return;\n\n const entry: OperationEntry = {\n ts: new Date().toISOString(),\n tool,\n params,\n result,\n resourceId: extractResourceId(tool, params, result),\n actor,\n };\n\n this.storage.append(entry);\n }\n\n /**\n * Read recent operations, optionally filtered by task ID.\n */\n read(options: OperationFilter = {}): OperationEntry[] {\n return this.storage.query(options);\n }\n\n /**\n * Count operations for a specific task (for badge display).\n */\n countForTask(taskId: string): number {\n return this.storage.countForTask(taskId);\n }\n}\n\nexport const operationLogger = new OperationLogger();\n\n// Re-export types for convenience\nexport type { Actor, OperationEntry, OperationFilter } from './types.js';\n"],"mappings":";;;;;;;;;AAWA,MAAM,QAAe;CACnB,MAAO,QAAQ,IAAI,sBAA2C;CAC9D,MAAM,QAAQ,IAAI,sBAAsB,QAAQ,IAAI,QAAQ;CAC5D,aAAa,QAAQ,IAAI;CACzB,aAAa,QAAQ,IAAI;CAC1B;AAED,IAAM,kBAAN,MAAsB;CACpB,AAAQ;CAER,cAAc;AACZ,OAAK,UAAU,IAAI,kBAAkB;;;;;CAMvC,IAAI,MAAc,QAAiC,QAAuB;AACxE,MAAI,CAAC,YAAY,SAAS,KAAY,CAAE;EAExC,MAAM,QAAwB;GAC5B,qBAAI,IAAI,MAAM,EAAC,aAAa;GAC5B;GACA;GACA;GACA,YAAY,kBAAkB,MAAM,QAAQ,OAAO;GACnD;GACD;AAED,OAAK,QAAQ,OAAO,MAAM;;;;;CAM5B,KAAK,UAA2B,EAAE,EAAoB;AACpD,SAAO,KAAK,QAAQ,MAAM,QAAQ;;;;;CAMpC,aAAa,QAAwB;AACnC,SAAO,KAAK,QAAQ,aAAa,OAAO;;;AAI5C,MAAa,kBAAkB,IAAI,iBAAiB"}
@@ -0,0 +1,11 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+
3
+ //#region src/operations/middleware.d.ts
4
+ /**
5
+ * Wrap an MCP server to log tool operations.
6
+ * Returns a proxy that intercepts registerTool calls.
7
+ */
8
+ declare function withOperationLogging(server: McpServer): McpServer;
9
+ //#endregion
10
+ export { withOperationLogging };
11
+ //# sourceMappingURL=middleware.d.mts.map
@@ -0,0 +1,23 @@
1
+ import { operationLogger } from "./logger.mjs";
2
+
3
+ //#region src/operations/middleware.ts
4
+ /**
5
+ * Wrap an MCP server to log tool operations.
6
+ * Returns a proxy that intercepts registerTool calls.
7
+ */
8
+ function withOperationLogging(server) {
9
+ const originalRegisterTool = server.registerTool.bind(server);
10
+ server.registerTool = function(name, config, callback) {
11
+ const wrappedCallback = async (...args) => {
12
+ const result = await callback(...args);
13
+ operationLogger.log(name, args[0] || {}, result);
14
+ return result;
15
+ };
16
+ return originalRegisterTool(name, config, wrappedCallback);
17
+ };
18
+ return server;
19
+ }
20
+
21
+ //#endregion
22
+ export { withOperationLogging };
23
+ //# sourceMappingURL=middleware.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"middleware.mjs","names":[],"sources":["../../src/operations/middleware.ts"],"sourcesContent":["import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { operationLogger } from './logger.js';\n\n/**\n * Wrap an MCP server to log tool operations.\n * Returns a proxy that intercepts registerTool calls.\n */\nexport function withOperationLogging(server: McpServer): McpServer {\n const originalRegisterTool = server.registerTool.bind(server);\n\n // Override registerTool to wrap callbacks with logging\n (server as any).registerTool = function(\n name: string,\n config: any,\n callback: (...args: any[]) => any\n ) {\n const wrappedCallback = async (...args: any[]) => {\n const result = await callback(...args);\n // args[0] is the params object for tool callbacks\n operationLogger.log(name, args[0] || {}, result);\n return result;\n };\n\n return originalRegisterTool(name, config, wrappedCallback as any);\n };\n\n return server;\n}\n"],"mappings":";;;;;;;AAOA,SAAgB,qBAAqB,QAA8B;CACjE,MAAM,uBAAuB,OAAO,aAAa,KAAK,OAAO;AAG7D,CAAC,OAAe,eAAe,SAC7B,MACA,QACA,UACA;EACA,MAAM,kBAAkB,OAAO,GAAG,SAAgB;GAChD,MAAM,SAAS,MAAM,SAAS,GAAG,KAAK;AAEtC,mBAAgB,IAAI,MAAM,KAAK,MAAM,EAAE,EAAE,OAAO;AAChD,UAAO;;AAGT,SAAO,qBAAqB,MAAM,QAAQ,gBAAuB;;AAGnE,QAAO"}
@@ -0,0 +1,12 @@
1
+ //#region src/operations/resource-id.d.ts
2
+ /**
3
+ * Resource ID extraction from tool params/results.
4
+ * Each tool has its own extraction strategy.
5
+ */
6
+ /**
7
+ * Extract resource ID from tool params or result for filtering.
8
+ */
9
+ declare function extractResourceId(tool: string, params: Record<string, unknown>, result: unknown): string | undefined;
10
+ //#endregion
11
+ export { extractResourceId };
12
+ //# sourceMappingURL=resource-id.d.mts.map
@@ -0,0 +1,24 @@
1
+ //#region src/operations/resource-id.ts
2
+ const extractors = {
3
+ backlog_create: (_, result) => {
4
+ const text = result?.content?.[0]?.text;
5
+ if (text) return text.match(/(TASK|EPIC)-\d+/)?.[0];
6
+ },
7
+ backlog_update: (params) => params.id,
8
+ backlog_delete: (params) => params.id,
9
+ write_resource: (params) => {
10
+ const uri = params.uri;
11
+ if (uri) return uri.match(/(TASK|EPIC)-\d+/)?.[0];
12
+ }
13
+ };
14
+ /**
15
+ * Extract resource ID from tool params or result for filtering.
16
+ */
17
+ function extractResourceId(tool, params, result) {
18
+ const extractor = extractors[tool];
19
+ return extractor ? extractor(params, result) : void 0;
20
+ }
21
+
22
+ //#endregion
23
+ export { extractResourceId };
24
+ //# sourceMappingURL=resource-id.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resource-id.mjs","names":[],"sources":["../../src/operations/resource-id.ts"],"sourcesContent":["/**\n * Resource ID extraction from tool params/results.\n * Each tool has its own extraction strategy.\n */\n\ntype Extractor = (params: Record<string, unknown>, result: unknown) => string | undefined;\n\nconst extractors: Record<string, Extractor> = {\n backlog_create: (_, result) => {\n const text = (result as any)?.content?.[0]?.text as string | undefined;\n if (text) {\n const match = text.match(/(TASK|EPIC)-\\d+/);\n return match?.[0];\n }\n return undefined;\n },\n\n backlog_update: (params) => params.id as string | undefined,\n\n backlog_delete: (params) => params.id as string | undefined,\n\n write_resource: (params) => {\n const uri = params.uri as string | undefined;\n if (uri) {\n const match = uri.match(/(TASK|EPIC)-\\d+/);\n return match?.[0];\n }\n return undefined;\n },\n};\n\n/**\n * Extract resource ID from tool params or result for filtering.\n */\nexport function extractResourceId(\n tool: string,\n params: Record<string, unknown>,\n result: unknown\n): string | undefined {\n const extractor = extractors[tool];\n return extractor ? extractor(params, result) : undefined;\n}\n"],"mappings":";AAOA,MAAM,aAAwC;CAC5C,iBAAiB,GAAG,WAAW;EAC7B,MAAM,OAAQ,QAAgB,UAAU,IAAI;AAC5C,MAAI,KAEF,QADc,KAAK,MAAM,kBAAkB,GAC5B;;CAKnB,iBAAiB,WAAW,OAAO;CAEnC,iBAAiB,WAAW,OAAO;CAEnC,iBAAiB,WAAW;EAC1B,MAAM,MAAM,OAAO;AACnB,MAAI,IAEF,QADc,IAAI,MAAM,kBAAkB,GAC3B;;CAIpB;;;;AAKD,SAAgB,kBACd,MACA,QACA,QACoB;CACpB,MAAM,YAAY,WAAW;AAC7B,QAAO,YAAY,UAAU,QAAQ,OAAO,GAAG"}
@@ -0,0 +1,26 @@
1
+ import { OperationEntry, OperationFilter } from "./types.mjs";
2
+
3
+ //#region src/operations/storage.d.ts
4
+ declare class OperationStorage {
5
+ private logPath;
6
+ constructor();
7
+ /**
8
+ * Append an operation entry to the log file.
9
+ */
10
+ append(entry: OperationEntry): void;
11
+ /**
12
+ * Read all operations from the log file.
13
+ */
14
+ readAll(): OperationEntry[];
15
+ /**
16
+ * Query operations with optional filtering.
17
+ */
18
+ query(filter?: OperationFilter): OperationEntry[];
19
+ /**
20
+ * Count operations for a specific task.
21
+ */
22
+ countForTask(taskId: string): number;
23
+ }
24
+ //#endregion
25
+ export { OperationStorage };
26
+ //# sourceMappingURL=storage.d.mts.map
@@ -0,0 +1,64 @@
1
+ import { paths } from "../utils/paths.mjs";
2
+ import { dirname, join } from "node:path";
3
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
4
+
5
+ //#region src/operations/storage.ts
6
+ /**
7
+ * JSONL storage for operation entries.
8
+ * Single responsibility: read/write operations to disk.
9
+ */
10
+ var OperationStorage = class {
11
+ logPath;
12
+ constructor() {
13
+ this.logPath = join(paths.backlogDataDir, ".internal", "operations.jsonl");
14
+ }
15
+ /**
16
+ * Append an operation entry to the log file.
17
+ */
18
+ append(entry) {
19
+ try {
20
+ const dir = dirname(this.logPath);
21
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
22
+ appendFileSync(this.logPath, JSON.stringify(entry) + "\n", "utf-8");
23
+ } catch {}
24
+ }
25
+ /**
26
+ * Read all operations from the log file.
27
+ */
28
+ readAll() {
29
+ if (!existsSync(this.logPath)) return [];
30
+ try {
31
+ return readFileSync(this.logPath, "utf-8").trim().split("\n").filter(Boolean).map((line) => {
32
+ try {
33
+ return JSON.parse(line);
34
+ } catch {
35
+ return null;
36
+ }
37
+ }).filter((e) => e !== null);
38
+ } catch {
39
+ return [];
40
+ }
41
+ }
42
+ /**
43
+ * Query operations with optional filtering.
44
+ */
45
+ query(filter = {}) {
46
+ const { taskId, limit = 50 } = filter;
47
+ let entries = this.readAll();
48
+ if (taskId) entries = entries.filter((e) => e.resourceId === taskId);
49
+ return entries.reverse().slice(0, limit);
50
+ }
51
+ /**
52
+ * Count operations for a specific task.
53
+ */
54
+ countForTask(taskId) {
55
+ return this.query({
56
+ taskId,
57
+ limit: 1e3
58
+ }).length;
59
+ }
60
+ };
61
+
62
+ //#endregion
63
+ export { OperationStorage };
64
+ //# sourceMappingURL=storage.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"storage.mjs","names":[],"sources":["../../src/operations/storage.ts"],"sourcesContent":["/**\n * JSONL storage for operation entries.\n * Single responsibility: read/write operations to disk.\n */\n\nimport { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';\nimport { dirname, join } from 'node:path';\nimport { paths } from '@/utils/paths.js';\nimport type { OperationEntry, OperationFilter } from './types.js';\n\nexport class OperationStorage {\n private logPath: string;\n\n constructor() {\n this.logPath = join(paths.backlogDataDir, '.internal', 'operations.jsonl');\n }\n\n /**\n * Append an operation entry to the log file.\n */\n append(entry: OperationEntry): void {\n try {\n const dir = dirname(this.logPath);\n if (!existsSync(dir)) {\n mkdirSync(dir, { recursive: true });\n }\n appendFileSync(this.logPath, JSON.stringify(entry) + '\\n', 'utf-8');\n } catch {\n // Fail silently - logging should not break tool execution\n }\n }\n\n /**\n * Read all operations from the log file.\n */\n readAll(): OperationEntry[] {\n if (!existsSync(this.logPath)) return [];\n\n try {\n const content = readFileSync(this.logPath, 'utf-8');\n const lines = content.trim().split('\\n').filter(Boolean);\n \n return lines\n .map(line => {\n try {\n return JSON.parse(line) as OperationEntry;\n } catch {\n return null;\n }\n })\n .filter((e): e is OperationEntry => e !== null);\n } catch {\n return [];\n }\n }\n\n /**\n * Query operations with optional filtering.\n */\n query(filter: OperationFilter = {}): OperationEntry[] {\n const { taskId, limit = 50 } = filter;\n \n let entries = this.readAll();\n\n if (taskId) {\n entries = entries.filter(e => e.resourceId === taskId);\n }\n\n // Return most recent first, limited\n return entries.reverse().slice(0, limit);\n }\n\n /**\n * Count operations for a specific task.\n */\n countForTask(taskId: string): number {\n return this.query({ taskId, limit: 1000 }).length;\n }\n}\n"],"mappings":";;;;;;;;;AAUA,IAAa,mBAAb,MAA8B;CAC5B,AAAQ;CAER,cAAc;AACZ,OAAK,UAAU,KAAK,MAAM,gBAAgB,aAAa,mBAAmB;;;;;CAM5E,OAAO,OAA6B;AAClC,MAAI;GACF,MAAM,MAAM,QAAQ,KAAK,QAAQ;AACjC,OAAI,CAAC,WAAW,IAAI,CAClB,WAAU,KAAK,EAAE,WAAW,MAAM,CAAC;AAErC,kBAAe,KAAK,SAAS,KAAK,UAAU,MAAM,GAAG,MAAM,QAAQ;UAC7D;;;;;CAQV,UAA4B;AAC1B,MAAI,CAAC,WAAW,KAAK,QAAQ,CAAE,QAAO,EAAE;AAExC,MAAI;AAIF,UAHgB,aAAa,KAAK,SAAS,QAAQ,CAC7B,MAAM,CAAC,MAAM,KAAK,CAAC,OAAO,QAAQ,CAGrD,KAAI,SAAQ;AACX,QAAI;AACF,YAAO,KAAK,MAAM,KAAK;YACjB;AACN,YAAO;;KAET,CACD,QAAQ,MAA2B,MAAM,KAAK;UAC3C;AACN,UAAO,EAAE;;;;;;CAOb,MAAM,SAA0B,EAAE,EAAoB;EACpD,MAAM,EAAE,QAAQ,QAAQ,OAAO;EAE/B,IAAI,UAAU,KAAK,SAAS;AAE5B,MAAI,OACF,WAAU,QAAQ,QAAO,MAAK,EAAE,eAAe,OAAO;AAIxD,SAAO,QAAQ,SAAS,CAAC,MAAM,GAAG,MAAM;;;;;CAM1C,aAAa,QAAwB;AACnC,SAAO,KAAK,MAAM;GAAE;GAAQ,OAAO;GAAM,CAAC,CAAC"}
@@ -0,0 +1,27 @@
1
+ //#region src/operations/types.d.ts
2
+ /**
3
+ * Types for operation logging.
4
+ */
5
+ interface Actor {
6
+ type: 'user' | 'agent';
7
+ name: string;
8
+ delegatedBy?: string;
9
+ taskContext?: string;
10
+ }
11
+ type ToolName = 'backlog_create' | 'backlog_update' | 'backlog_delete' | 'write_resource';
12
+ interface OperationEntry {
13
+ ts: string;
14
+ tool: string;
15
+ params: Record<string, unknown>;
16
+ result: unknown;
17
+ resourceId?: string;
18
+ actor: Actor;
19
+ }
20
+ interface OperationFilter {
21
+ taskId?: string;
22
+ limit?: number;
23
+ }
24
+ declare const WRITE_TOOLS: ToolName[];
25
+ //#endregion
26
+ export { Actor, OperationEntry, OperationFilter, ToolName, WRITE_TOOLS };
27
+ //# sourceMappingURL=types.d.mts.map
@@ -0,0 +1,11 @@
1
+ //#region src/operations/types.ts
2
+ const WRITE_TOOLS = [
3
+ "backlog_create",
4
+ "backlog_update",
5
+ "backlog_delete",
6
+ "write_resource"
7
+ ];
8
+
9
+ //#endregion
10
+ export { WRITE_TOOLS };
11
+ //# sourceMappingURL=types.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.mjs","names":[],"sources":["../../src/operations/types.ts"],"sourcesContent":["/**\n * Types for operation logging.\n */\n\nexport interface Actor {\n type: 'user' | 'agent';\n name: string;\n delegatedBy?: string;\n taskContext?: string;\n}\n\nexport type ToolName = 'backlog_create' | 'backlog_update' | 'backlog_delete' | 'write_resource';\n\nexport interface OperationEntry {\n ts: string;\n tool: string;\n params: Record<string, unknown>;\n result: unknown;\n resourceId?: string;\n actor: Actor;\n}\n\nexport interface OperationFilter {\n taskId?: string;\n limit?: number;\n}\n\nexport const WRITE_TOOLS: ToolName[] = ['backlog_create', 'backlog_update', 'backlog_delete', 'write_resource'];\n"],"mappings":";AA2BA,MAAa,cAA0B;CAAC;CAAkB;CAAkB;CAAkB;CAAiB"}
@@ -1,3 +1,4 @@
1
+ import { Resource } from "../search/types.mjs";
1
2
  import { Operation, WriteResourceResult } from "./types.mjs";
2
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
4
 
@@ -16,6 +17,12 @@ interface ResourceContent {
16
17
  declare class ResourceManager {
17
18
  private readonly dataDir;
18
19
  constructor(dataDir: string);
20
+ /**
21
+ * List all resources in the resources/ directory.
22
+ * Returns Resource objects ready for search indexing.
23
+ */
24
+ list(): Resource[];
25
+ private scanDirectory;
19
26
  /**
20
27
  * Resolve MCP URI to absolute file path.
21
28
  * Pure catch-all: mcp://backlog/path/file.md → {dataDir}/path/file.md
@@ -1,13 +1,20 @@
1
1
  import { paths } from "../utils/paths.mjs";
2
2
  import { applyOperation } from "./operations.mjs";
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
- import { dirname, join } from "node:path";
3
+ import { dirname, join, relative } from "node:path";
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
5
5
  import matter from "gray-matter";
6
6
  import { z } from "zod";
7
7
  import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
 
9
9
  //#region src/resources/manager.ts
10
10
  /**
11
+ * Extract title from markdown content.
12
+ * Returns first # heading or filename without extension.
13
+ */
14
+ function extractTitle(content, filename) {
15
+ return content.match(/^#\s+(.+)$/m)?.[1]?.trim() || filename.replace(/\.md$/, "");
16
+ }
17
+ /**
11
18
  * ResourceManager - Single point of responsibility for MCP resource operations.
12
19
  *
13
20
  * Pure catch-all design: mcp://backlog/{+path} → {dataDir}/{path}
@@ -18,6 +25,35 @@ var ResourceManager = class {
18
25
  this.dataDir = dataDir;
19
26
  }
20
27
  /**
28
+ * List all resources in the resources/ directory.
29
+ * Returns Resource objects ready for search indexing.
30
+ */
31
+ list() {
32
+ const resourcesDir = join(this.dataDir, "resources");
33
+ if (!existsSync(resourcesDir)) return [];
34
+ const resources = [];
35
+ this.scanDirectory(resourcesDir, resources);
36
+ return resources;
37
+ }
38
+ scanDirectory(dir, resources) {
39
+ const entries = readdirSync(dir, { withFileTypes: true });
40
+ for (const entry of entries) {
41
+ const fullPath = join(dir, entry.name);
42
+ if (entry.isDirectory()) this.scanDirectory(fullPath, resources);
43
+ else if (entry.isFile() && entry.name.endsWith(".md")) try {
44
+ const content = readFileSync(fullPath, "utf-8");
45
+ const relativePath = relative(this.dataDir, fullPath);
46
+ const uri = `mcp://backlog/${relativePath}`;
47
+ resources.push({
48
+ id: uri,
49
+ path: relativePath,
50
+ title: extractTitle(content, entry.name),
51
+ content
52
+ });
53
+ } catch {}
54
+ }
55
+ }
56
+ /**
21
57
  * Resolve MCP URI to absolute file path.
22
58
  * Pure catch-all: mcp://backlog/path/file.md → {dataDir}/path/file.md
23
59
  *
@@ -1 +1 @@
1
- {"version":3,"file":"manager.mjs","names":[],"sources":["../../src/resources/manager.ts"],"sourcesContent":["import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs';\nimport { join, dirname } from 'node:path';\nimport matter from 'gray-matter';\nimport { z } from 'zod';\nimport { paths } from '@/utils/paths.js';\nimport { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport type { Operation, WriteResourceResult } from './types.js';\nimport { applyOperation } from './operations.js';\n\nexport interface ResourceContent {\n content: string;\n frontmatter?: Record<string, any>;\n mimeType: string;\n}\n\n/**\n * ResourceManager - Single point of responsibility for MCP resource operations.\n * \n * Pure catch-all design: mcp://backlog/{+path} → {dataDir}/{path}\n * No special cases, no magic behavior.\n */\nexport class ResourceManager {\n constructor(private readonly dataDir: string) {}\n\n /**\n * Resolve MCP URI to absolute file path.\n * Pure catch-all: mcp://backlog/path/file.md → {dataDir}/path/file.md\n * \n * @param uri MCP URI (must start with mcp://backlog/)\n * @returns Absolute file path\n * @throws Error if URI is invalid or contains path traversal\n */\n resolve(uri: string): string {\n if (!uri.startsWith('mcp://')) {\n throw new Error(`Not an MCP URI: ${uri}`);\n }\n\n // Check for path traversal BEFORE URL parsing (URL normalizes ..)\n if (uri.includes('..')) {\n throw new Error(`Path traversal not allowed: ${uri}`);\n }\n\n const url = new URL(uri);\n \n if (url.hostname !== 'backlog') {\n throw new Error(`Invalid hostname: ${url.hostname}. Expected 'backlog'`);\n }\n \n const path = url.pathname.substring(1); // Remove leading /\n \n return join(this.dataDir, path);\n }\n\n /**\n * Read resource content from MCP URI.\n * Parses frontmatter for markdown files and detects MIME type.\n * \n * @param uri MCP URI\n * @returns Resource content with frontmatter and MIME type\n * @throws Error if file not found\n */\n read(uri: string): ResourceContent {\n const filePath = this.resolve(uri);\n \n if (!existsSync(filePath)) {\n // Helpful error for common mistake: extension-less task URIs\n if (/^mcp:\\/\\/backlog\\/tasks\\/(TASK|EPIC)-\\d+$/.test(uri)) {\n throw new Error(\n `Task URIs must include .md extension. Did you mean: ${uri}.md?`\n );\n }\n throw new Error(`Resource not found: ${uri} (resolved to ${filePath})`);\n }\n \n const content = readFileSync(filePath, 'utf-8');\n const ext = filePath.split('.').pop()?.toLowerCase() || 'txt';\n const mimeType = this.getMimeType(ext);\n \n // Parse frontmatter for markdown files\n if (ext === 'md') {\n const parsed = matter(content);\n return {\n content: parsed.content,\n frontmatter: Object.keys(parsed.data).length > 0 ? parsed.data : undefined,\n mimeType,\n };\n }\n \n return {\n content,\n mimeType,\n };\n }\n\n /**\n * Write/modify resource content.\n * Applies operations like str_replace, append, insert, etc.\n * \n * @param uri MCP URI\n * @param operation Operation to apply\n * @returns Result with success status and message\n */\n write(uri: string, operation: Operation): WriteResourceResult {\n try {\n const filePath = this.resolve(uri);\n const canCreate = ['create', 'append', 'insert'].includes(operation.type);\n \n if (!existsSync(filePath)) {\n if (canCreate) {\n // Auto-create file and parent directories\n mkdirSync(dirname(filePath), { recursive: true });\n writeFileSync(filePath, '', 'utf-8');\n } else {\n // str_replace/delete need existing content\n return {\n success: false,\n message: 'File not found',\n error: `Resource not found: ${uri} (${operation.type} requires existing file)`,\n };\n }\n }\n\n const fileContent = readFileSync(filePath, 'utf-8');\n const newContent = applyOperation(fileContent, operation);\n writeFileSync(filePath, newContent, 'utf-8');\n\n return {\n success: true,\n message: `Successfully applied ${operation.type} to ${uri}`,\n };\n } catch (error) {\n return {\n success: false,\n message: 'Operation failed',\n error: error instanceof Error ? error.message : String(error),\n };\n }\n }\n\n /**\n * Convert file path to MCP URI.\n * Pure mapping: {dataDir}/path/file.md → mcp://backlog/path/file.md\n * \n * @param filePath Absolute file path\n * @returns MCP URI or null if file is outside data directory\n */\n toUri(filePath: string): string | null {\n if (!filePath.startsWith(this.dataDir)) {\n return null;\n }\n \n const relativePath = filePath.substring(this.dataDir.length + 1);\n return `mcp://backlog/${relativePath}`;\n }\n\n /**\n * Register MCP resource handler (catch-all pattern).\n */\n registerResource(server: McpServer) {\n const template = new ResourceTemplate(\n 'mcp://backlog/{+path}',\n { list: undefined }\n );\n \n server.registerResource(\n 'Data Directory Resource',\n template,\n { description: 'Any file in the backlog data directory' },\n async (uri) => {\n const resource = this.read(uri.toString());\n return { \n contents: [{ \n uri: uri.toString(), \n mimeType: resource.mimeType, \n text: resource.content \n }] \n };\n }\n );\n }\n\n /**\n * Register write_resource MCP tool.\n */\n registerWriteTool(server: McpServer) {\n server.registerTool(\n 'write_resource',\n {\n description: `A tool for creating and editing files on the MCP server\n * The \\`create\\` command will override the file at \\`uri\\` if it already exists as a file, and otherwise create a new file\n * The \\`append\\` command will add content to the end of a file, automatically adding a newline if the file doesn't end with one. Creates the file if it doesn't exist.\n Notes for using the \\`str_replace\\` command:\n * The \\`old_str\\` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n * If the \\`old_str\\` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in \\`old_str\\` to make it unique\n * The \\`new_str\\` parameter should contain the edited lines that should replace the \\`old_str\\``,\n inputSchema: z.object({\n uri: z.string().describe('MCP resource URI, e.g. mcp://backlog/path/to/file.md'),\n operation: z.preprocess(\n // Workaround: MCP clients stringify object params with $ref/oneOf schemas\n // https://github.com/anthropics/claude-code/issues/18260\n (val) => typeof val === 'string' ? JSON.parse(val) : val,\n z.discriminatedUnion('type', [\n z.object({\n type: z.literal('create'),\n file_text: z.string().describe('Content of the file to be created'),\n }),\n z.object({\n type: z.literal('str_replace'),\n old_str: z.string().describe('String in file to replace (must match exactly)'),\n new_str: z.string().describe('New string to replace old_str with'),\n }),\n z.object({\n type: z.literal('insert'),\n insert_line: z.number().describe('Line number after which new_str will be inserted'),\n new_str: z.string().describe('String to insert'),\n }),\n z.object({\n type: z.literal('append'),\n new_str: z.string().describe('Content to append to the file'),\n }),\n ])).describe('Operation to apply'),\n }),\n },\n async ({ uri, operation }) => {\n const result = this.write(uri, operation);\n return {\n content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],\n };\n }\n );\n }\n\n private getMimeType(ext: string): string {\n const mimeMap: Record<string, string> = {\n md: 'text/markdown',\n json: 'application/json',\n ts: 'text/typescript',\n js: 'application/javascript',\n txt: 'text/plain',\n };\n \n return mimeMap[ext] || 'text/plain';\n }\n}\n\n/**\n * Singleton instance for dependency injection.\n * Uses the configured backlog data directory.\n */\nexport const resourceManager = new ResourceManager(paths.backlogDataDir);\n"],"mappings":"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"}
1
+ {"version":3,"file":"manager.mjs","names":[],"sources":["../../src/resources/manager.ts"],"sourcesContent":["import { readFileSync, existsSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'node:fs';\nimport { join, dirname, relative, basename } from 'node:path';\nimport matter from 'gray-matter';\nimport { z } from 'zod';\nimport { paths } from '@/utils/paths.js';\nimport { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport type { Operation, WriteResourceResult } from './types.js';\nimport { applyOperation } from './operations.js';\nimport type { Resource } from '@/search/types.js';\n\nexport interface ResourceContent {\n content: string;\n frontmatter?: Record<string, any>;\n mimeType: string;\n}\n\n/**\n * Extract title from markdown content.\n * Returns first # heading or filename without extension.\n */\nfunction extractTitle(content: string, filename: string): string {\n const match = content.match(/^#\\s+(.+)$/m);\n return match?.[1]?.trim() || filename.replace(/\\.md$/, '');\n}\n\n/**\n * ResourceManager - Single point of responsibility for MCP resource operations.\n * \n * Pure catch-all design: mcp://backlog/{+path} → {dataDir}/{path}\n * No special cases, no magic behavior.\n */\nexport class ResourceManager {\n constructor(private readonly dataDir: string) {}\n\n /**\n * List all resources in the resources/ directory.\n * Returns Resource objects ready for search indexing.\n */\n list(): Resource[] {\n const resourcesDir = join(this.dataDir, 'resources');\n if (!existsSync(resourcesDir)) return [];\n\n const resources: Resource[] = [];\n this.scanDirectory(resourcesDir, resources);\n return resources;\n }\n\n private scanDirectory(dir: string, resources: Resource[]): void {\n const entries = readdirSync(dir, { withFileTypes: true });\n \n for (const entry of entries) {\n const fullPath = join(dir, entry.name);\n \n if (entry.isDirectory()) {\n this.scanDirectory(fullPath, resources);\n } else if (entry.isFile() && entry.name.endsWith('.md')) {\n try {\n const content = readFileSync(fullPath, 'utf-8');\n const relativePath = relative(this.dataDir, fullPath);\n const uri = `mcp://backlog/${relativePath}`;\n \n resources.push({\n id: uri,\n path: relativePath,\n title: extractTitle(content, entry.name),\n content,\n });\n } catch {\n // Skip files that can't be read\n }\n }\n }\n }\n\n /**\n * Resolve MCP URI to absolute file path.\n * Pure catch-all: mcp://backlog/path/file.md → {dataDir}/path/file.md\n * \n * @param uri MCP URI (must start with mcp://backlog/)\n * @returns Absolute file path\n * @throws Error if URI is invalid or contains path traversal\n */\n resolve(uri: string): string {\n if (!uri.startsWith('mcp://')) {\n throw new Error(`Not an MCP URI: ${uri}`);\n }\n\n // Check for path traversal BEFORE URL parsing (URL normalizes ..)\n if (uri.includes('..')) {\n throw new Error(`Path traversal not allowed: ${uri}`);\n }\n\n const url = new URL(uri);\n \n if (url.hostname !== 'backlog') {\n throw new Error(`Invalid hostname: ${url.hostname}. Expected 'backlog'`);\n }\n \n const path = url.pathname.substring(1); // Remove leading /\n \n return join(this.dataDir, path);\n }\n\n /**\n * Read resource content from MCP URI.\n * Parses frontmatter for markdown files and detects MIME type.\n * \n * @param uri MCP URI\n * @returns Resource content with frontmatter and MIME type\n * @throws Error if file not found\n */\n read(uri: string): ResourceContent {\n const filePath = this.resolve(uri);\n \n if (!existsSync(filePath)) {\n // Helpful error for common mistake: extension-less task URIs\n if (/^mcp:\\/\\/backlog\\/tasks\\/(TASK|EPIC)-\\d+$/.test(uri)) {\n throw new Error(\n `Task URIs must include .md extension. Did you mean: ${uri}.md?`\n );\n }\n throw new Error(`Resource not found: ${uri} (resolved to ${filePath})`);\n }\n \n const content = readFileSync(filePath, 'utf-8');\n const ext = filePath.split('.').pop()?.toLowerCase() || 'txt';\n const mimeType = this.getMimeType(ext);\n \n // Parse frontmatter for markdown files\n if (ext === 'md') {\n const parsed = matter(content);\n return {\n content: parsed.content,\n frontmatter: Object.keys(parsed.data).length > 0 ? parsed.data : undefined,\n mimeType,\n };\n }\n \n return {\n content,\n mimeType,\n };\n }\n\n /**\n * Write/modify resource content.\n * Applies operations like str_replace, append, insert, etc.\n * \n * @param uri MCP URI\n * @param operation Operation to apply\n * @returns Result with success status and message\n */\n write(uri: string, operation: Operation): WriteResourceResult {\n try {\n const filePath = this.resolve(uri);\n const canCreate = ['create', 'append', 'insert'].includes(operation.type);\n \n if (!existsSync(filePath)) {\n if (canCreate) {\n // Auto-create file and parent directories\n mkdirSync(dirname(filePath), { recursive: true });\n writeFileSync(filePath, '', 'utf-8');\n } else {\n // str_replace/delete need existing content\n return {\n success: false,\n message: 'File not found',\n error: `Resource not found: ${uri} (${operation.type} requires existing file)`,\n };\n }\n }\n\n const fileContent = readFileSync(filePath, 'utf-8');\n const newContent = applyOperation(fileContent, operation);\n writeFileSync(filePath, newContent, 'utf-8');\n\n return {\n success: true,\n message: `Successfully applied ${operation.type} to ${uri}`,\n };\n } catch (error) {\n return {\n success: false,\n message: 'Operation failed',\n error: error instanceof Error ? error.message : String(error),\n };\n }\n }\n\n /**\n * Convert file path to MCP URI.\n * Pure mapping: {dataDir}/path/file.md → mcp://backlog/path/file.md\n * \n * @param filePath Absolute file path\n * @returns MCP URI or null if file is outside data directory\n */\n toUri(filePath: string): string | null {\n if (!filePath.startsWith(this.dataDir)) {\n return null;\n }\n \n const relativePath = filePath.substring(this.dataDir.length + 1);\n return `mcp://backlog/${relativePath}`;\n }\n\n /**\n * Register MCP resource handler (catch-all pattern).\n */\n registerResource(server: McpServer) {\n const template = new ResourceTemplate(\n 'mcp://backlog/{+path}',\n { list: undefined }\n );\n \n server.registerResource(\n 'Data Directory Resource',\n template,\n { description: 'Any file in the backlog data directory' },\n async (uri) => {\n const resource = this.read(uri.toString());\n return { \n contents: [{ \n uri: uri.toString(), \n mimeType: resource.mimeType, \n text: resource.content \n }] \n };\n }\n );\n }\n\n /**\n * Register write_resource MCP tool.\n */\n registerWriteTool(server: McpServer) {\n server.registerTool(\n 'write_resource',\n {\n description: `A tool for creating and editing files on the MCP server\n * The \\`create\\` command will override the file at \\`uri\\` if it already exists as a file, and otherwise create a new file\n * The \\`append\\` command will add content to the end of a file, automatically adding a newline if the file doesn't end with one. Creates the file if it doesn't exist.\n Notes for using the \\`str_replace\\` command:\n * The \\`old_str\\` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n * If the \\`old_str\\` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in \\`old_str\\` to make it unique\n * The \\`new_str\\` parameter should contain the edited lines that should replace the \\`old_str\\``,\n inputSchema: z.object({\n uri: z.string().describe('MCP resource URI, e.g. mcp://backlog/path/to/file.md'),\n operation: z.preprocess(\n // Workaround: MCP clients stringify object params with $ref/oneOf schemas\n // https://github.com/anthropics/claude-code/issues/18260\n (val) => typeof val === 'string' ? JSON.parse(val) : val,\n z.discriminatedUnion('type', [\n z.object({\n type: z.literal('create'),\n file_text: z.string().describe('Content of the file to be created'),\n }),\n z.object({\n type: z.literal('str_replace'),\n old_str: z.string().describe('String in file to replace (must match exactly)'),\n new_str: z.string().describe('New string to replace old_str with'),\n }),\n z.object({\n type: z.literal('insert'),\n insert_line: z.number().describe('Line number after which new_str will be inserted'),\n new_str: z.string().describe('String to insert'),\n }),\n z.object({\n type: z.literal('append'),\n new_str: z.string().describe('Content to append to the file'),\n }),\n ])).describe('Operation to apply'),\n }),\n },\n async ({ uri, operation }) => {\n const result = this.write(uri, operation);\n return {\n content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],\n };\n }\n );\n }\n\n private getMimeType(ext: string): string {\n const mimeMap: Record<string, string> = {\n md: 'text/markdown',\n json: 'application/json',\n ts: 'text/typescript',\n js: 'application/javascript',\n txt: 'text/plain',\n };\n \n return mimeMap[ext] || 'text/plain';\n }\n}\n\n/**\n * Singleton instance for dependency injection.\n * Uses the configured backlog data directory.\n */\nexport const resourceManager = new ResourceManager(paths.backlogDataDir);\n"],"mappings":"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"}
@@ -0,0 +1,27 @@
1
+ //#region src/search/embedding-service.d.ts
2
+ declare const EMBEDDING_DIMENSIONS = 384;
3
+ /**
4
+ * Local embedding service using transformers.js.
5
+ * Generates 384-dimensional vectors for semantic search.
6
+ */
7
+ declare class EmbeddingService {
8
+ private embedder;
9
+ private initPromise;
10
+ /**
11
+ * Initialize the embedding model (lazy, called on first use).
12
+ * Downloads model on first run (~23MB, cached in ~/.cache/huggingface).
13
+ */
14
+ init(): Promise<void>;
15
+ /**
16
+ * Generate embedding vector for text.
17
+ * @returns 384-dimensional normalized vector
18
+ */
19
+ embed(text: string): Promise<number[]>;
20
+ /**
21
+ * Check if the service is ready (model loaded).
22
+ */
23
+ isReady(): boolean;
24
+ }
25
+ //#endregion
26
+ export { EMBEDDING_DIMENSIONS, EmbeddingService };
27
+ //# sourceMappingURL=embedding-service.d.mts.map