@soleri/core 9.7.2 → 9.9.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 (91) hide show
  1. package/dist/brain/intelligence.d.ts.map +1 -1
  2. package/dist/brain/intelligence.js +11 -2
  3. package/dist/brain/intelligence.js.map +1 -1
  4. package/dist/brain/types.d.ts +1 -0
  5. package/dist/brain/types.d.ts.map +1 -1
  6. package/dist/enforcement/adapters/index.d.ts +15 -0
  7. package/dist/enforcement/adapters/index.d.ts.map +1 -1
  8. package/dist/enforcement/adapters/index.js +38 -0
  9. package/dist/enforcement/adapters/index.js.map +1 -1
  10. package/dist/enforcement/adapters/opencode.d.ts +21 -0
  11. package/dist/enforcement/adapters/opencode.d.ts.map +1 -0
  12. package/dist/enforcement/adapters/opencode.js +115 -0
  13. package/dist/enforcement/adapters/opencode.js.map +1 -0
  14. package/dist/index.d.ts +4 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +5 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/paths.d.ts +2 -0
  19. package/dist/paths.d.ts.map +1 -1
  20. package/dist/paths.js +4 -0
  21. package/dist/paths.js.map +1 -1
  22. package/dist/planning/evidence-collector.d.ts +2 -0
  23. package/dist/planning/evidence-collector.d.ts.map +1 -1
  24. package/dist/planning/evidence-collector.js +7 -2
  25. package/dist/planning/evidence-collector.js.map +1 -1
  26. package/dist/planning/gap-patterns.d.ts.map +1 -1
  27. package/dist/planning/gap-patterns.js +4 -1
  28. package/dist/planning/gap-patterns.js.map +1 -1
  29. package/dist/planning/plan-lifecycle.d.ts.map +1 -1
  30. package/dist/planning/plan-lifecycle.js +5 -0
  31. package/dist/planning/plan-lifecycle.js.map +1 -1
  32. package/dist/planning/planner-types.d.ts +2 -0
  33. package/dist/planning/planner-types.d.ts.map +1 -1
  34. package/dist/runtime/capture-ops.d.ts.map +1 -1
  35. package/dist/runtime/capture-ops.js +14 -6
  36. package/dist/runtime/capture-ops.js.map +1 -1
  37. package/dist/runtime/facades/curator-facade.d.ts.map +1 -1
  38. package/dist/runtime/facades/curator-facade.js +52 -4
  39. package/dist/runtime/facades/curator-facade.js.map +1 -1
  40. package/dist/runtime/orchestrate-ops.d.ts +12 -0
  41. package/dist/runtime/orchestrate-ops.d.ts.map +1 -1
  42. package/dist/runtime/orchestrate-ops.js +141 -1
  43. package/dist/runtime/orchestrate-ops.js.map +1 -1
  44. package/dist/runtime/quality-signals.d.ts +42 -0
  45. package/dist/runtime/quality-signals.d.ts.map +1 -0
  46. package/dist/runtime/quality-signals.js +124 -0
  47. package/dist/runtime/quality-signals.js.map +1 -0
  48. package/dist/skills/trust-classifier.js +1 -1
  49. package/dist/skills/trust-classifier.js.map +1 -1
  50. package/dist/vault/vault-markdown-sync.d.ts +5 -2
  51. package/dist/vault/vault-markdown-sync.d.ts.map +1 -1
  52. package/dist/vault/vault-markdown-sync.js +13 -2
  53. package/dist/vault/vault-markdown-sync.js.map +1 -1
  54. package/dist/workflows/index.d.ts +6 -0
  55. package/dist/workflows/index.d.ts.map +1 -0
  56. package/dist/workflows/index.js +5 -0
  57. package/dist/workflows/index.js.map +1 -0
  58. package/dist/workflows/workflow-loader.d.ts +83 -0
  59. package/dist/workflows/workflow-loader.d.ts.map +1 -0
  60. package/dist/workflows/workflow-loader.js +207 -0
  61. package/dist/workflows/workflow-loader.js.map +1 -0
  62. package/package.json +1 -1
  63. package/src/brain/intelligence.ts +15 -2
  64. package/src/brain/types.ts +1 -0
  65. package/src/enforcement/adapters/index.ts +45 -0
  66. package/src/enforcement/adapters/opencode.test.ts +406 -0
  67. package/src/enforcement/adapters/opencode.ts +153 -0
  68. package/src/index.ts +19 -0
  69. package/src/paths.ts +5 -0
  70. package/src/planning/evidence-collector.test.ts +95 -0
  71. package/src/planning/evidence-collector.ts +11 -0
  72. package/src/planning/gap-patterns.ts +7 -3
  73. package/src/planning/plan-lifecycle.test.ts +49 -0
  74. package/src/planning/plan-lifecycle.ts +5 -0
  75. package/src/planning/planner-types.ts +2 -0
  76. package/src/runtime/capture-ops.test.ts +58 -1
  77. package/src/runtime/capture-ops.ts +15 -4
  78. package/src/runtime/facades/curator-facade.test.ts +87 -9
  79. package/src/runtime/facades/curator-facade.ts +60 -4
  80. package/src/runtime/orchestrate-ops.test.ts +78 -1
  81. package/src/runtime/orchestrate-ops.ts +175 -1
  82. package/src/runtime/orchestrate-status-readiness.test.ts +162 -0
  83. package/src/runtime/quality-signals.test.ts +312 -0
  84. package/src/runtime/quality-signals.ts +169 -0
  85. package/src/skills/trust-classifier.ts +1 -1
  86. package/src/vault/vault-markdown-sync.test.ts +40 -0
  87. package/src/vault/vault-markdown-sync.ts +16 -3
  88. package/src/workflows/index.ts +12 -0
  89. package/src/workflows/orchestrate-integration.test.ts +166 -0
  90. package/src/workflows/workflow-loader.test.ts +149 -0
  91. package/src/workflows/workflow-loader.ts +238 -0
@@ -0,0 +1,312 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { analyzeQualitySignals, captureQualitySignals } from './quality-signals.js';
3
+ import type { EvidenceReport } from '../planning/evidence-collector.js';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Helpers
7
+ // ---------------------------------------------------------------------------
8
+
9
+ function makeReport(overrides: Partial<EvidenceReport> = {}): EvidenceReport {
10
+ return {
11
+ planId: 'plan-test',
12
+ planObjective: 'Test plan',
13
+ accuracy: 80,
14
+ evidenceSources: ['git'],
15
+ taskEvidence: [],
16
+ unplannedChanges: [],
17
+ missingWork: [],
18
+ verificationGaps: [],
19
+ summary: '',
20
+ ...overrides,
21
+ };
22
+ }
23
+
24
+ function makeVault() {
25
+ return {
26
+ search: vi.fn().mockReturnValue([]),
27
+ add: vi.fn(),
28
+ } as unknown as ReturnType<
29
+ (typeof import('../vault/vault.js'))['Vault']['prototype']['search']
30
+ > & { add: ReturnType<typeof vi.fn>; search: ReturnType<typeof vi.fn> };
31
+ }
32
+
33
+ function makeBrain() {
34
+ return {
35
+ recordFeedback: vi.fn(),
36
+ } as unknown as { recordFeedback: ReturnType<typeof vi.fn> };
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // analyzeQualitySignals
41
+ // ---------------------------------------------------------------------------
42
+
43
+ describe('analyzeQualitySignals', () => {
44
+ it('detects anti-pattern when fixIterations > 2', () => {
45
+ const report = makeReport({
46
+ taskEvidence: [
47
+ {
48
+ taskId: 't1',
49
+ taskTitle: 'Fix login bug',
50
+ plannedStatus: 'completed',
51
+ matchedFiles: [],
52
+ verdict: 'DONE',
53
+ fixIterations: 3,
54
+ },
55
+ ],
56
+ });
57
+
58
+ const result = analyzeQualitySignals(report);
59
+
60
+ expect(result.antiPatterns).toHaveLength(1);
61
+ expect(result.antiPatterns[0].taskId).toBe('t1');
62
+ expect(result.antiPatterns[0].kind).toBe('anti-pattern');
63
+ expect(result.antiPatterns[0].fixIterations).toBe(3);
64
+ });
65
+
66
+ it('detects clean task when fixIterations === 0 and verdict DONE', () => {
67
+ const report = makeReport({
68
+ taskEvidence: [
69
+ {
70
+ taskId: 't2',
71
+ taskTitle: 'Add feature',
72
+ plannedStatus: 'completed',
73
+ matchedFiles: [],
74
+ verdict: 'DONE',
75
+ fixIterations: 0,
76
+ },
77
+ ],
78
+ });
79
+
80
+ const result = analyzeQualitySignals(report);
81
+
82
+ expect(result.cleanTasks).toHaveLength(1);
83
+ expect(result.cleanTasks[0].taskId).toBe('t2');
84
+ expect(result.cleanTasks[0].kind).toBe('clean');
85
+ });
86
+
87
+ it('does not flag task with fixIterations === 0 but verdict PARTIAL', () => {
88
+ const report = makeReport({
89
+ taskEvidence: [
90
+ {
91
+ taskId: 't3',
92
+ taskTitle: 'Partial work',
93
+ plannedStatus: 'in_progress',
94
+ matchedFiles: [],
95
+ verdict: 'PARTIAL',
96
+ fixIterations: 0,
97
+ },
98
+ ],
99
+ });
100
+
101
+ const result = analyzeQualitySignals(report);
102
+
103
+ expect(result.cleanTasks).toHaveLength(0);
104
+ expect(result.antiPatterns).toHaveLength(0);
105
+ });
106
+
107
+ it('does not flag task with fixIterations === 2 (at threshold, not above)', () => {
108
+ const report = makeReport({
109
+ taskEvidence: [
110
+ {
111
+ taskId: 't4',
112
+ taskTitle: 'Borderline task',
113
+ plannedStatus: 'completed',
114
+ matchedFiles: [],
115
+ verdict: 'DONE',
116
+ fixIterations: 2,
117
+ },
118
+ ],
119
+ });
120
+
121
+ const result = analyzeQualitySignals(report);
122
+
123
+ expect(result.antiPatterns).toHaveLength(0);
124
+ });
125
+
126
+ it('detects scope creep from unplanned changes', () => {
127
+ const report = makeReport({
128
+ unplannedChanges: [
129
+ {
130
+ file: { path: 'src/extra.ts', status: 'added' },
131
+ possibleReason: 'unplanned scope',
132
+ },
133
+ ],
134
+ });
135
+
136
+ const result = analyzeQualitySignals(report);
137
+
138
+ expect(result.scopeCreep).toHaveLength(1);
139
+ expect(result.scopeCreep[0].kind).toBe('scope-creep');
140
+ });
141
+
142
+ it('handles undefined fixIterations as 0', () => {
143
+ const report = makeReport({
144
+ taskEvidence: [
145
+ {
146
+ taskId: 't5',
147
+ taskTitle: 'No iterations field',
148
+ plannedStatus: 'completed',
149
+ matchedFiles: [],
150
+ verdict: 'DONE',
151
+ // fixIterations omitted
152
+ },
153
+ ],
154
+ });
155
+
156
+ const result = analyzeQualitySignals(report);
157
+
158
+ expect(result.cleanTasks).toHaveLength(1);
159
+ expect(result.antiPatterns).toHaveLength(0);
160
+ });
161
+ });
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // captureQualitySignals
165
+ // ---------------------------------------------------------------------------
166
+
167
+ describe('captureQualitySignals', () => {
168
+ let vault: ReturnType<typeof makeVault>;
169
+ let brain: ReturnType<typeof makeBrain>;
170
+
171
+ beforeEach(() => {
172
+ vault = makeVault();
173
+ brain = makeBrain();
174
+ });
175
+
176
+ it('captures anti-pattern to vault and records negative brain feedback', () => {
177
+ const analysis = {
178
+ antiPatterns: [
179
+ {
180
+ taskId: 't1',
181
+ taskTitle: 'Fix login',
182
+ kind: 'anti-pattern' as const,
183
+ fixIterations: 3,
184
+ verdict: 'DONE',
185
+ },
186
+ ],
187
+ cleanTasks: [],
188
+ scopeCreep: [],
189
+ };
190
+
191
+ const result = captureQualitySignals(analysis, vault, brain, 'plan-1');
192
+
193
+ expect(vault.add).toHaveBeenCalledTimes(1);
194
+ const entry = vault.add.mock.calls[0][0];
195
+ expect(entry.type).toBe('anti-pattern');
196
+ expect(entry.severity).toBe('warning');
197
+ expect(entry.tags).toContain('rework');
198
+ expect(entry.tags).toContain('fix-trail');
199
+ expect(entry.tags).toContain('auto-captured');
200
+
201
+ expect(brain.recordFeedback).toHaveBeenCalledWith(
202
+ 'quality-signal:rework:Fix login',
203
+ 't1',
204
+ 'dismissed',
205
+ );
206
+
207
+ expect(result.captured).toBe(1);
208
+ expect(result.feedback).toBe(1);
209
+ });
210
+
211
+ it('records positive brain feedback for clean tasks', () => {
212
+ const analysis = {
213
+ antiPatterns: [],
214
+ cleanTasks: [
215
+ {
216
+ taskId: 't2',
217
+ taskTitle: 'Add feature',
218
+ kind: 'clean' as const,
219
+ fixIterations: 0,
220
+ verdict: 'DONE',
221
+ },
222
+ ],
223
+ scopeCreep: [],
224
+ };
225
+
226
+ const result = captureQualitySignals(analysis, vault, brain, 'plan-1');
227
+
228
+ expect(brain.recordFeedback).toHaveBeenCalledWith(
229
+ 'quality-signal:clean:Add feature',
230
+ 't2',
231
+ 'accepted',
232
+ );
233
+ expect(result.feedback).toBe(1);
234
+ expect(result.captured).toBe(0);
235
+ });
236
+
237
+ it('skips duplicate anti-patterns when vault search returns high-score match', () => {
238
+ vault.search.mockReturnValue([{ entry: { id: 'existing' }, score: 0.8 }]);
239
+
240
+ const analysis = {
241
+ antiPatterns: [
242
+ {
243
+ taskId: 't1',
244
+ taskTitle: 'Fix login',
245
+ kind: 'anti-pattern' as const,
246
+ fixIterations: 3,
247
+ verdict: 'DONE',
248
+ },
249
+ ],
250
+ cleanTasks: [],
251
+ scopeCreep: [],
252
+ };
253
+
254
+ const result = captureQualitySignals(analysis, vault, brain, 'plan-1');
255
+
256
+ expect(vault.add).not.toHaveBeenCalled();
257
+ expect(result.skipped).toBe(1);
258
+ expect(result.captured).toBe(0);
259
+ // Brain feedback still recorded even for deduplicated captures
260
+ expect(brain.recordFeedback).toHaveBeenCalledTimes(1);
261
+ });
262
+
263
+ it('assigns critical severity for fixIterations > 4', () => {
264
+ const analysis = {
265
+ antiPatterns: [
266
+ {
267
+ taskId: 't1',
268
+ taskTitle: 'Hard bug',
269
+ kind: 'anti-pattern' as const,
270
+ fixIterations: 5,
271
+ verdict: 'DONE',
272
+ },
273
+ ],
274
+ cleanTasks: [],
275
+ scopeCreep: [],
276
+ };
277
+
278
+ captureQualitySignals(analysis, vault, brain, 'plan-1');
279
+
280
+ const entry = vault.add.mock.calls[0][0];
281
+ expect(entry.severity).toBe('critical');
282
+ });
283
+
284
+ it('handles mixed signals correctly', () => {
285
+ const analysis = {
286
+ antiPatterns: [
287
+ {
288
+ taskId: 't1',
289
+ taskTitle: 'Rework task',
290
+ kind: 'anti-pattern' as const,
291
+ fixIterations: 4,
292
+ verdict: 'DONE',
293
+ },
294
+ ],
295
+ cleanTasks: [
296
+ {
297
+ taskId: 't2',
298
+ taskTitle: 'Clean task',
299
+ kind: 'clean' as const,
300
+ fixIterations: 0,
301
+ verdict: 'DONE',
302
+ },
303
+ ],
304
+ scopeCreep: [],
305
+ };
306
+
307
+ const result = captureQualitySignals(analysis, vault, brain, 'plan-1');
308
+
309
+ expect(result.captured).toBe(1);
310
+ expect(result.feedback).toBe(2); // 1 dismissed + 1 accepted
311
+ });
312
+ });
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Quality Signals — analyze evidence reports for rework patterns and clean execution.
3
+ *
4
+ * Extracts anti-patterns (high rework), clean tasks (first-pass success),
5
+ * and scope creep signals from evidence reports. Captures findings to vault
6
+ * and feeds brain feedback. Best-effort — never throws.
7
+ */
8
+
9
+ import type { EvidenceReport } from '../planning/evidence-collector.js';
10
+ import type { Plan } from '../planning/planner.js';
11
+ import type { Vault } from '../vault/vault.js';
12
+ import type { Brain } from '../brain/brain.js';
13
+ import type { IntelligenceEntry } from '../intelligence/types.js';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Types
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export interface QualitySignal {
20
+ taskId: string;
21
+ taskTitle: string;
22
+ kind: 'anti-pattern' | 'clean' | 'scope-creep';
23
+ fixIterations: number;
24
+ verdict: string;
25
+ }
26
+
27
+ export interface QualityAnalysis {
28
+ antiPatterns: QualitySignal[];
29
+ cleanTasks: QualitySignal[];
30
+ scopeCreep: QualitySignal[];
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Thresholds
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /** Tasks with more than this many fix iterations are flagged as anti-patterns. */
38
+ const REWORK_THRESHOLD = 2;
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Analysis
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /**
45
+ * Analyze an evidence report for quality signals.
46
+ *
47
+ * - fixIterations > 2 → anti-pattern (rework)
48
+ * - fixIterations === 0 + verdict DONE → clean (first-pass success)
49
+ * - unplannedChanges → scope-creep signals
50
+ */
51
+ export function analyzeQualitySignals(
52
+ report: EvidenceReport,
53
+ _plan?: Plan | null,
54
+ ): QualityAnalysis {
55
+ const antiPatterns: QualitySignal[] = [];
56
+ const cleanTasks: QualitySignal[] = [];
57
+ const scopeCreep: QualitySignal[] = [];
58
+
59
+ for (const te of report.taskEvidence) {
60
+ const iterations = te.fixIterations ?? 0;
61
+
62
+ if (iterations > REWORK_THRESHOLD) {
63
+ antiPatterns.push({
64
+ taskId: te.taskId,
65
+ taskTitle: te.taskTitle,
66
+ kind: 'anti-pattern',
67
+ fixIterations: iterations,
68
+ verdict: te.verdict,
69
+ });
70
+ } else if (iterations === 0 && te.verdict === 'DONE') {
71
+ cleanTasks.push({
72
+ taskId: te.taskId,
73
+ taskTitle: te.taskTitle,
74
+ kind: 'clean',
75
+ fixIterations: 0,
76
+ verdict: te.verdict,
77
+ });
78
+ }
79
+ }
80
+
81
+ // Unplanned changes signal scope creep
82
+ for (const uc of report.unplannedChanges) {
83
+ scopeCreep.push({
84
+ taskId: 'unplanned',
85
+ taskTitle: uc.file.path,
86
+ kind: 'scope-creep',
87
+ fixIterations: 0,
88
+ verdict: uc.possibleReason,
89
+ });
90
+ }
91
+
92
+ return { antiPatterns, cleanTasks, scopeCreep };
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Capture to vault + brain
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /**
100
+ * Persist quality signals to vault (anti-patterns) and brain (feedback).
101
+ * Deduplicates anti-patterns via vault search before adding.
102
+ * Best-effort — swallows all errors.
103
+ */
104
+ export function captureQualitySignals(
105
+ analysis: QualityAnalysis,
106
+ vault: Vault,
107
+ brain: Brain,
108
+ planId: string,
109
+ ): { captured: number; skipped: number; feedback: number } {
110
+ let captured = 0;
111
+ let skipped = 0;
112
+ let feedback = 0;
113
+
114
+ // Capture anti-patterns to vault (dedup first)
115
+ for (const ap of analysis.antiPatterns) {
116
+ try {
117
+ const query = `rework fix-trail ${ap.taskTitle}`;
118
+ const existing = vault.search(query, { type: 'anti-pattern', limit: 3 });
119
+ const isDuplicate = existing.some((r) => r.score > 0.7);
120
+
121
+ if (isDuplicate) {
122
+ skipped++;
123
+ continue;
124
+ }
125
+
126
+ const severity = ap.fixIterations > 4 ? 'critical' : 'warning';
127
+ const entry: IntelligenceEntry = {
128
+ id: `qs-ap-${planId}-${ap.taskId}-${Date.now()}`,
129
+ type: 'anti-pattern',
130
+ domain: 'engineering',
131
+ title: `Rework detected: ${ap.taskTitle}`,
132
+ severity,
133
+ description:
134
+ `Task "${ap.taskTitle}" required ${ap.fixIterations} fix iterations ` +
135
+ `(threshold: ${REWORK_THRESHOLD}). Investigate root cause — ` +
136
+ `unclear requirements, missing tests, or incomplete understanding.`,
137
+ tags: ['rework', 'fix-trail', 'auto-captured'],
138
+ origin: 'agent',
139
+ };
140
+
141
+ vault.add(entry);
142
+ captured++;
143
+ } catch {
144
+ // Best-effort — skip this signal
145
+ }
146
+ }
147
+
148
+ // Record negative brain feedback for rework tasks
149
+ for (const ap of analysis.antiPatterns) {
150
+ try {
151
+ brain.recordFeedback(`quality-signal:rework:${ap.taskTitle}`, ap.taskId, 'dismissed');
152
+ feedback++;
153
+ } catch {
154
+ // Best-effort
155
+ }
156
+ }
157
+
158
+ // Record positive brain feedback for clean tasks
159
+ for (const ct of analysis.cleanTasks) {
160
+ try {
161
+ brain.recordFeedback(`quality-signal:clean:${ct.taskTitle}`, ct.taskId, 'accepted');
162
+ feedback++;
163
+ } catch {
164
+ // Best-effort
165
+ }
166
+ }
167
+
168
+ return { captured, skipped, feedback };
169
+ }
@@ -92,7 +92,7 @@ function walkDir(rootDir: string, currentDir: string, inventory: SkillInventoryI
92
92
 
93
93
  if (!stat.isFile()) continue;
94
94
 
95
- const relPath = relative(rootDir, fullPath);
95
+ const relPath = relative(rootDir, fullPath).replaceAll('\\', '/');
96
96
  const ext = extname(name).toLowerCase();
97
97
  const kind = classifyFile(name, ext);
98
98
 
@@ -128,6 +128,46 @@ describe('vault-markdown-sync', () => {
128
128
  const filePath = join(deepDir, 'vault', 'architecture', 'deep-entry.md');
129
129
  expect(existsSync(filePath)).toBe(true);
130
130
  });
131
+
132
+ it('should skip rewrite when content hash matches (dedup)', async () => {
133
+ const entry = makeEntry({ domain: 'design', title: 'Stable Token' });
134
+
135
+ // First write
136
+ const first = await syncEntryToMarkdown(entry, tmpDir);
137
+ expect(first.written).toBe(true);
138
+
139
+ const filePath = join(tmpDir, 'vault', 'design', 'stable-token.md');
140
+ const mtimeBefore = readFileSync(filePath, 'utf-8');
141
+
142
+ // Second write with same content — should skip
143
+ const second = await syncEntryToMarkdown(entry, tmpDir);
144
+ expect(second.written).toBe(false);
145
+
146
+ // File content should be identical (not rewritten)
147
+ const mtimeAfter = readFileSync(filePath, 'utf-8');
148
+ expect(mtimeAfter).toBe(mtimeBefore);
149
+ });
150
+
151
+ it('should rewrite when content changes', async () => {
152
+ const entry = makeEntry({ domain: 'design', title: 'Changing Token' });
153
+ const first = await syncEntryToMarkdown(entry, tmpDir);
154
+ expect(first.written).toBe(true);
155
+
156
+ // Modify the entry
157
+ entry.description = 'Updated description that changes the hash.';
158
+ const second = await syncEntryToMarkdown(entry, tmpDir);
159
+ expect(second.written).toBe(true);
160
+
161
+ const filePath = join(tmpDir, 'vault', 'design', 'changing-token.md');
162
+ const content = readFileSync(filePath, 'utf-8');
163
+ expect(content).toContain('Updated description');
164
+ });
165
+
166
+ it('should return written:false for empty slug', async () => {
167
+ const entry = makeEntry({ title: '!!!' }); // slugifies to empty
168
+ const result = await syncEntryToMarkdown(entry, tmpDir);
169
+ expect(result.written).toBe(false);
170
+ });
131
171
  });
132
172
 
133
173
  // ── syncAllToMarkdown ────────────────────────────────────────────
@@ -76,21 +76,34 @@ export function entryToMarkdown(entry: IntelligenceEntry): string {
76
76
 
77
77
  // ─── Sync ───────────────────────────────────────────────────────────
78
78
 
79
- /** Write a single entry as a markdown file to knowledge/vault/{domain}/{slug}.md */
79
+ /** Write a single entry as a markdown file to knowledge/vault/{domain}/{slug}.md.
80
+ * Skips the write if the file already exists with a matching content hash (dedup). */
80
81
  export async function syncEntryToMarkdown(
81
82
  entry: IntelligenceEntry,
82
83
  knowledgeDir: string,
83
- ): Promise<void> {
84
+ ): Promise<{ written: boolean }> {
84
85
  const domain = entry.domain || '_general';
85
86
  const slug = titleToSlug(entry.title);
86
- if (!slug) return;
87
+ if (!slug) return { written: false };
87
88
 
88
89
  const dir = join(knowledgeDir, 'vault', domain);
89
90
  mkdirSync(dir, { recursive: true });
90
91
 
91
92
  const filePath = join(dir, `${slug}.md`);
93
+
94
+ // Content-hash dedup: skip rewrite when file content hasn't changed
95
+ const contentHash = computeContentHash(entry);
96
+ if (existsSync(filePath)) {
97
+ const existing = readFileSync(filePath, 'utf-8');
98
+ const hashMatch = existing.match(/^content_hash:\s*"([^"]+)"/m);
99
+ if (hashMatch && hashMatch[1] === contentHash) {
100
+ return { written: false };
101
+ }
102
+ }
103
+
92
104
  const content = entryToMarkdown(entry);
93
105
  writeFileSync(filePath, content, 'utf-8');
106
+ return { written: true };
94
107
  }
95
108
 
96
109
  /** Sync all vault entries to markdown, skipping entries whose content hash matches. */
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Workflow overrides — barrel export.
3
+ */
4
+ export {
5
+ loadAgentWorkflows,
6
+ getWorkflowForIntent,
7
+ WORKFLOW_TO_INTENT,
8
+ WorkflowGateSchema,
9
+ WorkflowOverrideSchema,
10
+ } from './workflow-loader.js';
11
+
12
+ export type { WorkflowGate, WorkflowOverride } from './workflow-loader.js';