@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
@@ -12,7 +12,43 @@ import type {
12
12
  IngestOptions,
13
13
  SourceRef,
14
14
  } from './types';
15
- import { KnowledgeStore } from './store';
15
+ import type { KnowledgeStore } from './store';
16
+ import { KnowledgeAudit } from './audit';
17
+
18
+ const MAX_SOURCE_REFS = 20;
19
+ const MAX_EXTERNAL_CONTENT_LENGTH = 5000;
20
+
21
+ /** Known prompt injection patterns to strip from external content */
22
+ const INJECTION_PATTERNS = [
23
+ /ignore\s+(all\s+)?previous\s+instructions/gi,
24
+ /ignore\s+(all\s+)?prior\s+instructions/gi,
25
+ /system:\s*/gi,
26
+ /\[INST\]/gi,
27
+ /\[\/INST\]/gi,
28
+ /<\|im_start\|>/gi,
29
+ /<\|im_end\|>/gi,
30
+ /you\s+are\s+now\s+/gi,
31
+ /forget\s+(everything|all)\s+(you|about)/gi,
32
+ /new\s+instructions?:/gi,
33
+ /override\s+(your|system)\s+(instructions|prompt)/gi,
34
+ ];
35
+
36
+ /**
37
+ * Sanitize external content for safe ingest.
38
+ * - Strips known prompt injection patterns
39
+ * - Limits content length
40
+ * - Returns sanitized string
41
+ */
42
+ export function sanitizeExternalContent(content: string): string {
43
+ let sanitized = content;
44
+ for (const pattern of INJECTION_PATTERNS) {
45
+ sanitized = sanitized.replace(pattern, '[FILTERED]');
46
+ }
47
+ if (sanitized.length > MAX_EXTERNAL_CONTENT_LENGTH) {
48
+ sanitized = sanitized.slice(0, MAX_EXTERNAL_CONTENT_LENGTH) + '...[truncated]';
49
+ }
50
+ return sanitized;
51
+ }
16
52
 
17
53
  // ── Ingest ─────────────────────────────────────────────────
18
54
 
@@ -34,14 +70,49 @@ export class KnowledgeIngest {
34
70
  ): KnowledgeEntry {
35
71
  const entry = this.buildEntry(partial, options);
36
72
 
73
+ // Quality gate: audit before saving
74
+ const audit = new KnowledgeAudit({ baseDir: this.store.getBaseDir() });
75
+ let issues = audit.validate(entry);
76
+ const critical = issues.filter(i => i.action === 'reject');
77
+ if (critical.length > 0) {
78
+ // Reject: return entry without saving, caller can check issues
79
+ (entry as any).__rejected = true;
80
+ (entry as any).__rejectReasons = critical.map(i => i.detail);
81
+ return entry;
82
+ }
83
+
84
+ // If caller explicitly set maturity, skip demote actions from audit.
85
+ // Demote is for auto-inferred maturity that's too high; explicit is a user decision.
86
+ if (options.maturity) {
87
+ issues = issues.filter(i => i.action !== 'demote');
88
+ }
89
+
37
90
  // Dedup check: same title + same type
38
- const existing = this.findDuplicate(entry.title, entry.type);
91
+ const existing = this.findDuplicate(entry.title, entry.content, entry.type);
39
92
  if (existing) {
40
93
  // Merge: update existing entry with new content and metadata
41
94
  return this.mergeEntries(existing, entry, options);
42
95
  }
43
96
 
44
97
  this.store.save(entry);
98
+
99
+ // Post-save: apply auto-fixes (archive/demote/flag)
100
+ const fixable = issues.filter(i => i.action !== 'reject');
101
+ if (fixable.length > 0) {
102
+ for (const issue of fixable) {
103
+ if (issue.action === 'archive') {
104
+ this.store.update(entry.id, { maturity: 'archived' });
105
+ } else if (issue.action === 'demote') {
106
+ this.store.update(entry.id, { maturity: 'draft' });
107
+ } else if (issue.action === 'flag') {
108
+ const saved = this.store.get(entry.id);
109
+ if (saved && !saved.tags.includes('low_quality')) {
110
+ this.store.update(entry.id, { tags: [...saved.tags, 'low_quality'] });
111
+ }
112
+ }
113
+ }
114
+ }
115
+
45
116
  return entry;
46
117
  }
47
118
 
@@ -56,6 +127,23 @@ export class KnowledgeIngest {
56
127
  return partials.map(p => this.ingestEntry(p, options));
57
128
  }
58
129
 
130
+ /**
131
+ * Ingest external content with sanitization.
132
+ * - Sanitizes content (strips injection patterns, limits length)
133
+ * - Forces origin: 'external'
134
+ * - Uses consumptionMode from options (default: 'reference')
135
+ */
136
+ ingestExternal(
137
+ partial: Partial<KnowledgeEntry>,
138
+ options: Omit<IngestOptions, 'origin'> & { fullContentPath?: string },
139
+ ): KnowledgeEntry {
140
+ const sanitizedContent = sanitizeExternalContent(partial.content || '');
141
+ return this.ingestEntry(
142
+ { ...partial, content: sanitizedContent },
143
+ { ...options, origin: 'external' },
144
+ );
145
+ }
146
+
59
147
  // ── Internal ───────────────────────────────────────────────
60
148
 
61
149
  private buildEntry(
@@ -81,6 +169,11 @@ export class KnowledgeIngest {
81
169
  applicablePhases: partial.applicablePhases || [],
82
170
  sourceReferences: partial.sourceReferences || this.defaultSourceRef(options.source),
83
171
  referencedBy: partial.referencedBy || [],
172
+ executionResults: partial.executionResults || [],
173
+ consumptionMode: partial.consumptionMode || options.consumptionMode || 'reference',
174
+ origin: partial.origin || options.origin || 'agent',
175
+ fullContentPath: partial.fullContentPath || options.fullContentPath,
176
+ skillId: partial.skillId,
84
177
  };
85
178
  }
86
179
 
@@ -96,9 +189,79 @@ export class KnowledgeIngest {
96
189
  return 'guideline';
97
190
  }
98
191
 
99
- private findDuplicate(title: string, type: KnowledgeType): KnowledgeEntry | undefined {
100
- const all = this.store.list({ types: [type] });
101
- return all.find(e => e.title.toLowerCase() === title.toLowerCase());
192
+ private findDuplicate(title: string, content: string, type: KnowledgeType): KnowledgeEntry | undefined {
193
+ // A1: Read from disk directly to avoid stale index causing dedup failure
194
+ const all = this.store.readEntriesFromDisk().filter(e => e.type === type);
195
+
196
+ // Exact match (case-insensitive)
197
+ const exact = all.find(e => e.title.toLowerCase() === title.toLowerCase());
198
+ if (exact) return exact;
199
+
200
+ // Semantic dedup: content prefix + title substring + keyword overlap
201
+ return this.findSemanticDuplicate(title, content, all);
202
+ }
203
+
204
+ /**
205
+ * Semantic dedup: detect same knowledge with different title wording.
206
+ * Signal priority: content prefix > title substring > title keyword overlap.
207
+ */
208
+ private findSemanticDuplicate(title: string, content: string, entries: KnowledgeEntry[]): KnowledgeEntry | undefined {
209
+ const normalized = this.normalizeForDedup(title);
210
+ const contentPrefix = this.getContentPrefix(content);
211
+
212
+ for (const entry of entries) {
213
+ const entryNorm = this.normalizeForDedup(entry.title);
214
+
215
+ // 1. Content prefix match (first 50 chars — shared root cause description)
216
+ if (contentPrefix.length >= 30) {
217
+ const entryPrefix = this.getContentPrefix(entry.content);
218
+ if (entryPrefix.length >= 30 && contentPrefix === entryPrefix) return entry;
219
+ }
220
+
221
+ // 2. Title substring match after normalization
222
+ if (normalized.length >= 6 && entryNorm.length >= 6) {
223
+ if (normalized.includes(entryNorm) || entryNorm.includes(normalized)) {
224
+ return entry;
225
+ }
226
+ }
227
+
228
+ // 3. Title keyword overlap >= 60%
229
+ if (this.titleOverlap(normalized, entryNorm) >= 0.6) {
230
+ return entry;
231
+ }
232
+ }
233
+
234
+ return undefined;
235
+ }
236
+
237
+ /** Strip [prefix] tags and normalize for comparison */
238
+ private normalizeForDedup(title: string): string {
239
+ const t = title.replace(/^\[.*?\]\s*/g, '').trim();
240
+ // Keep spaces between character types (Latin/Chinese boundary) for tokenization
241
+ return t.replace(/[,。、:;!?]/g, '').toLowerCase();
242
+ }
243
+
244
+ /** Extract first 50 chars of content body (after frontmatter), stripped of whitespace */
245
+ private getContentPrefix(content: string): string {
246
+ // Strip YAML frontmatter (may be nested if content includes raw markdown)
247
+ const body = content.replace(/^---[\s\S]*?---\n?/, '').trim();
248
+ return body.slice(0, 50).replace(/\s+/g, '');
249
+ }
250
+
251
+ /** Calculate keyword overlap ratio between two normalized titles */
252
+ private titleOverlap(a: string, b: string): number {
253
+ // Extract Chinese characters as individual tokens + Latin words
254
+ const tokenize = (s: string): string[] => {
255
+ const chinese = [...s.matchAll(/\p{Script=Han}/gu)].map(m => m[0]);
256
+ const latin = s.match(/[a-z0-9]{2,}/g) || [];
257
+ return [...chinese, ...latin];
258
+ };
259
+ const tokensA = tokenize(a);
260
+ const tokensB = tokenize(b);
261
+ if (tokensA.length === 0 || tokensB.length === 0) return 0;
262
+ const setB = new Set(tokensB);
263
+ const overlap = tokensA.filter(t => setB.has(t)).length;
264
+ return overlap / Math.max(tokensA.length, tokensB.length);
102
265
  }
103
266
 
104
267
  private mergeEntries(
@@ -108,11 +271,12 @@ export class KnowledgeIngest {
108
271
  ): KnowledgeEntry {
109
272
  const merged: Partial<KnowledgeEntry> = {
110
273
  content: incoming.content || existing.content,
274
+ maturity: options.maturity || existing.maturity,
111
275
  lastReferenced: new Date().toISOString(),
112
276
  contributors: [...new Set([...existing.contributors, ...incoming.contributors])],
113
277
  projects: [...new Set([...existing.projects, ...incoming.projects])],
114
278
  tags: [...new Set([...existing.tags, ...incoming.tags])],
115
- sourceReferences: [...existing.sourceReferences, ...incoming.sourceReferences],
279
+ sourceReferences: [...existing.sourceReferences, ...incoming.sourceReferences].slice(-MAX_SOURCE_REFS),
116
280
  };
117
281
  return this.store.update(existing.id, merged)!;
118
282
  }
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { KnowledgeEntry, KnowledgeType } from './types';
10
- import { KnowledgeStore } from './store';
10
+ import type { KnowledgeStore } from './store';
11
11
  import { KnowledgeQuery } from './query';
12
12
  import type { ContextSource } from '../context/types';
13
13
 
@@ -194,6 +194,9 @@ export class KnowledgeLifecycleHooks {
194
194
  timestamp: new Date().toISOString(),
195
195
  }],
196
196
  referencedBy: [],
197
+ executionResults: [],
198
+ consumptionMode: 'reference',
199
+ origin: 'agent',
197
200
  };
198
201
  }
199
202
  }
@@ -12,24 +12,47 @@ import type {
12
12
  DecayConfig,
13
13
  } from './types';
14
14
  import { DEFAULT_DECAY_CONFIG } from './types';
15
- import { KnowledgeStore } from './store';
15
+ import type { KnowledgeStore } from './store';
16
+
17
+ const MAX_REFERENCED_BY = 20;
18
+ const MIN_CONTENT_FOR_PROVEN = 100;
19
+ const TEST_ID_PATTERN = /^(test-|inj-test)/;
20
+ const SIGNAL_SATURATION_THRESHOLD = 3;
21
+ const CONTEXT_DECAY_MONTHS = 3;
22
+ const RULE_MIN_RESULTS_FOR_DECAY = 3;
23
+ const RULE_FAIL_THRESHOLD = 0.5;
16
24
 
17
25
  // ── Lifecycle ──────────────────────────────────────────────
18
26
 
27
+ export interface ConsumptionEvent {
28
+ entryId: string;
29
+ contributor: string;
30
+ timestamp: string;
31
+ context?: string;
32
+ success?: boolean;
33
+ }
34
+
19
35
  export class KnowledgeLifecycle {
20
36
  private store: KnowledgeStore;
21
37
  private config: DecayConfig;
38
+ private onReferenceCallbacks: Array<(event: ConsumptionEvent) => void> = [];
22
39
 
23
40
  constructor(store: KnowledgeStore, config?: Partial<DecayConfig>) {
24
41
  this.store = store;
25
42
  this.config = { ...DEFAULT_DECAY_CONFIG, ...config };
26
43
  }
27
44
 
45
+ /** Register a callback fired on every recordReference() call */
46
+ onReference(callback: (event: ConsumptionEvent) => void): void {
47
+ this.onReferenceCallbacks.push(callback);
48
+ }
49
+
28
50
  /**
29
51
  * Record that an entry was referenced.
30
52
  * Updates lastReferenced timestamp, contributors, and referencedBy.
53
+ * Optionally records execution success/failure.
31
54
  */
32
- recordReference(entryId: string, contributor?: string): KnowledgeEntry | undefined {
55
+ recordReference(entryId: string, contributor?: string, success?: boolean, source?: 'human' | 'auto'): KnowledgeEntry | undefined {
33
56
  const entry = this.store.get(entryId);
34
57
  if (!entry) return undefined;
35
58
 
@@ -39,84 +62,93 @@ export class KnowledgeLifecycle {
39
62
  : entry.contributors;
40
63
 
41
64
  const refKey = `${contributor || 'unknown'}:${now.slice(0, 10)}`;
42
- const referencedBy = entry.referencedBy.includes(refKey)
43
- ? entry.referencedBy
44
- : [...entry.referencedBy, refKey];
65
+ const execSource = source || (contributor?.startsWith('auto-') ? 'auto' : undefined);
45
66
 
46
- return this.store.update(entryId, {
67
+ // B4: Skip file write if this refKey already exists (same-day, same contributor)
68
+ if (entry.referencedBy.includes(refKey)) {
69
+ // But still record execution result if provided
70
+ if (success !== undefined) {
71
+ const executionResults = [
72
+ ...(entry.executionResults || []),
73
+ { contributor: contributor || 'unknown', success, timestamp: now, source: execSource },
74
+ ].slice(-MAX_REFERENCED_BY);
75
+ this.store.update(entryId, { executionResults });
76
+ }
77
+ return entry;
78
+ }
79
+
80
+ const referencedBy = [...entry.referencedBy, refKey].slice(-MAX_REFERENCED_BY);
81
+ const executionResults = success !== undefined
82
+ ? [...(entry.executionResults || []), { contributor: contributor || 'unknown', success, timestamp: now, source: execSource }].slice(-MAX_REFERENCED_BY)
83
+ : entry.executionResults;
84
+
85
+ const updated = this.store.update(entryId, {
47
86
  lastReferenced: now,
48
87
  contributors,
49
88
  referencedBy,
89
+ executionResults,
50
90
  });
91
+
92
+ // Fire consumption event callbacks
93
+ for (const cb of this.onReferenceCallbacks) {
94
+ try {
95
+ cb({ entryId, contributor: contributor || 'unknown', timestamp: now, success });
96
+ } catch { /* non-blocking */ }
97
+ }
98
+
99
+ return updated;
51
100
  }
52
101
 
53
102
  /**
54
103
  * Check if an entry meets promotion criteria.
55
104
  * Returns the target maturity level if promotion is warranted, otherwise undefined.
56
105
  *
57
- * Rules:
58
- * - draft verified: lastReferenced is set (referenced at least once)
59
- * - verified proven: two paths
60
- * A) Multi-project: contributors >= 3 AND projects >= 2
61
- * B) Single-project: referencedBy >= 3 AND sourceReferences from 2+ distinct workflows
106
+ * Branches by consumptionMode:
107
+ * - rule: draft→active (1 success execution)
108
+ * - reference: draftverified→proven (existing logic)
109
+ * - context: draft→active (1 reference)
110
+ * - signal: no promotion
62
111
  */
63
112
  checkPromotion(entryId: string): MaturityLevel | undefined {
64
113
  const entry = this.store.get(entryId);
65
114
  if (!entry) return undefined;
66
115
 
67
- switch (entry.maturity) {
68
- case 'draft':
69
- if (entry.lastReferenced) return 'verified';
70
- return undefined;
116
+ // RC2: block test entries from any promotion
117
+ if (TEST_ID_PATTERN.test(entryId)) return undefined;
71
118
 
72
- case 'verified': {
73
- // Path A: multi-project validation
74
- if (entry.contributors.length >= 3 && entry.projects.length >= 2) {
75
- return 'proven';
76
- }
77
- // Path B: single-project — multiple independent references from different sources
78
- const refCount = entry.referencedBy?.length || 0;
79
- const distinctSources = new Set(entry.sourceReferences?.map(s => s.workflow).filter(Boolean) || []);
80
- if (refCount >= 3 && distinctSources.size >= 2) {
81
- return 'proven';
82
- }
83
- return undefined;
84
- }
119
+ const mode = entry.consumptionMode || 'reference';
85
120
 
86
- case 'proven':
87
- case 'archived':
88
- return undefined;
121
+ switch (mode) {
122
+ case 'rule': return this.checkRulePromotion(entry);
123
+ case 'context': return this.checkContextPromotion(entry);
124
+ case 'signal': return undefined; // signal never promotes
125
+ case 'reference': return this.checkReferencePromotion(entry);
89
126
  }
90
127
  }
91
128
 
92
129
  /**
93
- * Check if an entry should decay based on time since last reference.
130
+ * Check if an entry should decay.
94
131
  * Returns the target maturity level if decay is warranted, otherwise undefined.
132
+ *
133
+ * - decayAt hard expiry takes precedence (all modes)
134
+ * - Then branches by consumptionMode for mode-specific decay
95
135
  */
96
136
  checkEntryDecay(entryId: string): MaturityLevel | undefined {
97
137
  const entry = this.store.get(entryId);
98
138
  if (!entry) return undefined;
99
139
 
100
- const lastRef = entry.lastReferenced || entry.created;
101
- if (!lastRef) return undefined;
102
-
103
- const monthsSinceRef = this.monthsSince(lastRef);
104
-
105
- switch (entry.maturity) {
106
- case 'proven':
107
- if (monthsSinceRef >= this.config.provenDecayMonths) return 'verified';
108
- return undefined;
109
-
110
- case 'verified':
111
- if (monthsSinceRef >= this.config.verifiedDecayMonths) return 'draft';
112
- return undefined;
140
+ // decayAt hard expiry all modes, highest priority
141
+ if (entry.decayAt && new Date(entry.decayAt) <= new Date()) {
142
+ return 'archived';
143
+ }
113
144
 
114
- case 'draft':
115
- if (monthsSinceRef >= this.config.draftDecayMonths) return 'archived';
116
- return undefined;
145
+ const mode = entry.consumptionMode || 'reference';
117
146
 
118
- case 'archived':
119
- return undefined;
147
+ switch (mode) {
148
+ case 'rule': return this.checkRuleDecay(entry);
149
+ case 'reference': return this.checkReferenceDecay(entry);
150
+ case 'context': return this.checkContextDecay(entry);
151
+ case 'signal': return this.checkSignalDecay(entry);
120
152
  }
121
153
  }
122
154
 
@@ -165,6 +197,143 @@ export class KnowledgeLifecycle {
165
197
  return change;
166
198
  }
167
199
 
200
+ /**
201
+ * Check if a source should auto-promote to verified on ingest.
202
+ * Used by KnowledgeBus to decide initial maturity level.
203
+ */
204
+ shouldAutoPromote(source: string): boolean {
205
+ return this.config.autoPromoteSources.some(s => source.includes(s));
206
+ }
207
+
208
+ /**
209
+ * Get execution success rate for an entry.
210
+ * Returns { rate, total } or undefined if no execution data.
211
+ */
212
+ getExecutionSuccessRate(entryId: string): { rate: number; total: number } | undefined {
213
+ const entry = this.store.get(entryId);
214
+ if (!entry || !entry.executionResults || entry.executionResults.length === 0) return undefined;
215
+ const total = entry.executionResults.length;
216
+ const successes = entry.executionResults.filter(r => r.success).length;
217
+ return { rate: successes / total, total };
218
+ }
219
+
220
+ /**
221
+ * Get execution success rate for human-sourced results only.
222
+ * Returns { rate, total } or undefined if no human execution data.
223
+ */
224
+ getHumanSuccessRate(entryId: string): { rate: number; total: number } | undefined {
225
+ const entry = this.store.get(entryId);
226
+ if (!entry || !entry.executionResults || entry.executionResults.length === 0) return undefined;
227
+ const humanResults = entry.executionResults.filter(r => r.source === 'human');
228
+ if (humanResults.length === 0) return undefined;
229
+ const total = humanResults.length;
230
+ const successes = humanResults.filter(r => r.success).length;
231
+ return { rate: successes / total, total };
232
+ }
233
+
234
+ // ── Per-mode promotion ─────────────────────────────────────
235
+
236
+ /** rule: draft→active with 1 success execution */
237
+ private checkRulePromotion(entry: KnowledgeEntry): MaturityLevel | undefined {
238
+ if (entry.maturity !== 'draft') return undefined;
239
+ const rate = this.getExecutionSuccessRate(entry.id);
240
+ if (rate && rate.total >= 1 && rate.rate >= 1.0) return 'active';
241
+ return undefined;
242
+ }
243
+
244
+ /** reference: draft→verified→proven (existing logic) */
245
+ private checkReferencePromotion(entry: KnowledgeEntry): MaturityLevel | undefined {
246
+ switch (entry.maturity) {
247
+ case 'draft':
248
+ if (entry.lastReferenced && entry.content.trim().length >= 50) return 'verified';
249
+ return undefined;
250
+
251
+ case 'verified': {
252
+ if (entry.content.trim().length < MIN_CONTENT_FOR_PROVEN) return undefined;
253
+ const humanRate = this.getHumanSuccessRate(entry.id);
254
+ if (humanRate && humanRate.total >= 3 && humanRate.rate >= 0.8) return 'proven';
255
+ const execRate = this.getExecutionSuccessRate(entry.id);
256
+ if (execRate && execRate.total >= 3 && execRate.rate >= 0.8) return 'proven';
257
+ if (entry.contributors.length >= 3 && entry.projects.length >= 2) return 'proven';
258
+ const refCount = entry.referencedBy?.length || 0;
259
+ const distinctSources = new Set(entry.sourceReferences?.map(s => s.workflow).filter(Boolean) || []);
260
+ if (refCount >= 3 && distinctSources.size >= 2) return 'proven';
261
+ return undefined;
262
+ }
263
+
264
+ default:
265
+ return undefined;
266
+ }
267
+ }
268
+
269
+ /** context: draft→active with 1 reference */
270
+ private checkContextPromotion(entry: KnowledgeEntry): MaturityLevel | undefined {
271
+ if (entry.maturity !== 'draft') return undefined;
272
+ if (entry.referencedBy.length >= 1) return 'active';
273
+ return undefined;
274
+ }
275
+
276
+ // ── Per-mode decay ─────────────────────────────────────────
277
+
278
+ /** rule: active→deprecated when fail rate >= 50% with 3+ results */
279
+ private checkRuleDecay(entry: KnowledgeEntry): MaturityLevel | undefined {
280
+ if (entry.maturity !== 'active') return undefined;
281
+ const rate = this.getExecutionSuccessRate(entry.id);
282
+ if (rate && rate.total >= RULE_MIN_RESULTS_FOR_DECAY && rate.rate < RULE_FAIL_THRESHOLD) return 'deprecated';
283
+ return undefined;
284
+ }
285
+
286
+ /** reference: proven→verified→draft→archived by time (existing logic) */
287
+ private checkReferenceDecay(entry: KnowledgeEntry): MaturityLevel | undefined {
288
+ const lastRef = entry.lastReferenced || entry.created;
289
+ if (!lastRef) return undefined;
290
+ const monthsSinceRef = this.monthsSince(lastRef);
291
+
292
+ switch (entry.maturity) {
293
+ case 'proven':
294
+ if (monthsSinceRef >= this.config.provenDecayMonths) return 'verified';
295
+ return undefined;
296
+ case 'verified':
297
+ if (monthsSinceRef >= this.config.verifiedDecayMonths) return 'draft';
298
+ return undefined;
299
+ case 'draft':
300
+ if (monthsSinceRef >= this.config.draftDecayMonths) return 'archived';
301
+ return undefined;
302
+ default:
303
+ return undefined;
304
+ }
305
+ }
306
+
307
+ /** context: active→archived after CONTEXT_DECAY_MONTHS unreferenced */
308
+ private checkContextDecay(entry: KnowledgeEntry): MaturityLevel | undefined {
309
+ if (entry.maturity !== 'active') return undefined;
310
+ const lastRef = entry.lastReferenced || entry.created;
311
+ if (!lastRef) return undefined;
312
+ if (this.monthsSince(lastRef) >= CONTEXT_DECAY_MONTHS) return 'archived';
313
+ return undefined;
314
+ }
315
+
316
+ /**
317
+ * signal: active→archived by consumption saturation.
318
+ * Saturated = referencedBy >= threshold AND newer same-tag signal entry exists.
319
+ */
320
+ private checkSignalDecay(entry: KnowledgeEntry): MaturityLevel | undefined {
321
+ if (entry.maturity !== 'active') return undefined;
322
+ const refCount = entry.referencedBy?.length || 0;
323
+ if (refCount < SIGNAL_SATURATION_THRESHOLD) return undefined;
324
+
325
+ const newer = this.store.list({
326
+ tags: entry.tags,
327
+ excludeArchived: false,
328
+ }).find(e =>
329
+ e.id !== entry.id &&
330
+ (e.consumptionMode || 'reference') === 'signal' &&
331
+ e.created > entry.created
332
+ );
333
+ if (newer) return 'archived';
334
+ return undefined;
335
+ }
336
+
168
337
  // ── Internal ───────────────────────────────────────────────
169
338
 
170
339
  private monthsSince(dateStr: string): number {
@@ -10,10 +10,12 @@
10
10
  * - 成熟度衰减
11
11
  */
12
12
 
13
- import { KnowledgeStore } from './store';
13
+ import type { KnowledgeStore } from './store';
14
14
  import { ReferenceTracker } from './reference-tracker';
15
15
  import type { KnowledgeEntry, LintIssue, LintIssueType, MaturityLevel } from './types';
16
16
 
17
+ const MAX_SOURCE_REFS = 20;
18
+
17
19
  export interface LintReport {
18
20
  timestamp: string;
19
21
  totalEntries: number;
@@ -77,7 +79,7 @@ export class KnowledgeLinter {
77
79
  */
78
80
  checkIndexConsistency(entries: KnowledgeEntry[]): LintIssue[] {
79
81
  const issues: LintIssue[] = [];
80
- const index = this.store['readIndex']();
82
+ const index = this.store.readIndex();
81
83
 
82
84
  // 检查索引中的条目是否都有对应文件
83
85
  for (const indexEntry of index) {
@@ -214,7 +216,7 @@ export class KnowledgeLinter {
214
216
  // 按 tag 组合分组
215
217
  for (const entry of entries) {
216
218
  if (entry.maturity === 'archived') continue;
217
- if (entry.tags.length === 0) continue;
219
+ if (!entry.tags || entry.tags.length === 0) continue;
218
220
 
219
221
  const tagKey = entry.tags.sort().join(',');
220
222
  const existing = byTagGroup.get(tagKey) || [];
@@ -284,7 +286,7 @@ export class KnowledgeLinter {
284
286
  }
285
287
 
286
288
  // Contradicts proven entry with same tags
287
- if (entry.tags.length > 0) {
289
+ if (entry.tags && entry.tags.length > 0) {
288
290
  const allEntries = this.store.list({ excludeArchived: false });
289
291
  for (const existing of allEntries) {
290
292
  if (existing.maturity !== 'proven') continue;
@@ -342,6 +344,45 @@ export class KnowledgeLinter {
342
344
  private autoFix(issues: LintIssue[]): number {
343
345
  let fixed = 0;
344
346
 
347
+ // A3: Deduplicate — group by entryId, keep newest, archive rest
348
+ const duplicateIssues = issues.filter(i => i.type === 'duplicate' && i.entryId);
349
+ const processedGroups = new Set<string>();
350
+ for (const issue of duplicateIssues) {
351
+ // Extract group from suggestion field: "考虑合并: GUI-003, GUI-004"
352
+ const match = issue.suggestion?.match(/: (.+)$/);
353
+ if (!match) continue;
354
+ const groupKey = match[1];
355
+ if (processedGroups.has(groupKey)) continue;
356
+ processedGroups.add(groupKey);
357
+
358
+ const ids = groupKey.split(', ').map(s => s.trim());
359
+ const entries = ids.map(id => this.store.get(id)).filter(Boolean) as KnowledgeEntry[];
360
+ if (entries.length <= 1) continue;
361
+
362
+ // Keep the one with most recent lastReferenced
363
+ entries.sort((a, b) => (b.lastReferenced || '').localeCompare(a.lastReferenced || ''));
364
+ const keep = entries[0];
365
+ for (let i = 1; i < entries.length; i++) {
366
+ this.store.update(entries[i].id, { maturity: 'archived' });
367
+ fixed++;
368
+ }
369
+ // Merge sourceReferences from archived entries into kept entry
370
+ const allRefs = entries.flatMap(e => e.sourceReferences || []);
371
+ if (allRefs.length > 0) {
372
+ const existing = keep.sourceReferences || [];
373
+ const merged = [...existing, ...allRefs];
374
+ // Deduplicate by workflow+timestamp
375
+ const seen = new Set<string>();
376
+ const deduped = merged.filter(ref => {
377
+ const key = `${ref.workflow}:${ref.timestamp}`;
378
+ if (seen.has(key)) return false;
379
+ seen.add(key);
380
+ return true;
381
+ }).slice(-MAX_SOURCE_REFS);
382
+ this.store.update(keep.id, { sourceReferences: deduped });
383
+ }
384
+ }
385
+
345
386
  for (const issue of issues) {
346
387
  if (issue.type === 'index_inconsistent') {
347
388
  this.store.rebuildIndex();