@besales/ops-framework 0.1.10 → 0.1.12

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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.12
4
+
5
+ - Auto-included detected relevant initiative sources when an intake run uses `--include`, unless `--no-include-relevant` is passed.
6
+ - Tightened initiative requirement quality checks for dangling fragments, table rows, run-on blocks and duplicate candidates.
7
+ - Added `initiative-requirements-synthesis` to produce the human/LLM synthesis pack before requirements are approved or planned.
8
+ - Renamed deterministic assumptions/open-question outputs to candidate packs and filtered schema/code noise.
9
+
10
+ ## 0.1.11
11
+
12
+ - Improved initiative requirements extraction with continuation-block capture for lists, tables and fenced blocks.
13
+ - Added requirement quality metadata and planning quality warnings for fragments and heading-only candidates.
14
+ - Added source coverage warnings plus deterministic open-question and assumption extraction during initiative intake.
15
+
3
16
  ## 0.1.10
4
17
 
5
18
  - Fixed `project.ops.yaml` parsing for comment-only lines, including generated header comments and placeholder root comments.
package/README.md CHANGED
@@ -234,6 +234,7 @@ ops-agent initiative-create delivery-os-mvp --title "Delivery OS MVP" --mode fas
234
234
  ops-agent initiative-add-work-package delivery-os-mvp WP-001-foundation --title "Foundation"
235
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
236
  ops-agent initiative-requirements delivery-os-mvp --phase phase-1
237
+ ops-agent initiative-requirements-synthesis delivery-os-mvp
237
238
  ops-agent initiative-status delivery-os-mvp
238
239
  ops-agent initiative-next delivery-os-mvp --materialize-task
239
240
  ```
@@ -258,10 +259,12 @@ Raw docs
258
259
  -> tasks
259
260
  ```
260
261
 
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
+ `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. When `--include` is used, detected relevant docs such as feedback, skills, profiles, ADRs and risk/governance docs are auto-included by default; pass `--no-include-relevant` only when intentionally running a narrow diagnostic pass.
262
263
 
263
264
  `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.
264
265
 
266
+ `initiative-requirements-synthesis <initiative>` writes `requirements-synthesis.md`: the human/LLM synthesis pack for converting raw candidates into clean requirements with acceptance criteria. It highlights source coverage gaps, fragments, table rows, run-on blocks and candidate assumptions/questions that must be rewritten before planning.
267
+
265
268
  ## Feedback Intake
266
269
 
267
270
  Feedback is stage-agnostic. Any user question, correction, review note or learning observation during an active task should be captured before it is acted on:
@@ -72,6 +72,7 @@ export function main() {
72
72
  docsDir: args.positional[1],
73
73
  phase: normalizePhaseFilter(getFlag(args, 'phase', null)),
74
74
  include: parseIncludeFilter(getFlag(args, 'include', null)),
75
+ includeRelevant: !args.flags.has('no-include-relevant'),
75
76
  force: args.flags.has('force'),
76
77
  });
77
78
  printChangeSummary(`Initiative intake written: ${result.initiativeId}`, result.changes);
@@ -90,7 +91,18 @@ export function main() {
90
91
  console.log(`- requirements: ${result.requirements.length}`);
91
92
  return;
92
93
  }
93
- fail('Usage: ops-agent initiative-create|initiative-add-work-package|initiative-status|initiative-next|initiative-intake|initiative-requirements ...');
94
+ if (command === 'initiative-requirements-synthesis') {
95
+ const result = initiativeRequirementsSynthesis({
96
+ projectRoot: process.cwd(),
97
+ initiativeId: args.positional[0],
98
+ force: args.flags.has('force'),
99
+ });
100
+ printChangeSummary(`Initiative requirements synthesis pack written: ${result.initiativeId}`, result.changes);
101
+ console.log(`- rewrite required: ${result.rewriteRequired}`);
102
+ console.log(`- requirement candidates: ${result.requirements.length}`);
103
+ return;
104
+ }
105
+ fail('Usage: ops-agent initiative-create|initiative-add-work-package|initiative-status|initiative-next|initiative-intake|initiative-requirements|initiative-requirements-synthesis ...');
94
106
  } catch (error) {
95
107
  fail(error.message);
96
108
  }
@@ -226,6 +238,7 @@ export function initiativeIntake({
226
238
  docsDir,
227
239
  phase = null,
228
240
  include = [],
241
+ includeRelevant = true,
229
242
  force = false,
230
243
  } = {}) {
231
244
  const initiative = readInitiative({ projectRoot, initiativeId });
@@ -239,25 +252,50 @@ export function initiativeIntake({
239
252
  const intakeDir = path.join(initiative.initiativeDir, 'intake');
240
253
  const changes = [];
241
254
  ensureDirectory(intakeDir, changes);
242
- const sources = collectSourceDocuments({ projectRoot, docsDir: absoluteDocsDir, include });
255
+ const allSources = collectSourceDocuments({ projectRoot, docsDir: absoluteDocsDir });
256
+ const initialSources = collectSourceDocuments({ projectRoot, docsDir: absoluteDocsDir, include });
257
+ const initialCoverage = buildSourceCoverage({ allSources, sources: initialSources, include });
258
+ const resolvedInclude = includeRelevant && include.length
259
+ ? uniqueStrings([
260
+ ...include,
261
+ ...initialCoverage.relevantExcludedSources.map((source) => source.path),
262
+ ])
263
+ : include;
264
+ const sources = collectSourceDocuments({ projectRoot, docsDir: absoluteDocsDir, include: resolvedInclude });
265
+ const sourceCoverage = buildSourceCoverage({
266
+ allSources,
267
+ sources,
268
+ include: resolvedInclude,
269
+ requestedInclude: include,
270
+ autoIncludedRelevantSources: resolvedInclude.filter((item) => !include.includes(item)),
271
+ });
243
272
  const candidates = collectRequirementCandidates({ projectRoot, sources, phase });
273
+ const questions = collectOpenQuestions({ projectRoot, sources });
274
+ const assumptions = collectAssumptions({ projectRoot, sources });
244
275
  writeFileIfAllowed(path.join(intakeDir, 'source-index.json'), JSON.stringify({
245
276
  schemaVersion: 1,
246
277
  generatedAt: new Date().toISOString(),
247
278
  docsDir: path.relative(projectRoot, absoluteDocsDir),
248
279
  phaseFilter: phase,
249
- include,
280
+ include: resolvedInclude,
281
+ requestedInclude: include,
282
+ includeRelevant,
283
+ sourceCoverage,
250
284
  sources,
251
285
  }, null, 2), { force: true, changes });
252
286
  writeFileIfAllowed(path.join(intakeDir, 'sources.md'), renderSourcesMarkdown({ docsDir: absoluteDocsDir, projectRoot, sources }), { force, changes });
287
+ writeFileIfAllowed(path.join(intakeDir, 'source-coverage.md'), renderSourceCoverage(sourceCoverage), { force: true, changes });
253
288
  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 });
289
+ writeFileIfAllowed(path.join(intakeDir, 'open-questions.md'), buildOpenQuestionsMarkdown(questions), { force: true, changes });
290
+ writeFileIfAllowed(path.join(intakeDir, 'assumptions.md'), buildAssumptionsMarkdown(assumptions), { force: true, changes });
256
291
  return {
257
292
  initiativeId,
258
293
  intakeDir,
259
294
  sources,
295
+ sourceCoverage,
260
296
  candidates,
297
+ questions,
298
+ assumptions,
261
299
  changes,
262
300
  };
263
301
  }
@@ -275,13 +313,16 @@ export function initiativeRequirements({
275
313
  }
276
314
  const candidates = parseRequirementCandidates(fs.readFileSync(candidatesPath, 'utf8'))
277
315
  .filter((candidate) => !phase || candidate.phase === phase);
278
- const requirements = candidates.map((candidate, index) => ({
316
+ const dedupedCandidates = dedupeRequirementCandidates(candidates);
317
+ const requirements = dedupedCandidates.map((candidate, index) => ({
279
318
  id: `REQ-${String(index + 1).padStart(3, '0')}`,
280
319
  status: 'candidate',
281
320
  title: summarizeRequirementTitle(candidate.text),
282
321
  source: candidate.source,
283
322
  sourceHash: candidate.sourceHash,
284
323
  phase: candidate.phase || 'unknown',
324
+ quality: candidate.quality || 'unknown',
325
+ qualityIssue: candidate.qualityIssue || '',
285
326
  text: candidate.text,
286
327
  workPackage: '',
287
328
  acceptance: '',
@@ -299,6 +340,44 @@ export function initiativeRequirements({
299
340
  };
300
341
  }
301
342
 
343
+ export function initiativeRequirementsSynthesis({
344
+ projectRoot = process.cwd(),
345
+ initiativeId,
346
+ force = false,
347
+ } = {}) {
348
+ const initiative = readInitiative({ projectRoot, initiativeId });
349
+ const requirementsPath = path.join(initiative.initiativeDir, 'requirements-map.md');
350
+ const sourceIndexPath = path.join(initiative.initiativeDir, 'intake', 'source-index.json');
351
+ if (!fs.existsSync(requirementsPath)) {
352
+ throw new Error('Missing requirements-map.md. Run initiative-requirements first.');
353
+ }
354
+ if (!fs.existsSync(sourceIndexPath)) {
355
+ throw new Error('Missing intake/source-index.json. Run initiative-intake first.');
356
+ }
357
+ const sourceIndex = JSON.parse(fs.readFileSync(sourceIndexPath, 'utf8'));
358
+ const requirements = parseRequirementsMap(fs.readFileSync(requirementsPath, 'utf8'));
359
+ const assumptionsPath = path.join(initiative.initiativeDir, 'intake', 'assumptions.md');
360
+ const questionsPath = path.join(initiative.initiativeDir, 'intake', 'open-questions.md');
361
+ const assumptions = fs.existsSync(assumptionsPath) ? fs.readFileSync(assumptionsPath, 'utf8') : '';
362
+ const questions = fs.existsSync(questionsPath) ? fs.readFileSync(questionsPath, 'utf8') : '';
363
+ const rewriteRequired = requirements.some((req) => requirementNeedsRewrite(req))
364
+ || Boolean(sourceIndex.sourceCoverage?.relevantExcludedSources?.length);
365
+ const changes = [];
366
+ writeFileIfAllowed(path.join(initiative.initiativeDir, 'requirements-synthesis.md'), renderRequirementsSynthesis({
367
+ sourceCoverage: sourceIndex.sourceCoverage,
368
+ requirements,
369
+ assumptions,
370
+ questions,
371
+ rewriteRequired,
372
+ }), { force: true, changes });
373
+ return {
374
+ initiativeId,
375
+ requirements,
376
+ rewriteRequired,
377
+ changes,
378
+ };
379
+ }
380
+
302
381
  function materializeWorkPackageTask({ projectRoot, initiativeId, workPackage, taskId, force }) {
303
382
  const task = createTask({
304
383
  projectRoot,
@@ -363,22 +442,30 @@ function collectRequirementCandidates({ projectRoot, sources, phase = null }) {
363
442
  const sourcePath = path.join(projectRoot, source.path);
364
443
  const lines = fs.readFileSync(sourcePath, 'utf8').split(/\r?\n/);
365
444
  let headingContext = '';
366
- lines.forEach((line, index) => {
445
+ for (let index = 0; index < lines.length; index += 1) {
446
+ const line = lines[index];
367
447
  const heading = /^#{1,6}\s+(.+)$/.exec(line);
368
448
  if (heading) {
369
449
  headingContext = heading[1].trim();
370
450
  }
371
451
  const normalized = normalizeRequirementCandidate(line);
372
452
  if (!isRequirementCandidate(normalized)) {
373
- return;
453
+ continue;
374
454
  }
375
455
  const candidatePhase = inferPhase(`${source.path} ${headingContext} ${normalized}`);
376
456
  if (phase && candidatePhase !== phase) {
377
- return;
457
+ continue;
378
458
  }
379
- const key = sha256(`${source.path}:${normalized}`).slice(0, 16);
459
+ const block = collectRequirementBlock({ lines, index, baseText: normalized });
460
+ const quality = classifyRequirementQuality({
461
+ rawLine: line,
462
+ baseText: normalized,
463
+ text: block.text,
464
+ continuation: block.continuation,
465
+ });
466
+ const key = requirementFingerprint(block.text);
380
467
  if (seen.has(key)) {
381
- return;
468
+ continue;
382
469
  }
383
470
  seen.add(key);
384
471
  candidates.push({
@@ -386,9 +473,11 @@ function collectRequirementCandidates({ projectRoot, sources, phase = null }) {
386
473
  source: `${source.path}:${index + 1}`,
387
474
  sourceHash: source.sha256,
388
475
  phase: candidatePhase,
389
- text: normalized,
476
+ quality: quality.kind,
477
+ qualityIssue: quality.issue,
478
+ text: block.text,
390
479
  });
391
- });
480
+ }
392
481
  }
393
482
  return candidates;
394
483
  }
@@ -401,6 +490,72 @@ function sourceIncluded({ projectRoot, filePath, include }) {
401
490
  return include.some((pattern) => relative === pattern || relative.endsWith(pattern) || relative.includes(pattern));
402
491
  }
403
492
 
493
+ function buildSourceCoverage({ allSources, sources, include, requestedInclude = include, autoIncludedRelevantSources = [] }) {
494
+ const included = new Set(sources.map((source) => source.path));
495
+ const excluded = allSources.filter((source) => !included.has(source.path));
496
+ const relevantExcluded = excluded.filter((source) => isLikelyRelevantSource(source.path));
497
+ return {
498
+ include,
499
+ requestedInclude,
500
+ autoIncludedRelevantSources,
501
+ totalSources: allSources.length,
502
+ includedSources: sources.length,
503
+ excludedSources: excluded.length,
504
+ relevantExcludedSources: relevantExcluded.map((source) => ({
505
+ path: source.path,
506
+ title: source.title,
507
+ reason: relevantSourceReason(source.path),
508
+ })),
509
+ };
510
+ }
511
+
512
+ function isLikelyRelevantSource(sourcePath) {
513
+ return /(skills?|profiles?|adr|governance|risk|feedback|glossary|stack|tools?|contract|workspace)/i.test(sourcePath);
514
+ }
515
+
516
+ function relevantSourceReason(sourcePath) {
517
+ if (/feedback/i.test(sourcePath)) {
518
+ return 'feedback/decision log may contain newer product or scope decisions.';
519
+ }
520
+ if (/skills?/i.test(sourcePath)) {
521
+ return 'skills catalog may contain implementation requirements for MVP skills.';
522
+ }
523
+ if (/profiles?/i.test(sourcePath)) {
524
+ return 'extraction profiles may contain acceptance and workflow requirements.';
525
+ }
526
+ if (/adr/i.test(sourcePath)) {
527
+ return 'ADRs may contain architecture constraints.';
528
+ }
529
+ if (/governance|risk/i.test(sourcePath)) {
530
+ return 'governance/risk docs may affect approval, trust, security or rollout gates.';
531
+ }
532
+ return 'source may contain constraints relevant to requirements review.';
533
+ }
534
+
535
+ function renderSourceCoverage(sourceCoverage) {
536
+ return [
537
+ '# Source Coverage',
538
+ '',
539
+ `- Total supported sources: ${sourceCoverage.totalSources}`,
540
+ `- Included sources: ${sourceCoverage.includedSources}`,
541
+ `- Excluded sources: ${sourceCoverage.excludedSources}`,
542
+ `- Relevant excluded sources: ${sourceCoverage.relevantExcludedSources.length}`,
543
+ `- Auto-included relevant sources: ${sourceCoverage.autoIncludedRelevantSources?.length || 0}`,
544
+ '',
545
+ '## Auto-Included Relevant Sources',
546
+ '',
547
+ ...(sourceCoverage.autoIncludedRelevantSources?.length
548
+ ? sourceCoverage.autoIncludedRelevantSources.map((sourcePath) => `- \`${sourcePath}\``)
549
+ : ['- None.']),
550
+ '',
551
+ '## Relevant Sources Not Included',
552
+ '',
553
+ ...sourceCoverage.relevantExcludedSources.map((source) => `- \`${source.path}\`: ${source.reason}`),
554
+ ...(sourceCoverage.relevantExcludedSources.length ? [] : ['- None.']),
555
+ '',
556
+ ].join('\n');
557
+ }
558
+
404
559
  function normalizeRequirementCandidate(line) {
405
560
  return line
406
561
  .replace(/^#{1,6}\s+/, '')
@@ -410,11 +565,119 @@ function normalizeRequirementCandidate(line) {
410
565
  .trim();
411
566
  }
412
567
 
568
+ function collectRequirementBlock({ lines, index, baseText }) {
569
+ const continuation = [];
570
+ const shouldExpand = /[::]$/.test(baseText) && !isMarkdownTableRow(lines[index]);
571
+ if (!shouldExpand) {
572
+ return {
573
+ text: baseText,
574
+ continuation,
575
+ };
576
+ }
577
+ let inCodeBlock = false;
578
+ const maxContinuationLines = 12;
579
+ for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
580
+ const line = lines[cursor];
581
+ if (/^#{1,6}\s+/.test(line)) {
582
+ break;
583
+ }
584
+ if (/^\s*```/.test(line)) {
585
+ inCodeBlock = !inCodeBlock;
586
+ continue;
587
+ }
588
+ if (!line.trim()) {
589
+ if (continuation.length || inCodeBlock) {
590
+ break;
591
+ }
592
+ continue;
593
+ }
594
+ if (inCodeBlock) {
595
+ continuation.push(normalizeContinuationLine(line));
596
+ continue;
597
+ }
598
+ if (isContinuationLine(line)) {
599
+ continuation.push(normalizeContinuationLine(line));
600
+ if (continuation.length >= maxContinuationLines) {
601
+ break;
602
+ }
603
+ continue;
604
+ }
605
+ break;
606
+ }
607
+ return {
608
+ text: [baseText, ...continuation].filter(Boolean).join(' '),
609
+ continuation,
610
+ };
611
+ }
612
+
613
+ function isContinuationLine(line) {
614
+ return /^\s*[-*]\s+/.test(line)
615
+ || /^\s*\d+[.)]\s+/.test(line)
616
+ || isMarkdownTableRow(line)
617
+ || /^\s{2,}\S/.test(line);
618
+ }
619
+
620
+ function normalizeContinuationLine(line) {
621
+ return line
622
+ .replace(/^\s*[-*]\s+/, '')
623
+ .replace(/^\s*\d+[.)]\s+/, '')
624
+ .replace(/\s+/g, ' ')
625
+ .trim();
626
+ }
627
+
628
+ function isMarkdownTableRow(line) {
629
+ return /^\s*\|.+\|\s*$/.test(line);
630
+ }
631
+
632
+ function classifyRequirementQuality({ rawLine, baseText, text, continuation }) {
633
+ if (isMarkdownTableRow(rawLine)) {
634
+ return {
635
+ kind: 'table_row',
636
+ issue: 'Candidate came from a markdown table row; verify headers/context before planning.',
637
+ };
638
+ }
639
+ if (/[::]$/.test(baseText) && continuation.length === 0) {
640
+ return {
641
+ kind: 'heading_only',
642
+ issue: 'Candidate ends with a colon and no continuation block was captured; rewrite before planning.',
643
+ };
644
+ }
645
+ if (continuation.length > 0) {
646
+ if (text.length > 700 || continuation.length >= 12) {
647
+ return {
648
+ kind: 'run_on_block',
649
+ issue: 'Candidate captured a large continuation block; synthesize a clean requirement with explicit acceptance before planning.',
650
+ };
651
+ }
652
+ return {
653
+ kind: 'expanded_block',
654
+ issue: '',
655
+ };
656
+ }
657
+ if (isDanglingRequirementFragment(text)) {
658
+ return {
659
+ kind: 'fragment',
660
+ issue: 'Candidate appears to end mid-list or mid-sentence; rewrite before planning.',
661
+ };
662
+ }
663
+ return {
664
+ kind: 'complete',
665
+ issue: '',
666
+ };
667
+ }
668
+
669
+ function isDanglingRequirementFragment(text) {
670
+ return /[,;]$/.test(text)
671
+ || /\b(and|or|и|или)$/i.test(text)
672
+ || /(?:^|\s)(?:должна|должен|должны|must|should)\s+(?:включать|include|contain)$/i.test(text)
673
+ || /(?:^|\s)(?:включает|includes?|contains?)$/i.test(text);
674
+ }
675
+
413
676
  function isRequirementCandidate(text) {
414
677
  if (text.length < 20 || text.length > 500) {
415
678
  return false;
416
679
  }
417
- return /(must|should|required|requirement|user can|user must|system must|needs to|нужно|должен|должна|должны|требован|пользователь может|пользователь должен|система должна|необходимо|важно)/i.test(text);
680
+ return /(must|should|required|requirement|includes?|does not include|user can|user must|system must|needs to|нужно|должен|должна|должны|требован|включает|не включает|пользователь может|пользователь должен|система должна|необходимо|важно)/i.test(text);
418
681
  }
419
682
 
420
683
  function renderSourcesMarkdown({ docsDir, projectRoot, sources }) {
@@ -442,6 +705,8 @@ function renderRequirementCandidatesMarkdown(candidates) {
442
705
  `- Source: \`${candidate.source}\``,
443
706
  `- Source hash: \`${candidate.sourceHash}\``,
444
707
  `- Phase: \`${candidate.phase || 'unknown'}\``,
708
+ `- Quality: \`${candidate.quality || 'unknown'}\``,
709
+ `- Quality issue: ${candidate.qualityIssue || '(none)'}`,
445
710
  `- Candidate: ${candidate.text}`,
446
711
  '',
447
712
  ].join('\n')),
@@ -457,6 +722,8 @@ function parseRequirementCandidates(content) {
457
722
  source: readMarkdownListField(body, 'Source'),
458
723
  sourceHash: readMarkdownListField(body, 'Source hash'),
459
724
  phase: readMarkdownListField(body, 'Phase') || 'unknown',
725
+ quality: readMarkdownListField(body, 'Quality') || 'unknown',
726
+ qualityIssue: readMarkdownListField(body, 'Quality issue').replace(/^\(none\)$/, ''),
460
727
  text: readMarkdownListField(body, 'Candidate'),
461
728
  };
462
729
  }).filter((candidate) => candidate.id && candidate.source && candidate.text);
@@ -468,9 +735,9 @@ function renderRequirementsMap(requirements) {
468
735
  '',
469
736
  'Statuses: `candidate | approved | planned | implemented | rejected`.',
470
737
  '',
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 || ''} |`),
738
+ '| ID | Status | Phase | Quality | Requirement | Source | Work package | Acceptance |',
739
+ '| --- | --- | --- | --- | --- | --- | --- | --- |',
740
+ ...requirements.map((req) => `| ${req.id} | ${req.status} | ${req.phase || 'unknown'} | ${req.quality || 'unknown'} | ${escapeTable(req.title)} | \`${req.source}\` | ${req.workPackage || ''} | ${req.acceptance || ''} |`),
474
741
  '',
475
742
  '## Requirement Details',
476
743
  '',
@@ -481,6 +748,8 @@ function renderRequirementsMap(requirements) {
481
748
  `- Source: \`${req.source}\``,
482
749
  `- Source hash: \`${req.sourceHash}\``,
483
750
  `- Phase: \`${req.phase || 'unknown'}\``,
751
+ `- Quality: \`${req.quality || 'unknown'}\``,
752
+ `- Quality issue: ${req.qualityIssue || '(none)'}`,
484
753
  `- Work package: ${req.workPackage || '(unassigned)'}`,
485
754
  `- Acceptance: ${req.acceptance || '(fill before implementation)'}`,
486
755
  `- Decision: \`${req.decision || 'pending'}\``,
@@ -507,6 +776,8 @@ function renderRequirementsReview(requirements) {
507
776
  `- Source: \`${req.source}\``,
508
777
  `- Source hash: \`${req.sourceHash}\``,
509
778
  `- Phase: \`${req.phase || 'unknown'}\``,
779
+ `- Quality: \`${req.quality || 'unknown'}\``,
780
+ `- Quality issue: ${req.qualityIssue || '(none)'}`,
510
781
  `- Suggested status: \`${req.status}\``,
511
782
  `- Requirement: ${req.text}`,
512
783
  `- Proposed work package: ${req.workPackage || '(unassigned)'}`,
@@ -522,6 +793,8 @@ function renderRequirementsReview(requirements) {
522
793
 
523
794
  function renderCoverage({ requirements }) {
524
795
  const counts = countBy(requirements, (req) => req.status || 'candidate');
796
+ const qualityCounts = countBy(requirements, (req) => req.quality || 'unknown');
797
+ const blockingQuality = requirements.filter((req) => requirementNeedsRewrite(req));
525
798
  return [
526
799
  '# Initiative Coverage',
527
800
  '',
@@ -531,6 +804,18 @@ function renderCoverage({ requirements }) {
531
804
  `- Planned: ${counts.planned || 0}`,
532
805
  `- Implemented: ${counts.implemented || 0}`,
533
806
  `- Rejected: ${counts.rejected || 0}`,
807
+ `- Quality complete: ${qualityCounts.complete || 0}`,
808
+ `- Quality expanded block: ${qualityCounts.expanded_block || 0}`,
809
+ `- Quality table row: ${qualityCounts.table_row || 0}`,
810
+ `- Quality fragment: ${qualityCounts.fragment || 0}`,
811
+ `- Quality heading only: ${qualityCounts.heading_only || 0}`,
812
+ `- Quality run-on block: ${qualityCounts.run_on_block || 0}`,
813
+ `- Planning blocked by quality: ${blockingQuality.length ? 'yes' : 'no'}`,
814
+ '',
815
+ '## Quality Warnings',
816
+ '',
817
+ ...blockingQuality.map((req) => `- ${req.id}: ${req.qualityIssue || 'Rewrite before planning.'}`),
818
+ ...(blockingQuality.length ? [] : ['- None.']),
534
819
  '',
535
820
  '## Unassigned Requirements',
536
821
  '',
@@ -573,24 +858,199 @@ function normalizePhaseLabel(text) {
573
858
  return null;
574
859
  }
575
860
 
576
- function buildOpenQuestionsMarkdown() {
861
+ function collectOpenQuestions({ projectRoot, sources }) {
862
+ return collectSourceLines({
863
+ projectRoot,
864
+ sources,
865
+ pattern: /(open question|question|tbd|to decide|needs decision|под вопросом|открыт(ый|ые)? вопрос|нужно решить|решить позже)/i,
866
+ });
867
+ }
868
+
869
+ function collectAssumptions({ projectRoot, sources }) {
870
+ return collectSourceLines({
871
+ projectRoot,
872
+ sources,
873
+ pattern: /(assumption|default hypothesis|decision|decided|решено|предположение|гипотеза|не включает|не делаем|deferred|отложен|scope)/i,
874
+ });
875
+ }
876
+
877
+ function collectSourceLines({ projectRoot, sources, pattern }) {
878
+ const results = [];
879
+ const seen = new Set();
880
+ for (const source of sources) {
881
+ const sourcePath = path.join(projectRoot, source.path);
882
+ const lines = fs.readFileSync(sourcePath, 'utf8').split(/\r?\n/);
883
+ lines.forEach((line, index) => {
884
+ const normalized = normalizeRequirementCandidate(line);
885
+ if (normalized.length < 12 || !pattern.test(normalized) || isNoisySourceLine(normalized)) {
886
+ return;
887
+ }
888
+ const key = sha256(normalizeForDedup(normalized)).slice(0, 16);
889
+ if (seen.has(key)) {
890
+ return;
891
+ }
892
+ seen.add(key);
893
+ results.push({
894
+ source: `${source.path}:${index + 1}`,
895
+ text: normalized,
896
+ });
897
+ });
898
+ }
899
+ return results;
900
+ }
901
+
902
+ function isNoisySourceLine(text) {
903
+ return /^(create table|alter table|insert into|select |update |delete from)\b/i.test(text)
904
+ || /\b(jsonb|varchar|integer|bigint|boolean|timestamp|default\s+['"[]|primary key|foreign key)\b/i.test(text)
905
+ || /^[a-z_]+\s+(text|jsonb|integer|bigint|boolean|timestamp)\b/i.test(text)
906
+ || /^[-\w]+\s*:\s*(string|number|boolean|array|object|null)\b/i.test(text)
907
+ || /--\s*markdown описание/i.test(text);
908
+ }
909
+
910
+ function buildOpenQuestionsMarkdown(questions = []) {
577
911
  return [
578
- '# Open Questions',
912
+ '# Open Question Candidates',
913
+ '',
914
+ 'Deterministic candidates only. Rewrite into real open questions during requirements synthesis before planning.',
579
915
  '',
580
- '- Add human decisions discovered during initiative intake.',
916
+ ...(questions.length
917
+ ? questions.map((question) => `- \`${question.source}\`: ${question.text}`)
918
+ : ['- No deterministic open questions found. Add human decisions discovered during initiative intake.']),
581
919
  '',
582
920
  ].join('\n');
583
921
  }
584
922
 
585
- function buildAssumptionsMarkdown() {
923
+ function buildAssumptionsMarkdown(assumptions = []) {
586
924
  return [
587
- '# Assumptions',
925
+ '# Assumption Candidates',
588
926
  '',
589
- '- Add assumptions that were required to interpret source docs.',
927
+ 'Deterministic candidates only. Rewrite into real assumptions during requirements synthesis before planning.',
928
+ '',
929
+ ...(assumptions.length
930
+ ? assumptions.map((assumption) => `- \`${assumption.source}\`: ${assumption.text}`)
931
+ : ['- No deterministic assumptions found. Add assumptions required to interpret source docs.']),
590
932
  '',
591
933
  ].join('\n');
592
934
  }
593
935
 
936
+ function dedupeRequirementCandidates(candidates) {
937
+ const seen = new Set();
938
+ const result = [];
939
+ for (const candidate of candidates) {
940
+ const key = requirementFingerprint(candidate.text);
941
+ if (seen.has(key)) {
942
+ continue;
943
+ }
944
+ seen.add(key);
945
+ result.push(candidate);
946
+ }
947
+ return result;
948
+ }
949
+
950
+ function requirementFingerprint(text) {
951
+ return sha256(normalizeForDedup(text)).slice(0, 16);
952
+ }
953
+
954
+ function normalizeForDedup(text) {
955
+ return String(text || '')
956
+ .toLowerCase()
957
+ .replace(/`[^`]+`/g, ' ')
958
+ .replace(/\|/g, ' ')
959
+ .replace(/https?:\/\/\S+/g, ' ')
960
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
961
+ .replace(/\b(phase|фаза)\s*[- ]?\d+\b/gi, 'phase')
962
+ .replace(/\s+/g, ' ')
963
+ .trim();
964
+ }
965
+
966
+ function requirementNeedsRewrite(req) {
967
+ return ['fragment', 'heading_only', 'table_row', 'run_on_block'].includes(req.quality);
968
+ }
969
+
970
+ function parseRequirementsMap(content) {
971
+ return content.split(/^###\s+/m).slice(1).map((section) => {
972
+ const [rawId, ...bodyLines] = section.split('\n');
973
+ const body = bodyLines.join('\n');
974
+ return {
975
+ id: rawId.trim(),
976
+ source: readMarkdownListField(body, 'Source'),
977
+ sourceHash: readMarkdownListField(body, 'Source hash'),
978
+ phase: readMarkdownListField(body, 'Phase') || 'unknown',
979
+ quality: readMarkdownListField(body, 'Quality') || 'unknown',
980
+ qualityIssue: readMarkdownListField(body, 'Quality issue').replace(/^\(none\)$/, ''),
981
+ workPackage: readMarkdownListField(body, 'Work package').replace(/^\(unassigned\)$/, ''),
982
+ acceptance: readMarkdownListField(body, 'Acceptance').replace(/^\(fill before implementation\)$/, ''),
983
+ decision: readMarkdownListField(body, 'Decision') || 'pending',
984
+ notes: readMarkdownListField(body, 'Notes').replace(/^\(empty\)$/, ''),
985
+ text: readMarkdownListField(body, 'Requirement'),
986
+ };
987
+ }).filter((req) => req.id && req.source && req.text);
988
+ }
989
+
990
+ function renderRequirementsSynthesis({ sourceCoverage, requirements, assumptions, questions, rewriteRequired }) {
991
+ const rewriteCandidates = requirements.filter((req) => requirementNeedsRewrite(req));
992
+ return [
993
+ '# Requirements Synthesis Pack',
994
+ '',
995
+ `- Rewrite required before planning: ${rewriteRequired ? 'yes' : 'no'}`,
996
+ `- Requirement candidates: ${requirements.length}`,
997
+ `- Candidates needing rewrite: ${rewriteCandidates.length}`,
998
+ `- Relevant sources still missing: ${sourceCoverage?.relevantExcludedSources?.length || 0}`,
999
+ '',
1000
+ '## Source Coverage Gate',
1001
+ '',
1002
+ ...(sourceCoverage?.relevantExcludedSources?.length
1003
+ ? sourceCoverage.relevantExcludedSources.map((source) => `- Missing \`${source.path}\`: ${source.reason}`)
1004
+ : ['- All detected relevant sources were included.']),
1005
+ '',
1006
+ '## Rewrite Required',
1007
+ '',
1008
+ ...(rewriteCandidates.length
1009
+ ? rewriteCandidates.map((req) => `- ${req.id} (${req.quality}): ${req.qualityIssue || 'Rewrite into a clean requirement with acceptance.'}`)
1010
+ : ['- None.']),
1011
+ '',
1012
+ '## Synthesis Instructions',
1013
+ '',
1014
+ 'Create final requirements only after human/LLM synthesis. Each final requirement must have:',
1015
+ '',
1016
+ '- one clear behavior or constraint;',
1017
+ '- source references;',
1018
+ '- acceptance criteria;',
1019
+ '- explicit phase/scope;',
1020
+ '- decision: `approve | defer | reject | rewrite`.',
1021
+ '',
1022
+ 'Do not approve raw table rows, fragments, duplicated source snippets or grep-derived assumptions/questions as final requirements.',
1023
+ '',
1024
+ '## Requirement Candidate Cards',
1025
+ '',
1026
+ ...requirements.map((req) => [
1027
+ `### ${req.id}`,
1028
+ '',
1029
+ `- Source: \`${req.source}\``,
1030
+ `- Phase: \`${req.phase || 'unknown'}\``,
1031
+ `- Quality: \`${req.quality || 'unknown'}\``,
1032
+ `- Quality issue: ${req.qualityIssue || '(none)'}`,
1033
+ `- Raw candidate: ${req.text}`,
1034
+ '- Synthesized requirement: `(write clean requirement)`',
1035
+ '- Acceptance: `(write acceptance criteria)`',
1036
+ '- Decision: `pending`',
1037
+ '',
1038
+ ].join('\n')),
1039
+ '## Assumption Candidates',
1040
+ '',
1041
+ assumptions.trim() || '- No assumption candidates found.',
1042
+ '',
1043
+ '## Open Question Candidates',
1044
+ '',
1045
+ questions.trim() || '- No open question candidates found.',
1046
+ '',
1047
+ ].join('\n');
1048
+ }
1049
+
1050
+ function uniqueStrings(items) {
1051
+ return [...new Set(items.filter(Boolean))];
1052
+ }
1053
+
594
1054
  function readInitiative({ projectRoot, initiativeId }) {
595
1055
  assertInitiativeId(initiativeId);
596
1056
  const context = resolveProjectContext({ cwd: projectRoot });
@@ -8,6 +8,7 @@ import {
8
8
  initiativeIntake,
9
9
  initiativeNext,
10
10
  initiativeRequirements,
11
+ initiativeRequirementsSynthesis,
11
12
  initiativeStatus,
12
13
  } from './initiative.mjs';
13
14
 
@@ -158,6 +159,222 @@ describe('initiative framework', () => {
158
159
  expect(requirements.requirements).toHaveLength(1);
159
160
  expect(requirements.requirements[0].text).toContain('meeting transcripts');
160
161
  });
162
+
163
+ it('captures continuation blocks and flags requirement quality', () => {
164
+ const root = makeProject();
165
+ const docsDir = path.join(root, 'docs', 'delivery-os');
166
+ fs.mkdirSync(docsDir, { recursive: true });
167
+ fs.writeFileSync(path.join(docsDir, 'phase1.md'), [
168
+ '# Phase 1',
169
+ '',
170
+ 'В Phase 1 НЕ нужно создавать все таблицы. Минимум:',
171
+ '',
172
+ '```',
173
+ 'team_members',
174
+ 'clients',
175
+ 'evidence',
176
+ '```',
177
+ '',
178
+ 'Phase 1 намеренно не включает contribution loop, skill router,',
179
+ ].join('\n'));
180
+ createInitiative({
181
+ projectRoot: root,
182
+ initiativeId: 'delivery-os-mvp',
183
+ title: 'Delivery OS MVP',
184
+ });
185
+
186
+ const intake = initiativeIntake({
187
+ projectRoot: root,
188
+ initiativeId: 'delivery-os-mvp',
189
+ docsDir: 'docs/delivery-os',
190
+ phase: 'phase-1',
191
+ });
192
+ const requirements = initiativeRequirements({
193
+ projectRoot: root,
194
+ initiativeId: 'delivery-os-mvp',
195
+ phase: 'phase-1',
196
+ });
197
+
198
+ expect(intake.candidates[0]).toMatchObject({
199
+ quality: 'expanded_block',
200
+ });
201
+ expect(intake.candidates[0].text).toContain('team_members');
202
+ expect(requirements.requirements[0].quality).toBe('expanded_block');
203
+ expect(requirements.requirements[1].quality).toBe('fragment');
204
+ const initiativeDir = path.join(root, 'ops', 'agent-pipeline', 'initiatives', 'delivery-os-mvp');
205
+ const coverage = fs.readFileSync(path.join(initiativeDir, 'coverage.md'), 'utf8');
206
+ const review = fs.readFileSync(path.join(initiativeDir, 'requirements-review.md'), 'utf8');
207
+ expect(coverage).toContain('- Planning blocked by quality: yes');
208
+ expect(review).toContain('- Quality: `fragment`');
209
+ });
210
+
211
+ it('auto-includes relevant source docs and writes question/assumption candidates', () => {
212
+ const root = makeProject();
213
+ const docsDir = path.join(root, 'docs', 'delivery-os');
214
+ fs.mkdirSync(docsDir, { recursive: true });
215
+ fs.writeFileSync(path.join(docsDir, '06-roadmap.md'), [
216
+ '# Phase 1',
217
+ '',
218
+ '- System must import meeting transcripts.',
219
+ '- Admin UI provider is TBD and needs decision.',
220
+ '- Phase 1 не включает Telegram capture.',
221
+ ].join('\n'));
222
+ fs.writeFileSync(path.join(docsDir, '05-skills-catalog.md'), [
223
+ '# Skills Catalog',
224
+ '',
225
+ '- System must define /process-meeting.',
226
+ ].join('\n'));
227
+ fs.writeFileSync(path.join(docsDir, '99-feedback-log.md'), [
228
+ '# Feedback Log',
229
+ '',
230
+ '- Decision: backfill heavy extraction is deferred.',
231
+ ].join('\n'));
232
+ createInitiative({
233
+ projectRoot: root,
234
+ initiativeId: 'delivery-os-mvp',
235
+ title: 'Delivery OS MVP',
236
+ });
237
+
238
+ const intake = initiativeIntake({
239
+ projectRoot: root,
240
+ initiativeId: 'delivery-os-mvp',
241
+ docsDir: 'docs/delivery-os',
242
+ phase: 'phase-1',
243
+ include: ['06-roadmap.md'],
244
+ });
245
+
246
+ const initiativeDir = path.join(root, 'ops', 'agent-pipeline', 'initiatives', 'delivery-os-mvp');
247
+ const sourceCoverage = fs.readFileSync(path.join(initiativeDir, 'intake', 'source-coverage.md'), 'utf8');
248
+ const questions = fs.readFileSync(path.join(initiativeDir, 'intake', 'open-questions.md'), 'utf8');
249
+ const assumptions = fs.readFileSync(path.join(initiativeDir, 'intake', 'assumptions.md'), 'utf8');
250
+ expect(intake.sources.map((source) => source.path)).toContain('docs/delivery-os/05-skills-catalog.md');
251
+ expect(intake.sources.map((source) => source.path)).toContain('docs/delivery-os/99-feedback-log.md');
252
+ expect(intake.sourceCoverage.autoIncludedRelevantSources).toContain('docs/delivery-os/05-skills-catalog.md');
253
+ expect(intake.sourceCoverage.autoIncludedRelevantSources).toContain('docs/delivery-os/99-feedback-log.md');
254
+ expect(sourceCoverage).toContain('Auto-Included Relevant Sources');
255
+ expect(questions).toContain('Admin UI provider is TBD');
256
+ expect(questions).toContain('Open Question Candidates');
257
+ expect(assumptions).toContain('Phase 1 не включает Telegram capture.');
258
+ expect(assumptions).toContain('Assumption Candidates');
259
+ });
260
+
261
+ it('flags dangling fragments, avoids table over-capture and deduplicates repeated requirements', () => {
262
+ const root = makeProject();
263
+ const docsDir = path.join(root, 'docs', 'delivery-os');
264
+ fs.mkdirSync(docsDir, { recursive: true });
265
+ fs.writeFileSync(path.join(docsDir, '06-roadmap.md'), [
266
+ '# Phase 1',
267
+ '',
268
+ '- MVP taxonomy должна включать',
269
+ '| Tool | Phase | Requirement |',
270
+ '| --- | --- | --- |',
271
+ '| Golden set | Phase 1 | System must run golden set evaluation. |',
272
+ '| Telegram bot | Future | System must send Telegram digests. |',
273
+ '- Phase 1 не включает contribution loop, skill router.',
274
+ ].join('\n'));
275
+ fs.writeFileSync(path.join(docsDir, '11-architecture.md'), [
276
+ '# Phase 1',
277
+ '',
278
+ '- Phase 1 не включает contribution loop, skill router.',
279
+ ].join('\n'));
280
+ createInitiative({
281
+ projectRoot: root,
282
+ initiativeId: 'delivery-os-mvp',
283
+ title: 'Delivery OS MVP',
284
+ });
285
+
286
+ const intake = initiativeIntake({
287
+ projectRoot: root,
288
+ initiativeId: 'delivery-os-mvp',
289
+ docsDir: 'docs/delivery-os',
290
+ phase: 'phase-1',
291
+ });
292
+ const requirements = initiativeRequirements({
293
+ projectRoot: root,
294
+ initiativeId: 'delivery-os-mvp',
295
+ phase: 'phase-1',
296
+ });
297
+
298
+ const dangling = intake.candidates.find((candidate) => candidate.text.includes('MVP taxonomy'));
299
+ const table = intake.candidates.find((candidate) => candidate.text.includes('Golden set'));
300
+ expect(dangling.quality).toBe('fragment');
301
+ expect(table.quality).toBe('table_row');
302
+ expect(table.text).toContain('Golden set');
303
+ expect(table.text).not.toContain('Telegram bot');
304
+ expect(requirements.requirements.filter((req) => req.text.includes('contribution loop'))).toHaveLength(1);
305
+ });
306
+
307
+ it('filters schema noise out of assumption and question candidates', () => {
308
+ const root = makeProject();
309
+ const docsDir = path.join(root, 'docs', 'delivery-os');
310
+ fs.mkdirSync(docsDir, { recursive: true });
311
+ fs.writeFileSync(path.join(docsDir, '99-feedback-log.md'), [
312
+ '# Feedback',
313
+ '',
314
+ 'CREATE TABLE decisions (',
315
+ "decided_by JSONB DEFAULT '[]',",
316
+ 'scope TEXT, -- markdown описание scope',
317
+ '- Decision: Phase 1 uses client and internal profiles only.',
318
+ '- Open question: Admin UI provider must be decided.',
319
+ ].join('\n'));
320
+ createInitiative({
321
+ projectRoot: root,
322
+ initiativeId: 'delivery-os-mvp',
323
+ title: 'Delivery OS MVP',
324
+ });
325
+
326
+ initiativeIntake({
327
+ projectRoot: root,
328
+ initiativeId: 'delivery-os-mvp',
329
+ docsDir: 'docs/delivery-os',
330
+ });
331
+
332
+ const initiativeDir = path.join(root, 'ops', 'agent-pipeline', 'initiatives', 'delivery-os-mvp');
333
+ const questions = fs.readFileSync(path.join(initiativeDir, 'intake', 'open-questions.md'), 'utf8');
334
+ const assumptions = fs.readFileSync(path.join(initiativeDir, 'intake', 'assumptions.md'), 'utf8');
335
+ expect(assumptions).toContain('Phase 1 uses client and internal profiles only');
336
+ expect(assumptions).not.toContain('decided_by JSONB');
337
+ expect(assumptions).not.toContain('scope TEXT');
338
+ expect(questions).toContain('Admin UI provider must be decided');
339
+ });
340
+
341
+ it('writes a requirements synthesis pack before planning', () => {
342
+ const root = makeProject();
343
+ const docsDir = path.join(root, 'docs', 'delivery-os');
344
+ fs.mkdirSync(docsDir, { recursive: true });
345
+ fs.writeFileSync(path.join(docsDir, '06-roadmap.md'), [
346
+ '# Phase 1',
347
+ '',
348
+ '- MVP taxonomy должна включать',
349
+ ].join('\n'));
350
+ createInitiative({
351
+ projectRoot: root,
352
+ initiativeId: 'delivery-os-mvp',
353
+ title: 'Delivery OS MVP',
354
+ });
355
+
356
+ initiativeIntake({
357
+ projectRoot: root,
358
+ initiativeId: 'delivery-os-mvp',
359
+ docsDir: 'docs/delivery-os',
360
+ phase: 'phase-1',
361
+ });
362
+ initiativeRequirements({
363
+ projectRoot: root,
364
+ initiativeId: 'delivery-os-mvp',
365
+ phase: 'phase-1',
366
+ });
367
+ const synthesis = initiativeRequirementsSynthesis({
368
+ projectRoot: root,
369
+ initiativeId: 'delivery-os-mvp',
370
+ });
371
+
372
+ const initiativeDir = path.join(root, 'ops', 'agent-pipeline', 'initiatives', 'delivery-os-mvp');
373
+ const pack = fs.readFileSync(path.join(initiativeDir, 'requirements-synthesis.md'), 'utf8');
374
+ expect(synthesis.rewriteRequired).toBe(true);
375
+ expect(pack).toContain('Requirements Synthesis Pack');
376
+ expect(pack).toContain('Synthesized requirement');
377
+ });
161
378
  });
162
379
 
163
380
  function makeProject() {
@@ -127,6 +127,7 @@ export function buildOpsScripts(packageSpec) {
127
127
  'agent:initiative-next': run('initiative-next'),
128
128
  'agent:initiative-intake': run('initiative-intake'),
129
129
  'agent:initiative-requirements': run('initiative-requirements'),
130
+ 'agent:initiative-requirements-synthesis': run('initiative-requirements-synthesis'),
130
131
  'agent:test': run('test/self-test'),
131
132
  };
132
133
  }
@@ -144,6 +144,7 @@ describe('buildOpsScripts', () => {
144
144
  expect(scripts['agent:initiative-next']).toBe('ops-agent initiative-next');
145
145
  expect(scripts['agent:initiative-intake']).toBe('ops-agent initiative-intake');
146
146
  expect(scripts['agent:initiative-requirements']).toBe('ops-agent initiative-requirements');
147
+ expect(scripts['agent:initiative-requirements-synthesis']).toBe('ops-agent initiative-requirements-synthesis');
147
148
  expect(scripts['agent:test']).toBe('ops-agent test/self-test');
148
149
  });
149
150
  });
package/bin/ops-agent.mjs CHANGED
@@ -40,6 +40,7 @@ const COMMANDS = new Map([
40
40
  ['initiative-next', 'initiative.mjs'],
41
41
  ['initiative-intake', 'initiative.mjs'],
42
42
  ['initiative-requirements', 'initiative.mjs'],
43
+ ['initiative-requirements-synthesis', 'initiative.mjs'],
43
44
  ['test/self-test', null],
44
45
  ]);
45
46
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@besales/ops-framework",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "ops-agent": "bin/ops-agent.mjs"