@soleri/core 9.8.0 → 9.10.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 (71) 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/index.d.ts +4 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +5 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/paths.d.ts +12 -0
  11. package/dist/paths.d.ts.map +1 -1
  12. package/dist/paths.js +45 -2
  13. package/dist/paths.js.map +1 -1
  14. package/dist/planning/gap-patterns.d.ts.map +1 -1
  15. package/dist/planning/gap-patterns.js +4 -1
  16. package/dist/planning/gap-patterns.js.map +1 -1
  17. package/dist/runtime/admin-setup-ops.d.ts.map +1 -1
  18. package/dist/runtime/admin-setup-ops.js +29 -4
  19. package/dist/runtime/admin-setup-ops.js.map +1 -1
  20. package/dist/runtime/capture-ops.d.ts.map +1 -1
  21. package/dist/runtime/capture-ops.js +14 -6
  22. package/dist/runtime/capture-ops.js.map +1 -1
  23. package/dist/runtime/claude-md-helpers.d.ts +11 -0
  24. package/dist/runtime/claude-md-helpers.d.ts.map +1 -1
  25. package/dist/runtime/claude-md-helpers.js +18 -0
  26. package/dist/runtime/claude-md-helpers.js.map +1 -1
  27. package/dist/runtime/facades/curator-facade.d.ts.map +1 -1
  28. package/dist/runtime/facades/curator-facade.js +52 -4
  29. package/dist/runtime/facades/curator-facade.js.map +1 -1
  30. package/dist/runtime/facades/memory-facade.d.ts.map +1 -1
  31. package/dist/runtime/facades/memory-facade.js +2 -1
  32. package/dist/runtime/facades/memory-facade.js.map +1 -1
  33. package/dist/runtime/orchestrate-ops.d.ts +12 -0
  34. package/dist/runtime/orchestrate-ops.d.ts.map +1 -1
  35. package/dist/runtime/orchestrate-ops.js +76 -0
  36. package/dist/runtime/orchestrate-ops.js.map +1 -1
  37. package/dist/vault/vault-markdown-sync.d.ts +5 -2
  38. package/dist/vault/vault-markdown-sync.d.ts.map +1 -1
  39. package/dist/vault/vault-markdown-sync.js +13 -2
  40. package/dist/vault/vault-markdown-sync.js.map +1 -1
  41. package/dist/workflows/index.d.ts +6 -0
  42. package/dist/workflows/index.d.ts.map +1 -0
  43. package/dist/workflows/index.js +5 -0
  44. package/dist/workflows/index.js.map +1 -0
  45. package/dist/workflows/workflow-loader.d.ts +83 -0
  46. package/dist/workflows/workflow-loader.d.ts.map +1 -0
  47. package/dist/workflows/workflow-loader.js +207 -0
  48. package/dist/workflows/workflow-loader.js.map +1 -0
  49. package/package.json +1 -1
  50. package/src/__tests__/paths.test.ts +31 -0
  51. package/src/brain/intelligence.ts +15 -2
  52. package/src/brain/types.ts +1 -0
  53. package/src/enforcement/adapters/opencode.test.ts +4 -2
  54. package/src/index.ts +20 -0
  55. package/src/paths.ts +47 -2
  56. package/src/planning/gap-patterns.ts +7 -3
  57. package/src/runtime/admin-setup-ops.ts +31 -3
  58. package/src/runtime/capture-ops.test.ts +58 -1
  59. package/src/runtime/capture-ops.ts +15 -4
  60. package/src/runtime/claude-md-helpers.test.ts +81 -0
  61. package/src/runtime/claude-md-helpers.ts +25 -0
  62. package/src/runtime/facades/curator-facade.test.ts +87 -9
  63. package/src/runtime/facades/curator-facade.ts +60 -4
  64. package/src/runtime/facades/memory-facade.ts +2 -1
  65. package/src/runtime/orchestrate-ops.ts +84 -0
  66. package/src/vault/vault-markdown-sync.test.ts +40 -0
  67. package/src/vault/vault-markdown-sync.ts +16 -3
  68. package/src/workflows/index.ts +12 -0
  69. package/src/workflows/orchestrate-integration.test.ts +166 -0
  70. package/src/workflows/workflow-loader.test.ts +149 -0
  71. package/src/workflows/workflow-loader.ts +238 -0
@@ -12,6 +12,7 @@ import {
12
12
  composeIntegrationSection,
13
13
  buildInjectionContent,
14
14
  injectEngineRulesBlock,
15
+ removeEngineRulesFromGlobal,
15
16
  } from './claude-md-helpers.js';
16
17
  import type { AgentRuntimeConfig } from './types.js';
17
18
 
@@ -186,4 +187,84 @@ describe('injectEngineRulesBlock', () => {
186
187
  expect(result).toContain('AFTER');
187
188
  expect(result).toContain('REPLACED');
188
189
  });
190
+
191
+ it('handles empty content by appending rules', () => {
192
+ const result = injectEngineRulesBlock('', 'RULES');
193
+ expect(result).toContain('RULES');
194
+ });
195
+
196
+ it('is idempotent — double injection replaces cleanly', () => {
197
+ const first = injectEngineRulesBlock('# File', `${RULES_START}\nV1\n${RULES_END}`);
198
+ const second = injectEngineRulesBlock(first, `${RULES_START}\nV2\n${RULES_END}`);
199
+ expect(second).toContain('V2');
200
+ expect(second).not.toContain('V1');
201
+ // Should have exactly one start marker
202
+ const startCount = (second.match(/<!-- soleri:engine-rules -->/g) || []).length;
203
+ expect(startCount).toBe(1);
204
+ });
205
+ });
206
+
207
+ describe('removeEngineRulesFromGlobal', () => {
208
+ const RULES_START = '<!-- soleri:engine-rules -->';
209
+ const RULES_END = '<!-- /soleri:engine-rules -->';
210
+
211
+ it('returns unchanged content when no engine rules present', () => {
212
+ const content = '# Global CLAUDE.md\n\nSome user content.';
213
+ const { cleaned, removed } = removeEngineRulesFromGlobal(content);
214
+ expect(removed).toBe(false);
215
+ expect(cleaned).toBe(content);
216
+ });
217
+
218
+ it('removes engine rules block from content', () => {
219
+ const content = `# Global\n\n${RULES_START}\nEngine rules here\n${RULES_END}\n\nUser content`;
220
+ const { cleaned, removed } = removeEngineRulesFromGlobal(content);
221
+ expect(removed).toBe(true);
222
+ expect(cleaned).not.toContain('Engine rules here');
223
+ expect(cleaned).not.toContain(RULES_START);
224
+ expect(cleaned).not.toContain(RULES_END);
225
+ expect(cleaned).toContain('# Global');
226
+ expect(cleaned).toContain('User content');
227
+ });
228
+
229
+ it('handles engine rules at end of file', () => {
230
+ const content = `# Global\n\n${RULES_START}\nRules\n${RULES_END}`;
231
+ const { cleaned, removed } = removeEngineRulesFromGlobal(content);
232
+ expect(removed).toBe(true);
233
+ expect(cleaned).toContain('# Global');
234
+ expect(cleaned).not.toContain('Rules');
235
+ });
236
+
237
+ it('handles engine rules at start of file', () => {
238
+ const content = `${RULES_START}\nRules\n${RULES_END}\n\n# Global`;
239
+ const { cleaned, removed } = removeEngineRulesFromGlobal(content);
240
+ expect(removed).toBe(true);
241
+ expect(cleaned).toContain('# Global');
242
+ expect(cleaned).not.toContain(RULES_START);
243
+ });
244
+
245
+ it('preserves agent blocks when removing engine rules', () => {
246
+ const content = [
247
+ '# Global',
248
+ '',
249
+ '<!-- agent:mybot:mode -->',
250
+ 'Agent content',
251
+ '<!-- /agent:mybot:mode -->',
252
+ '',
253
+ RULES_START,
254
+ 'Engine rules',
255
+ RULES_END,
256
+ ].join('\n');
257
+ const { cleaned, removed } = removeEngineRulesFromGlobal(content);
258
+ expect(removed).toBe(true);
259
+ expect(cleaned).toContain('<!-- agent:mybot:mode -->');
260
+ expect(cleaned).toContain('Agent content');
261
+ expect(cleaned).not.toContain('Engine rules');
262
+ });
263
+
264
+ it('returns non-empty string for content that is only engine rules', () => {
265
+ const content = `${RULES_START}\nOnly rules\n${RULES_END}`;
266
+ const { cleaned, removed } = removeEngineRulesFromGlobal(content);
267
+ expect(removed).toBe(true);
268
+ expect(cleaned.length).toBeGreaterThan(0);
269
+ });
189
270
  });
@@ -216,3 +216,28 @@ export function injectEngineRulesBlock(content: string, engineRulesContent: stri
216
216
  // Append
217
217
  return content.trimEnd() + '\n\n' + engineRulesContent + '\n';
218
218
  }
219
+
220
+ /**
221
+ * Remove engine rules block from content.
222
+ * Used during self-healing to strip engine rules from global CLAUDE.md
223
+ * when they were incorrectly injected there (they belong in _engine.md).
224
+ *
225
+ * Returns the content without the engine rules block, or unchanged if no block found.
226
+ */
227
+ export function removeEngineRulesFromGlobal(content: string): {
228
+ cleaned: string;
229
+ removed: boolean;
230
+ } {
231
+ if (!hasEngineRules(content)) {
232
+ return { cleaned: content, removed: false };
233
+ }
234
+
235
+ const startIdx = content.indexOf(ENGINE_RULES_START);
236
+ const endIdx = content.indexOf(ENGINE_RULES_END);
237
+
238
+ const before = content.slice(0, startIdx).trimEnd();
239
+ const after = content.slice(endIdx + ENGINE_RULES_END.length).trimStart();
240
+
241
+ const cleaned = before + (before && after ? '\n\n' : '') + after;
242
+ return { cleaned: cleaned || '\n', removed: true };
243
+ }
@@ -3,24 +3,43 @@ import { createCuratorFacadeOps } from './curator-facade.js';
3
3
  import type { OpDefinition } from '../../facades/types.js';
4
4
  import type { AgentRuntime } from '../types.js';
5
5
 
6
+ interface MockLinkManager {
7
+ backfillLinks: ReturnType<typeof vi.fn>;
8
+ getOrphans: ReturnType<typeof vi.fn>;
9
+ }
10
+
11
+ function getLinkManager(rt: AgentRuntime): MockLinkManager {
12
+ return (rt as unknown as { linkManager: MockLinkManager }).linkManager;
13
+ }
14
+
6
15
  function mockRuntime(): AgentRuntime {
7
16
  return {
8
17
  curator: {
9
- getStatus: vi.fn().mockReturnValue({ initialized: true, entriesGroomed: 5 }),
18
+ getStatus: vi
19
+ .fn()
20
+ .mockReturnValue({ initialized: true, entriesGroomed: 5, tables: { entries: 100 } }),
10
21
  detectDuplicates: vi.fn().mockReturnValue([]),
11
22
  detectContradictions: vi.fn(),
12
23
  getContradictions: vi.fn().mockReturnValue([]),
13
24
  resolveContradiction: vi.fn().mockReturnValue({ resolved: true }),
14
25
  groomEntry: vi.fn().mockReturnValue({ groomed: true }),
15
26
  groomAll: vi.fn().mockReturnValue({ groomed: 10 }),
16
- consolidate: vi.fn().mockReturnValue({ duplicates: 0, stale: 0 }),
17
- healthAudit: vi.fn().mockReturnValue({ score: 85 }),
27
+ consolidate: vi.fn().mockReturnValue({ duplicates: 0, stale: 0, durationMs: 10 }),
28
+ healthAudit: vi.fn().mockReturnValue({
29
+ score: 85,
30
+ metrics: { coverage: 1, freshness: 1, quality: 1, tagHealth: 1 },
31
+ recommendations: [],
32
+ }),
18
33
  getVersionHistory: vi.fn().mockReturnValue([]),
19
34
  recordSnapshot: vi.fn().mockReturnValue({ recorded: true }),
20
35
  getQueueStats: vi.fn().mockReturnValue({ total: 20, groomed: 15 }),
21
36
  enrichMetadata: vi.fn().mockReturnValue({ enriched: true }),
22
37
  detectContradictionsHybrid: vi.fn().mockReturnValue([]),
23
38
  },
39
+ linkManager: {
40
+ backfillLinks: vi.fn().mockReturnValue({ processed: 5, linksCreated: 3, durationMs: 50 }),
41
+ getOrphans: vi.fn().mockReturnValue([]),
42
+ },
24
43
  jobQueue: {
25
44
  enqueue: vi.fn().mockImplementation((_type, _params) => `job-${Date.now()}`),
26
45
  getStats: vi.fn().mockReturnValue({ pending: 0, running: 0 }),
@@ -91,7 +110,7 @@ describe('createCuratorFacadeOps', () => {
91
110
  describe('curator_status', () => {
92
111
  it('returns curator status', async () => {
93
112
  const result = await findOp(ops, 'curator_status').handler({});
94
- expect(result).toEqual({ initialized: true, entriesGroomed: 5 });
113
+ expect(result).toEqual({ initialized: true, entriesGroomed: 5, tables: { entries: 100 } });
95
114
  });
96
115
  });
97
116
 
@@ -168,15 +187,18 @@ describe('createCuratorFacadeOps', () => {
168
187
  });
169
188
 
170
189
  describe('curator_consolidate', () => {
171
- it('consolidates with default params', async () => {
190
+ it('consolidates with default params and includes linksCreated', async () => {
172
191
  const result = await findOp(ops, 'curator_consolidate').handler({});
173
- expect(result).toEqual({ duplicates: 0, stale: 0 });
192
+ expect(result).toEqual({ duplicates: 0, stale: 0, durationMs: 10, linksCreated: 3 });
174
193
  expect(runtime.curator.consolidate).toHaveBeenCalledWith({
175
194
  dryRun: undefined,
176
195
  staleDaysThreshold: undefined,
177
196
  duplicateThreshold: undefined,
178
197
  contradictionThreshold: undefined,
179
198
  });
199
+ expect(getLinkManager(runtime).backfillLinks).toHaveBeenCalledWith({
200
+ dryRun: undefined,
201
+ });
180
202
  });
181
203
 
182
204
  it('consolidates with custom params', async () => {
@@ -193,12 +215,68 @@ describe('createCuratorFacadeOps', () => {
193
215
  contradictionThreshold: 0.3,
194
216
  });
195
217
  });
218
+
219
+ it('returns linksCreated: 0 when linkManager throws', async () => {
220
+ const lm = getLinkManager(runtime);
221
+ vi.mocked(lm.backfillLinks).mockImplementation(() => {
222
+ throw new Error('link module unavailable');
223
+ });
224
+ const result = (await findOp(ops, 'curator_consolidate').handler({})) as Record<
225
+ string,
226
+ unknown
227
+ >;
228
+ expect(result.linksCreated).toBe(0);
229
+ });
196
230
  });
197
231
 
198
232
  describe('curator_health_audit', () => {
199
- it('returns audit result', async () => {
200
- const result = await findOp(ops, 'curator_health_audit').handler({});
201
- expect(result).toEqual({ score: 85 });
233
+ it('returns audit result with orphan metrics', async () => {
234
+ const result = (await findOp(ops, 'curator_health_audit').handler({})) as Record<
235
+ string,
236
+ unknown
237
+ >;
238
+ expect(result.score).toBe(85);
239
+ expect(result.orphanCount).toBe(0);
240
+ expect(result.orphanPercentage).toBe(0);
241
+ });
242
+
243
+ it('reduces quality when orphan percentage > 10%', async () => {
244
+ const lm = getLinkManager(runtime);
245
+ // 15 orphans out of 100 entries = 15%
246
+ vi.mocked(lm.getOrphans).mockReturnValue(
247
+ Array.from({ length: 15 }, (_, i) => ({
248
+ id: `orphan-${i}`,
249
+ title: `o${i}`,
250
+ type: 'pattern',
251
+ domain: 'test',
252
+ })),
253
+ );
254
+ const result = (await findOp(ops, 'curator_health_audit').handler({})) as Record<
255
+ string,
256
+ unknown
257
+ >;
258
+ expect(result.orphanCount).toBe(15);
259
+ expect(result.orphanPercentage).toBe(15);
260
+ const metrics = result.metrics as Record<string, number>;
261
+ expect(metrics.quality).toBe(0.7); // 1 * 0.7
262
+ expect(
263
+ (result.recommendations as string[]).some(
264
+ (r: string) => r.includes('orphan entries') && r.includes('no links'),
265
+ ),
266
+ ).toBe(true);
267
+ });
268
+
269
+ it('succeeds when linkManager throws', async () => {
270
+ const lm = getLinkManager(runtime);
271
+ vi.mocked(lm.getOrphans).mockImplementation(() => {
272
+ throw new Error('link module unavailable');
273
+ });
274
+ const result = (await findOp(ops, 'curator_health_audit').handler({})) as Record<
275
+ string,
276
+ unknown
277
+ >;
278
+ expect(result.orphanCount).toBe(0);
279
+ expect(result.orphanPercentage).toBe(0);
202
280
  });
203
281
  });
204
282
 
@@ -108,7 +108,7 @@ export function createCuratorFacadeOps(runtime: AgentRuntime): OpDefinition[] {
108
108
  {
109
109
  name: 'curator_consolidate',
110
110
  description:
111
- 'Consolidate vault — find duplicates, stale entries, contradictions. Dry-run by default.',
111
+ 'Consolidate vault — find duplicates, stale entries, contradictions, and backfill Zettelkasten links for orphan entries. Dry-run by default.',
112
112
  auth: 'write',
113
113
  schema: z.object({
114
114
  dryRun: z.boolean().optional().describe('Default true. Set false to apply mutations.'),
@@ -126,21 +126,77 @@ export function createCuratorFacadeOps(runtime: AgentRuntime): OpDefinition[] {
126
126
  .describe('Contradiction threshold. Default 0.4.'),
127
127
  }),
128
128
  handler: async (params) => {
129
- return curator.consolidate({
129
+ const result = curator.consolidate({
130
130
  dryRun: params.dryRun as boolean | undefined,
131
131
  staleDaysThreshold: params.staleDaysThreshold as number | undefined,
132
132
  duplicateThreshold: params.duplicateThreshold as number | undefined,
133
133
  contradictionThreshold: params.contradictionThreshold as number | undefined,
134
134
  });
135
+
136
+ // Backfill Zettelkasten links for orphan entries
137
+ let linksCreated = 0;
138
+ try {
139
+ const { linkManager } = runtime;
140
+ if (linkManager) {
141
+ const backfillResult = linkManager.backfillLinks({
142
+ dryRun: params.dryRun as boolean | undefined,
143
+ });
144
+ linksCreated = backfillResult.linksCreated;
145
+ }
146
+ } catch {
147
+ // Link module unavailable — degrade gracefully
148
+ }
149
+
150
+ return { ...result, linksCreated };
135
151
  },
136
152
  },
137
153
  {
138
154
  name: 'curator_health_audit',
139
155
  description:
140
- 'Audit vault health — score (0-100), coverage, freshness, quality, tag health, recommendations.',
156
+ 'Audit vault health — score (0-100), coverage, freshness, quality, tag health, orphan count, recommendations.',
141
157
  auth: 'read',
142
158
  handler: async () => {
143
- return curator.healthAudit();
159
+ const result = curator.healthAudit();
160
+
161
+ // Enrich with orphan statistics from link manager
162
+ let orphanCount = 0;
163
+ let orphanPercentage = 0;
164
+ try {
165
+ const { linkManager } = runtime;
166
+ if (linkManager) {
167
+ // getOrphans returns up to limit entries; use a high limit to count all
168
+ const orphans = linkManager.getOrphans(10000);
169
+ orphanCount = orphans.length;
170
+ // Compute percentage against total entries via curator status
171
+ const status = curator.getStatus();
172
+ const totalEntries = Object.values(status.tables).reduce(
173
+ (sum, count) => sum + count,
174
+ 0,
175
+ );
176
+ orphanPercentage =
177
+ totalEntries > 0 ? Math.round((orphanCount / totalEntries) * 100) : 0;
178
+ }
179
+ } catch {
180
+ // Link module unavailable — degrade gracefully
181
+ }
182
+
183
+ // Apply quality penalty if orphan percentage > 10%
184
+ const metrics = { ...result.metrics };
185
+ const recommendations = [...result.recommendations];
186
+ if (orphanPercentage > 10) {
187
+ metrics.quality = Math.round(metrics.quality * 0.7 * 100) / 100;
188
+ recommendations.push(
189
+ `${orphanCount} orphan entries (${orphanPercentage}%) have no links — run consolidation to backfill.`,
190
+ );
191
+ }
192
+
193
+ return {
194
+ ...result,
195
+ metrics,
196
+ recommendations,
197
+ orphanCount,
198
+ orphanPercentage,
199
+ };
144
200
  },
145
201
  },
146
202
 
@@ -151,7 +151,8 @@ export function createMemoryFacadeOps(runtime: AgentRuntime): OpDefinition[] {
151
151
  }),
152
152
  handler: async (params) => {
153
153
  const { resolve } = await import('node:path');
154
- const projectPath = resolve((params.projectPath as string) ?? '.');
154
+ const { findProjectRoot } = await import('../../paths.js');
155
+ const projectPath = findProjectRoot(resolve((params.projectPath as string) ?? '.'));
155
156
  const summary = (params.summary ?? params.conversationContext) as string;
156
157
  if (!summary) {
157
158
  return { captured: false, error: 'Either summary or conversationContext is required.' };
@@ -21,6 +21,8 @@ import { runEpilogue } from '../flows/epilogue.js';
21
21
  import type { OrchestrationPlan, ExecutionResult } from '../flows/types.js';
22
22
  import type { ContextHealthStatus } from './context-health.js';
23
23
  import type { OperatorSignals } from '../operator/operator-context-types.js';
24
+ import { loadAgentWorkflows, getWorkflowForIntent } from '../workflows/workflow-loader.js';
25
+ import type { WorkflowOverride } from '../workflows/workflow-loader.js';
24
26
  import {
25
27
  detectGitHubContext,
26
28
  findMatchingMilestone,
@@ -65,6 +67,70 @@ function detectIntent(prompt: string): string {
65
67
  return 'BUILD'; // default
66
68
  }
67
69
 
70
+ // ---------------------------------------------------------------------------
71
+ // Workflow override merge
72
+ // ---------------------------------------------------------------------------
73
+
74
+ /**
75
+ * Merge a workflow override into an OrchestrationPlan (mutates in place).
76
+ *
77
+ * - Gates: each workflow gate becomes a gate on the matching plan step
78
+ * (matched by phase → step id prefix). Unmatched gates are appended as
79
+ * new gate-only steps at the end.
80
+ * - Tools: workflow tools are merged into every step's `tools` array
81
+ * (deduped). This ensures the tools are available to the executor.
82
+ */
83
+ export function applyWorkflowOverride(plan: OrchestrationPlan, override: WorkflowOverride): void {
84
+ // Merge gates into plan steps
85
+ for (const gate of override.gates) {
86
+ // Try to find a step whose id starts with the gate phase
87
+ const matchingStep = plan.steps.find((s) =>
88
+ s.id.toLowerCase().startsWith(gate.phase.toLowerCase()),
89
+ );
90
+ if (matchingStep) {
91
+ // Attach/replace gate on the step
92
+ matchingStep.gate = {
93
+ type: 'GATE',
94
+ condition: gate.requirement,
95
+ onFail: { action: 'STOP', message: `Gate check failed: ${gate.check}` },
96
+ };
97
+ } else {
98
+ // No matching step — append a new gate-only step
99
+ plan.steps.push({
100
+ id: `workflow-gate-${gate.phase}`,
101
+ name: `${gate.phase} gate (${override.name})`,
102
+ tools: [],
103
+ parallel: false,
104
+ requires: [],
105
+ gate: {
106
+ type: 'GATE',
107
+ condition: gate.requirement,
108
+ onFail: { action: 'STOP', message: `Gate check failed: ${gate.check}` },
109
+ },
110
+ status: 'pending',
111
+ });
112
+ }
113
+ }
114
+
115
+ // Merge tools into plan steps (deduplicated)
116
+ if (override.tools.length > 0) {
117
+ for (const step of plan.steps) {
118
+ for (const tool of override.tools) {
119
+ if (!step.tools.includes(tool)) {
120
+ step.tools.push(tool);
121
+ }
122
+ }
123
+ }
124
+ // Update estimated tools count
125
+ plan.estimatedTools = plan.steps.reduce((acc, s) => acc + s.tools.length, 0);
126
+ }
127
+
128
+ // Add workflow info to warnings for visibility
129
+ plan.warnings.push(
130
+ `Workflow override "${override.name}" applied (${override.gates.length} gate(s), ${override.tools.length} tool(s)).`,
131
+ );
132
+ }
133
+
68
134
  // ---------------------------------------------------------------------------
69
135
  // In-memory plan store
70
136
  // ---------------------------------------------------------------------------
@@ -312,6 +378,23 @@ export function createOrchestrateOps(
312
378
  // 3. Build flow-engine plan
313
379
  const plan = await buildPlan(intent, agentId, projectPath, runtime, prompt);
314
380
 
381
+ // 3b. Merge workflow overrides (gates + tools) if agent has a matching workflow
382
+ let workflowApplied: string | undefined;
383
+ const agentDir = runtime.config.agentDir;
384
+ if (agentDir) {
385
+ try {
386
+ const workflowsDir = path.join(agentDir, 'workflows');
387
+ const agentWorkflows = loadAgentWorkflows(workflowsDir);
388
+ const workflowOverride = getWorkflowForIntent(agentWorkflows, intent);
389
+ if (workflowOverride) {
390
+ applyWorkflowOverride(plan, workflowOverride);
391
+ workflowApplied = workflowOverride.name;
392
+ }
393
+ } catch {
394
+ // Workflow loading failed — plan is still valid without overrides
395
+ }
396
+ }
397
+
315
398
  // 4. Store in planStore
316
399
  planStore.set(plan.planId, { plan, createdAt: Date.now() });
317
400
 
@@ -373,6 +456,7 @@ export function createOrchestrateOps(
373
456
  skippedCount: plan.skipped.length,
374
457
  warnings: plan.warnings,
375
458
  estimatedTools: plan.estimatedTools,
459
+ ...(workflowApplied ? { workflowOverride: workflowApplied } : {}),
376
460
  },
377
461
  };
378
462
  },
@@ -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';