@soleri/core 9.7.2 → 9.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/enforcement/adapters/index.d.ts +15 -0
- package/dist/enforcement/adapters/index.d.ts.map +1 -1
- package/dist/enforcement/adapters/index.js +38 -0
- package/dist/enforcement/adapters/index.js.map +1 -1
- package/dist/enforcement/adapters/opencode.d.ts +21 -0
- package/dist/enforcement/adapters/opencode.d.ts.map +1 -0
- package/dist/enforcement/adapters/opencode.js +115 -0
- package/dist/enforcement/adapters/opencode.js.map +1 -0
- 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/evidence-collector.d.ts +2 -0
- package/dist/planning/evidence-collector.d.ts.map +1 -1
- package/dist/planning/evidence-collector.js +7 -2
- package/dist/planning/evidence-collector.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/planning/plan-lifecycle.d.ts.map +1 -1
- package/dist/planning/plan-lifecycle.js +5 -0
- package/dist/planning/plan-lifecycle.js.map +1 -1
- package/dist/planning/planner-types.d.ts +2 -0
- package/dist/planning/planner-types.d.ts.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 +141 -1
- package/dist/runtime/orchestrate-ops.js.map +1 -1
- package/dist/runtime/quality-signals.d.ts +42 -0
- package/dist/runtime/quality-signals.d.ts.map +1 -0
- package/dist/runtime/quality-signals.js +124 -0
- package/dist/runtime/quality-signals.js.map +1 -0
- package/dist/skills/trust-classifier.js +1 -1
- package/dist/skills/trust-classifier.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/index.ts +45 -0
- package/src/enforcement/adapters/opencode.test.ts +406 -0
- package/src/enforcement/adapters/opencode.ts +153 -0
- package/src/index.ts +19 -0
- package/src/paths.ts +5 -0
- package/src/planning/evidence-collector.test.ts +95 -0
- package/src/planning/evidence-collector.ts +11 -0
- package/src/planning/gap-patterns.ts +7 -3
- package/src/planning/plan-lifecycle.test.ts +49 -0
- package/src/planning/plan-lifecycle.ts +5 -0
- package/src/planning/planner-types.ts +2 -0
- 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.test.ts +78 -1
- package/src/runtime/orchestrate-ops.ts +175 -1
- package/src/runtime/orchestrate-status-readiness.test.ts +162 -0
- package/src/runtime/quality-signals.test.ts +312 -0
- package/src/runtime/quality-signals.ts +169 -0
- package/src/skills/trust-classifier.ts +1 -1
- 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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
+
import { loadAgentWorkflows, getWorkflowForIntent, WORKFLOW_TO_INTENT } from './workflow-loader.js';
|
|
4
|
+
import type { WorkflowOverride } from './workflow-loader.js';
|
|
5
|
+
|
|
6
|
+
vi.mock('node:fs', () => ({
|
|
7
|
+
default: {
|
|
8
|
+
readdirSync: vi.fn(),
|
|
9
|
+
statSync: vi.fn(),
|
|
10
|
+
readFileSync: vi.fn(),
|
|
11
|
+
},
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
describe('workflow-loader', () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
vi.resetAllMocks();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('loadAgentWorkflows', () => {
|
|
20
|
+
it('returns empty map when directory does not exist', () => {
|
|
21
|
+
(fs.readdirSync as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
|
22
|
+
throw new Error('ENOENT');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const result = loadAgentWorkflows('/nonexistent/workflows');
|
|
26
|
+
expect(result.size).toBe(0);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('loads gates and tools from workflow folder', () => {
|
|
30
|
+
(fs.readdirSync as ReturnType<typeof vi.fn>).mockReturnValue(['feature-dev']);
|
|
31
|
+
(fs.statSync as ReturnType<typeof vi.fn>).mockReturnValue({ isDirectory: () => true });
|
|
32
|
+
(fs.readFileSync as ReturnType<typeof vi.fn>).mockImplementation((filePath: string) => {
|
|
33
|
+
if (filePath.endsWith('prompt.md')) {
|
|
34
|
+
return '# Feature Dev\nBuild new features.';
|
|
35
|
+
}
|
|
36
|
+
if (filePath.endsWith('gates.yaml')) {
|
|
37
|
+
return `gates:
|
|
38
|
+
- phase: pre-execution
|
|
39
|
+
requirement: Plan approved
|
|
40
|
+
check: plan-approved
|
|
41
|
+
- phase: completion
|
|
42
|
+
requirement: Tests pass
|
|
43
|
+
check: tests-pass
|
|
44
|
+
`;
|
|
45
|
+
}
|
|
46
|
+
if (filePath.endsWith('tools.yaml')) {
|
|
47
|
+
return `tools:
|
|
48
|
+
- soleri_vault op:search_intelligent
|
|
49
|
+
- soleri_plan op:create_plan
|
|
50
|
+
`;
|
|
51
|
+
}
|
|
52
|
+
throw new Error('ENOENT');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const result = loadAgentWorkflows('/agent/workflows');
|
|
56
|
+
expect(result.size).toBe(1);
|
|
57
|
+
|
|
58
|
+
const workflow = result.get('feature-dev')!;
|
|
59
|
+
expect(workflow.name).toBe('feature-dev');
|
|
60
|
+
expect(workflow.prompt).toBe('# Feature Dev\nBuild new features.');
|
|
61
|
+
expect(workflow.gates).toHaveLength(2);
|
|
62
|
+
expect(workflow.gates[0]).toEqual({
|
|
63
|
+
phase: 'pre-execution',
|
|
64
|
+
requirement: 'Plan approved',
|
|
65
|
+
check: 'plan-approved',
|
|
66
|
+
});
|
|
67
|
+
expect(workflow.gates[1]).toEqual({
|
|
68
|
+
phase: 'completion',
|
|
69
|
+
requirement: 'Tests pass',
|
|
70
|
+
check: 'tests-pass',
|
|
71
|
+
});
|
|
72
|
+
expect(workflow.tools).toEqual([
|
|
73
|
+
'soleri_vault op:search_intelligent',
|
|
74
|
+
'soleri_plan op:create_plan',
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('skips non-directory entries', () => {
|
|
79
|
+
(fs.readdirSync as ReturnType<typeof vi.fn>).mockReturnValue(['README.md']);
|
|
80
|
+
(fs.statSync as ReturnType<typeof vi.fn>).mockReturnValue({ isDirectory: () => false });
|
|
81
|
+
|
|
82
|
+
const result = loadAgentWorkflows('/agent/workflows');
|
|
83
|
+
expect(result.size).toBe(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('skips workflow folders with no content', () => {
|
|
87
|
+
(fs.readdirSync as ReturnType<typeof vi.fn>).mockReturnValue(['empty-workflow']);
|
|
88
|
+
(fs.statSync as ReturnType<typeof vi.fn>).mockReturnValue({ isDirectory: () => true });
|
|
89
|
+
(fs.readFileSync as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
|
90
|
+
throw new Error('ENOENT');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const result = loadAgentWorkflows('/agent/workflows');
|
|
94
|
+
expect(result.size).toBe(0);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('getWorkflowForIntent', () => {
|
|
99
|
+
it('returns matching workflow for BUILD intent', () => {
|
|
100
|
+
const workflows = new Map<string, WorkflowOverride>([
|
|
101
|
+
['feature-dev', { name: 'feature-dev', gates: [], tools: ['tool1'] }],
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
const result = getWorkflowForIntent(workflows, 'BUILD');
|
|
105
|
+
expect(result).not.toBeNull();
|
|
106
|
+
expect(result!.name).toBe('feature-dev');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('returns null when no matching workflow', () => {
|
|
110
|
+
const workflows = new Map<string, WorkflowOverride>([
|
|
111
|
+
['feature-dev', { name: 'feature-dev', gates: [], tools: ['tool1'] }],
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
const result = getWorkflowForIntent(workflows, 'EXPLORE');
|
|
115
|
+
expect(result).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('uses custom mapping when provided', () => {
|
|
119
|
+
const workflows = new Map<string, WorkflowOverride>([
|
|
120
|
+
['my-custom', { name: 'my-custom', gates: [], tools: ['t1'] }],
|
|
121
|
+
]);
|
|
122
|
+
|
|
123
|
+
const result = getWorkflowForIntent(workflows, 'DESIGN', {
|
|
124
|
+
'my-custom': 'DESIGN',
|
|
125
|
+
});
|
|
126
|
+
expect(result).not.toBeNull();
|
|
127
|
+
expect(result!.name).toBe('my-custom');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('is case-insensitive for intent', () => {
|
|
131
|
+
const workflows = new Map<string, WorkflowOverride>([
|
|
132
|
+
['bug-fix', { name: 'bug-fix', gates: [], tools: [] }],
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
// bug-fix maps to FIX in WORKFLOW_TO_INTENT
|
|
136
|
+
const result = getWorkflowForIntent(workflows, 'fix');
|
|
137
|
+
expect(result).not.toBeNull();
|
|
138
|
+
expect(result!.name).toBe('bug-fix');
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('WORKFLOW_TO_INTENT', () => {
|
|
143
|
+
it('maps known workflow names to intents', () => {
|
|
144
|
+
expect(WORKFLOW_TO_INTENT['feature-dev']).toBe('BUILD');
|
|
145
|
+
expect(WORKFLOW_TO_INTENT['bug-fix']).toBe('FIX');
|
|
146
|
+
expect(WORKFLOW_TO_INTENT['code-review']).toBe('REVIEW');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow loader — reads agent workflow overrides from the file tree.
|
|
3
|
+
*
|
|
4
|
+
* Each workflow is a folder under `workflows/` containing:
|
|
5
|
+
* - `prompt.md` — system prompt for the workflow (optional)
|
|
6
|
+
* - `gates.yaml` — gate definitions (optional)
|
|
7
|
+
* - `tools.yaml` — tool allowlist (optional)
|
|
8
|
+
*
|
|
9
|
+
* These overrides are merged into the OrchestrationPlan when
|
|
10
|
+
* the detected intent matches a workflow via WORKFLOW_TO_INTENT.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { z } from 'zod';
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Schemas
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
export const WorkflowGateSchema = z.object({
|
|
22
|
+
phase: z.string(),
|
|
23
|
+
requirement: z.string(),
|
|
24
|
+
check: z.string(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export const WorkflowOverrideSchema = z.object({
|
|
28
|
+
name: z.string(),
|
|
29
|
+
prompt: z.string().optional(),
|
|
30
|
+
gates: z.array(WorkflowGateSchema).default([]),
|
|
31
|
+
tools: z.array(z.string()).default([]),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export type WorkflowGate = z.infer<typeof WorkflowGateSchema>;
|
|
35
|
+
export type WorkflowOverride = z.infer<typeof WorkflowOverrideSchema>;
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Workflow → Intent mapping
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Maps workflow folder names to intent strings.
|
|
43
|
+
* Used by `getWorkflowForIntent()` to find a matching workflow.
|
|
44
|
+
*/
|
|
45
|
+
export const WORKFLOW_TO_INTENT: Record<string, string> = {
|
|
46
|
+
'feature-dev': 'BUILD',
|
|
47
|
+
'bug-fix': 'FIX',
|
|
48
|
+
'code-review': 'REVIEW',
|
|
49
|
+
'component-build': 'BUILD',
|
|
50
|
+
'token-migration': 'ENHANCE',
|
|
51
|
+
'a11y-remediation': 'FIX',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Loader
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Load all workflow overrides from an agent's `workflows/` directory.
|
|
60
|
+
*
|
|
61
|
+
* Returns an empty Map if the directory doesn't exist or can't be read
|
|
62
|
+
* (graceful degradation — no throw).
|
|
63
|
+
*/
|
|
64
|
+
export function loadAgentWorkflows(workflowsDir: string): Map<string, WorkflowOverride> {
|
|
65
|
+
const workflows = new Map<string, WorkflowOverride>();
|
|
66
|
+
|
|
67
|
+
let entries: string[];
|
|
68
|
+
try {
|
|
69
|
+
entries = fs.readdirSync(workflowsDir);
|
|
70
|
+
} catch {
|
|
71
|
+
// Directory doesn't exist or can't be read — that's fine
|
|
72
|
+
return workflows;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
const fullPath = path.join(workflowsDir, entry);
|
|
77
|
+
let stat: fs.Stats;
|
|
78
|
+
try {
|
|
79
|
+
stat = fs.statSync(fullPath);
|
|
80
|
+
} catch {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (!stat.isDirectory()) continue;
|
|
84
|
+
|
|
85
|
+
const override: WorkflowOverride = { name: entry, gates: [], tools: [] };
|
|
86
|
+
|
|
87
|
+
// Read prompt.md
|
|
88
|
+
const promptPath = path.join(fullPath, 'prompt.md');
|
|
89
|
+
try {
|
|
90
|
+
override.prompt = fs.readFileSync(promptPath, 'utf-8').trim();
|
|
91
|
+
} catch {
|
|
92
|
+
// No prompt — that's fine
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Read gates.yaml
|
|
96
|
+
const gatesPath = path.join(fullPath, 'gates.yaml');
|
|
97
|
+
try {
|
|
98
|
+
const raw = fs.readFileSync(gatesPath, 'utf-8');
|
|
99
|
+
// Simple YAML parsing for the gates structure
|
|
100
|
+
const gates = parseGatesYaml(raw);
|
|
101
|
+
override.gates = gates;
|
|
102
|
+
} catch {
|
|
103
|
+
// No gates — that's fine
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Read tools.yaml
|
|
107
|
+
const toolsPath = path.join(fullPath, 'tools.yaml');
|
|
108
|
+
try {
|
|
109
|
+
const raw = fs.readFileSync(toolsPath, 'utf-8');
|
|
110
|
+
const tools = parseToolsYaml(raw);
|
|
111
|
+
override.tools = tools;
|
|
112
|
+
} catch {
|
|
113
|
+
// No tools — that's fine
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Only store if we got something useful
|
|
117
|
+
if (override.prompt || override.gates.length > 0 || override.tools.length > 0) {
|
|
118
|
+
workflows.set(entry, override);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return workflows;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Intent matching
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Find a workflow override that matches the given intent.
|
|
131
|
+
*
|
|
132
|
+
* Uses WORKFLOW_TO_INTENT mapping, optionally overridden by customMapping.
|
|
133
|
+
* Returns null if no matching workflow is found.
|
|
134
|
+
*/
|
|
135
|
+
export function getWorkflowForIntent(
|
|
136
|
+
workflows: Map<string, WorkflowOverride>,
|
|
137
|
+
intent: string,
|
|
138
|
+
customMapping?: Record<string, string>,
|
|
139
|
+
): WorkflowOverride | null {
|
|
140
|
+
const mapping = customMapping ?? WORKFLOW_TO_INTENT;
|
|
141
|
+
const normalizedIntent = intent.toUpperCase();
|
|
142
|
+
|
|
143
|
+
for (const [workflowName, mappedIntent] of Object.entries(mapping)) {
|
|
144
|
+
if (mappedIntent.toUpperCase() === normalizedIntent && workflows.has(workflowName)) {
|
|
145
|
+
return workflows.get(workflowName)!;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// Minimal YAML parsers (no external dependency)
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Parse a simple gates.yaml file. Expected format:
|
|
158
|
+
*
|
|
159
|
+
* ```yaml
|
|
160
|
+
* gates:
|
|
161
|
+
* - phase: brainstorming
|
|
162
|
+
* requirement: Requirements are clear
|
|
163
|
+
* check: user-approval
|
|
164
|
+
* ```
|
|
165
|
+
*/
|
|
166
|
+
function parseGatesYaml(raw: string): WorkflowGate[] {
|
|
167
|
+
const gates: WorkflowGate[] = [];
|
|
168
|
+
const lines = raw.split('\n');
|
|
169
|
+
|
|
170
|
+
let current: Partial<WorkflowGate> | null = null;
|
|
171
|
+
|
|
172
|
+
for (const line of lines) {
|
|
173
|
+
const trimmed = line.trim();
|
|
174
|
+
|
|
175
|
+
// Skip empty lines and the root "gates:" key
|
|
176
|
+
if (!trimmed || trimmed === 'gates:') continue;
|
|
177
|
+
|
|
178
|
+
// New list item
|
|
179
|
+
if (trimmed.startsWith('- ')) {
|
|
180
|
+
if (current && current.phase && current.requirement && current.check) {
|
|
181
|
+
gates.push(current as WorkflowGate);
|
|
182
|
+
}
|
|
183
|
+
current = {};
|
|
184
|
+
// Parse inline key from "- phase: value"
|
|
185
|
+
const inlineMatch = trimmed.match(/^-\s+(\w+):\s*(.+)$/);
|
|
186
|
+
if (inlineMatch) {
|
|
187
|
+
const [, key, value] = inlineMatch;
|
|
188
|
+
if (key === 'phase' || key === 'requirement' || key === 'check') {
|
|
189
|
+
current[key] = value.trim();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Continuation key: " requirement: value"
|
|
196
|
+
if (current) {
|
|
197
|
+
const kvMatch = trimmed.match(/^(\w+):\s*(.+)$/);
|
|
198
|
+
if (kvMatch) {
|
|
199
|
+
const [, key, value] = kvMatch;
|
|
200
|
+
if (key === 'phase' || key === 'requirement' || key === 'check') {
|
|
201
|
+
current[key] = value.trim();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Flush last entry
|
|
208
|
+
if (current && current.phase && current.requirement && current.check) {
|
|
209
|
+
gates.push(current as WorkflowGate);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return gates;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Parse a simple tools.yaml file. Expected format:
|
|
217
|
+
*
|
|
218
|
+
* ```yaml
|
|
219
|
+
* tools:
|
|
220
|
+
* - soleri_vault op:search_intelligent
|
|
221
|
+
* - soleri_plan op:create_plan
|
|
222
|
+
* ```
|
|
223
|
+
*/
|
|
224
|
+
function parseToolsYaml(raw: string): string[] {
|
|
225
|
+
const tools: string[] = [];
|
|
226
|
+
const lines = raw.split('\n');
|
|
227
|
+
|
|
228
|
+
for (const line of lines) {
|
|
229
|
+
const trimmed = line.trim();
|
|
230
|
+
if (!trimmed || trimmed === 'tools:') continue;
|
|
231
|
+
|
|
232
|
+
if (trimmed.startsWith('- ')) {
|
|
233
|
+
tools.push(trimmed.slice(2).trim());
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return tools;
|
|
238
|
+
}
|