@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.
- package/dist/brain/intelligence.d.ts.map +1 -1
- package/dist/brain/intelligence.js +11 -2
- package/dist/brain/intelligence.js.map +1 -1
- package/dist/brain/types.d.ts +1 -0
- package/dist/brain/types.d.ts.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/paths.d.ts +12 -0
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +45 -2
- package/dist/paths.js.map +1 -1
- package/dist/planning/gap-patterns.d.ts.map +1 -1
- package/dist/planning/gap-patterns.js +4 -1
- package/dist/planning/gap-patterns.js.map +1 -1
- package/dist/runtime/admin-setup-ops.d.ts.map +1 -1
- package/dist/runtime/admin-setup-ops.js +29 -4
- package/dist/runtime/admin-setup-ops.js.map +1 -1
- package/dist/runtime/capture-ops.d.ts.map +1 -1
- package/dist/runtime/capture-ops.js +14 -6
- package/dist/runtime/capture-ops.js.map +1 -1
- package/dist/runtime/claude-md-helpers.d.ts +11 -0
- package/dist/runtime/claude-md-helpers.d.ts.map +1 -1
- package/dist/runtime/claude-md-helpers.js +18 -0
- package/dist/runtime/claude-md-helpers.js.map +1 -1
- package/dist/runtime/facades/curator-facade.d.ts.map +1 -1
- package/dist/runtime/facades/curator-facade.js +52 -4
- package/dist/runtime/facades/curator-facade.js.map +1 -1
- package/dist/runtime/facades/memory-facade.d.ts.map +1 -1
- package/dist/runtime/facades/memory-facade.js +2 -1
- package/dist/runtime/facades/memory-facade.js.map +1 -1
- package/dist/runtime/orchestrate-ops.d.ts +12 -0
- package/dist/runtime/orchestrate-ops.d.ts.map +1 -1
- package/dist/runtime/orchestrate-ops.js +76 -0
- package/dist/runtime/orchestrate-ops.js.map +1 -1
- package/dist/vault/vault-markdown-sync.d.ts +5 -2
- package/dist/vault/vault-markdown-sync.d.ts.map +1 -1
- package/dist/vault/vault-markdown-sync.js +13 -2
- package/dist/vault/vault-markdown-sync.js.map +1 -1
- package/dist/workflows/index.d.ts +6 -0
- package/dist/workflows/index.d.ts.map +1 -0
- package/dist/workflows/index.js +5 -0
- package/dist/workflows/index.js.map +1 -0
- package/dist/workflows/workflow-loader.d.ts +83 -0
- package/dist/workflows/workflow-loader.d.ts.map +1 -0
- package/dist/workflows/workflow-loader.js +207 -0
- package/dist/workflows/workflow-loader.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/paths.test.ts +31 -0
- package/src/brain/intelligence.ts +15 -2
- package/src/brain/types.ts +1 -0
- package/src/enforcement/adapters/opencode.test.ts +4 -2
- package/src/index.ts +20 -0
- package/src/paths.ts +47 -2
- package/src/planning/gap-patterns.ts +7 -3
- package/src/runtime/admin-setup-ops.ts +31 -3
- package/src/runtime/capture-ops.test.ts +58 -1
- package/src/runtime/capture-ops.ts +15 -4
- package/src/runtime/claude-md-helpers.test.ts +81 -0
- package/src/runtime/claude-md-helpers.ts +25 -0
- package/src/runtime/facades/curator-facade.test.ts +87 -9
- package/src/runtime/facades/curator-facade.ts +60 -4
- package/src/runtime/facades/memory-facade.ts +2 -1
- package/src/runtime/orchestrate-ops.ts +84 -0
- package/src/vault/vault-markdown-sync.test.ts +40 -0
- package/src/vault/vault-markdown-sync.ts +16 -3
- package/src/workflows/index.ts +12 -0
- package/src/workflows/orchestrate-integration.test.ts +166 -0
- package/src/workflows/workflow-loader.test.ts +149 -0
- 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
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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<
|
|
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';
|