pi-harness-runtime 1.1.326 → 1.1.328

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 (112) hide show
  1. package/package.json +1 -1
  2. package/packages/a2a-adapter/package.json +1 -1
  3. package/packages/agent-policy/package.json +1 -1
  4. package/packages/architecture-generator/package.json +1 -1
  5. package/packages/auth/package.json +1 -1
  6. package/packages/autonomous-refactor/package.json +1 -1
  7. package/packages/autonomous-runtime/package.json +1 -1
  8. package/packages/cache-strategy/package.json +1 -1
  9. package/packages/capability-registry/package.json +1 -1
  10. package/packages/checkpoint/package.json +1 -1
  11. package/packages/cli-plugin-sdk/package.json +1 -1
  12. package/packages/clipboard/package.json +1 -1
  13. package/packages/clipboard-plugin/package.json +1 -1
  14. package/packages/code-generation/package.json +1 -1
  15. package/packages/code-review/package.json +1 -1
  16. package/packages/codex-adapter/package.json +1 -1
  17. package/packages/codex-watcher/package.json +1 -1
  18. package/packages/config-capture/package.json +1 -1
  19. package/packages/context-compiler/package.json +1 -1
  20. package/packages/context-discovery/package.json +1 -1
  21. package/packages/context-manager/package.json +1 -1
  22. package/packages/cookie-sanitizer/package.json +1 -1
  23. package/packages/cost-optimizer/package.json +1 -1
  24. package/packages/dependency-analyzer/package.json +1 -1
  25. package/packages/django-plugin/package.json +1 -1
  26. package/packages/doc-generator/package.json +1 -1
  27. package/packages/durable-review/package.json +1 -1
  28. package/packages/e2e-tester/package.json +1 -1
  29. package/packages/evaluation-engine/package.json +1 -1
  30. package/packages/evaluation-runner/package.json +1 -1
  31. package/packages/event-bus/package.json +1 -1
  32. package/packages/event-store/package.json +1 -1
  33. package/packages/experience-replay/package.json +1 -1
  34. package/packages/feedback-collector/package.json +1 -1
  35. package/packages/file-copy-helper/package.json +1 -1
  36. package/packages/framework-detector/package.json +1 -1
  37. package/packages/framework-plugin-sdk/package.json +1 -1
  38. package/packages/frappe-plugin/package.json +1 -1
  39. package/packages/generic-web-plugin/package.json +1 -1
  40. package/packages/health-monitor/package.json +1 -1
  41. package/packages/intent-analyzer/package.json +1 -1
  42. package/packages/jev-judge/package.json +1 -1
  43. package/packages/knowledge-graph/package.json +1 -1
  44. package/packages/knowledge-retrieval/package.json +1 -1
  45. package/packages/laravel-plugin/package.json +1 -1
  46. package/packages/learning-engine/package.json +1 -1
  47. package/packages/mcp-adapter/package.json +1 -1
  48. package/packages/memory-engine/package.json +1 -1
  49. package/packages/milestone-manager/package.json +1 -1
  50. package/packages/model-registry/package.json +1 -1
  51. package/packages/nextjs-plugin/package.json +1 -1
  52. package/packages/notification/package.json +1 -1
  53. package/packages/observability/package.json +1 -1
  54. package/packages/okf-indexer/package.json +1 -1
  55. package/packages/paperclip-integration/package.json +1 -1
  56. package/packages/performance-optimizer/package.json +1 -1
  57. package/packages/privilege-broker/package.json +1 -1
  58. package/packages/project-analyzer/package.json +1 -1
  59. package/packages/project-bootstrap/package.json +1 -1
  60. package/packages/projection-engine/package.json +1 -1
  61. package/packages/prompt-compiler/package.json +1 -1
  62. package/packages/prompt-versioning/package.json +1 -1
  63. package/packages/provider-adapter-sdk/package.json +1 -1
  64. package/packages/provider-router/package.json +1 -1
  65. package/packages/provider-selector/package.json +1 -1
  66. package/packages/providers/package.json +1 -1
  67. package/packages/qdrant-skills/package.json +1 -1
  68. package/packages/quota-manager/package.json +1 -1
  69. package/packages/rate-limiter/package.json +1 -1
  70. package/packages/react-vite-plugin/package.json +1 -1
  71. package/packages/release-manager/package.json +1 -1
  72. package/packages/requirement-compiler/package.json +1 -1
  73. package/packages/runtime/package.json +1 -1
  74. package/packages/scheduler/package.json +1 -1
  75. package/packages/scheduler-adapter/package.json +1 -1
  76. package/packages/session/package.json +1 -1
  77. package/packages/session-api/package.json +1 -1
  78. package/packages/session-export/package.json +1 -1
  79. package/packages/shared-context/package.json +1 -1
  80. package/packages/skill-mcp-client/package.json +1 -1
  81. package/packages/skill-registry/package.json +1 -1
  82. package/packages/skills/package.json +1 -1
  83. package/packages/sprint-planner/package.json +1 -1
  84. package/packages/subscription-engine/package.json +1 -1
  85. package/packages/task-compiler/package.json +1 -1
  86. package/packages/telegram/package.json +1 -1
  87. package/packages/tencentdb-memory/package.json +1 -1
  88. package/packages/tencentdb-sync/package.json +1 -1
  89. package/packages/test-data-generator/package.json +1 -1
  90. package/packages/test-generator/package.json +1 -1
  91. package/packages/todo-bd-sync/package.json +1 -1
  92. package/packages/token-estimation/package.json +1 -1
  93. package/packages/token-optimizer/package.json +1 -1
  94. package/packages/trajectory/package.json +1 -1
  95. package/packages/tui/package.json +1 -1
  96. package/packages/types/package.json +1 -1
  97. package/packages/webmcp-tester/package.json +1 -1
  98. package/packages/wiki/dist/index.d.ts +3 -1
  99. package/packages/wiki/dist/index.d.ts.map +1 -1
  100. package/packages/wiki/dist/index.js +56 -23
  101. package/packages/wiki/dist/index.js.map +1 -1
  102. package/packages/wiki/dist/storage.d.ts +37 -7
  103. package/packages/wiki/dist/storage.d.ts.map +1 -1
  104. package/packages/wiki/dist/storage.js +206 -53
  105. package/packages/wiki/dist/storage.js.map +1 -1
  106. package/packages/wiki/package.json +1 -1
  107. package/packages/wiki/src/index.ts +68 -26
  108. package/packages/wiki/src/storage.ts +224 -49
  109. package/packages/workflow-events/package.json +1 -1
  110. package/packages/workspace-scanner/package.json +1 -1
  111. package/packages/worktree/package.json +1 -1
  112. package/packages/write-review/package.json +1 -1
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { existsSync, readFileSync } from "node:fs";
8
- import { join, resolve, relative } from "node:path";
8
+ import { join, resolve } from "node:path";
9
9
  import { randomUUID } from "node:crypto";
10
10
  import {
11
11
  WikiStorage,
@@ -13,7 +13,6 @@ import {
13
13
  computeHash,
14
14
  now,
15
15
  parseFrontmatter,
16
- combinePage,
17
16
  } from "./storage.js";
18
17
  import type {
19
18
  WikiConfig,
@@ -43,12 +42,16 @@ export function isWikiEnabled(): boolean {
43
42
  }
44
43
 
45
44
  /**
46
- * Require wiki to be enabled
45
+ * Require wiki to be enabled and ensure storage is initialized
47
46
  */
48
- function requireWiki(): void {
47
+ function requireWiki(storage: WikiStorage): void {
49
48
  if (!isWikiEnabled()) {
50
49
  throw new Error("Wiki feature is disabled. Set HARNESS_KNOWLEDGE_WIKI=1 to enable.");
51
50
  }
51
+ // Lazy initialization: ensure storage is ready on first use
52
+ if (!storage.isInitialized()) {
53
+ storage.initialize();
54
+ }
52
55
  }
53
56
 
54
57
  // ============================================================================
@@ -82,7 +85,9 @@ export class WikiManager {
82
85
  * Initialize wiki structure
83
86
  */
84
87
  initialize(): WikiInitResult {
85
- requireWiki();
88
+ // Initialize storage before checking readiness
89
+ this.storage.initialize();
90
+ requireWiki(this.storage);
86
91
  return this.storage.initialize();
87
92
  }
88
93
 
@@ -99,12 +104,13 @@ export class WikiManager {
99
104
 
100
105
  /**
101
106
  * Ingest a source file and create snapshot
107
+ * Returns existing snapshot if same requestId was already processed
102
108
  */
103
109
  ingestSource(
104
110
  sourcePath: string,
105
111
  requestId?: string
106
- ): { snapshot: SourceSnapshot; operation: WikiOperation } {
107
- requireWiki();
112
+ ): { snapshot: SourceSnapshot; operation: WikiOperation; existing: boolean } {
113
+ requireWiki(this.storage);
108
114
 
109
115
  // Validate source path
110
116
  const resolvedPath = resolve(sourcePath);
@@ -112,6 +118,30 @@ export class WikiManager {
112
118
  throw new Error(`Source file not found: ${sourcePath}`);
113
119
  }
114
120
 
121
+ // Check for duplicate request
122
+ if (requestId) {
123
+ const operations = this.storage.getRecentOperations(1000);
124
+ const existingOp = operations.find(
125
+ (op) => op.requestId === requestId && op.status === "success"
126
+ );
127
+ if (existingOp) {
128
+ const details = existingOp.details as { snapshotId?: string; sourcePath?: string };
129
+ // Check if the source path matches (same requestId for different source = conflict)
130
+ if (details.sourcePath && details.sourcePath !== resolvedPath) {
131
+ throw new Error(
132
+ `Request ID ${requestId} was already used for a different source path. ` +
133
+ `Original: ${details.sourcePath}, Requested: ${sourcePath}`
134
+ );
135
+ }
136
+ if (details.snapshotId) {
137
+ const snapshot = this.storage.getSnapshot(details.snapshotId);
138
+ if (snapshot) {
139
+ return { snapshot, operation: existingOp, existing: true };
140
+ }
141
+ }
142
+ }
143
+ }
144
+
115
145
  // Read content
116
146
  const content = readFileSync(resolvedPath, "utf8");
117
147
 
@@ -122,10 +152,13 @@ export class WikiManager {
122
152
  // Create snapshot
123
153
  const snapshot = this.storage.createSnapshot(resolvedPath, content, operation.id);
124
154
 
155
+ // Update operation with snapshot ID for future deduplication
156
+ this.storage.updateOperationDetails(operation.id, { snapshotId: snapshot.id });
157
+
125
158
  // Complete operation
126
159
  this.storage.completeOperation(operation.id, "success");
127
160
 
128
- return { snapshot, operation };
161
+ return { snapshot, operation, existing: false };
129
162
  } catch (err) {
130
163
  const error = err instanceof Error ? err.message : String(err);
131
164
  this.storage.completeOperation(operation.id, "failed", error);
@@ -137,7 +170,7 @@ export class WikiManager {
137
170
  * Get source snapshot
138
171
  */
139
172
  getSnapshot(snapshotId: string): SourceSnapshot | null {
140
- requireWiki();
173
+ requireWiki(this.storage);
141
174
  return this.storage.getSnapshot(snapshotId);
142
175
  }
143
176
 
@@ -156,7 +189,7 @@ export class WikiManager {
156
189
  relatedPages?: string[];
157
190
  codeVersion?: string;
158
191
  }): WikiPage {
159
- requireWiki();
192
+ requireWiki(this.storage);
160
193
 
161
194
  const id = `page-${randomUUID().slice(0, 8)}`;
162
195
  const timestamp = now();
@@ -199,7 +232,7 @@ export class WikiManager {
199
232
  verification: WikiPageFrontmatter["verification"];
200
233
  }>
201
234
  ): WikiPage {
202
- requireWiki();
235
+ requireWiki(this.storage);
203
236
 
204
237
  const existing = this.storage.loadPage(pageId);
205
238
  if (!existing) {
@@ -227,7 +260,7 @@ export class WikiManager {
227
260
  * Load a wiki page
228
261
  */
229
262
  loadPage(pageId: string): WikiPage | null {
230
- requireWiki();
263
+ requireWiki(this.storage);
231
264
  return this.storage.loadPage(pageId);
232
265
  }
233
266
 
@@ -235,7 +268,7 @@ export class WikiManager {
235
268
  * Delete a wiki page
236
269
  */
237
270
  deletePage(pageId: string): void {
238
- requireWiki();
271
+ requireWiki(this.storage);
239
272
  this.storage.deletePage(pageId);
240
273
  }
241
274
 
@@ -243,7 +276,7 @@ export class WikiManager {
243
276
  * Get all pages
244
277
  */
245
278
  getAllPages(): WikiPage[] {
246
- requireWiki();
279
+ requireWiki(this.storage);
247
280
  return this.storage.getAllPages();
248
281
  }
249
282
 
@@ -255,7 +288,7 @@ export class WikiManager {
255
288
  * Search wiki pages
256
289
  */
257
290
  search(query: string, filters?: WikiSearchFilters, limit = 10): WikiSearchResult[] {
258
- requireWiki();
291
+ requireWiki(this.storage);
259
292
  return this.storage.search(query, filters, limit);
260
293
  }
261
294
 
@@ -263,7 +296,7 @@ export class WikiManager {
263
296
  * Get page index
264
297
  */
265
298
  getIndex() {
266
- requireWiki();
299
+ requireWiki(this.storage);
267
300
  return this.storage.getIndex();
268
301
  }
269
302
 
@@ -275,7 +308,7 @@ export class WikiManager {
275
308
  * Stage a change for later application
276
309
  */
277
310
  stageChange(pageId: string, expectedRevision: string, newContent: string): StagedChange {
278
- requireWiki();
311
+ requireWiki(this.storage);
279
312
 
280
313
  const existing = this.storage.loadPage(pageId);
281
314
  if (!existing) {
@@ -297,7 +330,7 @@ export class WikiManager {
297
330
  * Get staged change for a page
298
331
  */
299
332
  getStagedChange(pageId: string): StagedChange | undefined {
300
- requireWiki();
333
+ requireWiki(this.storage);
301
334
  return this.storage.getStagedChange(pageId);
302
335
  }
303
336
 
@@ -305,7 +338,7 @@ export class WikiManager {
305
338
  * Clear staged change
306
339
  */
307
340
  clearStagedChange(pageId: string): void {
308
- requireWiki();
341
+ requireWiki(this.storage);
309
342
  this.storage.clearStagedChange(pageId);
310
343
  }
311
344
 
@@ -316,8 +349,8 @@ export class WikiManager {
316
349
  /**
317
350
  * Apply staged change with conflict detection
318
351
  */
319
- applyChange(pageId: string, requestId?: string): WikiPage {
320
- requireWiki();
352
+ applyChange(pageId: string, _requestId?: string): WikiPage {
353
+ requireWiki(this.storage);
321
354
 
322
355
  const staged = this.storage.getStagedChange(pageId);
323
356
  if (!staged) {
@@ -329,8 +362,10 @@ export class WikiManager {
329
362
  throw new Error(`Page not found: ${pageId}`);
330
363
  }
331
364
 
332
- // Check for conflicts
333
- const currentRevision = computeHash(existing.rawFrontmatter || "");
365
+ // Check for conflicts - hash entire page content to detect any changes
366
+ const currentRevision = computeHash(
367
+ (existing.rawFrontmatter || "") + "\n" + existing.body
368
+ );
334
369
  if (staged.expectedRevision !== currentRevision) {
335
370
  throw new Error(
336
371
  `Conflict: page was modified. Expected revision ${staged.expectedRevision}, found ${currentRevision}`
@@ -368,7 +403,7 @@ export class WikiManager {
368
403
  * Run lint checks on wiki
369
404
  */
370
405
  lint(scope?: "all" | "pages" | "links" | "freshness"): LintFinding[] {
371
- requireWiki();
406
+ requireWiki(this.storage);
372
407
 
373
408
  const findings: LintFinding[] = [];
374
409
 
@@ -543,7 +578,7 @@ export class WikiManager {
543
578
  * Get recent operations
544
579
  */
545
580
  getRecentOperations(limit = 50): WikiOperation[] {
546
- requireWiki();
581
+ requireWiki(this.storage);
547
582
  return this.storage.getRecentOperations(limit);
548
583
  }
549
584
  }
@@ -557,7 +592,14 @@ export class WikiManager {
557
592
  */
558
593
  export function createWikiManager(workspaceRoot: string, projectId: string): WikiManager {
559
594
  const storage = createWikiStorage(workspaceRoot, projectId);
560
- return new WikiManager(storage);
595
+ const manager = new WikiManager(storage);
596
+
597
+ // Initialize storage if feature is enabled (may be called lazily later)
598
+ if (process.env.HARNESS_KNOWLEDGE_WIKI === "1") {
599
+ storage.initialize();
600
+ }
601
+
602
+ return manager;
561
603
  }
562
604
 
563
605
  // Re-export storage class
@@ -14,7 +14,7 @@ import {
14
14
  readdirSync,
15
15
  statSync,
16
16
  } from "node:fs";
17
- import { join, dirname, relative } from "node:path";
17
+ import { join, dirname, relative, resolve } from "node:path";
18
18
  import { createHash, randomUUID } from "node:crypto";
19
19
  import type {
20
20
  WikiConfig,
@@ -66,7 +66,9 @@ export function parseFrontmatter(
66
66
  const frontmatter = parseYaml(yamlStr);
67
67
 
68
68
  return {
69
- frontmatter: frontmatter as unknown as WikiPageFrontmatter | null,
69
+ // SAFETY: parseYaml returns Record<string, unknown>, cast through unknown to satisfy TS
70
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
71
+ frontmatter: frontmatter as any,
70
72
  body: body.trim(),
71
73
  raw: yamlStr,
72
74
  };
@@ -74,26 +76,75 @@ export function parseFrontmatter(
74
76
 
75
77
  /**
76
78
  * Simple YAML parser for frontmatter
77
- * Handles: strings, booleans, numbers, arrays, nested objects
79
+ * Handles: strings, booleans, numbers, arrays, nested objects, YAML list items
78
80
  */
79
81
  function parseYaml(yamlStr: string): Record<string, unknown> {
80
82
  const result: Record<string, unknown> = {};
81
83
  const lines = yamlStr.split("\n");
82
- let currentKey = "";
83
- let currentIndent = 0;
84
84
  const stack: Array<{ obj: Record<string, unknown>; indent: number }> = [];
85
85
 
86
- for (const line of lines) {
86
+ for (let i = 0; i < lines.length; i++) {
87
+ const line = lines[i];
87
88
  const trimmed = line.trim();
88
89
  if (!trimmed || trimmed.startsWith("#")) continue;
89
90
 
90
91
  const indent = line.search(/\S/);
91
- const colonIdx = trimmed.indexOf(":");
92
92
 
93
+ // Handle YAML list items (lines starting with '-')
94
+ if (trimmed.startsWith("-")) {
95
+ const itemContent = trimmed.slice(1).trim();
96
+ const colonIdx = itemContent.indexOf(":");
97
+
98
+ if (colonIdx !== -1) {
99
+ // This is a list item with key-value pairs on same line or next lines
100
+ const key = itemContent.slice(0, colonIdx).trim();
101
+ const value = itemContent.slice(colonIdx + 1).trim();
102
+
103
+ // Pop stack while indent decreases
104
+ while (stack.length > 0 && indent < stack[stack.length - 1].indent) {
105
+ stack.pop();
106
+ }
107
+
108
+ // Find or create the array
109
+ const parent = stack.length > 0 ? stack[stack.length - 1].obj : result;
110
+ const arrayKey = parent ? Object.keys(parent).slice(-1)[0] : undefined;
111
+ let array: unknown[];
112
+
113
+ if (arrayKey && Array.isArray(parent[arrayKey])) {
114
+ array = parent[arrayKey] as unknown[];
115
+ } else {
116
+ // Start a new array
117
+ const newArray: unknown[] = [];
118
+ if (parent) {
119
+ parent[arrayKey || key] = newArray;
120
+ } else {
121
+ result[key] = newArray;
122
+ }
123
+ array = newArray;
124
+ }
125
+
126
+ // Create new object for this list item
127
+ const item: Record<string, unknown> = {};
128
+ array.push(item);
129
+
130
+ // Parse the key-value on this line
131
+ const parsedValue = parseValue(value);
132
+ if (parsedValue !== undefined) {
133
+ item[key] = parsedValue;
134
+ }
135
+
136
+ // Push this item to stack for nested properties
137
+ stack.push({ obj: item, indent });
138
+ }
139
+ continue;
140
+ }
141
+
142
+ // Regular key-value line
143
+ const colonIdx = trimmed.indexOf(":");
93
144
  if (colonIdx === -1) continue;
94
145
 
95
146
  const key = trimmed.slice(0, colonIdx).trim();
96
- let value: unknown = trimmed.slice(colonIdx + 1).trim();
147
+ const rawValue = trimmed.slice(colonIdx + 1).trim();
97
148
 
98
149
  // Pop stack while indent decreases
99
150
  while (stack.length > 0 && indent <= stack[stack.length - 1].indent) {
@@ -102,34 +153,41 @@ function parseYaml(yamlStr: string): Record<string, unknown> {
102
153
 
103
154
  const parent = stack.length > 0 ? stack[stack.length - 1].obj : result;
104
155
 
105
- // Parse value
106
- if (value === "" || value === "null" || value === "~") {
107
- value = null;
108
- } else if (value === "true") {
109
- value = true;
110
- } else if (value === "false") {
111
- value = false;
112
- } else if (/^-?\d+(\.\d+)?$/.test(value as string)) {
113
- value = parseFloat(value as string);
114
- } else if ((value as string).startsWith("[") && (value as string).endsWith("]")) {
115
- // Array
116
- const arrContent = (value as string).slice(1, -1);
117
- value = arrContent
118
- ? arrContent.split(",").map((s) => s.trim().replace(/^["']|["']$/g, ""))
119
- : [];
120
- } else {
121
- // String - remove quotes
122
- value = (value as string).replace(/^["']|["']$/g, "");
156
+ // Check if value is empty (nested content follows)
157
+ if (rawValue === "" || rawValue === "~" || rawValue === "null") {
158
+ // Nested content follows - create placeholder
159
+ const nested: Record<string, unknown> = {};
160
+ parent[key] = nested;
161
+ stack.push({ obj: nested, indent });
162
+ continue;
123
163
  }
124
164
 
125
- parent[key] = value;
126
- currentKey = key;
127
- currentIndent = indent;
165
+ parent[key] = parseValue(rawValue);
128
166
  }
129
167
 
130
168
  return result;
131
169
  }
132
170
 
171
+ /**
172
+ * Parse a YAML scalar value or array
173
+ */
174
+ type YamlValue = string | number | boolean | null | string[];
175
+ function parseValue(value: string): YamlValue {
176
+ if (value === "" || value === "null" || value === "~") {
177
+ return null;
178
+ }
179
+ if (value === "true") return true;
180
+ if (value === "false") return false;
181
+ if (/^-?\d+(\.\d+)?$/.test(value)) {
182
+ return parseFloat(value);
183
+ }
184
+ if (value.startsWith("[") && value.endsWith("]")) {
185
+ const content = value.slice(1, -1);
186
+ return content ? content.split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")) : [];
187
+ }
188
+ return value.replace(/^["']|["']$/g, "");
189
+ }
190
+
133
191
  /**
134
192
  * Serialize frontmatter to YAML string
135
193
  */
@@ -144,11 +202,15 @@ export function serializeFrontmatter(
144
202
  if (Array.isArray(value)) {
145
203
  if (value.length === 0) {
146
204
  lines.push(`${key}: []`);
147
- } else if (value.every((v) => typeof v === "object")) {
148
- // Array of objects
205
+ } else if (value.every((v) => typeof v === "object" && v !== null)) {
206
+ // Array of objects - serialize as proper YAML
149
207
  lines.push(`${key}:`);
150
208
  for (const item of value) {
151
- lines.push(` - ${JSON.stringify(item)}`);
209
+ lines.push(` - sourceId: ${(item as Record<string, unknown>).sourceId}`);
210
+ lines.push(` path: ${(item as Record<string, unknown>).path}`);
211
+ lines.push(` contentHash: ${(item as Record<string, unknown>).contentHash}`);
212
+ lines.push(` capturedAt: ${(item as Record<string, unknown>).capturedAt}`);
213
+ lines.push(` redacted: ${(item as Record<string, unknown>).redacted}`);
152
214
  }
153
215
  } else {
154
216
  lines.push(`${key}: [${value.join(", ")}]`);
@@ -195,16 +257,38 @@ export function combinePage(
195
257
  export class WikiStorage {
196
258
  private config: WikiConfig;
197
259
  private index: Map<string, PageIndexEntry> = new Map();
260
+ private indexMtime = 0; // Track when index was last loaded
198
261
  private operations: WikiOperation[] = [];
199
262
  private stagedChanges: Map<string, StagedChange> = new Map();
200
- private dirty = false;
263
+ private initialized = false;
201
264
 
202
265
  constructor(config: WikiConfig) {
203
266
  this.config = config;
204
- this.ensureStructure();
267
+ // Don't create directories until explicitly initialized
268
+ // This prevents writes when the feature is disabled
269
+ }
270
+
271
+ /**
272
+ * Initialize storage (call before any read/write operations)
273
+ * This is called automatically by WikiManager when feature is enabled
274
+ */
275
+ /**
276
+ * Prepare storage for use (load indexes and operations)
277
+ * Called after directories are ensured by initialize()
278
+ */
279
+ private prepareStorage(): void {
280
+ if (this.initialized) return;
205
281
  this.loadIndex();
206
282
  this.loadOperations();
207
283
  this.loadStaged();
284
+ this.initialized = true;
285
+ }
286
+
287
+ /**
288
+ * Check if storage is initialized
289
+ */
290
+ isInitialized(): boolean {
291
+ return this.initialized;
208
292
  }
209
293
 
210
294
  /**
@@ -302,20 +386,26 @@ verification:
302
386
  }
303
387
 
304
388
  /**
305
- * Ensure directory structure exists
389
+ * Load page index from disk (if changed since last load)
306
390
  */
307
- private ensureStructure(): void {
308
- if (!existsSync(this.config.wikiRoot)) {
309
- mkdirSync(this.config.wikiRoot, { recursive: true });
391
+ private loadIndex(force = false): void {
392
+ if (!force) {
393
+ try {
394
+ const stat = statSync(this.config.indexFile);
395
+ // Only reload if file was modified since last load
396
+ if (stat.mtimeMs <= this.indexMtime) {
397
+ return;
398
+ }
399
+ } catch {
400
+ // File doesn't exist or can't be stat'd
401
+ }
310
402
  }
311
- }
312
403
 
313
- /**
314
- * Load page index from disk
315
- */
316
- private loadIndex(): void {
317
404
  this.index.clear();
318
- if (!existsSync(this.config.indexFile)) return;
405
+ if (!existsSync(this.config.indexFile)) {
406
+ this.indexMtime = 0;
407
+ return;
408
+ }
319
409
 
320
410
  try {
321
411
  const content = readFileSync(this.config.indexFile, "utf8");
@@ -323,21 +413,51 @@ verification:
323
413
  for (const entry of entries) {
324
414
  this.index.set(entry.id, entry);
325
415
  }
416
+ const stat = statSync(this.config.indexFile);
417
+ this.indexMtime = stat.mtimeMs;
326
418
  } catch {
327
419
  // Invalid index, rebuild
420
+ this.indexMtime = 0;
328
421
  }
329
422
  }
330
423
 
331
424
  /**
332
- * Save page index to disk (atomic)
425
+ * Save page index to disk (atomic, merges with existing entries)
333
426
  */
334
427
  private saveIndex(): void {
335
- const entries = Array.from(this.index.values());
428
+ // Reload from disk to merge (handles concurrent writes)
429
+ const diskEntries = this.loadIndexFromDisk();
430
+ const merged = new Map<string, PageIndexEntry>(diskEntries);
431
+
432
+ // Apply in-memory changes
433
+ for (const [id, entry] of this.index) {
434
+ merged.set(id, entry);
435
+ }
436
+
437
+ const entries = Array.from(merged.values());
336
438
  const content = JSON.stringify(entries, null, 2);
337
439
  const tmpFile = this.config.indexFile + ".tmp";
338
440
  writeFileSync(tmpFile, content, "utf8");
339
441
  renameSync(tmpFile, this.config.indexFile);
340
- this.dirty = false;
442
+ }
443
+
444
+ /**
445
+ * Load index from disk only (no cache)
446
+ */
447
+ private loadIndexFromDisk(): Map<string, PageIndexEntry> {
448
+ const result = new Map<string, PageIndexEntry>();
449
+ if (!existsSync(this.config.indexFile)) return result;
450
+
451
+ try {
452
+ const content = readFileSync(this.config.indexFile, "utf8");
453
+ const entries: PageIndexEntry[] = JSON.parse(content);
454
+ for (const entry of entries) {
455
+ result.set(entry.id, entry);
456
+ }
457
+ } catch {
458
+ // Invalid index, return empty
459
+ }
460
+ return result;
341
461
  }
342
462
 
343
463
  /**
@@ -436,7 +556,9 @@ verification:
436
556
  * Get source snapshot by ID
437
557
  */
438
558
  getSnapshot(snapshotId: string): SourceSnapshot | null {
559
+ this.validateSnapshotId(snapshotId);
439
560
  const snapFile = join(this.config.rawDir, `${snapshotId}.json`);
561
+ this.ensureWithinDirectory(snapFile, this.config.rawDir);
440
562
  if (!existsSync(snapFile)) return null;
441
563
 
442
564
  try {
@@ -455,8 +577,10 @@ verification:
455
577
  * Save a wiki page
456
578
  */
457
579
  savePage(page: WikiPage): void {
580
+ this.validatePageId(page.frontmatter.id);
458
581
  const content = combinePage(page.frontmatter, page.body);
459
582
  const pageFile = join(this.config.knowledgeDir, `${page.frontmatter.id}.md`);
583
+ this.ensureWithinDirectory(pageFile, this.config.knowledgeDir);
460
584
  const tmpFile = pageFile + ".tmp";
461
585
  writeFileSync(tmpFile, content, "utf8");
462
586
  renameSync(tmpFile, pageFile);
@@ -474,14 +598,15 @@ verification:
474
598
  };
475
599
  this.index.set(entry.id, entry);
476
600
  this.saveIndex();
477
- this.dirty = true;
478
601
  }
479
602
 
480
603
  /**
481
604
  * Load a wiki page
482
605
  */
483
606
  loadPage(pageId: string): WikiPage | null {
607
+ this.validatePageId(pageId);
484
608
  const pageFile = join(this.config.knowledgeDir, `${pageId}.md`);
609
+ this.ensureWithinDirectory(pageFile, this.config.knowledgeDir);
485
610
  if (!existsSync(pageFile)) return null;
486
611
 
487
612
  const content = readFileSync(pageFile, "utf8");
@@ -492,10 +617,42 @@ verification:
492
617
  return { frontmatter, body, rawFrontmatter: raw };
493
618
  }
494
619
 
620
+ /**
621
+ * Validate page ID to prevent path traversal attacks
622
+ */
623
+ private validatePageId(pageId: string): void {
624
+ // Reject paths with directory separators or parent references
625
+ if (pageId.includes("/") || pageId.includes("\\") || pageId.includes("..")) {
626
+ throw new Error(`Invalid page ID: ${pageId}`);
627
+ }
628
+ }
629
+
630
+ /**
631
+ * Validate snapshot ID to prevent path traversal attacks
632
+ */
633
+ private validateSnapshotId(snapshotId: string): void {
634
+ // Reject paths with directory separators or parent references
635
+ if (snapshotId.includes("/") || snapshotId.includes("\\") || snapshotId.includes("..")) {
636
+ throw new Error(`Invalid snapshot ID: ${snapshotId}`);
637
+ }
638
+ }
639
+
640
+ /**
641
+ * Ensure a path is within the expected directory (defense in depth)
642
+ */
643
+ private ensureWithinDirectory(filePath: string, expectedDir: string): void {
644
+ const resolved = resolve(filePath);
645
+ const expected = resolve(expectedDir);
646
+ if (!resolved.startsWith(expected + "/") && resolved !== expected) {
647
+ throw new Error(`Path ${filePath} is outside expected directory ${expectedDir}`);
648
+ }
649
+ }
650
+
495
651
  /**
496
652
  * Delete a wiki page
497
653
  */
498
654
  deletePage(pageId: string): void {
655
+ this.validatePageId(pageId);
499
656
  const pageFile = join(this.config.knowledgeDir, `${pageId}.md`);
500
657
  if (existsSync(pageFile)) {
501
658
  unlinkSync(pageFile);
@@ -510,6 +667,9 @@ verification:
510
667
  * Get all pages
511
668
  */
512
669
  getAllPages(): WikiPage[] {
670
+ // Reload index to get latest changes from other processes
671
+ this.loadIndex();
672
+
513
673
  const pages: WikiPage[] = [];
514
674
  if (!existsSync(this.config.knowledgeDir)) return pages;
515
675
 
@@ -555,6 +715,8 @@ verification:
555
715
  * Get all staged changes
556
716
  */
557
717
  getAllStagedChanges(): StagedChange[] {
718
+ // Reload staged changes to get latest from other processes
719
+ this.loadStaged();
558
720
  return Array.from(this.stagedChanges.values());
559
721
  }
560
722
 
@@ -603,6 +765,19 @@ verification:
603
765
  renameSync(tmpFile, this.config.logFile);
604
766
  }
605
767
 
768
+ /**
769
+ * Update operation details (before completion)
770
+ */
771
+ updateOperationDetails(opId: string, details: Record<string, unknown>): void {
772
+ const opIndex = this.operations.findIndex((o) => o.id === opId);
773
+ if (opIndex === -1) return;
774
+
775
+ this.operations[opIndex] = {
776
+ ...this.operations[opIndex],
777
+ details: { ...this.operations[opIndex].details, ...details },
778
+ };
779
+ }
780
+
606
781
  /**
607
782
  * Get recent operations
608
783
  */
@@ -621,7 +796,7 @@ verification:
621
796
  const queryLower = query.toLowerCase();
622
797
  const results: WikiSearchResult[] = [];
623
798
 
624
- for (const [id, entry] of this.index) {
799
+ for (const [_id, entry] of this.index) {
625
800
  // Apply filters
626
801
  if (filters?.kinds?.length && !filters.kinds.includes(entry.kind)) continue;
627
802
  if (filters?.statuses?.length && !filters.statuses.includes(entry.status)) continue;