@soleri/core 9.8.0 → 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.
- 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 +2 -0
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +4 -0
- 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/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/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/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/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 +19 -0
- package/src/paths.ts +5 -0
- package/src/planning/gap-patterns.ts +7 -3
- package/src/runtime/capture-ops.test.ts +58 -1
- package/src/runtime/capture-ops.ts +15 -4
- package/src/runtime/facades/curator-facade.test.ts +87 -9
- package/src/runtime/facades/curator-facade.ts +60 -4
- 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
|
@@ -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
|
|
|
@@ -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';
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { applyWorkflowOverride } from '../runtime/orchestrate-ops.js';
|
|
3
|
+
import type { OrchestrationPlan, PlanStep } from '../flows/types.js';
|
|
4
|
+
import type { WorkflowOverride } from './workflow-loader.js';
|
|
5
|
+
|
|
6
|
+
function makePlan(steps: PlanStep[]): OrchestrationPlan {
|
|
7
|
+
return {
|
|
8
|
+
planId: 'test-plan-1',
|
|
9
|
+
intent: 'BUILD',
|
|
10
|
+
flowId: 'BUILD-flow',
|
|
11
|
+
steps,
|
|
12
|
+
skipped: [],
|
|
13
|
+
epilogue: ['capture_knowledge'],
|
|
14
|
+
warnings: [],
|
|
15
|
+
summary: 'Test plan',
|
|
16
|
+
estimatedTools: steps.reduce((acc, s) => acc + s.tools.length, 0),
|
|
17
|
+
context: {
|
|
18
|
+
intent: 'BUILD',
|
|
19
|
+
probes: {
|
|
20
|
+
vault: true,
|
|
21
|
+
brain: false,
|
|
22
|
+
designSystem: false,
|
|
23
|
+
sessionStore: true,
|
|
24
|
+
projectRules: false,
|
|
25
|
+
active: true,
|
|
26
|
+
},
|
|
27
|
+
entities: { components: [], actions: [] },
|
|
28
|
+
projectPath: '.',
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeStep(id: string, tools: string[] = []): PlanStep {
|
|
34
|
+
return {
|
|
35
|
+
id,
|
|
36
|
+
name: id,
|
|
37
|
+
tools,
|
|
38
|
+
parallel: false,
|
|
39
|
+
requires: [],
|
|
40
|
+
status: 'pending',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('applyWorkflowOverride', () => {
|
|
45
|
+
it('merges gates into matching plan steps', () => {
|
|
46
|
+
const plan = makePlan([
|
|
47
|
+
makeStep('pre-execution-vault-search', ['vault_search']),
|
|
48
|
+
makeStep('completion-capture', ['capture_knowledge']),
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
const override: WorkflowOverride = {
|
|
52
|
+
name: 'feature-dev',
|
|
53
|
+
gates: [
|
|
54
|
+
{
|
|
55
|
+
phase: 'pre-execution',
|
|
56
|
+
requirement: 'Plan approved by user',
|
|
57
|
+
check: 'plan-approved',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
phase: 'completion',
|
|
61
|
+
requirement: 'Knowledge captured',
|
|
62
|
+
check: 'knowledge-captured',
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
tools: [],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
applyWorkflowOverride(plan, override);
|
|
69
|
+
|
|
70
|
+
// Gates should be attached to matching steps
|
|
71
|
+
expect(plan.steps[0].gate).toBeDefined();
|
|
72
|
+
expect(plan.steps[0].gate!.type).toBe('GATE');
|
|
73
|
+
expect(plan.steps[0].gate!.condition).toBe('Plan approved by user');
|
|
74
|
+
|
|
75
|
+
expect(plan.steps[1].gate).toBeDefined();
|
|
76
|
+
expect(plan.steps[1].gate!.condition).toBe('Knowledge captured');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('appends unmatched gates as new steps', () => {
|
|
80
|
+
const plan = makePlan([makeStep('vault-search', ['vault_search'])]);
|
|
81
|
+
|
|
82
|
+
const override: WorkflowOverride = {
|
|
83
|
+
name: 'bug-fix',
|
|
84
|
+
gates: [
|
|
85
|
+
{
|
|
86
|
+
phase: 'post-task',
|
|
87
|
+
requirement: 'All tests pass',
|
|
88
|
+
check: 'tests-pass',
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
tools: [],
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
applyWorkflowOverride(plan, override);
|
|
95
|
+
|
|
96
|
+
// Original step untouched
|
|
97
|
+
expect(plan.steps[0].gate).toBeUndefined();
|
|
98
|
+
|
|
99
|
+
// New gate step appended
|
|
100
|
+
expect(plan.steps).toHaveLength(2);
|
|
101
|
+
expect(plan.steps[1].id).toBe('workflow-gate-post-task');
|
|
102
|
+
expect(plan.steps[1].gate!.condition).toBe('All tests pass');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('merges tools into all plan steps (deduplicated)', () => {
|
|
106
|
+
const plan = makePlan([makeStep('step1', ['existing_tool']), makeStep('step2', [])]);
|
|
107
|
+
|
|
108
|
+
const override: WorkflowOverride = {
|
|
109
|
+
name: 'feature-dev',
|
|
110
|
+
gates: [],
|
|
111
|
+
tools: ['soleri_vault op:search_intelligent', 'existing_tool'],
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
applyWorkflowOverride(plan, override);
|
|
115
|
+
|
|
116
|
+
// step1 already had existing_tool — should not duplicate
|
|
117
|
+
expect(plan.steps[0].tools).toEqual(['existing_tool', 'soleri_vault op:search_intelligent']);
|
|
118
|
+
// step2 gets the tools
|
|
119
|
+
expect(plan.steps[1].tools).toEqual(['soleri_vault op:search_intelligent', 'existing_tool']);
|
|
120
|
+
// estimatedTools updated
|
|
121
|
+
expect(plan.estimatedTools).toBe(plan.steps.reduce((acc, s) => acc + s.tools.length, 0));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('does nothing when override has no gates or tools', () => {
|
|
125
|
+
const plan = makePlan([makeStep('step1', ['t1'])]);
|
|
126
|
+
const originalSteps = plan.steps.length;
|
|
127
|
+
const originalTools = plan.steps[0].tools.length;
|
|
128
|
+
|
|
129
|
+
const override: WorkflowOverride = {
|
|
130
|
+
name: 'empty',
|
|
131
|
+
gates: [],
|
|
132
|
+
tools: [],
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
applyWorkflowOverride(plan, override);
|
|
136
|
+
|
|
137
|
+
expect(plan.steps).toHaveLength(originalSteps);
|
|
138
|
+
expect(plan.steps[0].tools).toHaveLength(originalTools);
|
|
139
|
+
// Warning still added
|
|
140
|
+
expect(plan.warnings).toContain('Workflow override "empty" applied (0 gate(s), 0 tool(s)).');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('plan remains unchanged when no workflow matches', () => {
|
|
144
|
+
// This tests the calling code path — if getWorkflowForIntent returns null,
|
|
145
|
+
// applyWorkflowOverride is never called
|
|
146
|
+
const plan = makePlan([makeStep('step1', ['t1'])]);
|
|
147
|
+
expect(plan.warnings).toHaveLength(0);
|
|
148
|
+
expect(plan.steps[0].gate).toBeUndefined();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('adds info warning about applied override', () => {
|
|
152
|
+
const plan = makePlan([makeStep('step1')]);
|
|
153
|
+
|
|
154
|
+
const override: WorkflowOverride = {
|
|
155
|
+
name: 'feature-dev',
|
|
156
|
+
gates: [{ phase: 'pre', requirement: 'ok', check: 'go' }],
|
|
157
|
+
tools: ['tool1', 'tool2'],
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
applyWorkflowOverride(plan, override);
|
|
161
|
+
|
|
162
|
+
expect(plan.warnings).toContain(
|
|
163
|
+
'Workflow override "feature-dev" applied (1 gate(s), 2 tool(s)).',
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
});
|