@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 +11 -0
- package/README.md +4 -1
- package/bin/initiative.mjs +239 -14
- package/bin/initiative.test.mjs +127 -4
- package/bin/lib/bootstrap-utils.mjs +1 -0
- package/bin/lib/bootstrap-utils.test.mjs +1 -0
- package/bin/ops-agent.mjs +12 -1
- package/package.json +1 -1
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:
|
package/bin/initiative.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
244
|
-
const
|
|
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
|
|
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 =
|
|
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)
|
|
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 (
|
|
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) =>
|
|
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(
|
|
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
|
|
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
|
-
'#
|
|
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 });
|
package/bin/initiative.test.mjs
CHANGED
|
@@ -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('
|
|
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.
|
|
250
|
-
expect(intake.
|
|
251
|
-
expect(sourceCoverage).toContain('
|
|
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(
|
|
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();
|