@dommaker/harness 0.13.3 → 0.16.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 (117) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/bin/harness-knowledge-check.js +38 -23
  3. package/bin/harness-knowledge-track.js +30 -4
  4. package/bin/harness.js +24 -1
  5. package/dist/cli/commands/index.d.ts +1 -1
  6. package/dist/cli/commands/index.d.ts.map +1 -1
  7. package/dist/cli/commands/index.js +4 -1
  8. package/dist/cli/commands/index.js.map +1 -1
  9. package/dist/cli/commands/knowledge.d.ts +21 -0
  10. package/dist/cli/commands/knowledge.d.ts.map +1 -1
  11. package/dist/cli/commands/knowledge.js +160 -9
  12. package/dist/cli/commands/knowledge.js.map +1 -1
  13. package/dist/cli/commands/release.d.ts.map +1 -1
  14. package/dist/cli/commands/release.js +43 -6
  15. package/dist/cli/commands/release.js.map +1 -1
  16. package/dist/core/constraints/checker.js +1 -1
  17. package/dist/core/constraints/checker.js.map +1 -1
  18. package/dist/core/constraints/definitions.d.ts.map +1 -1
  19. package/dist/core/constraints/definitions.js +34 -26
  20. package/dist/core/constraints/definitions.js.map +1 -1
  21. package/dist/index.d.ts +3 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +6 -2
  24. package/dist/index.js.map +1 -1
  25. package/dist/knowledge/audit.d.ts +76 -0
  26. package/dist/knowledge/audit.d.ts.map +1 -0
  27. package/dist/knowledge/audit.js +566 -0
  28. package/dist/knowledge/audit.js.map +1 -0
  29. package/dist/knowledge/doctor.d.ts +1 -1
  30. package/dist/knowledge/doctor.d.ts.map +1 -1
  31. package/dist/knowledge/import.d.ts +1 -1
  32. package/dist/knowledge/import.d.ts.map +1 -1
  33. package/dist/knowledge/import.js +3 -0
  34. package/dist/knowledge/import.js.map +1 -1
  35. package/dist/knowledge/index.d.ts +7 -2
  36. package/dist/knowledge/index.d.ts.map +1 -1
  37. package/dist/knowledge/index.js +7 -2
  38. package/dist/knowledge/index.js.map +1 -1
  39. package/dist/knowledge/ingest.d.ts +28 -1
  40. package/dist/knowledge/ingest.d.ts.map +1 -1
  41. package/dist/knowledge/ingest.js +150 -5
  42. package/dist/knowledge/ingest.js.map +1 -1
  43. package/dist/knowledge/lifecycle-hooks.d.ts +1 -1
  44. package/dist/knowledge/lifecycle-hooks.d.ts.map +1 -1
  45. package/dist/knowledge/lifecycle-hooks.js +3 -0
  46. package/dist/knowledge/lifecycle-hooks.js.map +1 -1
  47. package/dist/knowledge/lifecycle.d.ts +60 -8
  48. package/dist/knowledge/lifecycle.d.ts.map +1 -1
  49. package/dist/knowledge/lifecycle.js +213 -52
  50. package/dist/knowledge/lifecycle.js.map +1 -1
  51. package/dist/knowledge/lint.d.ts +1 -1
  52. package/dist/knowledge/lint.d.ts.map +1 -1
  53. package/dist/knowledge/lint.js +44 -3
  54. package/dist/knowledge/lint.js.map +1 -1
  55. package/dist/knowledge/migration.d.ts +25 -0
  56. package/dist/knowledge/migration.d.ts.map +1 -0
  57. package/dist/knowledge/migration.js +96 -0
  58. package/dist/knowledge/migration.js.map +1 -0
  59. package/dist/knowledge/query.d.ts +30 -2
  60. package/dist/knowledge/query.d.ts.map +1 -1
  61. package/dist/knowledge/query.js +58 -0
  62. package/dist/knowledge/query.js.map +1 -1
  63. package/dist/knowledge/reference-tracker.d.ts +1 -1
  64. package/dist/knowledge/reference-tracker.d.ts.map +1 -1
  65. package/dist/knowledge/store.d.ts +50 -3
  66. package/dist/knowledge/store.d.ts.map +1 -1
  67. package/dist/knowledge/store.js +111 -9
  68. package/dist/knowledge/store.js.map +1 -1
  69. package/dist/knowledge/types.d.ts +43 -1
  70. package/dist/knowledge/types.d.ts.map +1 -1
  71. package/dist/knowledge/types.js +1 -0
  72. package/dist/knowledge/types.js.map +1 -1
  73. package/dist/monitoring/knowledge-evolver.d.ts +1 -1
  74. package/dist/monitoring/knowledge-evolver.d.ts.map +1 -1
  75. package/dist/types/constraint.d.ts +1 -1
  76. package/dist/types/constraint.d.ts.map +1 -1
  77. package/package.json +1 -1
  78. package/src/__tests__/checker.test.ts +4 -4
  79. package/src/cli/commands/__tests__/knowledge.test.ts +167 -0
  80. package/src/cli/commands/index.ts +1 -1
  81. package/src/cli/commands/knowledge.ts +176 -5
  82. package/src/cli/commands/release.ts +44 -6
  83. package/src/context/__tests__/knowledge-injector.test.ts +4 -1
  84. package/src/core/constraints/checker.ts +1 -1
  85. package/src/core/constraints/definitions.ts +35 -27
  86. package/src/dashboard/__tests__/data.test.ts +3 -0
  87. package/src/dashboard/__tests__/stats.test.ts +3 -0
  88. package/src/index.ts +3 -1
  89. package/src/knowledge/CONTEXT.md +29 -7
  90. package/src/knowledge/__tests__/audit.test.ts +598 -0
  91. package/src/knowledge/__tests__/doctor.test.ts +28 -1
  92. package/src/knowledge/__tests__/import.test.ts +1 -1
  93. package/src/knowledge/__tests__/ingest.test.ts +320 -1
  94. package/src/knowledge/__tests__/lifecycle-hooks.test.ts +4 -1
  95. package/src/knowledge/__tests__/lifecycle.test.ts +656 -3
  96. package/src/knowledge/__tests__/lint.test.ts +52 -1
  97. package/src/knowledge/__tests__/migration.test.ts +115 -0
  98. package/src/knowledge/__tests__/query.test.ts +87 -1
  99. package/src/knowledge/__tests__/reference-tracker.test.ts +4 -1
  100. package/src/knowledge/__tests__/store.test.ts +208 -1
  101. package/src/knowledge/audit.ts +641 -0
  102. package/src/knowledge/doctor.ts +1 -1
  103. package/src/knowledge/import.ts +4 -1
  104. package/src/knowledge/index.ts +7 -2
  105. package/src/knowledge/ingest.ts +170 -6
  106. package/src/knowledge/lifecycle-hooks.ts +4 -1
  107. package/src/knowledge/lifecycle.ts +219 -50
  108. package/src/knowledge/lint.ts +45 -4
  109. package/src/knowledge/migration.ts +73 -0
  110. package/src/knowledge/query.ts +72 -1
  111. package/src/knowledge/reference-tracker.ts +1 -1
  112. package/src/knowledge/store.ts +125 -8
  113. package/src/knowledge/types.ts +50 -1
  114. package/src/monitoring/__tests__/knowledge-doctor.test.ts +3 -0
  115. package/src/monitoring/__tests__/knowledge-evolver.test.ts +3 -0
  116. package/src/monitoring/knowledge-evolver.ts +1 -1
  117. package/src/types/constraint.ts +1 -1
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { KnowledgeLinter } from '../lint';
6
- import { KnowledgeStore } from '../store';
6
+ import { FileKnowledgeStore as KnowledgeStore } from '../store';
7
7
  import { ReferenceTracker } from '../reference-tracker';
8
8
  import * as fs from 'fs';
9
9
  import * as path from 'path';
@@ -42,6 +42,9 @@ describe('KnowledgeLinter', () => {
42
42
  applicablePhases: [],
43
43
  sourceReferences: [],
44
44
  referencedBy: [],
45
+ executionResults: [],
46
+ consumptionMode: 'reference',
47
+ origin: 'agent',
45
48
  ...overrides,
46
49
  });
47
50
  }
@@ -397,5 +400,53 @@ describe('KnowledgeLinter', () => {
397
400
  const entry = store.get('old-1');
398
401
  expect(entry?.maturity).toBe('archived');
399
402
  });
403
+
404
+ it('A3: 应该归档重复条目,保留最新,合并 sourceReferences', () => {
405
+ // Create 3 entries with same title/type, different lastReferenced dates
406
+ saveEntry({
407
+ id: 'dup-old',
408
+ title: 'Duplicate Title',
409
+ type: 'decision',
410
+ lastReferenced: '2026-01-01T00:00:00.000Z',
411
+ sourceReferences: [{ workflow: 'old-wf', timestamp: '2026-01-01T00:00:00.000Z' }],
412
+ });
413
+ saveEntry({
414
+ id: 'dup-mid',
415
+ title: 'Duplicate Title',
416
+ type: 'decision',
417
+ lastReferenced: '2026-03-01T00:00:00.000Z',
418
+ sourceReferences: [{ workflow: 'mid-wf', timestamp: '2026-03-01T00:00:00.000Z' }],
419
+ });
420
+ saveEntry({
421
+ id: 'dup-new',
422
+ title: 'Duplicate Title',
423
+ type: 'decision',
424
+ lastReferenced: '2026-05-01T00:00:00.000Z',
425
+ sourceReferences: [{ workflow: 'new-wf', timestamp: '2026-05-01T00:00:00.000Z' }],
426
+ });
427
+
428
+ const report = linter.run(true);
429
+
430
+ // Should have fixed 2 duplicates (archived old and mid)
431
+ const dupFixes = report.issues.filter(i => i.type === 'duplicate');
432
+ expect(dupFixes.length).toBe(3); // all 3 reported as duplicates
433
+ expect(report.fixed).toBeGreaterThanOrEqual(2);
434
+
435
+ // Newest should be kept
436
+ const kept = store.get('dup-new');
437
+ expect(kept).toBeDefined();
438
+ expect(kept!.maturity).not.toBe('archived');
439
+
440
+ // Old and mid should be archived
441
+ expect(store.get('dup-old')!.maturity).toBe('archived');
442
+ expect(store.get('dup-mid')!.maturity).toBe('archived');
443
+
444
+ // SourceReferences from archived entries should be merged into kept entry
445
+ expect(kept!.sourceReferences.length).toBeGreaterThanOrEqual(3);
446
+ const workflows = kept!.sourceReferences.map(r => r.workflow);
447
+ expect(workflows).toContain('old-wf');
448
+ expect(workflows).toContain('mid-wf');
449
+ expect(workflows).toContain('new-wf');
450
+ });
400
451
  });
401
452
  });
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Knowledge Migration 测试
3
+ */
4
+
5
+ import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals';
6
+ import { migrateKnowledgeEntries } from '../migration';
7
+ import * as fs from 'fs';
8
+ import * as path from 'path';
9
+
10
+ describe('migrateKnowledgeEntries', () => {
11
+ const tempDir = path.join(process.cwd(), 'temp-test-migration');
12
+
13
+ beforeAll(() => {
14
+ fs.mkdirSync(tempDir, { recursive: true });
15
+ });
16
+
17
+ afterAll(() => {
18
+ try {
19
+ fs.rmSync(tempDir, { recursive: true, force: true });
20
+ } catch {
21
+ // ignore
22
+ }
23
+ });
24
+
25
+ beforeEach(() => {
26
+ // Clean directory
27
+ for (const f of fs.readdirSync(tempDir)) {
28
+ fs.unlinkSync(path.join(tempDir, f));
29
+ }
30
+ });
31
+
32
+ function writeEntry(filename: string, meta: Record<string, unknown>, content = 'Body content') {
33
+ const frontmatter = Object.entries(meta)
34
+ .map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
35
+ .join('\n');
36
+ const raw = `---\n${frontmatter}\n---\n\n${content}`;
37
+ fs.writeFileSync(path.join(tempDir, filename), raw, 'utf-8');
38
+ }
39
+
40
+ function readMeta(filename: string): Record<string, unknown> {
41
+ const raw = fs.readFileSync(path.join(tempDir, filename), 'utf-8');
42
+ const match = raw.match(/^---\n([\s\S]*?)\n---/)!;
43
+ return JSON.parse(JSON.stringify(eval(`(${match[1].replace(/(\w+):/g, '"$1":').replace(/'/g, '"')})`)));
44
+ }
45
+
46
+ it('should add consumptionMode and origin to entries missing them', () => {
47
+ writeEntry('DEC-001.md', {
48
+ id: 'DEC-001', type: 'decision', title: 'Test', maturity: 'draft',
49
+ layer: 'project', created: '2026-05-01', lastReferenced: '',
50
+ contributors: [], projects: [], tags: [], applicablePhases: [],
51
+ sourceReferences: [], referencedBy: [], executionResults: [],
52
+ });
53
+
54
+ const result = migrateKnowledgeEntries(tempDir);
55
+ expect(result.total).toBe(1);
56
+ expect(result.migrated).toBe(1);
57
+ expect(result.skipped).toBe(0);
58
+ expect(result.errors).toHaveLength(0);
59
+
60
+ // Verify the file was updated
61
+ const raw = fs.readFileSync(path.join(tempDir, 'DEC-001.md'), 'utf-8');
62
+ expect(raw).toContain('consumptionMode');
63
+ expect(raw).toContain('origin');
64
+ });
65
+
66
+ it('should skip entries that already have both fields', () => {
67
+ writeEntry('DEC-002.md', {
68
+ id: 'DEC-002', type: 'decision', title: 'Test', maturity: 'draft',
69
+ layer: 'project', created: '2026-05-01', lastReferenced: '',
70
+ contributors: [], projects: [], tags: [], applicablePhases: [],
71
+ sourceReferences: [], referencedBy: [], executionResults: [],
72
+ consumptionMode: 'rule', origin: 'human',
73
+ });
74
+
75
+ const result = migrateKnowledgeEntries(tempDir);
76
+ expect(result.total).toBe(1);
77
+ expect(result.migrated).toBe(0);
78
+ expect(result.skipped).toBe(1);
79
+ });
80
+
81
+ it('should handle empty directory', () => {
82
+ const result = migrateKnowledgeEntries(path.join(tempDir, 'nonexistent'));
83
+ expect(result.total).toBe(0);
84
+ expect(result.migrated).toBe(0);
85
+ });
86
+
87
+ it('should handle non-markdown files gracefully', () => {
88
+ fs.writeFileSync(path.join(tempDir, 'index.json'), '{}', 'utf-8');
89
+ writeEntry('DEC-003.md', {
90
+ id: 'DEC-003', type: 'decision', title: 'Test', maturity: 'draft',
91
+ layer: 'project', created: '2026-05-01', lastReferenced: '',
92
+ contributors: [], projects: [], tags: [], applicablePhases: [],
93
+ sourceReferences: [], referencedBy: [], executionResults: [],
94
+ });
95
+
96
+ const result = migrateKnowledgeEntries(tempDir);
97
+ expect(result.total).toBe(1); // only .md files counted
98
+ expect(result.migrated).toBe(1);
99
+ });
100
+
101
+ it('should be idempotent', () => {
102
+ writeEntry('DEC-004.md', {
103
+ id: 'DEC-004', type: 'decision', title: 'Test', maturity: 'draft',
104
+ layer: 'project', created: '2026-05-01', lastReferenced: '',
105
+ contributors: [], projects: [], tags: [], applicablePhases: [],
106
+ sourceReferences: [], referencedBy: [], executionResults: [],
107
+ });
108
+
109
+ const first = migrateKnowledgeEntries(tempDir);
110
+ const second = migrateKnowledgeEntries(tempDir);
111
+ expect(first.migrated).toBe(1);
112
+ expect(second.migrated).toBe(0);
113
+ expect(second.skipped).toBe(1);
114
+ });
115
+ });
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals';
6
- import { KnowledgeStore } from '../store';
6
+ import { FileKnowledgeStore as KnowledgeStore } from '../store';
7
7
  import { KnowledgeQuery } from '../query';
8
8
  import type { KnowledgeEntry, QueryBudget } from '../types';
9
9
  import * as fs from 'fs';
@@ -29,6 +29,9 @@ describe('KnowledgeQuery', () => {
29
29
  applicablePhases: [],
30
30
  sourceReferences: [],
31
31
  referencedBy: [],
32
+ executionResults: [],
33
+ consumptionMode: 'reference',
34
+ origin: 'agent',
32
35
  ...overrides,
33
36
  });
34
37
 
@@ -182,4 +185,87 @@ describe('KnowledgeQuery', () => {
182
185
  expect(result.fromCache).toBe(false);
183
186
  });
184
187
  });
188
+
189
+ describe('queryByMode', () => {
190
+ it('should filter by consumptionMode', () => {
191
+ store.save(makeEntry({ id: 'RUL-001', consumptionMode: 'rule', title: 'Rule' }));
192
+ store.save(makeEntry({ id: 'REF-001', consumptionMode: 'reference', title: 'Ref' }));
193
+ store.save(makeEntry({ id: 'SIG-001', consumptionMode: 'signal', title: 'Signal' }));
194
+
195
+ const rules = query.queryByMode('rule');
196
+ expect(rules).toHaveLength(1);
197
+ expect(rules[0].id).toBe('RUL-001');
198
+ });
199
+
200
+ it('should combine with additional filter', () => {
201
+ store.save(makeEntry({ id: 'REF-001', consumptionMode: 'reference', tags: ['arch'] }));
202
+ store.save(makeEntry({ id: 'REF-002', consumptionMode: 'reference', tags: ['api'] }));
203
+
204
+ const refs = query.queryByMode('reference', { tags: ['arch'] });
205
+ expect(refs).toHaveLength(1);
206
+ expect(refs[0].id).toBe('REF-001');
207
+ });
208
+ });
209
+
210
+ describe('consume', () => {
211
+ it('should return rules, references, and context', () => {
212
+ store.save(makeEntry({ id: 'RUL-001', consumptionMode: 'rule', title: 'Port Rule' }));
213
+ store.save(makeEntry({
214
+ id: 'REF-001', consumptionMode: 'reference', title: 'Architecture',
215
+ content: 'The API uses TypeScript and REST endpoints',
216
+ }));
217
+ store.save(makeEntry({
218
+ id: 'CTX-001', consumptionMode: 'context', title: 'Env',
219
+ applicablePhases: ['build'],
220
+ }));
221
+
222
+ const result = query.consume({ requirementText: 'API TypeScript', phase: 'build' });
223
+ expect(result.rules).toHaveLength(1);
224
+ expect(result.rules[0].id).toBe('RUL-001');
225
+ expect(result.references.length).toBeGreaterThanOrEqual(1);
226
+ expect(result.context).toHaveLength(1);
227
+ expect(result.context[0].id).toBe('CTX-001');
228
+ });
229
+
230
+ it('should filter context by phase', () => {
231
+ store.save(makeEntry({
232
+ id: 'CTX-001', consumptionMode: 'context', title: 'Build Config',
233
+ applicablePhases: ['build'],
234
+ }));
235
+ store.save(makeEntry({
236
+ id: 'CTX-002', consumptionMode: 'context', title: 'Deploy Config',
237
+ applicablePhases: ['deploy'],
238
+ }));
239
+
240
+ const result = query.consume({ phase: 'build' });
241
+ expect(result.context).toHaveLength(1);
242
+ expect(result.context[0].id).toBe('CTX-001');
243
+ });
244
+
245
+ it('should return empty references when no requirementText', () => {
246
+ store.save(makeEntry({ id: 'REF-001', consumptionMode: 'reference' }));
247
+ const result = query.consume({});
248
+ // With no text filter, returns up to 3
249
+ expect(result.references.length).toBeLessThanOrEqual(3);
250
+ });
251
+ });
252
+
253
+ describe('formatForPrompt', () => {
254
+ it('should add External Source prefix for external origin', () => {
255
+ const { KnowledgeQuery: KQ } = require('../query');
256
+ const entry = makeEntry({ origin: 'external', title: 'GitHub Doc', content: 'Architecture' });
257
+ const result = KQ.formatForPrompt(entry);
258
+ expect(result).toContain('[External Source');
259
+ expect(result).toContain('GitHub Doc');
260
+ expect(result).toContain('Architecture');
261
+ });
262
+
263
+ it('should not add prefix for agent origin', () => {
264
+ const { KnowledgeQuery: KQ } = require('../query');
265
+ const entry = makeEntry({ origin: 'agent', title: 'Internal', content: 'Details' });
266
+ const result = KQ.formatForPrompt(entry);
267
+ expect(result).not.toContain('[External Source');
268
+ expect(result).toBe('Internal: Details');
269
+ });
270
+ });
185
271
  });
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals';
6
- import { KnowledgeStore } from '../store';
6
+ import { FileKnowledgeStore as KnowledgeStore } from '../store';
7
7
  import { ReferenceTracker } from '../reference-tracker';
8
8
  import type { KnowledgeEntry } from '../types';
9
9
  import * as fs from 'fs';
@@ -29,6 +29,9 @@ describe('ReferenceTracker', () => {
29
29
  applicablePhases: [],
30
30
  sourceReferences: [],
31
31
  referencedBy: [],
32
+ executionResults: [],
33
+ consumptionMode: 'reference',
34
+ origin: 'agent',
32
35
  ...overrides,
33
36
  });
34
37
 
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals';
6
- import { KnowledgeStore } from '../store';
6
+ import { FileKnowledgeStore as KnowledgeStore } from '../store';
7
7
  import type { KnowledgeEntry } from '../types';
8
8
  import * as fs from 'fs';
9
9
  import * as path from 'path';
@@ -27,6 +27,9 @@ describe('KnowledgeStore', () => {
27
27
  applicablePhases: ['ARCHITECT'],
28
28
  sourceReferences: [],
29
29
  referencedBy: [],
30
+ executionResults: [],
31
+ consumptionMode: 'reference',
32
+ origin: 'agent',
30
33
  ...overrides,
31
34
  });
32
35
 
@@ -125,6 +128,23 @@ describe('KnowledgeStore', () => {
125
128
  const list = store.list({ excludeArchived: false });
126
129
  expect(list).toHaveLength(2);
127
130
  });
131
+
132
+ it('should filter by consumptionMode', () => {
133
+ store.save(makeEntry({ id: 'DEC-001', consumptionMode: 'reference' }));
134
+ store.save(makeEntry({ id: 'DEC-002', consumptionMode: 'rule', title: 'Rule' }));
135
+ store.save(makeEntry({ id: 'DEC-003', consumptionMode: 'signal', title: 'Signal' }));
136
+ const list = store.list({ consumptionModes: ['rule', 'signal'] });
137
+ expect(list).toHaveLength(2);
138
+ expect(list.map(e => e.id).sort()).toEqual(['DEC-002', 'DEC-003']);
139
+ });
140
+
141
+ it('should filter by origin', () => {
142
+ store.save(makeEntry({ id: 'DEC-001', origin: 'agent' }));
143
+ store.save(makeEntry({ id: 'DEC-002', origin: 'human', title: 'Human' }));
144
+ const list = store.list({ origins: ['human'] });
145
+ expect(list).toHaveLength(1);
146
+ expect(list[0].origin).toBe('human');
147
+ });
128
148
  });
129
149
 
130
150
  describe('delete', () => {
@@ -164,6 +184,32 @@ describe('KnowledgeStore', () => {
164
184
  });
165
185
 
166
186
  describe('edge cases', () => {
187
+ it('should round-trip consumptionMode and origin through save/load', () => {
188
+ store.save(makeEntry({
189
+ id: 'DEC-001',
190
+ consumptionMode: 'rule',
191
+ origin: 'human',
192
+ fullContentPath: 'https://example.com/doc',
193
+ skillId: 'skill-001',
194
+ }));
195
+ const loaded = store.get('DEC-001');
196
+ expect(loaded?.consumptionMode).toBe('rule');
197
+ expect(loaded?.origin).toBe('human');
198
+ expect(loaded?.fullContentPath).toBe('https://example.com/doc');
199
+ expect(loaded?.skillId).toBe('skill-001');
200
+ });
201
+
202
+ it('should default consumptionMode to reference and origin to agent', () => {
203
+ store.save(makeEntry({ id: 'DEC-001' }));
204
+ const loaded = store.get('DEC-001');
205
+ expect(loaded?.consumptionMode).toBe('reference');
206
+ expect(loaded?.origin).toBe('agent');
207
+ });
208
+
209
+ it('getBaseDir() returns configured baseDir', () => {
210
+ expect(store.getBaseDir()).toBe(tempDir);
211
+ });
212
+
167
213
  it('should create directory if it does not exist', () => {
168
214
  const newDir = path.join(process.cwd(), 'temp-test-knowledge-nested', 'sub', 'deep');
169
215
  const s = new KnowledgeStore({ baseDir: newDir });
@@ -235,4 +281,165 @@ describe('KnowledgeStore', () => {
235
281
  expect(retrieved!.decayAt).toBeUndefined();
236
282
  });
237
283
  });
284
+
285
+ describe('findFile precision (E3)', () => {
286
+ it('should not collide when one id is a suffix of another', () => {
287
+ // pitfall with id containing hyphen: file = pitfall-mppq8n44-1829.md
288
+ const pitfall = makeEntry({
289
+ id: 'PIT-mppq8n44-1829',
290
+ type: 'pitfall',
291
+ title: 'Pitfall Entry',
292
+ content: 'pitfall content',
293
+ });
294
+ // decision with numeric id that is a suffix: file = decision-1829.md
295
+ const decision = makeEntry({
296
+ id: '1829',
297
+ type: 'decision',
298
+ title: 'Decision Entry',
299
+ content: 'decision content',
300
+ });
301
+ store.save(pitfall);
302
+ store.save(decision);
303
+
304
+ // get('1829') must return the decision, not the pitfall
305
+ const retrieved = store.get('1829');
306
+ expect(retrieved).toBeDefined();
307
+ expect(retrieved!.id).toBe('1829');
308
+ expect(retrieved!.type).toBe('decision');
309
+
310
+ // get('PIT-mppq8n44-1829') must return the pitfall
311
+ const pit = store.get('PIT-mppq8n44-1829');
312
+ expect(pit).toBeDefined();
313
+ expect(pit!.id).toBe('PIT-mppq8n44-1829');
314
+ expect(pit!.type).toBe('pitfall');
315
+ });
316
+
317
+ it('should not collide when id contains type prefix of another entry', () => {
318
+ // model with id that contains "GUI" prefix: file = model-GUI-003.md
319
+ const model = makeEntry({
320
+ id: 'GUI-003',
321
+ type: 'model',
322
+ title: 'Model Entry',
323
+ content: 'model content',
324
+ });
325
+ // guideline with id "003": file = guideline-003.md
326
+ const guideline = makeEntry({
327
+ id: '003',
328
+ type: 'guideline',
329
+ title: 'Guideline Entry',
330
+ content: 'guideline content',
331
+ });
332
+ store.save(model);
333
+ store.save(guideline);
334
+
335
+ const retrieved = store.get('003');
336
+ expect(retrieved).toBeDefined();
337
+ expect(retrieved!.id).toBe('003');
338
+ expect(retrieved!.type).toBe('guideline');
339
+ });
340
+
341
+ it('should handle delete correctly with colliding ids', () => {
342
+ const pitfall = makeEntry({
343
+ id: 'PIT-mppq8n44-1829',
344
+ type: 'pitfall',
345
+ title: 'Pitfall Entry',
346
+ content: 'pitfall content',
347
+ });
348
+ const decision = makeEntry({
349
+ id: '1829',
350
+ type: 'decision',
351
+ title: 'Decision Entry',
352
+ content: 'decision content',
353
+ });
354
+ store.save(pitfall);
355
+ store.save(decision);
356
+
357
+ // delete('1829') should only delete the decision
358
+ expect(store.delete('1829')).toBe(true);
359
+ expect(store.get('1829')).toBeUndefined();
360
+ // pitfall should still exist
361
+ expect(store.get('PIT-mppq8n44-1829')).toBeDefined();
362
+ });
363
+ });
364
+
365
+ describe('snapshot', () => {
366
+ it('should create snapshot file in .snapshots/', () => {
367
+ store.save(makeEntry({ id: 'SNAP-001' }));
368
+ const snapPath = store.snapshot();
369
+ expect(fs.existsSync(snapPath)).toBe(true);
370
+ expect(snapPath).toContain('.snapshots/index-');
371
+ const content = JSON.parse(fs.readFileSync(snapPath, 'utf-8'));
372
+ expect(content.length).toBeGreaterThanOrEqual(1);
373
+ });
374
+
375
+ it('should overwrite existing snapshot for same date', () => {
376
+ store.save(makeEntry({ id: 'SNAP-001' }));
377
+ store.snapshot();
378
+ store.save(makeEntry({ id: 'SNAP-002', title: 'Second' }));
379
+ store.snapshot();
380
+ const today = new Date().toISOString().slice(0, 10);
381
+ const snapPath = path.join(tempDir, '.snapshots', `index-${today}.json`);
382
+ const content = JSON.parse(fs.readFileSync(snapPath, 'utf-8'));
383
+ expect(content.some((e: any) => e.id === 'SNAP-002')).toBe(true);
384
+ });
385
+
386
+ it('should read snapshot by date', () => {
387
+ store.save(makeEntry({ id: 'SNAP-001' }));
388
+ store.snapshot();
389
+ const today = new Date().toISOString().slice(0, 10);
390
+ const snapshot = store.getSnapshot(today);
391
+ expect(snapshot).toBeDefined();
392
+ expect(snapshot!.some(e => e.id === 'SNAP-001')).toBe(true);
393
+ });
394
+
395
+ it('should return undefined for non-existent snapshot date', () => {
396
+ expect(store.getSnapshot('2020-01-01')).toBeUndefined();
397
+ });
398
+ });
399
+
400
+ describe('getSurvivalRate', () => {
401
+ it('should return undefined when no snapshot exists', () => {
402
+ expect(store.getSurvivalRate(30)).toBeUndefined();
403
+ });
404
+
405
+ it('should return 100% when all entries still active', () => {
406
+ store.save(makeEntry({ id: 'SUR-001' }));
407
+ store.save(makeEntry({ id: 'SUR-002' }));
408
+ // Create a snapshot with today's date, then check survival against 0 days ago
409
+ store.snapshot();
410
+ // Manually copy snapshot to look like 30 days ago
411
+ const today = new Date().toISOString().slice(0, 10);
412
+ const pastDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
413
+ const snapDir = path.join(tempDir, '.snapshots');
414
+ fs.copyFileSync(
415
+ path.join(snapDir, `index-${today}.json`),
416
+ path.join(snapDir, `index-${pastDate}.json`),
417
+ );
418
+ const result = store.getSurvivalRate(30);
419
+ expect(result).toBeDefined();
420
+ expect(result!.rate).toBe(100);
421
+ expect(result!.survived).toBe(2);
422
+ expect(result!.total).toBe(2);
423
+ });
424
+
425
+ it('should count archived entries as not survived', () => {
426
+ store.save(makeEntry({ id: 'SUR-001' }));
427
+ store.save(makeEntry({ id: 'SUR-002' }));
428
+ store.snapshot();
429
+ // Archive one entry after snapshot
430
+ store.update('SUR-002', { maturity: 'archived' });
431
+ // Copy snapshot to 30 days ago
432
+ const today = new Date().toISOString().slice(0, 10);
433
+ const pastDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
434
+ const snapDir = path.join(tempDir, '.snapshots');
435
+ fs.copyFileSync(
436
+ path.join(snapDir, `index-${today}.json`),
437
+ path.join(snapDir, `index-${pastDate}.json`),
438
+ );
439
+ const result = store.getSurvivalRate(30);
440
+ expect(result!.rate).toBe(50);
441
+ expect(result!.survived).toBe(1);
442
+ expect(result!.total).toBe(2);
443
+ });
444
+ });
238
445
  });