@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 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.
@@ -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.forEach((line, index) => {
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
- return;
385
+ continue;
374
386
  }
375
387
  const candidatePhase = inferPhase(`${source.path} ${headingContext} ${normalized}`);
376
388
  if (phase && candidatePhase !== phase) {
377
- return;
389
+ continue;
378
390
  }
379
- const key = sha256(`${source.path}:${normalized}`).slice(0, 16);
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
- return;
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
- text: normalized,
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|нужно|должен|должна|должны|требован|пользователь может|пользователь должен|система должна|необходимо|важно)/i.test(text);
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 buildOpenQuestionsMarkdown() {
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
- '- Add human decisions discovered during initiative intake.',
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
- '- Add assumptions that were required to interpret source docs.',
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
  }
@@ -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');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@besales/ops-framework",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "ops-agent": "bin/ops-agent.mjs"