@besales/ops-framework 0.1.8 → 0.1.10
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 +5 -3
- package/bin/initiative.mjs +107 -8
- package/bin/initiative.test.mjs +45 -0
- package/bin/lib/project-config.mjs +3 -0
- package/bin/lib/project-config.test.mjs +30 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.10
|
|
4
|
+
|
|
5
|
+
- Fixed `project.ops.yaml` parsing for comment-only lines, including generated header comments and placeholder root comments.
|
|
6
|
+
|
|
7
|
+
## 0.1.9
|
|
8
|
+
|
|
9
|
+
- Added `--phase` and `--include` filters to initiative intake/requirements flows.
|
|
10
|
+
- Added `requirements-review.md` human approval cards for requirement candidates.
|
|
11
|
+
- Added phase metadata to extracted requirement candidates and requirements map rows.
|
|
12
|
+
|
|
3
13
|
## 0.1.8
|
|
4
14
|
|
|
5
15
|
- Added `initiative-intake` for source-doc indexing, source SHA tracking and deterministic requirement candidates.
|
package/README.md
CHANGED
|
@@ -232,8 +232,8 @@ Initiatives are the program-level layer above tasks. Use them for MVPs, large fe
|
|
|
232
232
|
```bash
|
|
233
233
|
ops-agent initiative-create delivery-os-mvp --title "Delivery OS MVP" --mode fast_mvp
|
|
234
234
|
ops-agent initiative-add-work-package delivery-os-mvp WP-001-foundation --title "Foundation"
|
|
235
|
-
ops-agent initiative-intake delivery-os-mvp docs/delivery-os
|
|
236
|
-
ops-agent initiative-requirements delivery-os-mvp
|
|
235
|
+
ops-agent initiative-intake delivery-os-mvp docs/delivery-os --phase phase-1 --include "06-roadmap.md,04-data-model.md,11-tooling-and-data-flows.md"
|
|
236
|
+
ops-agent initiative-requirements delivery-os-mvp --phase phase-1
|
|
237
237
|
ops-agent initiative-status delivery-os-mvp
|
|
238
238
|
ops-agent initiative-next delivery-os-mvp --materialize-task
|
|
239
239
|
```
|
|
@@ -258,7 +258,9 @@ Raw docs
|
|
|
258
258
|
-> tasks
|
|
259
259
|
```
|
|
260
260
|
|
|
261
|
-
`initiative-intake <initiative> <docs-dir>` indexes supported source documents, writes source SHA hashes and deterministic requirement candidates into `intake/`. `
|
|
261
|
+
`initiative-intake <initiative> <docs-dir>` indexes supported source documents, writes source SHA hashes and deterministic requirement candidates into `intake/`. Use `--include "file-a.md,file-b.md"` to restrict the first pass to the most relevant source files, and `--phase phase-1` to keep candidates whose local heading/source context matches the target phase.
|
|
262
|
+
|
|
263
|
+
`initiative-requirements <initiative>` turns those candidates into `REQ-*` rows in `requirements-map.md`, writes `requirements-review.md` approval cards and writes `coverage.md`. Human review is still required before treating candidates as approved or planned.
|
|
262
264
|
|
|
263
265
|
## Feedback Intake
|
|
264
266
|
|
package/bin/initiative.mjs
CHANGED
|
@@ -70,6 +70,8 @@ export function main() {
|
|
|
70
70
|
projectRoot: process.cwd(),
|
|
71
71
|
initiativeId: args.positional[0],
|
|
72
72
|
docsDir: args.positional[1],
|
|
73
|
+
phase: normalizePhaseFilter(getFlag(args, 'phase', null)),
|
|
74
|
+
include: parseIncludeFilter(getFlag(args, 'include', null)),
|
|
73
75
|
force: args.flags.has('force'),
|
|
74
76
|
});
|
|
75
77
|
printChangeSummary(`Initiative intake written: ${result.initiativeId}`, result.changes);
|
|
@@ -81,6 +83,7 @@ export function main() {
|
|
|
81
83
|
const result = initiativeRequirements({
|
|
82
84
|
projectRoot: process.cwd(),
|
|
83
85
|
initiativeId: args.positional[0],
|
|
86
|
+
phase: normalizePhaseFilter(getFlag(args, 'phase', null)),
|
|
84
87
|
force: args.flags.has('force'),
|
|
85
88
|
});
|
|
86
89
|
printChangeSummary(`Initiative requirements map written: ${result.initiativeId}`, result.changes);
|
|
@@ -221,6 +224,8 @@ export function initiativeIntake({
|
|
|
221
224
|
projectRoot = process.cwd(),
|
|
222
225
|
initiativeId,
|
|
223
226
|
docsDir,
|
|
227
|
+
phase = null,
|
|
228
|
+
include = [],
|
|
224
229
|
force = false,
|
|
225
230
|
} = {}) {
|
|
226
231
|
const initiative = readInitiative({ projectRoot, initiativeId });
|
|
@@ -234,12 +239,14 @@ export function initiativeIntake({
|
|
|
234
239
|
const intakeDir = path.join(initiative.initiativeDir, 'intake');
|
|
235
240
|
const changes = [];
|
|
236
241
|
ensureDirectory(intakeDir, changes);
|
|
237
|
-
const sources = collectSourceDocuments({ projectRoot, docsDir: absoluteDocsDir });
|
|
238
|
-
const candidates = collectRequirementCandidates({ projectRoot, sources });
|
|
242
|
+
const sources = collectSourceDocuments({ projectRoot, docsDir: absoluteDocsDir, include });
|
|
243
|
+
const candidates = collectRequirementCandidates({ projectRoot, sources, phase });
|
|
239
244
|
writeFileIfAllowed(path.join(intakeDir, 'source-index.json'), JSON.stringify({
|
|
240
245
|
schemaVersion: 1,
|
|
241
246
|
generatedAt: new Date().toISOString(),
|
|
242
247
|
docsDir: path.relative(projectRoot, absoluteDocsDir),
|
|
248
|
+
phaseFilter: phase,
|
|
249
|
+
include,
|
|
243
250
|
sources,
|
|
244
251
|
}, null, 2), { force: true, changes });
|
|
245
252
|
writeFileIfAllowed(path.join(intakeDir, 'sources.md'), renderSourcesMarkdown({ docsDir: absoluteDocsDir, projectRoot, sources }), { force, changes });
|
|
@@ -258,6 +265,7 @@ export function initiativeIntake({
|
|
|
258
265
|
export function initiativeRequirements({
|
|
259
266
|
projectRoot = process.cwd(),
|
|
260
267
|
initiativeId,
|
|
268
|
+
phase = null,
|
|
261
269
|
force = false,
|
|
262
270
|
} = {}) {
|
|
263
271
|
const initiative = readInitiative({ projectRoot, initiativeId });
|
|
@@ -265,19 +273,24 @@ export function initiativeRequirements({
|
|
|
265
273
|
if (!fs.existsSync(candidatesPath)) {
|
|
266
274
|
throw new Error('Missing intake/extracted-requirements.md. Run initiative-intake first.');
|
|
267
275
|
}
|
|
268
|
-
const candidates = parseRequirementCandidates(fs.readFileSync(candidatesPath, 'utf8'))
|
|
276
|
+
const candidates = parseRequirementCandidates(fs.readFileSync(candidatesPath, 'utf8'))
|
|
277
|
+
.filter((candidate) => !phase || candidate.phase === phase);
|
|
269
278
|
const requirements = candidates.map((candidate, index) => ({
|
|
270
279
|
id: `REQ-${String(index + 1).padStart(3, '0')}`,
|
|
271
280
|
status: 'candidate',
|
|
272
281
|
title: summarizeRequirementTitle(candidate.text),
|
|
273
282
|
source: candidate.source,
|
|
274
283
|
sourceHash: candidate.sourceHash,
|
|
284
|
+
phase: candidate.phase || 'unknown',
|
|
275
285
|
text: candidate.text,
|
|
276
286
|
workPackage: '',
|
|
277
287
|
acceptance: '',
|
|
288
|
+
decision: 'pending',
|
|
289
|
+
notes: '',
|
|
278
290
|
}));
|
|
279
291
|
const changes = [];
|
|
280
292
|
writeFileIfAllowed(path.join(initiative.initiativeDir, 'requirements-map.md'), renderRequirementsMap(requirements), { force: true, changes });
|
|
293
|
+
writeFileIfAllowed(path.join(initiative.initiativeDir, 'requirements-review.md'), renderRequirementsReview(requirements), { force: true, changes });
|
|
281
294
|
writeFileIfAllowed(path.join(initiative.initiativeDir, 'coverage.md'), renderCoverage({ requirements }), { force, changes });
|
|
282
295
|
return {
|
|
283
296
|
initiativeId,
|
|
@@ -310,11 +323,12 @@ function materializeWorkPackageTask({ projectRoot, initiativeId, workPackage, ta
|
|
|
310
323
|
return task;
|
|
311
324
|
}
|
|
312
325
|
|
|
313
|
-
function collectSourceDocuments({ projectRoot, docsDir }) {
|
|
326
|
+
function collectSourceDocuments({ projectRoot, docsDir, include = [] }) {
|
|
314
327
|
const files = [];
|
|
315
328
|
walkDocs(docsDir, files);
|
|
316
329
|
return files
|
|
317
330
|
.sort()
|
|
331
|
+
.filter((filePath) => sourceIncluded({ projectRoot, filePath, include }))
|
|
318
332
|
.map((filePath) => {
|
|
319
333
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
320
334
|
return {
|
|
@@ -342,17 +356,26 @@ function walkDocs(directory, files) {
|
|
|
342
356
|
}
|
|
343
357
|
}
|
|
344
358
|
|
|
345
|
-
function collectRequirementCandidates({ projectRoot, sources }) {
|
|
359
|
+
function collectRequirementCandidates({ projectRoot, sources, phase = null }) {
|
|
346
360
|
const candidates = [];
|
|
347
361
|
const seen = new Set();
|
|
348
362
|
for (const source of sources) {
|
|
349
363
|
const sourcePath = path.join(projectRoot, source.path);
|
|
350
364
|
const lines = fs.readFileSync(sourcePath, 'utf8').split(/\r?\n/);
|
|
365
|
+
let headingContext = '';
|
|
351
366
|
lines.forEach((line, index) => {
|
|
367
|
+
const heading = /^#{1,6}\s+(.+)$/.exec(line);
|
|
368
|
+
if (heading) {
|
|
369
|
+
headingContext = heading[1].trim();
|
|
370
|
+
}
|
|
352
371
|
const normalized = normalizeRequirementCandidate(line);
|
|
353
372
|
if (!isRequirementCandidate(normalized)) {
|
|
354
373
|
return;
|
|
355
374
|
}
|
|
375
|
+
const candidatePhase = inferPhase(`${source.path} ${headingContext} ${normalized}`);
|
|
376
|
+
if (phase && candidatePhase !== phase) {
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
356
379
|
const key = sha256(`${source.path}:${normalized}`).slice(0, 16);
|
|
357
380
|
if (seen.has(key)) {
|
|
358
381
|
return;
|
|
@@ -362,6 +385,7 @@ function collectRequirementCandidates({ projectRoot, sources }) {
|
|
|
362
385
|
id: `RC-${String(candidates.length + 1).padStart(3, '0')}`,
|
|
363
386
|
source: `${source.path}:${index + 1}`,
|
|
364
387
|
sourceHash: source.sha256,
|
|
388
|
+
phase: candidatePhase,
|
|
365
389
|
text: normalized,
|
|
366
390
|
});
|
|
367
391
|
});
|
|
@@ -369,6 +393,14 @@ function collectRequirementCandidates({ projectRoot, sources }) {
|
|
|
369
393
|
return candidates;
|
|
370
394
|
}
|
|
371
395
|
|
|
396
|
+
function sourceIncluded({ projectRoot, filePath, include }) {
|
|
397
|
+
if (!include.length) {
|
|
398
|
+
return true;
|
|
399
|
+
}
|
|
400
|
+
const relative = path.relative(projectRoot, filePath);
|
|
401
|
+
return include.some((pattern) => relative === pattern || relative.endsWith(pattern) || relative.includes(pattern));
|
|
402
|
+
}
|
|
403
|
+
|
|
372
404
|
function normalizeRequirementCandidate(line) {
|
|
373
405
|
return line
|
|
374
406
|
.replace(/^#{1,6}\s+/, '')
|
|
@@ -409,6 +441,7 @@ function renderRequirementCandidatesMarkdown(candidates) {
|
|
|
409
441
|
'',
|
|
410
442
|
`- Source: \`${candidate.source}\``,
|
|
411
443
|
`- Source hash: \`${candidate.sourceHash}\``,
|
|
444
|
+
`- Phase: \`${candidate.phase || 'unknown'}\``,
|
|
412
445
|
`- Candidate: ${candidate.text}`,
|
|
413
446
|
'',
|
|
414
447
|
].join('\n')),
|
|
@@ -423,6 +456,7 @@ function parseRequirementCandidates(content) {
|
|
|
423
456
|
id: rawId.trim(),
|
|
424
457
|
source: readMarkdownListField(body, 'Source'),
|
|
425
458
|
sourceHash: readMarkdownListField(body, 'Source hash'),
|
|
459
|
+
phase: readMarkdownListField(body, 'Phase') || 'unknown',
|
|
426
460
|
text: readMarkdownListField(body, 'Candidate'),
|
|
427
461
|
};
|
|
428
462
|
}).filter((candidate) => candidate.id && candidate.source && candidate.text);
|
|
@@ -434,9 +468,9 @@ function renderRequirementsMap(requirements) {
|
|
|
434
468
|
'',
|
|
435
469
|
'Statuses: `candidate | approved | planned | implemented | rejected`.',
|
|
436
470
|
'',
|
|
437
|
-
'| ID | Status | Requirement | Source | Work package | Acceptance |',
|
|
438
|
-
'| --- | --- | --- | --- | --- | --- |',
|
|
439
|
-
...requirements.map((req) => `| ${req.id} | ${req.status} | ${escapeTable(req.title)} | \`${req.source}\` | ${req.workPackage || ''} | ${req.acceptance || ''} |`),
|
|
471
|
+
'| ID | Status | Phase | Requirement | Source | Work package | Acceptance |',
|
|
472
|
+
'| --- | --- | --- | --- | --- | --- | --- |',
|
|
473
|
+
...requirements.map((req) => `| ${req.id} | ${req.status} | ${req.phase || 'unknown'} | ${escapeTable(req.title)} | \`${req.source}\` | ${req.workPackage || ''} | ${req.acceptance || ''} |`),
|
|
440
474
|
'',
|
|
441
475
|
'## Requirement Details',
|
|
442
476
|
'',
|
|
@@ -446,14 +480,46 @@ function renderRequirementsMap(requirements) {
|
|
|
446
480
|
`- Status: \`${req.status}\``,
|
|
447
481
|
`- Source: \`${req.source}\``,
|
|
448
482
|
`- Source hash: \`${req.sourceHash}\``,
|
|
483
|
+
`- Phase: \`${req.phase || 'unknown'}\``,
|
|
449
484
|
`- Work package: ${req.workPackage || '(unassigned)'}`,
|
|
450
485
|
`- Acceptance: ${req.acceptance || '(fill before implementation)'}`,
|
|
486
|
+
`- Decision: \`${req.decision || 'pending'}\``,
|
|
487
|
+
`- Notes: ${req.notes || '(empty)'}`,
|
|
451
488
|
`- Requirement: ${req.text}`,
|
|
452
489
|
'',
|
|
453
490
|
].join('\n')),
|
|
454
491
|
].join('\n');
|
|
455
492
|
}
|
|
456
493
|
|
|
494
|
+
function renderRequirementsReview(requirements) {
|
|
495
|
+
return [
|
|
496
|
+
'# Requirements Review',
|
|
497
|
+
'',
|
|
498
|
+
'Human approval pack. Review each requirement before planning work packages.',
|
|
499
|
+
'',
|
|
500
|
+
'Allowed decisions: `approve | defer | reject | rewrite`.',
|
|
501
|
+
'',
|
|
502
|
+
'## Pending Requirement Decisions',
|
|
503
|
+
'',
|
|
504
|
+
...requirements.map((req) => [
|
|
505
|
+
`### ${req.id}: ${req.title}`,
|
|
506
|
+
'',
|
|
507
|
+
`- Source: \`${req.source}\``,
|
|
508
|
+
`- Source hash: \`${req.sourceHash}\``,
|
|
509
|
+
`- Phase: \`${req.phase || 'unknown'}\``,
|
|
510
|
+
`- Suggested status: \`${req.status}\``,
|
|
511
|
+
`- Requirement: ${req.text}`,
|
|
512
|
+
`- Proposed work package: ${req.workPackage || '(unassigned)'}`,
|
|
513
|
+
`- Acceptance: ${req.acceptance || '(fill before implementation)'}`,
|
|
514
|
+
`- Decision: \`${req.decision || 'pending'}\``,
|
|
515
|
+
`- Human notes: ${req.notes || '(empty)'}`,
|
|
516
|
+
'',
|
|
517
|
+
'Decision to set in `requirements-map.md`: `approve | defer | reject | rewrite`',
|
|
518
|
+
'',
|
|
519
|
+
].join('\n')),
|
|
520
|
+
].join('\n');
|
|
521
|
+
}
|
|
522
|
+
|
|
457
523
|
function renderCoverage({ requirements }) {
|
|
458
524
|
const counts = countBy(requirements, (req) => req.status || 'candidate');
|
|
459
525
|
return [
|
|
@@ -474,6 +540,39 @@ function renderCoverage({ requirements }) {
|
|
|
474
540
|
].join('\n');
|
|
475
541
|
}
|
|
476
542
|
|
|
543
|
+
function parseIncludeFilter(value) {
|
|
544
|
+
if (!value) {
|
|
545
|
+
return [];
|
|
546
|
+
}
|
|
547
|
+
return String(value).split(',').map((item) => item.trim()).filter(Boolean);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function normalizePhaseFilter(value) {
|
|
551
|
+
if (!value) {
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
return normalizePhaseLabel(value);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function inferPhase(text) {
|
|
558
|
+
return normalizePhaseLabel(text) || 'unknown';
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function normalizePhaseLabel(text) {
|
|
562
|
+
const value = String(text || '').toLowerCase();
|
|
563
|
+
if (/future|later|deferred|not in phase 1|не делаем|отложен/.test(value)) {
|
|
564
|
+
return 'future';
|
|
565
|
+
}
|
|
566
|
+
const match = /phase\s*[- ]?(\d+)|фаз[аы]\s*[- ]?(\d+)/i.exec(value);
|
|
567
|
+
if (match) {
|
|
568
|
+
return `phase-${match[1] || match[2]}`;
|
|
569
|
+
}
|
|
570
|
+
if (/\bmvp\b|meeting loop/.test(value)) {
|
|
571
|
+
return 'phase-1';
|
|
572
|
+
}
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
|
|
477
576
|
function buildOpenQuestionsMarkdown() {
|
|
478
577
|
return [
|
|
479
578
|
'# Open Questions',
|
package/bin/initiative.test.mjs
CHANGED
|
@@ -112,6 +112,51 @@ describe('initiative framework', () => {
|
|
|
112
112
|
expect(requirementsMap).toContain('REQ-001');
|
|
113
113
|
expect(requirementsMap).toContain('User must create delivery orders');
|
|
114
114
|
expect(requirementsMap).toContain('REQ-002');
|
|
115
|
+
expect(requirementsMap).toContain('| REQ-001 | candidate | phase-1 |');
|
|
116
|
+
const review = fs.readFileSync(path.join(initiativeDir, 'requirements-review.md'), 'utf8');
|
|
117
|
+
expect(review).toContain('### REQ-001: User must create delivery orders from the admin UI.');
|
|
118
|
+
expect(review).toContain('Decision to set in `requirements-map.md`: `approve | defer | reject | rewrite`');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('filters source docs and requirements by include and phase', () => {
|
|
122
|
+
const root = makeProject();
|
|
123
|
+
const docsDir = path.join(root, 'docs', 'delivery-os');
|
|
124
|
+
fs.mkdirSync(docsDir, { recursive: true });
|
|
125
|
+
fs.writeFileSync(path.join(docsDir, 'phase1.md'), [
|
|
126
|
+
'# Phase 1',
|
|
127
|
+
'',
|
|
128
|
+
'- System must import meeting transcripts.',
|
|
129
|
+
].join('\n'));
|
|
130
|
+
fs.writeFileSync(path.join(docsDir, 'future.md'), [
|
|
131
|
+
'# Phase 3',
|
|
132
|
+
'',
|
|
133
|
+
'- System must send Telegram digests.',
|
|
134
|
+
].join('\n'));
|
|
135
|
+
createInitiative({
|
|
136
|
+
projectRoot: root,
|
|
137
|
+
initiativeId: 'delivery-os-mvp',
|
|
138
|
+
title: 'Delivery OS MVP',
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const intake = initiativeIntake({
|
|
142
|
+
projectRoot: root,
|
|
143
|
+
initiativeId: 'delivery-os-mvp',
|
|
144
|
+
docsDir: 'docs/delivery-os',
|
|
145
|
+
phase: 'phase-1',
|
|
146
|
+
include: ['phase1.md'],
|
|
147
|
+
});
|
|
148
|
+
const requirements = initiativeRequirements({
|
|
149
|
+
projectRoot: root,
|
|
150
|
+
initiativeId: 'delivery-os-mvp',
|
|
151
|
+
phase: 'phase-1',
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(intake.sources).toHaveLength(1);
|
|
155
|
+
expect(intake.sources[0].path).toBe('docs/delivery-os/phase1.md');
|
|
156
|
+
expect(intake.candidates).toHaveLength(1);
|
|
157
|
+
expect(intake.candidates[0].phase).toBe('phase-1');
|
|
158
|
+
expect(requirements.requirements).toHaveLength(1);
|
|
159
|
+
expect(requirements.requirements[0].text).toContain('meeting transcripts');
|
|
115
160
|
});
|
|
116
161
|
});
|
|
117
162
|
|
|
@@ -56,6 +56,9 @@ export function parseProjectOpsConfig(content) {
|
|
|
56
56
|
const config = {};
|
|
57
57
|
const stack = [{ indent: -1, value: config, parent: null, key: null }];
|
|
58
58
|
for (const rawLine of content.split(/\r?\n/)) {
|
|
59
|
+
if (/^\s*#/.test(rawLine)) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
59
62
|
const withoutComment = rawLine.replace(/\s+#.*$/, '');
|
|
60
63
|
if (!withoutComment.trim()) {
|
|
61
64
|
continue;
|
|
@@ -33,6 +33,36 @@ risk:
|
|
|
33
33
|
expect(config.risk.backendRoots).toEqual(['services/api']);
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
+
it('ignores comment-only and inline comments in project.ops.yaml', () => {
|
|
37
|
+
const config = parseProjectOpsConfig(`
|
|
38
|
+
# Internal development tooling only. This agent pipeline helps plan/check repo work.
|
|
39
|
+
name: ExampleProject # inline project name note
|
|
40
|
+
ops:
|
|
41
|
+
legacyPipelineDir: ops/agent-pipeline
|
|
42
|
+
tasksDir: ops/agent-pipeline/tasks
|
|
43
|
+
initiativesDir: ops/agent-pipeline/initiatives
|
|
44
|
+
memoryDir: ops/agent-pipeline/memory
|
|
45
|
+
cacheDir: ops/agent-pipeline/cache
|
|
46
|
+
playbooksDir: ops/agent-pipeline/playbooks
|
|
47
|
+
agents:
|
|
48
|
+
configFile: ops/agent-pipeline/config/agents.json
|
|
49
|
+
risk:
|
|
50
|
+
uiRoots:
|
|
51
|
+
# - apps/web
|
|
52
|
+
- web/app # real UI root
|
|
53
|
+
backendRoots:
|
|
54
|
+
# - apps/api
|
|
55
|
+
workerRoots:
|
|
56
|
+
# - apps/workers
|
|
57
|
+
`);
|
|
58
|
+
|
|
59
|
+
expect(config.name).toBe('ExampleProject');
|
|
60
|
+
expect(config.ops.initiativesDir).toBe('ops/agent-pipeline/initiatives');
|
|
61
|
+
expect(config.risk.uiRoots).toEqual(['web/app']);
|
|
62
|
+
expect(config.risk.backendRoots).toEqual({});
|
|
63
|
+
expect(config.risk.workerRoots).toEqual({});
|
|
64
|
+
});
|
|
65
|
+
|
|
36
66
|
it('finds project config by walking upward from cwd', () => {
|
|
37
67
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ops-project-'));
|
|
38
68
|
const nested = path.join(root, 'apps', 'tool');
|