@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.
Files changed (56) 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 +2 -0
  11. package/dist/paths.d.ts.map +1 -1
  12. package/dist/paths.js +4 -0
  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/capture-ops.d.ts.map +1 -1
  18. package/dist/runtime/capture-ops.js +14 -6
  19. package/dist/runtime/capture-ops.js.map +1 -1
  20. package/dist/runtime/facades/curator-facade.d.ts.map +1 -1
  21. package/dist/runtime/facades/curator-facade.js +52 -4
  22. package/dist/runtime/facades/curator-facade.js.map +1 -1
  23. package/dist/runtime/orchestrate-ops.d.ts +12 -0
  24. package/dist/runtime/orchestrate-ops.d.ts.map +1 -1
  25. package/dist/runtime/orchestrate-ops.js +76 -0
  26. package/dist/runtime/orchestrate-ops.js.map +1 -1
  27. package/dist/vault/vault-markdown-sync.d.ts +5 -2
  28. package/dist/vault/vault-markdown-sync.d.ts.map +1 -1
  29. package/dist/vault/vault-markdown-sync.js +13 -2
  30. package/dist/vault/vault-markdown-sync.js.map +1 -1
  31. package/dist/workflows/index.d.ts +6 -0
  32. package/dist/workflows/index.d.ts.map +1 -0
  33. package/dist/workflows/index.js +5 -0
  34. package/dist/workflows/index.js.map +1 -0
  35. package/dist/workflows/workflow-loader.d.ts +83 -0
  36. package/dist/workflows/workflow-loader.d.ts.map +1 -0
  37. package/dist/workflows/workflow-loader.js +207 -0
  38. package/dist/workflows/workflow-loader.js.map +1 -0
  39. package/package.json +1 -1
  40. package/src/brain/intelligence.ts +15 -2
  41. package/src/brain/types.ts +1 -0
  42. package/src/enforcement/adapters/opencode.test.ts +4 -2
  43. package/src/index.ts +19 -0
  44. package/src/paths.ts +5 -0
  45. package/src/planning/gap-patterns.ts +7 -3
  46. package/src/runtime/capture-ops.test.ts +58 -1
  47. package/src/runtime/capture-ops.ts +15 -4
  48. package/src/runtime/facades/curator-facade.test.ts +87 -9
  49. package/src/runtime/facades/curator-facade.ts +60 -4
  50. package/src/runtime/orchestrate-ops.ts +84 -0
  51. package/src/vault/vault-markdown-sync.test.ts +40 -0
  52. package/src/vault/vault-markdown-sync.ts +16 -3
  53. package/src/workflows/index.ts +12 -0
  54. package/src/workflows/orchestrate-integration.test.ts +166 -0
  55. package/src/workflows/workflow-loader.test.ts +149 -0
  56. 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.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
 
@@ -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';
@@ -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
+ });