@besales/ops-framework 0.1.11 → 0.1.13

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,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.13
4
+
5
+ - Fixed `ops-agent --version` to read the installed package version instead of printing a stale hardcoded value.
6
+
7
+ ## 0.1.12
8
+
9
+ - Auto-included detected relevant initiative sources when an intake run uses `--include`, unless `--no-include-relevant` is passed.
10
+ - Tightened initiative requirement quality checks for dangling fragments, table rows, run-on blocks and duplicate candidates.
11
+ - Added `initiative-requirements-synthesis` to produce the human/LLM synthesis pack before requirements are approved or planned.
12
+ - Renamed deterministic assumptions/open-question outputs to candidate packs and filtered schema/code noise.
13
+
3
14
  ## 0.1.11
4
15
 
5
16
  - Improved initiative requirements extraction with continuation-block capture for lists, tables and fenced blocks.
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 });
@@ -240,8 +253,22 @@ export function initiativeIntake({
240
253
  const changes = [];
241
254
  ensureDirectory(intakeDir, changes);
242
255
  const allSources = collectSourceDocuments({ projectRoot, docsDir: absoluteDocsDir });
243
- const sources = collectSourceDocuments({ projectRoot, docsDir: absoluteDocsDir, include });
244
- const sourceCoverage = buildSourceCoverage({ allSources, sources, include });
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
+ });
245
272
  const candidates = collectRequirementCandidates({ projectRoot, sources, phase });
246
273
  const questions = collectOpenQuestions({ projectRoot, sources });
247
274
  const assumptions = collectAssumptions({ projectRoot, sources });
@@ -250,7 +277,9 @@ export function initiativeIntake({
250
277
  generatedAt: new Date().toISOString(),
251
278
  docsDir: path.relative(projectRoot, absoluteDocsDir),
252
279
  phaseFilter: phase,
253
- include,
280
+ include: resolvedInclude,
281
+ requestedInclude: include,
282
+ includeRelevant,
254
283
  sourceCoverage,
255
284
  sources,
256
285
  }, null, 2), { force: true, changes });
@@ -284,7 +313,8 @@ export function initiativeRequirements({
284
313
  }
285
314
  const candidates = parseRequirementCandidates(fs.readFileSync(candidatesPath, 'utf8'))
286
315
  .filter((candidate) => !phase || candidate.phase === phase);
287
- const requirements = candidates.map((candidate, index) => ({
316
+ const dedupedCandidates = dedupeRequirementCandidates(candidates);
317
+ const requirements = dedupedCandidates.map((candidate, index) => ({
288
318
  id: `REQ-${String(index + 1).padStart(3, '0')}`,
289
319
  status: 'candidate',
290
320
  title: summarizeRequirementTitle(candidate.text),
@@ -310,6 +340,44 @@ export function initiativeRequirements({
310
340
  };
311
341
  }
312
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
+
313
381
  function materializeWorkPackageTask({ projectRoot, initiativeId, workPackage, taskId, force }) {
314
382
  const task = createTask({
315
383
  projectRoot,
@@ -395,7 +463,7 @@ function collectRequirementCandidates({ projectRoot, sources, phase = null }) {
395
463
  text: block.text,
396
464
  continuation: block.continuation,
397
465
  });
398
- const key = sha256(`${source.path}:${block.text}`).slice(0, 16);
466
+ const key = requirementFingerprint(block.text);
399
467
  if (seen.has(key)) {
400
468
  continue;
401
469
  }
@@ -422,12 +490,14 @@ function sourceIncluded({ projectRoot, filePath, include }) {
422
490
  return include.some((pattern) => relative === pattern || relative.endsWith(pattern) || relative.includes(pattern));
423
491
  }
424
492
 
425
- function buildSourceCoverage({ allSources, sources, include }) {
493
+ function buildSourceCoverage({ allSources, sources, include, requestedInclude = include, autoIncludedRelevantSources = [] }) {
426
494
  const included = new Set(sources.map((source) => source.path));
427
495
  const excluded = allSources.filter((source) => !included.has(source.path));
428
496
  const relevantExcluded = excluded.filter((source) => isLikelyRelevantSource(source.path));
429
497
  return {
430
498
  include,
499
+ requestedInclude,
500
+ autoIncludedRelevantSources,
431
501
  totalSources: allSources.length,
432
502
  includedSources: sources.length,
433
503
  excludedSources: excluded.length,
@@ -470,6 +540,13 @@ function renderSourceCoverage(sourceCoverage) {
470
540
  `- Included sources: ${sourceCoverage.includedSources}`,
471
541
  `- Excluded sources: ${sourceCoverage.excludedSources}`,
472
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.']),
473
550
  '',
474
551
  '## Relevant Sources Not Included',
475
552
  '',
@@ -490,7 +567,7 @@ function normalizeRequirementCandidate(line) {
490
567
 
491
568
  function collectRequirementBlock({ lines, index, baseText }) {
492
569
  const continuation = [];
493
- const shouldExpand = /[::]$/.test(baseText) || isMarkdownTableRow(lines[index]);
570
+ const shouldExpand = /[::]$/.test(baseText) && !isMarkdownTableRow(lines[index]);
494
571
  if (!shouldExpand) {
495
572
  return {
496
573
  text: baseText,
@@ -498,6 +575,7 @@ function collectRequirementBlock({ lines, index, baseText }) {
498
575
  };
499
576
  }
500
577
  let inCodeBlock = false;
578
+ const maxContinuationLines = 12;
501
579
  for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
502
580
  const line = lines[cursor];
503
581
  if (/^#{1,6}\s+/.test(line)) {
@@ -519,6 +597,9 @@ function collectRequirementBlock({ lines, index, baseText }) {
519
597
  }
520
598
  if (isContinuationLine(line)) {
521
599
  continuation.push(normalizeContinuationLine(line));
600
+ if (continuation.length >= maxContinuationLines) {
601
+ break;
602
+ }
522
603
  continue;
523
604
  }
524
605
  break;
@@ -562,12 +643,18 @@ function classifyRequirementQuality({ rawLine, baseText, text, continuation }) {
562
643
  };
563
644
  }
564
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
+ }
565
652
  return {
566
653
  kind: 'expanded_block',
567
654
  issue: '',
568
655
  };
569
656
  }
570
- if (/[,;]$/.test(text) || /\b(and|or|и|или)$/i.test(text)) {
657
+ if (isDanglingRequirementFragment(text)) {
571
658
  return {
572
659
  kind: 'fragment',
573
660
  issue: 'Candidate appears to end mid-list or mid-sentence; rewrite before planning.',
@@ -579,6 +666,13 @@ function classifyRequirementQuality({ rawLine, baseText, text, continuation }) {
579
666
  };
580
667
  }
581
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
+
582
676
  function isRequirementCandidate(text) {
583
677
  if (text.length < 20 || text.length > 500) {
584
678
  return false;
@@ -700,7 +794,7 @@ function renderRequirementsReview(requirements) {
700
794
  function renderCoverage({ requirements }) {
701
795
  const counts = countBy(requirements, (req) => req.status || 'candidate');
702
796
  const qualityCounts = countBy(requirements, (req) => req.quality || 'unknown');
703
- const blockingQuality = requirements.filter((req) => ['fragment', 'heading_only'].includes(req.quality));
797
+ const blockingQuality = requirements.filter((req) => requirementNeedsRewrite(req));
704
798
  return [
705
799
  '# Initiative Coverage',
706
800
  '',
@@ -715,6 +809,7 @@ function renderCoverage({ requirements }) {
715
809
  `- Quality table row: ${qualityCounts.table_row || 0}`,
716
810
  `- Quality fragment: ${qualityCounts.fragment || 0}`,
717
811
  `- Quality heading only: ${qualityCounts.heading_only || 0}`,
812
+ `- Quality run-on block: ${qualityCounts.run_on_block || 0}`,
718
813
  `- Planning blocked by quality: ${blockingQuality.length ? 'yes' : 'no'}`,
719
814
  '',
720
815
  '## Quality Warnings',
@@ -787,10 +882,10 @@ function collectSourceLines({ projectRoot, sources, pattern }) {
787
882
  const lines = fs.readFileSync(sourcePath, 'utf8').split(/\r?\n/);
788
883
  lines.forEach((line, index) => {
789
884
  const normalized = normalizeRequirementCandidate(line);
790
- if (normalized.length < 12 || !pattern.test(normalized)) {
885
+ if (normalized.length < 12 || !pattern.test(normalized) || isNoisySourceLine(normalized)) {
791
886
  return;
792
887
  }
793
- const key = sha256(`${source.path}:${normalized}`).slice(0, 16);
888
+ const key = sha256(normalizeForDedup(normalized)).slice(0, 16);
794
889
  if (seen.has(key)) {
795
890
  return;
796
891
  }
@@ -804,9 +899,19 @@ function collectSourceLines({ projectRoot, sources, pattern }) {
804
899
  return results;
805
900
  }
806
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
+
807
910
  function buildOpenQuestionsMarkdown(questions = []) {
808
911
  return [
809
- '# Open Questions',
912
+ '# Open Question Candidates',
913
+ '',
914
+ 'Deterministic candidates only. Rewrite into real open questions during requirements synthesis before planning.',
810
915
  '',
811
916
  ...(questions.length
812
917
  ? questions.map((question) => `- \`${question.source}\`: ${question.text}`)
@@ -817,7 +922,9 @@ function buildOpenQuestionsMarkdown(questions = []) {
817
922
 
818
923
  function buildAssumptionsMarkdown(assumptions = []) {
819
924
  return [
820
- '# Assumptions',
925
+ '# Assumption Candidates',
926
+ '',
927
+ 'Deterministic candidates only. Rewrite into real assumptions during requirements synthesis before planning.',
821
928
  '',
822
929
  ...(assumptions.length
823
930
  ? assumptions.map((assumption) => `- \`${assumption.source}\`: ${assumption.text}`)
@@ -826,6 +933,124 @@ function buildAssumptionsMarkdown(assumptions = []) {
826
933
  ].join('\n');
827
934
  }
828
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
+
829
1054
  function readInitiative({ projectRoot, initiativeId }) {
830
1055
  assertInitiativeId(initiativeId);
831
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
 
@@ -207,7 +208,7 @@ describe('initiative framework', () => {
207
208
  expect(review).toContain('- Quality: `fragment`');
208
209
  });
209
210
 
210
- it('writes source coverage warnings plus open questions and assumptions', () => {
211
+ it('auto-includes relevant source docs and writes question/assumption candidates', () => {
211
212
  const root = makeProject();
212
213
  const docsDir = path.join(root, 'docs', 'delivery-os');
213
214
  fs.mkdirSync(docsDir, { recursive: true });
@@ -246,11 +247,133 @@ describe('initiative framework', () => {
246
247
  const sourceCoverage = fs.readFileSync(path.join(initiativeDir, 'intake', 'source-coverage.md'), 'utf8');
247
248
  const questions = fs.readFileSync(path.join(initiativeDir, 'intake', 'open-questions.md'), 'utf8');
248
249
  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');
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');
252
255
  expect(questions).toContain('Admin UI provider is TBD');
256
+ expect(questions).toContain('Open Question Candidates');
253
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');
254
377
  });
255
378
  });
256
379
 
@@ -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
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import fs from 'node:fs';
2
3
  import { spawnSync } from 'node:child_process';
3
4
  import path from 'node:path';
4
5
  import { fileURLToPath } from 'node:url';
@@ -40,6 +41,7 @@ const COMMANDS = new Map([
40
41
  ['initiative-next', 'initiative.mjs'],
41
42
  ['initiative-intake', 'initiative.mjs'],
42
43
  ['initiative-requirements', 'initiative.mjs'],
44
+ ['initiative-requirements-synthesis', 'initiative.mjs'],
43
45
  ['test/self-test', null],
44
46
  ]);
45
47
 
@@ -50,7 +52,7 @@ function main() {
50
52
  process.exit(command ? 0 : 1);
51
53
  }
52
54
  if (command === '--version' || command === '-v') {
53
- console.log('0.1.0');
55
+ console.log(readPackageVersion());
54
56
  return;
55
57
  }
56
58
  if (!COMMANDS.has(command)) {
@@ -86,4 +88,13 @@ function printHelp() {
86
88
  }
87
89
  }
88
90
 
91
+ function readPackageVersion() {
92
+ const packageJsonPath = path.join(binRoot, '..', 'package.json');
93
+ try {
94
+ return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')).version || 'unknown';
95
+ } catch {
96
+ return 'unknown';
97
+ }
98
+ }
99
+
89
100
  main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@besales/ops-framework",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "ops-agent": "bin/ops-agent.mjs"