@besales/ops-framework 0.1.9 → 0.1.11
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/bin/initiative.mjs +252 -17
- package/bin/initiative.test.mjs +94 -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.11
|
|
4
|
+
|
|
5
|
+
- Improved initiative requirements extraction with continuation-block capture for lists, tables and fenced blocks.
|
|
6
|
+
- Added requirement quality metadata and planning quality warnings for fragments and heading-only candidates.
|
|
7
|
+
- Added source coverage warnings plus deterministic open-question and assumption extraction during initiative intake.
|
|
8
|
+
|
|
9
|
+
## 0.1.10
|
|
10
|
+
|
|
11
|
+
- Fixed `project.ops.yaml` parsing for comment-only lines, including generated header comments and placeholder root comments.
|
|
12
|
+
|
|
3
13
|
## 0.1.9
|
|
4
14
|
|
|
5
15
|
- Added `--phase` and `--include` filters to initiative intake/requirements flows.
|
package/bin/initiative.mjs
CHANGED
|
@@ -239,25 +239,34 @@ export function initiativeIntake({
|
|
|
239
239
|
const intakeDir = path.join(initiative.initiativeDir, 'intake');
|
|
240
240
|
const changes = [];
|
|
241
241
|
ensureDirectory(intakeDir, changes);
|
|
242
|
+
const allSources = collectSourceDocuments({ projectRoot, docsDir: absoluteDocsDir });
|
|
242
243
|
const sources = collectSourceDocuments({ projectRoot, docsDir: absoluteDocsDir, include });
|
|
244
|
+
const sourceCoverage = buildSourceCoverage({ allSources, sources, include });
|
|
243
245
|
const candidates = collectRequirementCandidates({ projectRoot, sources, phase });
|
|
246
|
+
const questions = collectOpenQuestions({ projectRoot, sources });
|
|
247
|
+
const assumptions = collectAssumptions({ projectRoot, sources });
|
|
244
248
|
writeFileIfAllowed(path.join(intakeDir, 'source-index.json'), JSON.stringify({
|
|
245
249
|
schemaVersion: 1,
|
|
246
250
|
generatedAt: new Date().toISOString(),
|
|
247
251
|
docsDir: path.relative(projectRoot, absoluteDocsDir),
|
|
248
252
|
phaseFilter: phase,
|
|
249
253
|
include,
|
|
254
|
+
sourceCoverage,
|
|
250
255
|
sources,
|
|
251
256
|
}, null, 2), { force: true, changes });
|
|
252
257
|
writeFileIfAllowed(path.join(intakeDir, 'sources.md'), renderSourcesMarkdown({ docsDir: absoluteDocsDir, projectRoot, sources }), { force, changes });
|
|
258
|
+
writeFileIfAllowed(path.join(intakeDir, 'source-coverage.md'), renderSourceCoverage(sourceCoverage), { force: true, changes });
|
|
253
259
|
writeFileIfAllowed(path.join(intakeDir, 'extracted-requirements.md'), renderRequirementCandidatesMarkdown(candidates), { force: true, changes });
|
|
254
|
-
writeFileIfAllowed(path.join(intakeDir, 'open-questions.md'), buildOpenQuestionsMarkdown(), { force, changes });
|
|
255
|
-
writeFileIfAllowed(path.join(intakeDir, 'assumptions.md'), buildAssumptionsMarkdown(), { force, changes });
|
|
260
|
+
writeFileIfAllowed(path.join(intakeDir, 'open-questions.md'), buildOpenQuestionsMarkdown(questions), { force: true, changes });
|
|
261
|
+
writeFileIfAllowed(path.join(intakeDir, 'assumptions.md'), buildAssumptionsMarkdown(assumptions), { force: true, changes });
|
|
256
262
|
return {
|
|
257
263
|
initiativeId,
|
|
258
264
|
intakeDir,
|
|
259
265
|
sources,
|
|
266
|
+
sourceCoverage,
|
|
260
267
|
candidates,
|
|
268
|
+
questions,
|
|
269
|
+
assumptions,
|
|
261
270
|
changes,
|
|
262
271
|
};
|
|
263
272
|
}
|
|
@@ -282,6 +291,8 @@ export function initiativeRequirements({
|
|
|
282
291
|
source: candidate.source,
|
|
283
292
|
sourceHash: candidate.sourceHash,
|
|
284
293
|
phase: candidate.phase || 'unknown',
|
|
294
|
+
quality: candidate.quality || 'unknown',
|
|
295
|
+
qualityIssue: candidate.qualityIssue || '',
|
|
285
296
|
text: candidate.text,
|
|
286
297
|
workPackage: '',
|
|
287
298
|
acceptance: '',
|
|
@@ -363,22 +374,30 @@ function collectRequirementCandidates({ projectRoot, sources, phase = null }) {
|
|
|
363
374
|
const sourcePath = path.join(projectRoot, source.path);
|
|
364
375
|
const lines = fs.readFileSync(sourcePath, 'utf8').split(/\r?\n/);
|
|
365
376
|
let headingContext = '';
|
|
366
|
-
lines.
|
|
377
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
378
|
+
const line = lines[index];
|
|
367
379
|
const heading = /^#{1,6}\s+(.+)$/.exec(line);
|
|
368
380
|
if (heading) {
|
|
369
381
|
headingContext = heading[1].trim();
|
|
370
382
|
}
|
|
371
383
|
const normalized = normalizeRequirementCandidate(line);
|
|
372
384
|
if (!isRequirementCandidate(normalized)) {
|
|
373
|
-
|
|
385
|
+
continue;
|
|
374
386
|
}
|
|
375
387
|
const candidatePhase = inferPhase(`${source.path} ${headingContext} ${normalized}`);
|
|
376
388
|
if (phase && candidatePhase !== phase) {
|
|
377
|
-
|
|
389
|
+
continue;
|
|
378
390
|
}
|
|
379
|
-
const
|
|
391
|
+
const block = collectRequirementBlock({ lines, index, baseText: normalized });
|
|
392
|
+
const quality = classifyRequirementQuality({
|
|
393
|
+
rawLine: line,
|
|
394
|
+
baseText: normalized,
|
|
395
|
+
text: block.text,
|
|
396
|
+
continuation: block.continuation,
|
|
397
|
+
});
|
|
398
|
+
const key = sha256(`${source.path}:${block.text}`).slice(0, 16);
|
|
380
399
|
if (seen.has(key)) {
|
|
381
|
-
|
|
400
|
+
continue;
|
|
382
401
|
}
|
|
383
402
|
seen.add(key);
|
|
384
403
|
candidates.push({
|
|
@@ -386,9 +405,11 @@ function collectRequirementCandidates({ projectRoot, sources, phase = null }) {
|
|
|
386
405
|
source: `${source.path}:${index + 1}`,
|
|
387
406
|
sourceHash: source.sha256,
|
|
388
407
|
phase: candidatePhase,
|
|
389
|
-
|
|
408
|
+
quality: quality.kind,
|
|
409
|
+
qualityIssue: quality.issue,
|
|
410
|
+
text: block.text,
|
|
390
411
|
});
|
|
391
|
-
}
|
|
412
|
+
}
|
|
392
413
|
}
|
|
393
414
|
return candidates;
|
|
394
415
|
}
|
|
@@ -401,6 +422,63 @@ function sourceIncluded({ projectRoot, filePath, include }) {
|
|
|
401
422
|
return include.some((pattern) => relative === pattern || relative.endsWith(pattern) || relative.includes(pattern));
|
|
402
423
|
}
|
|
403
424
|
|
|
425
|
+
function buildSourceCoverage({ allSources, sources, include }) {
|
|
426
|
+
const included = new Set(sources.map((source) => source.path));
|
|
427
|
+
const excluded = allSources.filter((source) => !included.has(source.path));
|
|
428
|
+
const relevantExcluded = excluded.filter((source) => isLikelyRelevantSource(source.path));
|
|
429
|
+
return {
|
|
430
|
+
include,
|
|
431
|
+
totalSources: allSources.length,
|
|
432
|
+
includedSources: sources.length,
|
|
433
|
+
excludedSources: excluded.length,
|
|
434
|
+
relevantExcludedSources: relevantExcluded.map((source) => ({
|
|
435
|
+
path: source.path,
|
|
436
|
+
title: source.title,
|
|
437
|
+
reason: relevantSourceReason(source.path),
|
|
438
|
+
})),
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function isLikelyRelevantSource(sourcePath) {
|
|
443
|
+
return /(skills?|profiles?|adr|governance|risk|feedback|glossary|stack|tools?|contract|workspace)/i.test(sourcePath);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function relevantSourceReason(sourcePath) {
|
|
447
|
+
if (/feedback/i.test(sourcePath)) {
|
|
448
|
+
return 'feedback/decision log may contain newer product or scope decisions.';
|
|
449
|
+
}
|
|
450
|
+
if (/skills?/i.test(sourcePath)) {
|
|
451
|
+
return 'skills catalog may contain implementation requirements for MVP skills.';
|
|
452
|
+
}
|
|
453
|
+
if (/profiles?/i.test(sourcePath)) {
|
|
454
|
+
return 'extraction profiles may contain acceptance and workflow requirements.';
|
|
455
|
+
}
|
|
456
|
+
if (/adr/i.test(sourcePath)) {
|
|
457
|
+
return 'ADRs may contain architecture constraints.';
|
|
458
|
+
}
|
|
459
|
+
if (/governance|risk/i.test(sourcePath)) {
|
|
460
|
+
return 'governance/risk docs may affect approval, trust, security or rollout gates.';
|
|
461
|
+
}
|
|
462
|
+
return 'source may contain constraints relevant to requirements review.';
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function renderSourceCoverage(sourceCoverage) {
|
|
466
|
+
return [
|
|
467
|
+
'# Source Coverage',
|
|
468
|
+
'',
|
|
469
|
+
`- Total supported sources: ${sourceCoverage.totalSources}`,
|
|
470
|
+
`- Included sources: ${sourceCoverage.includedSources}`,
|
|
471
|
+
`- Excluded sources: ${sourceCoverage.excludedSources}`,
|
|
472
|
+
`- Relevant excluded sources: ${sourceCoverage.relevantExcludedSources.length}`,
|
|
473
|
+
'',
|
|
474
|
+
'## Relevant Sources Not Included',
|
|
475
|
+
'',
|
|
476
|
+
...sourceCoverage.relevantExcludedSources.map((source) => `- \`${source.path}\`: ${source.reason}`),
|
|
477
|
+
...(sourceCoverage.relevantExcludedSources.length ? [] : ['- None.']),
|
|
478
|
+
'',
|
|
479
|
+
].join('\n');
|
|
480
|
+
}
|
|
481
|
+
|
|
404
482
|
function normalizeRequirementCandidate(line) {
|
|
405
483
|
return line
|
|
406
484
|
.replace(/^#{1,6}\s+/, '')
|
|
@@ -410,11 +488,102 @@ function normalizeRequirementCandidate(line) {
|
|
|
410
488
|
.trim();
|
|
411
489
|
}
|
|
412
490
|
|
|
491
|
+
function collectRequirementBlock({ lines, index, baseText }) {
|
|
492
|
+
const continuation = [];
|
|
493
|
+
const shouldExpand = /[::]$/.test(baseText) || isMarkdownTableRow(lines[index]);
|
|
494
|
+
if (!shouldExpand) {
|
|
495
|
+
return {
|
|
496
|
+
text: baseText,
|
|
497
|
+
continuation,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
let inCodeBlock = false;
|
|
501
|
+
for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
|
|
502
|
+
const line = lines[cursor];
|
|
503
|
+
if (/^#{1,6}\s+/.test(line)) {
|
|
504
|
+
break;
|
|
505
|
+
}
|
|
506
|
+
if (/^\s*```/.test(line)) {
|
|
507
|
+
inCodeBlock = !inCodeBlock;
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
if (!line.trim()) {
|
|
511
|
+
if (continuation.length || inCodeBlock) {
|
|
512
|
+
break;
|
|
513
|
+
}
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
if (inCodeBlock) {
|
|
517
|
+
continuation.push(normalizeContinuationLine(line));
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
if (isContinuationLine(line)) {
|
|
521
|
+
continuation.push(normalizeContinuationLine(line));
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
break;
|
|
525
|
+
}
|
|
526
|
+
return {
|
|
527
|
+
text: [baseText, ...continuation].filter(Boolean).join(' '),
|
|
528
|
+
continuation,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function isContinuationLine(line) {
|
|
533
|
+
return /^\s*[-*]\s+/.test(line)
|
|
534
|
+
|| /^\s*\d+[.)]\s+/.test(line)
|
|
535
|
+
|| isMarkdownTableRow(line)
|
|
536
|
+
|| /^\s{2,}\S/.test(line);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function normalizeContinuationLine(line) {
|
|
540
|
+
return line
|
|
541
|
+
.replace(/^\s*[-*]\s+/, '')
|
|
542
|
+
.replace(/^\s*\d+[.)]\s+/, '')
|
|
543
|
+
.replace(/\s+/g, ' ')
|
|
544
|
+
.trim();
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function isMarkdownTableRow(line) {
|
|
548
|
+
return /^\s*\|.+\|\s*$/.test(line);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function classifyRequirementQuality({ rawLine, baseText, text, continuation }) {
|
|
552
|
+
if (isMarkdownTableRow(rawLine)) {
|
|
553
|
+
return {
|
|
554
|
+
kind: 'table_row',
|
|
555
|
+
issue: 'Candidate came from a markdown table row; verify headers/context before planning.',
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
if (/[::]$/.test(baseText) && continuation.length === 0) {
|
|
559
|
+
return {
|
|
560
|
+
kind: 'heading_only',
|
|
561
|
+
issue: 'Candidate ends with a colon and no continuation block was captured; rewrite before planning.',
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
if (continuation.length > 0) {
|
|
565
|
+
return {
|
|
566
|
+
kind: 'expanded_block',
|
|
567
|
+
issue: '',
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
if (/[,;]$/.test(text) || /\b(and|or|и|или)$/i.test(text)) {
|
|
571
|
+
return {
|
|
572
|
+
kind: 'fragment',
|
|
573
|
+
issue: 'Candidate appears to end mid-list or mid-sentence; rewrite before planning.',
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
return {
|
|
577
|
+
kind: 'complete',
|
|
578
|
+
issue: '',
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
413
582
|
function isRequirementCandidate(text) {
|
|
414
583
|
if (text.length < 20 || text.length > 500) {
|
|
415
584
|
return false;
|
|
416
585
|
}
|
|
417
|
-
return /(must|should|required|requirement|user can|user must|system must|needs to
|
|
586
|
+
return /(must|should|required|requirement|includes?|does not include|user can|user must|system must|needs to|нужно|должен|должна|должны|требован|включает|не включает|пользователь может|пользователь должен|система должна|необходимо|важно)/i.test(text);
|
|
418
587
|
}
|
|
419
588
|
|
|
420
589
|
function renderSourcesMarkdown({ docsDir, projectRoot, sources }) {
|
|
@@ -442,6 +611,8 @@ function renderRequirementCandidatesMarkdown(candidates) {
|
|
|
442
611
|
`- Source: \`${candidate.source}\``,
|
|
443
612
|
`- Source hash: \`${candidate.sourceHash}\``,
|
|
444
613
|
`- Phase: \`${candidate.phase || 'unknown'}\``,
|
|
614
|
+
`- Quality: \`${candidate.quality || 'unknown'}\``,
|
|
615
|
+
`- Quality issue: ${candidate.qualityIssue || '(none)'}`,
|
|
445
616
|
`- Candidate: ${candidate.text}`,
|
|
446
617
|
'',
|
|
447
618
|
].join('\n')),
|
|
@@ -457,6 +628,8 @@ function parseRequirementCandidates(content) {
|
|
|
457
628
|
source: readMarkdownListField(body, 'Source'),
|
|
458
629
|
sourceHash: readMarkdownListField(body, 'Source hash'),
|
|
459
630
|
phase: readMarkdownListField(body, 'Phase') || 'unknown',
|
|
631
|
+
quality: readMarkdownListField(body, 'Quality') || 'unknown',
|
|
632
|
+
qualityIssue: readMarkdownListField(body, 'Quality issue').replace(/^\(none\)$/, ''),
|
|
460
633
|
text: readMarkdownListField(body, 'Candidate'),
|
|
461
634
|
};
|
|
462
635
|
}).filter((candidate) => candidate.id && candidate.source && candidate.text);
|
|
@@ -468,9 +641,9 @@ function renderRequirementsMap(requirements) {
|
|
|
468
641
|
'',
|
|
469
642
|
'Statuses: `candidate | approved | planned | implemented | rejected`.',
|
|
470
643
|
'',
|
|
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 || ''} |`),
|
|
644
|
+
'| ID | Status | Phase | Quality | Requirement | Source | Work package | Acceptance |',
|
|
645
|
+
'| --- | --- | --- | --- | --- | --- | --- | --- |',
|
|
646
|
+
...requirements.map((req) => `| ${req.id} | ${req.status} | ${req.phase || 'unknown'} | ${req.quality || 'unknown'} | ${escapeTable(req.title)} | \`${req.source}\` | ${req.workPackage || ''} | ${req.acceptance || ''} |`),
|
|
474
647
|
'',
|
|
475
648
|
'## Requirement Details',
|
|
476
649
|
'',
|
|
@@ -481,6 +654,8 @@ function renderRequirementsMap(requirements) {
|
|
|
481
654
|
`- Source: \`${req.source}\``,
|
|
482
655
|
`- Source hash: \`${req.sourceHash}\``,
|
|
483
656
|
`- Phase: \`${req.phase || 'unknown'}\``,
|
|
657
|
+
`- Quality: \`${req.quality || 'unknown'}\``,
|
|
658
|
+
`- Quality issue: ${req.qualityIssue || '(none)'}`,
|
|
484
659
|
`- Work package: ${req.workPackage || '(unassigned)'}`,
|
|
485
660
|
`- Acceptance: ${req.acceptance || '(fill before implementation)'}`,
|
|
486
661
|
`- Decision: \`${req.decision || 'pending'}\``,
|
|
@@ -507,6 +682,8 @@ function renderRequirementsReview(requirements) {
|
|
|
507
682
|
`- Source: \`${req.source}\``,
|
|
508
683
|
`- Source hash: \`${req.sourceHash}\``,
|
|
509
684
|
`- Phase: \`${req.phase || 'unknown'}\``,
|
|
685
|
+
`- Quality: \`${req.quality || 'unknown'}\``,
|
|
686
|
+
`- Quality issue: ${req.qualityIssue || '(none)'}`,
|
|
510
687
|
`- Suggested status: \`${req.status}\``,
|
|
511
688
|
`- Requirement: ${req.text}`,
|
|
512
689
|
`- Proposed work package: ${req.workPackage || '(unassigned)'}`,
|
|
@@ -522,6 +699,8 @@ function renderRequirementsReview(requirements) {
|
|
|
522
699
|
|
|
523
700
|
function renderCoverage({ requirements }) {
|
|
524
701
|
const counts = countBy(requirements, (req) => req.status || 'candidate');
|
|
702
|
+
const qualityCounts = countBy(requirements, (req) => req.quality || 'unknown');
|
|
703
|
+
const blockingQuality = requirements.filter((req) => ['fragment', 'heading_only'].includes(req.quality));
|
|
525
704
|
return [
|
|
526
705
|
'# Initiative Coverage',
|
|
527
706
|
'',
|
|
@@ -531,6 +710,17 @@ function renderCoverage({ requirements }) {
|
|
|
531
710
|
`- Planned: ${counts.planned || 0}`,
|
|
532
711
|
`- Implemented: ${counts.implemented || 0}`,
|
|
533
712
|
`- Rejected: ${counts.rejected || 0}`,
|
|
713
|
+
`- Quality complete: ${qualityCounts.complete || 0}`,
|
|
714
|
+
`- Quality expanded block: ${qualityCounts.expanded_block || 0}`,
|
|
715
|
+
`- Quality table row: ${qualityCounts.table_row || 0}`,
|
|
716
|
+
`- Quality fragment: ${qualityCounts.fragment || 0}`,
|
|
717
|
+
`- Quality heading only: ${qualityCounts.heading_only || 0}`,
|
|
718
|
+
`- Planning blocked by quality: ${blockingQuality.length ? 'yes' : 'no'}`,
|
|
719
|
+
'',
|
|
720
|
+
'## Quality Warnings',
|
|
721
|
+
'',
|
|
722
|
+
...blockingQuality.map((req) => `- ${req.id}: ${req.qualityIssue || 'Rewrite before planning.'}`),
|
|
723
|
+
...(blockingQuality.length ? [] : ['- None.']),
|
|
534
724
|
'',
|
|
535
725
|
'## Unassigned Requirements',
|
|
536
726
|
'',
|
|
@@ -573,20 +763,65 @@ function normalizePhaseLabel(text) {
|
|
|
573
763
|
return null;
|
|
574
764
|
}
|
|
575
765
|
|
|
576
|
-
function
|
|
766
|
+
function collectOpenQuestions({ projectRoot, sources }) {
|
|
767
|
+
return collectSourceLines({
|
|
768
|
+
projectRoot,
|
|
769
|
+
sources,
|
|
770
|
+
pattern: /(open question|question|tbd|to decide|needs decision|под вопросом|открыт(ый|ые)? вопрос|нужно решить|решить позже)/i,
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function collectAssumptions({ projectRoot, sources }) {
|
|
775
|
+
return collectSourceLines({
|
|
776
|
+
projectRoot,
|
|
777
|
+
sources,
|
|
778
|
+
pattern: /(assumption|default hypothesis|decision|decided|решено|предположение|гипотеза|не включает|не делаем|deferred|отложен|scope)/i,
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function collectSourceLines({ projectRoot, sources, pattern }) {
|
|
783
|
+
const results = [];
|
|
784
|
+
const seen = new Set();
|
|
785
|
+
for (const source of sources) {
|
|
786
|
+
const sourcePath = path.join(projectRoot, source.path);
|
|
787
|
+
const lines = fs.readFileSync(sourcePath, 'utf8').split(/\r?\n/);
|
|
788
|
+
lines.forEach((line, index) => {
|
|
789
|
+
const normalized = normalizeRequirementCandidate(line);
|
|
790
|
+
if (normalized.length < 12 || !pattern.test(normalized)) {
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
const key = sha256(`${source.path}:${normalized}`).slice(0, 16);
|
|
794
|
+
if (seen.has(key)) {
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
seen.add(key);
|
|
798
|
+
results.push({
|
|
799
|
+
source: `${source.path}:${index + 1}`,
|
|
800
|
+
text: normalized,
|
|
801
|
+
});
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
return results;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function buildOpenQuestionsMarkdown(questions = []) {
|
|
577
808
|
return [
|
|
578
809
|
'# Open Questions',
|
|
579
810
|
'',
|
|
580
|
-
|
|
811
|
+
...(questions.length
|
|
812
|
+
? questions.map((question) => `- \`${question.source}\`: ${question.text}`)
|
|
813
|
+
: ['- No deterministic open questions found. Add human decisions discovered during initiative intake.']),
|
|
581
814
|
'',
|
|
582
815
|
].join('\n');
|
|
583
816
|
}
|
|
584
817
|
|
|
585
|
-
function buildAssumptionsMarkdown() {
|
|
818
|
+
function buildAssumptionsMarkdown(assumptions = []) {
|
|
586
819
|
return [
|
|
587
820
|
'# Assumptions',
|
|
588
821
|
'',
|
|
589
|
-
|
|
822
|
+
...(assumptions.length
|
|
823
|
+
? assumptions.map((assumption) => `- \`${assumption.source}\`: ${assumption.text}`)
|
|
824
|
+
: ['- No deterministic assumptions found. Add assumptions required to interpret source docs.']),
|
|
590
825
|
'',
|
|
591
826
|
].join('\n');
|
|
592
827
|
}
|
package/bin/initiative.test.mjs
CHANGED
|
@@ -158,6 +158,100 @@ describe('initiative framework', () => {
|
|
|
158
158
|
expect(requirements.requirements).toHaveLength(1);
|
|
159
159
|
expect(requirements.requirements[0].text).toContain('meeting transcripts');
|
|
160
160
|
});
|
|
161
|
+
|
|
162
|
+
it('captures continuation blocks and flags requirement quality', () => {
|
|
163
|
+
const root = makeProject();
|
|
164
|
+
const docsDir = path.join(root, 'docs', 'delivery-os');
|
|
165
|
+
fs.mkdirSync(docsDir, { recursive: true });
|
|
166
|
+
fs.writeFileSync(path.join(docsDir, 'phase1.md'), [
|
|
167
|
+
'# Phase 1',
|
|
168
|
+
'',
|
|
169
|
+
'В Phase 1 НЕ нужно создавать все таблицы. Минимум:',
|
|
170
|
+
'',
|
|
171
|
+
'```',
|
|
172
|
+
'team_members',
|
|
173
|
+
'clients',
|
|
174
|
+
'evidence',
|
|
175
|
+
'```',
|
|
176
|
+
'',
|
|
177
|
+
'Phase 1 намеренно не включает contribution loop, skill router,',
|
|
178
|
+
].join('\n'));
|
|
179
|
+
createInitiative({
|
|
180
|
+
projectRoot: root,
|
|
181
|
+
initiativeId: 'delivery-os-mvp',
|
|
182
|
+
title: 'Delivery OS MVP',
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const intake = initiativeIntake({
|
|
186
|
+
projectRoot: root,
|
|
187
|
+
initiativeId: 'delivery-os-mvp',
|
|
188
|
+
docsDir: 'docs/delivery-os',
|
|
189
|
+
phase: 'phase-1',
|
|
190
|
+
});
|
|
191
|
+
const requirements = initiativeRequirements({
|
|
192
|
+
projectRoot: root,
|
|
193
|
+
initiativeId: 'delivery-os-mvp',
|
|
194
|
+
phase: 'phase-1',
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
expect(intake.candidates[0]).toMatchObject({
|
|
198
|
+
quality: 'expanded_block',
|
|
199
|
+
});
|
|
200
|
+
expect(intake.candidates[0].text).toContain('team_members');
|
|
201
|
+
expect(requirements.requirements[0].quality).toBe('expanded_block');
|
|
202
|
+
expect(requirements.requirements[1].quality).toBe('fragment');
|
|
203
|
+
const initiativeDir = path.join(root, 'ops', 'agent-pipeline', 'initiatives', 'delivery-os-mvp');
|
|
204
|
+
const coverage = fs.readFileSync(path.join(initiativeDir, 'coverage.md'), 'utf8');
|
|
205
|
+
const review = fs.readFileSync(path.join(initiativeDir, 'requirements-review.md'), 'utf8');
|
|
206
|
+
expect(coverage).toContain('- Planning blocked by quality: yes');
|
|
207
|
+
expect(review).toContain('- Quality: `fragment`');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('writes source coverage warnings plus open questions and assumptions', () => {
|
|
211
|
+
const root = makeProject();
|
|
212
|
+
const docsDir = path.join(root, 'docs', 'delivery-os');
|
|
213
|
+
fs.mkdirSync(docsDir, { recursive: true });
|
|
214
|
+
fs.writeFileSync(path.join(docsDir, '06-roadmap.md'), [
|
|
215
|
+
'# Phase 1',
|
|
216
|
+
'',
|
|
217
|
+
'- System must import meeting transcripts.',
|
|
218
|
+
'- Admin UI provider is TBD and needs decision.',
|
|
219
|
+
'- Phase 1 не включает Telegram capture.',
|
|
220
|
+
].join('\n'));
|
|
221
|
+
fs.writeFileSync(path.join(docsDir, '05-skills-catalog.md'), [
|
|
222
|
+
'# Skills Catalog',
|
|
223
|
+
'',
|
|
224
|
+
'- System must define /process-meeting.',
|
|
225
|
+
].join('\n'));
|
|
226
|
+
fs.writeFileSync(path.join(docsDir, '99-feedback-log.md'), [
|
|
227
|
+
'# Feedback Log',
|
|
228
|
+
'',
|
|
229
|
+
'- Decision: backfill heavy extraction is deferred.',
|
|
230
|
+
].join('\n'));
|
|
231
|
+
createInitiative({
|
|
232
|
+
projectRoot: root,
|
|
233
|
+
initiativeId: 'delivery-os-mvp',
|
|
234
|
+
title: 'Delivery OS MVP',
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const intake = initiativeIntake({
|
|
238
|
+
projectRoot: root,
|
|
239
|
+
initiativeId: 'delivery-os-mvp',
|
|
240
|
+
docsDir: 'docs/delivery-os',
|
|
241
|
+
phase: 'phase-1',
|
|
242
|
+
include: ['06-roadmap.md'],
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const initiativeDir = path.join(root, 'ops', 'agent-pipeline', 'initiatives', 'delivery-os-mvp');
|
|
246
|
+
const sourceCoverage = fs.readFileSync(path.join(initiativeDir, 'intake', 'source-coverage.md'), 'utf8');
|
|
247
|
+
const questions = fs.readFileSync(path.join(initiativeDir, 'intake', 'open-questions.md'), 'utf8');
|
|
248
|
+
const assumptions = fs.readFileSync(path.join(initiativeDir, 'intake', 'assumptions.md'), 'utf8');
|
|
249
|
+
expect(intake.sourceCoverage.relevantExcludedSources.map((source) => source.path)).toContain('docs/delivery-os/05-skills-catalog.md');
|
|
250
|
+
expect(intake.sourceCoverage.relevantExcludedSources.map((source) => source.path)).toContain('docs/delivery-os/99-feedback-log.md');
|
|
251
|
+
expect(sourceCoverage).toContain('Relevant Sources Not Included');
|
|
252
|
+
expect(questions).toContain('Admin UI provider is TBD');
|
|
253
|
+
expect(assumptions).toContain('Phase 1 не включает Telegram capture.');
|
|
254
|
+
});
|
|
161
255
|
});
|
|
162
256
|
|
|
163
257
|
function makeProject() {
|
|
@@ -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');
|