@besales/ops-framework 0.1.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/CHANGELOG.md +10 -0
- package/README.md +328 -0
- package/bin/build-check-context.mjs +67 -0
- package/bin/build-execution-ledger.mjs +54 -0
- package/bin/estimate-llm-input.mjs +160 -0
- package/bin/guard-task.mjs +384 -0
- package/bin/hash-task-artifacts.mjs +44 -0
- package/bin/init-project.mjs +49 -0
- package/bin/intake-execution-feedback.mjs +207 -0
- package/bin/intake-feedback.test.mjs +73 -0
- package/bin/learning-loop.mjs +658 -0
- package/bin/learning-loop.test.mjs +175 -0
- package/bin/lib/bootstrap-utils.mjs +542 -0
- package/bin/lib/bootstrap-utils.test.mjs +156 -0
- package/bin/lib/check-context-utils.mjs +1448 -0
- package/bin/lib/check-context-utils.test.mjs +497 -0
- package/bin/lib/execution-ledger-utils.mjs +162 -0
- package/bin/lib/execution-ledger-utils.test.mjs +74 -0
- package/bin/lib/llm-input-pack-utils.mjs +663 -0
- package/bin/lib/llm-input-pack-utils.test.mjs +262 -0
- package/bin/lib/project-config.mjs +229 -0
- package/bin/lib/project-config.test.mjs +102 -0
- package/bin/lib/task-manifest-utils.mjs +512 -0
- package/bin/lib/task-manifest-utils.test.mjs +218 -0
- package/bin/lib/task-metrics-utils.mjs +63 -0
- package/bin/lib/task-metrics-utils.test.mjs +40 -0
- package/bin/lib/test-setup.mjs +37 -0
- package/bin/new-task.mjs +42 -0
- package/bin/ops-agent.mjs +81 -0
- package/bin/preflight.mjs +56 -0
- package/bin/providers/external-cli-checker.mjs +190 -0
- package/bin/providers/openai-checker.mjs +62 -0
- package/bin/quality-gates.mjs +92 -0
- package/bin/run-check.mjs +559 -0
- package/bin/run-plan-check-loop.mjs +392 -0
- package/bin/run-verify.mjs +627 -0
- package/bin/self-lint.mjs +88 -0
- package/bin/supervisor-turn.mjs +146 -0
- package/bin/supervisor-turn.test.mjs +72 -0
- package/bin/task-manifest.mjs +57 -0
- package/bin/task-metrics.mjs +48 -0
- package/bin/transition.mjs +94 -0
- package/bin/validate-check-artifacts.mjs +418 -0
- package/config/default-agents.json +100 -0
- package/package.json +28 -0
- package/playbooks/checker-context.md +9 -0
- package/playbooks/complexity-performance.md +13 -0
- package/playbooks/production-rollout.md +9 -0
- package/playbooks/source-sync-provider.md +9 -0
- package/playbooks/ui-acceptance.md +9 -0
- package/prompts/checker.md +170 -0
- package/prompts/executor.md +54 -0
- package/prompts/planner.md +128 -0
- package/prompts/researcher.md +44 -0
- package/prompts/supervisor.md +337 -0
- package/prompts/verifier.md +128 -0
- package/templates/brief.md +15 -0
- package/templates/check-resolution.md +69 -0
- package/templates/check-result.json +32 -0
- package/templates/check.md +46 -0
- package/templates/execution-feedback.md +25 -0
- package/templates/execution.md +101 -0
- package/templates/human-gate-summary.md +49 -0
- package/templates/orchestration-log.md +8 -0
- package/templates/plan.md +86 -0
- package/templates/research.md +13 -0
- package/templates/retrospective.md +48 -0
- package/templates/status.md +53 -0
- package/templates/verify-result.json +19 -0
- package/templates/verify.md +41 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import {
|
|
6
|
+
buildCheckerLlmInputPack,
|
|
7
|
+
buildContextModeSequence,
|
|
8
|
+
buildVerifierLlmInputPack,
|
|
9
|
+
estimatePayload,
|
|
10
|
+
isContextInsufficientResult,
|
|
11
|
+
resolveLlmContextMode,
|
|
12
|
+
} from './llm-input-pack-utils.mjs';
|
|
13
|
+
|
|
14
|
+
const tempDirs = [];
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
for (const dir of tempDirs.splice(0)) {
|
|
18
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('llm input pack utilities', () => {
|
|
23
|
+
it('estimates compact payload size and reports compacted artifacts', () => {
|
|
24
|
+
const taskDir = createTask();
|
|
25
|
+
const pack = buildCheckerLlmInputPack({
|
|
26
|
+
taskDir,
|
|
27
|
+
taskId: 'TASK-999-token-pack',
|
|
28
|
+
checkerPromptSha: 'sha256:test',
|
|
29
|
+
cacheKey: { test: true },
|
|
30
|
+
checkContext: {
|
|
31
|
+
planSha: 'sha256:plan',
|
|
32
|
+
memorySha: 'sha256:memory',
|
|
33
|
+
riskProfile: 'medium',
|
|
34
|
+
riskTriggers: ['panel-ui'],
|
|
35
|
+
},
|
|
36
|
+
checkEvidence: '# Evidence\n\nok',
|
|
37
|
+
checkerContextPack: '# Checker Context Pack\n\nok',
|
|
38
|
+
taskManifest: '{}',
|
|
39
|
+
projectMemory: [{ path: 'memory.md', sha: 'sha256:m', content: 'memory' }],
|
|
40
|
+
mode: 'fast',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(pack.meta.estimatedTokens).toBeGreaterThan(0);
|
|
44
|
+
expect(pack.meta.compactedArtifacts).toContain('plan.md');
|
|
45
|
+
expect(estimatePayload(pack.input).estimatedTokens).toBe(pack.meta.estimatedTokens);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('does not include full orchestration log in fast or standard verifier packs', () => {
|
|
49
|
+
const taskDir = createTask({ orchestrationEvents: 100 });
|
|
50
|
+
const fullLog = fs.readFileSync(path.join(taskDir, 'orchestration-log.md'), 'utf8');
|
|
51
|
+
const pack = buildVerifierLlmInputPack({
|
|
52
|
+
taskDir,
|
|
53
|
+
taskId: 'TASK-999-token-pack',
|
|
54
|
+
planSha: 'sha256:plan',
|
|
55
|
+
executionSha: 'sha256:execution',
|
|
56
|
+
verifier: { provider: 'test', model: 'test', reasoningEffort: 'none', runId: 'run' },
|
|
57
|
+
mode: 'standard',
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(pack.input.taskArtifacts['orchestration-log.md']).toContain('# Orchestration Log Compact');
|
|
61
|
+
expect(pack.input.taskArtifacts['orchestration-log.md'].length).toBeLessThan(fullLog.length);
|
|
62
|
+
expect(pack.meta.compactedArtifacts).toContain('orchestration-log.md');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('keeps full artifacts in strict verifier pack', () => {
|
|
66
|
+
const taskDir = createTask({ orchestrationEvents: 8 });
|
|
67
|
+
const fullLog = fs.readFileSync(path.join(taskDir, 'orchestration-log.md'), 'utf8');
|
|
68
|
+
const pack = buildVerifierLlmInputPack({
|
|
69
|
+
taskDir,
|
|
70
|
+
taskId: 'TASK-999-token-pack',
|
|
71
|
+
planSha: 'sha256:plan',
|
|
72
|
+
executionSha: 'sha256:execution',
|
|
73
|
+
verifier: { provider: 'test', model: 'test', reasoningEffort: 'none', runId: 'run' },
|
|
74
|
+
mode: 'strict',
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(pack.input.taskArtifacts['orchestration-log.md']).toBe(fullLog);
|
|
78
|
+
expect(pack.meta.compactedArtifacts).not.toContain('orchestration-log.md');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('builds bounded fallback mode sequence for context insufficient results', () => {
|
|
82
|
+
expect(buildContextModeSequence('fast')).toEqual(['fast', 'standard', 'strict']);
|
|
83
|
+
expect(buildContextModeSequence('standard')).toEqual(['standard', 'strict']);
|
|
84
|
+
expect(buildContextModeSequence('strict')).toEqual(['strict']);
|
|
85
|
+
expect(isContextInsufficientResult({ verdict: 'context_insufficient' })).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('preserves protected verification sections when compacting long plans', () => {
|
|
89
|
+
const taskDir = createTask();
|
|
90
|
+
const longPlan = [
|
|
91
|
+
'# Plan',
|
|
92
|
+
'',
|
|
93
|
+
'## Цель',
|
|
94
|
+
'',
|
|
95
|
+
'A'.repeat(6000),
|
|
96
|
+
'',
|
|
97
|
+
'## Verification Plan',
|
|
98
|
+
'',
|
|
99
|
+
'Must run UI acceptance and performance checks.',
|
|
100
|
+
'',
|
|
101
|
+
'## Что требует human approval',
|
|
102
|
+
'',
|
|
103
|
+
'B'.repeat(6000),
|
|
104
|
+
].join('\n');
|
|
105
|
+
write(taskDir, 'plan.md', longPlan);
|
|
106
|
+
|
|
107
|
+
const pack = buildCheckerLlmInputPack({
|
|
108
|
+
taskDir,
|
|
109
|
+
taskId: 'TASK-999-token-pack',
|
|
110
|
+
checkerPromptSha: 'sha256:test',
|
|
111
|
+
cacheKey: { test: true },
|
|
112
|
+
checkContext: {
|
|
113
|
+
planSha: 'sha256:plan',
|
|
114
|
+
memorySha: 'sha256:memory',
|
|
115
|
+
riskProfile: 'medium',
|
|
116
|
+
riskTriggers: ['panel-ui'],
|
|
117
|
+
},
|
|
118
|
+
checkEvidence: '# Evidence\n\nok',
|
|
119
|
+
checkerContextPack: '# Checker Context Pack\n\nok',
|
|
120
|
+
taskManifest: '{}',
|
|
121
|
+
projectMemory: [],
|
|
122
|
+
mode: 'fast',
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(pack.input.taskArtifacts['plan.md']).toContain('## Verification Plan');
|
|
126
|
+
expect(pack.input.taskArtifacts['plan.md']).toContain('Must run UI acceptance and performance checks.');
|
|
127
|
+
expect(pack.input.taskArtifacts['plan.md'].length).toBeLessThan(5000);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('marks dropped protected sections when a compact pack cannot keep all of them', () => {
|
|
131
|
+
const taskDir = createTask();
|
|
132
|
+
write(taskDir, 'execution.md', [
|
|
133
|
+
'# Execution',
|
|
134
|
+
'',
|
|
135
|
+
'## UI Acceptance Evidence',
|
|
136
|
+
'',
|
|
137
|
+
'A'.repeat(2200),
|
|
138
|
+
'',
|
|
139
|
+
'## Complexity / Performance Evidence',
|
|
140
|
+
'',
|
|
141
|
+
'B'.repeat(2200),
|
|
142
|
+
'',
|
|
143
|
+
'## Production Rollout Evidence',
|
|
144
|
+
'',
|
|
145
|
+
'C'.repeat(2200),
|
|
146
|
+
].join('\n'));
|
|
147
|
+
|
|
148
|
+
const pack = buildVerifierLlmInputPack({
|
|
149
|
+
taskDir,
|
|
150
|
+
taskId: 'TASK-999-token-pack',
|
|
151
|
+
planSha: 'sha256:plan',
|
|
152
|
+
executionSha: 'sha256:execution',
|
|
153
|
+
verifier: { provider: 'test', model: 'test', reasoningEffort: 'none', runId: 'run' },
|
|
154
|
+
mode: 'fast',
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(pack.input.taskArtifacts['execution.md']).toContain('<!-- dropped-protected:');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('includes non-blocking findings and questions in compact check markdown', () => {
|
|
161
|
+
const taskDir = createTask();
|
|
162
|
+
write(taskDir, 'check.result.json', JSON.stringify({
|
|
163
|
+
verdict: 'ready_for_human_gate',
|
|
164
|
+
findings: [
|
|
165
|
+
{
|
|
166
|
+
id: 'F-001',
|
|
167
|
+
severity: 'non_blocking',
|
|
168
|
+
claimCategory: 'missing_verification',
|
|
169
|
+
claim: 'Non-blocking concern should remain visible.',
|
|
170
|
+
expectedCorrection: 'Track in verifier.',
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: 'F-002',
|
|
174
|
+
severity: 'question',
|
|
175
|
+
claimCategory: 'human_decision_required',
|
|
176
|
+
claim: 'Human question should remain visible.',
|
|
177
|
+
expectedCorrection: 'Ask human.',
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
}, null, 2));
|
|
181
|
+
|
|
182
|
+
const pack = buildVerifierLlmInputPack({
|
|
183
|
+
taskDir,
|
|
184
|
+
taskId: 'TASK-999-token-pack',
|
|
185
|
+
planSha: 'sha256:plan',
|
|
186
|
+
executionSha: 'sha256:execution',
|
|
187
|
+
verifier: { provider: 'test', model: 'test', reasoningEffort: 'none', runId: 'run' },
|
|
188
|
+
mode: 'standard',
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
expect(pack.input.taskArtifacts['check.md']).toContain('Non-blocking concern should remain visible.');
|
|
192
|
+
expect(pack.input.taskArtifacts['check.md']).toContain('Human question should remain visible.');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('uses a conservative estimate for Cyrillic and JSON-heavy payloads', () => {
|
|
196
|
+
const value = JSON.stringify({
|
|
197
|
+
text: 'Проверка русскоязычного JSON payload с большим количеством кавычек и структурных символов.',
|
|
198
|
+
rows: Array.from({ length: 20 }, (_, index) => ({ index, value: 'значение' })),
|
|
199
|
+
});
|
|
200
|
+
const pack = estimatePayload(value);
|
|
201
|
+
|
|
202
|
+
expect(pack.estimatedTokens).toBeGreaterThan(Math.ceil(value.length / 2.3));
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('defaults high-risk production/source-sync tasks to strict context', () => {
|
|
206
|
+
expect(resolveLlmContextMode({ riskTriggers: ['production-runtime'] })).toBe('strict');
|
|
207
|
+
expect(resolveLlmContextMode({ riskTriggers: ['source-sync-provider'] })).toBe('strict');
|
|
208
|
+
expect(resolveLlmContextMode({ riskTriggers: ['panel-ui'] })).toBe('standard');
|
|
209
|
+
expect(resolveLlmContextMode({ requestedMode: 'fast', riskTriggers: ['production-runtime'] })).toBe('fast');
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
function createTask({ orchestrationEvents = 40 } = {}) {
|
|
214
|
+
const taskDir = fs.mkdtempSync(path.join(os.tmpdir(), 'TASK-999-token-pack-'));
|
|
215
|
+
tempDirs.push(taskDir);
|
|
216
|
+
write(taskDir, 'brief.md', '# Brief\n\nShort brief.');
|
|
217
|
+
write(taskDir, 'research.md', '# Research\n\n## Findings\n\n- `web/app/src/example.tsx` is relevant.');
|
|
218
|
+
write(taskDir, 'plan.md', [
|
|
219
|
+
'# Plan',
|
|
220
|
+
'',
|
|
221
|
+
'## Затронутые модули и файлы',
|
|
222
|
+
'',
|
|
223
|
+
'- `web/app/src/example.tsx`',
|
|
224
|
+
'',
|
|
225
|
+
'## UI Acceptance Scenarios',
|
|
226
|
+
'',
|
|
227
|
+
'| ID | User intent | Setup/data | Steps | Expected visible result | Must catch |',
|
|
228
|
+
'| --- | --- | --- | --- | --- | --- |',
|
|
229
|
+
'| UI-001 | inspect page | seeded data | open page | visible rows | stale rows |',
|
|
230
|
+
].join('\n'));
|
|
231
|
+
write(taskDir, 'execution.md', [
|
|
232
|
+
'# Execution',
|
|
233
|
+
'',
|
|
234
|
+
'## Измененные файлы',
|
|
235
|
+
'',
|
|
236
|
+
'| File | Change summary | Planned item / reason |',
|
|
237
|
+
'| --- | --- | --- |',
|
|
238
|
+
'| `web/app/src/example.tsx` | update | plan |',
|
|
239
|
+
'',
|
|
240
|
+
'## UI Acceptance Evidence',
|
|
241
|
+
'',
|
|
242
|
+
'| Scenario ID | Result | Observed evidence / screenshot / payload | Notes |',
|
|
243
|
+
'| --- | --- | --- | --- |',
|
|
244
|
+
'| UI-001 | pass | observed visible rows screenshot `/tmp/ui.png` | ok |',
|
|
245
|
+
].join('\n'));
|
|
246
|
+
write(taskDir, 'status.md', '# Status\n\n## Текущий этап\n\nverify\n\n## Следующий шаг\n\nRun verify.');
|
|
247
|
+
write(taskDir, 'check.result.json', JSON.stringify({ verdict: 'ready_for_human_gate', findings: [] }, null, 2));
|
|
248
|
+
write(taskDir, 'check.md', '# Check\n\n## Итоговая оценка\n\nReady.');
|
|
249
|
+
write(taskDir, 'execution-ledger.json', JSON.stringify({ git: { changedFiles: [] } }, null, 2));
|
|
250
|
+
write(taskDir, 'task-manifest.json', JSON.stringify({ context: { riskTriggers: ['panel-ui'] } }, null, 2));
|
|
251
|
+
write(taskDir, 'orchestration-log.md', [
|
|
252
|
+
'# Orchestration Log',
|
|
253
|
+
'',
|
|
254
|
+
'## Entries',
|
|
255
|
+
...Array.from({ length: orchestrationEvents }, (_, index) => `- \`2026-05-21T00:${String(index).padStart(2, '0')}:00.000Z\` - event ${index} return_to_plan detail ${'x'.repeat(80)}`),
|
|
256
|
+
].join('\n'));
|
|
257
|
+
return taskDir;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function write(taskDir, fileName, content) {
|
|
261
|
+
fs.writeFileSync(path.join(taskDir, fileName), `${content}\n`);
|
|
262
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
6
|
+
const __dirname = path.dirname(__filename);
|
|
7
|
+
|
|
8
|
+
export const frameworkRoot = path.resolve(__dirname, '..', '..');
|
|
9
|
+
export const projectConfigFileName = path.join('ops', 'project.ops.yaml');
|
|
10
|
+
|
|
11
|
+
export function resolveProjectContext({ cwd = process.cwd() } = {}) {
|
|
12
|
+
const configPath = findProjectConfig(cwd);
|
|
13
|
+
if (configPath) {
|
|
14
|
+
const projectRoot = path.dirname(path.dirname(configPath));
|
|
15
|
+
const config = parseProjectOpsConfig(fs.readFileSync(configPath, 'utf8'));
|
|
16
|
+
return buildConfiguredContext({ projectRoot, configPath, config });
|
|
17
|
+
}
|
|
18
|
+
return buildLegacyContext({ cwd });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function findProjectConfig(startDir) {
|
|
22
|
+
let current = path.resolve(startDir);
|
|
23
|
+
while (true) {
|
|
24
|
+
const candidate = findProjectConfigInDirectory(current);
|
|
25
|
+
if (fs.existsSync(candidate)) {
|
|
26
|
+
return candidate;
|
|
27
|
+
}
|
|
28
|
+
const parent = path.dirname(current);
|
|
29
|
+
if (parent === current) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
current = parent;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function findProjectConfigInDirectory(directory) {
|
|
37
|
+
const defaultCandidate = path.join(directory, projectConfigFileName);
|
|
38
|
+
if (fs.existsSync(defaultCandidate)) {
|
|
39
|
+
return defaultCandidate;
|
|
40
|
+
}
|
|
41
|
+
let entries = [];
|
|
42
|
+
try {
|
|
43
|
+
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
44
|
+
} catch {
|
|
45
|
+
return defaultCandidate;
|
|
46
|
+
}
|
|
47
|
+
const candidates = entries
|
|
48
|
+
.filter((entry) => entry.isDirectory())
|
|
49
|
+
.map((entry) => path.join(directory, entry.name, 'project.ops.yaml'))
|
|
50
|
+
.filter((candidate) => fs.existsSync(candidate))
|
|
51
|
+
.sort();
|
|
52
|
+
return candidates[0] || defaultCandidate;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function parseProjectOpsConfig(content) {
|
|
56
|
+
const config = {};
|
|
57
|
+
const stack = [{ indent: -1, value: config, parent: null, key: null }];
|
|
58
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
59
|
+
const withoutComment = rawLine.replace(/\s+#.*$/, '');
|
|
60
|
+
if (!withoutComment.trim()) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const listMatch = /^(\s*)-\s+(.*)$/.exec(withoutComment);
|
|
64
|
+
if (listMatch) {
|
|
65
|
+
const indent = listMatch[1].length;
|
|
66
|
+
while (stack.length > 1 && indent < stack[stack.length - 1].indent) {
|
|
67
|
+
stack.pop();
|
|
68
|
+
}
|
|
69
|
+
const frame = stack[stack.length - 1];
|
|
70
|
+
if (!Array.isArray(frame.value)) {
|
|
71
|
+
if (!frame.parent || frame.key === null || Object.keys(frame.value || {}).length > 0) {
|
|
72
|
+
throw new Error(`Unsupported project.ops.yaml list item: ${rawLine}`);
|
|
73
|
+
}
|
|
74
|
+
frame.parent[frame.key] = [];
|
|
75
|
+
frame.value = frame.parent[frame.key];
|
|
76
|
+
}
|
|
77
|
+
frame.value.push(parseScalar(listMatch[2].trim()));
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const match = /^(\s*)([A-Za-z0-9_-]+):(?:\s+(.*))?$/.exec(withoutComment);
|
|
81
|
+
if (!match) {
|
|
82
|
+
throw new Error(`Unsupported project.ops.yaml line: ${rawLine}`);
|
|
83
|
+
}
|
|
84
|
+
const indent = match[1].length;
|
|
85
|
+
const key = match[2];
|
|
86
|
+
const rawValue = match[3];
|
|
87
|
+
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
|
|
88
|
+
stack.pop();
|
|
89
|
+
}
|
|
90
|
+
const parent = stack[stack.length - 1].value;
|
|
91
|
+
if (rawValue === undefined || rawValue === '') {
|
|
92
|
+
parent[key] = {};
|
|
93
|
+
stack.push({
|
|
94
|
+
indent,
|
|
95
|
+
value: parent[key],
|
|
96
|
+
parent,
|
|
97
|
+
key,
|
|
98
|
+
});
|
|
99
|
+
} else {
|
|
100
|
+
parent[key] = parseScalar(rawValue.trim());
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return config;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function readJsonIfExists(filePath) {
|
|
107
|
+
if (!filePath || !fs.existsSync(filePath)) {
|
|
108
|
+
return {};
|
|
109
|
+
}
|
|
110
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function deepMerge(base, override) {
|
|
114
|
+
if (!isPlainObject(base) || !isPlainObject(override)) {
|
|
115
|
+
return override === undefined ? base : override;
|
|
116
|
+
}
|
|
117
|
+
const merged = { ...base };
|
|
118
|
+
for (const [key, value] of Object.entries(override)) {
|
|
119
|
+
merged[key] = key in merged ? deepMerge(merged[key], value) : value;
|
|
120
|
+
}
|
|
121
|
+
return merged;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function buildConfiguredContext({ projectRoot, configPath, config }) {
|
|
125
|
+
const ops = config.ops || {};
|
|
126
|
+
const agents = config.agents || {};
|
|
127
|
+
const risk = config.risk || {};
|
|
128
|
+
const legacyPipelineRoot = resolveProjectPath(projectRoot, ops.legacyPipelineDir || 'ops/agent-pipeline');
|
|
129
|
+
return {
|
|
130
|
+
configPath,
|
|
131
|
+
config,
|
|
132
|
+
projectRoot,
|
|
133
|
+
repoRoot: projectRoot,
|
|
134
|
+
frameworkRoot,
|
|
135
|
+
pipelineRoot: legacyPipelineRoot,
|
|
136
|
+
tasksRoot: resolveProjectPath(projectRoot, ops.tasksDir || 'ops/agent-pipeline/tasks'),
|
|
137
|
+
memoryRoot: resolveProjectPath(projectRoot, ops.memoryDir || 'ops/agent-pipeline/memory'),
|
|
138
|
+
cacheRoot: resolveProjectPath(projectRoot, ops.cacheDir || 'ops/agent-pipeline/cache'),
|
|
139
|
+
promptsRoot: path.join(frameworkRoot, 'prompts'),
|
|
140
|
+
templatesRoot: path.join(frameworkRoot, 'templates'),
|
|
141
|
+
playbooksRoot: path.join(frameworkRoot, 'playbooks'),
|
|
142
|
+
sharedPlaybooksRoot: path.join(frameworkRoot, 'playbooks'),
|
|
143
|
+
projectPlaybooksRoot: ops.playbooksDir ? resolveProjectPath(projectRoot, ops.playbooksDir) : null,
|
|
144
|
+
configRoot: path.join(frameworkRoot, 'config'),
|
|
145
|
+
projectAgentsConfigPath: agents.configFile ? resolveProjectPath(projectRoot, agents.configFile) : null,
|
|
146
|
+
risk: {
|
|
147
|
+
uiRoots: normalizeStringList(risk.uiRoots),
|
|
148
|
+
backendRoots: normalizeStringList(risk.backendRoots),
|
|
149
|
+
workerRoots: normalizeStringList(risk.workerRoots),
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function buildLegacyContext({ cwd }) {
|
|
155
|
+
const resolvedCwd = path.resolve(cwd);
|
|
156
|
+
const legacyPipelineRoot = findLegacyPipelineRoot(resolvedCwd) || frameworkRoot;
|
|
157
|
+
const projectRoot = path.resolve(legacyPipelineRoot, '..', '..');
|
|
158
|
+
return {
|
|
159
|
+
configPath: null,
|
|
160
|
+
config: {},
|
|
161
|
+
projectRoot,
|
|
162
|
+
repoRoot: projectRoot,
|
|
163
|
+
frameworkRoot,
|
|
164
|
+
pipelineRoot: legacyPipelineRoot,
|
|
165
|
+
tasksRoot: path.join(legacyPipelineRoot, 'tasks'),
|
|
166
|
+
memoryRoot: path.join(legacyPipelineRoot, 'memory'),
|
|
167
|
+
cacheRoot: path.join(legacyPipelineRoot, 'cache'),
|
|
168
|
+
promptsRoot: path.join(frameworkRoot, 'prompts'),
|
|
169
|
+
templatesRoot: path.join(frameworkRoot, 'templates'),
|
|
170
|
+
playbooksRoot: path.join(frameworkRoot, 'playbooks'),
|
|
171
|
+
sharedPlaybooksRoot: path.join(frameworkRoot, 'playbooks'),
|
|
172
|
+
projectPlaybooksRoot: path.join(legacyPipelineRoot, 'playbooks'),
|
|
173
|
+
configRoot: path.join(frameworkRoot, 'config'),
|
|
174
|
+
projectAgentsConfigPath: path.join(legacyPipelineRoot, 'config', 'agents.json'),
|
|
175
|
+
risk: {
|
|
176
|
+
uiRoots: [],
|
|
177
|
+
backendRoots: [],
|
|
178
|
+
workerRoots: [],
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function findLegacyPipelineRoot(startDir) {
|
|
184
|
+
let current = path.resolve(startDir);
|
|
185
|
+
while (true) {
|
|
186
|
+
const candidate = path.join(current, 'ops', 'agent-pipeline');
|
|
187
|
+
if (fs.existsSync(candidate)) {
|
|
188
|
+
return candidate;
|
|
189
|
+
}
|
|
190
|
+
const parent = path.dirname(current);
|
|
191
|
+
if (parent === current) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
current = parent;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function resolveProjectPath(projectRoot, maybeRelative) {
|
|
199
|
+
return path.isAbsolute(maybeRelative)
|
|
200
|
+
? maybeRelative
|
|
201
|
+
: path.resolve(projectRoot, maybeRelative);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function parseScalar(value) {
|
|
205
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
206
|
+
return value.slice(1, -1);
|
|
207
|
+
}
|
|
208
|
+
if (value === 'true') {
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
if (value === 'false') {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
return value;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function isPlainObject(value) {
|
|
218
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function normalizeStringList(value) {
|
|
222
|
+
if (Array.isArray(value)) {
|
|
223
|
+
return value.map(String).filter(Boolean);
|
|
224
|
+
}
|
|
225
|
+
if (typeof value === 'string' && value.trim()) {
|
|
226
|
+
return value.split(',').map((item) => item.trim()).filter(Boolean);
|
|
227
|
+
}
|
|
228
|
+
return [];
|
|
229
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import {
|
|
6
|
+
deepMerge,
|
|
7
|
+
findProjectConfig,
|
|
8
|
+
parseProjectOpsConfig,
|
|
9
|
+
resolveProjectContext,
|
|
10
|
+
} from './project-config.mjs';
|
|
11
|
+
|
|
12
|
+
describe('project config resolver', () => {
|
|
13
|
+
it('parses the supported project.ops.yaml shape', () => {
|
|
14
|
+
const config = parseProjectOpsConfig(`
|
|
15
|
+
name: ExampleProject
|
|
16
|
+
ops:
|
|
17
|
+
tasksDir: ops/agent-pipeline/tasks
|
|
18
|
+
memoryDir: ops/agent-pipeline/memory
|
|
19
|
+
agents:
|
|
20
|
+
configFile: ops/agent-pipeline/config/agents.json
|
|
21
|
+
risk:
|
|
22
|
+
uiRoots:
|
|
23
|
+
- web/app
|
|
24
|
+
backendRoots:
|
|
25
|
+
- services/api
|
|
26
|
+
`);
|
|
27
|
+
|
|
28
|
+
expect(config.name).toBe('ExampleProject');
|
|
29
|
+
expect(config.ops.tasksDir).toBe('ops/agent-pipeline/tasks');
|
|
30
|
+
expect(config.ops.memoryDir).toBe('ops/agent-pipeline/memory');
|
|
31
|
+
expect(config.agents.configFile).toBe('ops/agent-pipeline/config/agents.json');
|
|
32
|
+
expect(config.risk.uiRoots).toEqual(['web/app']);
|
|
33
|
+
expect(config.risk.backendRoots).toEqual(['services/api']);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('finds project config by walking upward from cwd', () => {
|
|
37
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ops-project-'));
|
|
38
|
+
const nested = path.join(root, 'apps', 'tool');
|
|
39
|
+
fs.mkdirSync(path.join(root, 'ops'), { recursive: true });
|
|
40
|
+
fs.mkdirSync(nested, { recursive: true });
|
|
41
|
+
fs.writeFileSync(path.join(root, 'ops', 'project.ops.yaml'), 'name: Fixture\n');
|
|
42
|
+
|
|
43
|
+
expect(findProjectConfig(nested)).toBe(path.join(root, 'ops', 'project.ops.yaml'));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('finds project config in a custom ops root by walking upward from cwd', () => {
|
|
47
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ops-project-'));
|
|
48
|
+
const nested = path.join(root, 'apps', 'tool');
|
|
49
|
+
fs.mkdirSync(path.join(root, '.ops'), { recursive: true });
|
|
50
|
+
fs.mkdirSync(nested, { recursive: true });
|
|
51
|
+
fs.writeFileSync(path.join(root, '.ops', 'project.ops.yaml'), 'name: Fixture\n');
|
|
52
|
+
|
|
53
|
+
expect(findProjectConfig(nested)).toBe(path.join(root, '.ops', 'project.ops.yaml'));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('resolves project-owned paths separately from framework assets', () => {
|
|
57
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ops-project-'));
|
|
58
|
+
const nested = path.join(root, 'workspace');
|
|
59
|
+
fs.mkdirSync(path.join(root, 'ops'), { recursive: true });
|
|
60
|
+
fs.mkdirSync(nested, { recursive: true });
|
|
61
|
+
fs.writeFileSync(path.join(root, 'ops', 'project.ops.yaml'), `
|
|
62
|
+
name: Fixture
|
|
63
|
+
ops:
|
|
64
|
+
legacyPipelineDir: ops/agent-pipeline
|
|
65
|
+
tasksDir: ops/agent-pipeline/tasks
|
|
66
|
+
memoryDir: ops/agent-pipeline/memory
|
|
67
|
+
cacheDir: ops/agent-pipeline/cache
|
|
68
|
+
playbooksDir: ops/agent-pipeline/playbooks
|
|
69
|
+
agents:
|
|
70
|
+
configFile: ops/agent-pipeline/config/agents.json
|
|
71
|
+
risk:
|
|
72
|
+
uiRoots:
|
|
73
|
+
- web/app
|
|
74
|
+
backendRoots:
|
|
75
|
+
- services/api
|
|
76
|
+
workerRoots:
|
|
77
|
+
- workers/backend
|
|
78
|
+
`);
|
|
79
|
+
|
|
80
|
+
const context = resolveProjectContext({ cwd: nested });
|
|
81
|
+
|
|
82
|
+
expect(context.repoRoot).toBe(root);
|
|
83
|
+
expect(context.tasksRoot).toBe(path.join(root, 'ops', 'agent-pipeline', 'tasks'));
|
|
84
|
+
expect(context.memoryRoot).toBe(path.join(root, 'ops', 'agent-pipeline', 'memory'));
|
|
85
|
+
expect(context.cacheRoot).toBe(path.join(root, 'ops', 'agent-pipeline', 'cache'));
|
|
86
|
+
expect(context.promptsRoot).toBe(path.join(context.frameworkRoot, 'prompts'));
|
|
87
|
+
expect(context.projectPlaybooksRoot).toBe(path.join(root, 'ops', 'agent-pipeline', 'playbooks'));
|
|
88
|
+
expect(context.risk.uiRoots).toEqual(['web/app']);
|
|
89
|
+
expect(context.risk.backendRoots).toEqual(['services/api']);
|
|
90
|
+
expect(context.risk.workerRoots).toEqual(['workers/backend']);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('deep merges shared defaults with project overrides', () => {
|
|
94
|
+
expect(deepMerge(
|
|
95
|
+
{ checker: { provider: 'codex-cli', model: 'base' }, checkerProviders: { custom: { input: 'stdin' } } },
|
|
96
|
+
{ checker: { model: 'project' }, checkerProviders: { custom: { output: 'stdout' } } },
|
|
97
|
+
)).toEqual({
|
|
98
|
+
checker: { provider: 'codex-cli', model: 'project' },
|
|
99
|
+
checkerProviders: { custom: { input: 'stdin', output: 'stdout' } },
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
});
|