@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 +13 -0
- package/README.md +4 -1
- package/bin/initiative.mjs +483 -23
- package/bin/initiative.test.mjs +217 -0
- package/bin/lib/bootstrap-utils.mjs +1 -0
- package/bin/lib/bootstrap-utils.test.mjs +1 -0
- package/bin/ops-agent.mjs +1 -0
- package/package.json +1 -1
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:
|
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 });
|
|
@@ -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
|
|
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
|
|
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.
|
|
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
|
-
|
|
453
|
+
continue;
|
|
374
454
|
}
|
|
375
455
|
const candidatePhase = inferPhase(`${source.path} ${headingContext} ${normalized}`);
|
|
376
456
|
if (phase && candidatePhase !== phase) {
|
|
377
|
-
|
|
457
|
+
continue;
|
|
378
458
|
}
|
|
379
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
912
|
+
'# Open Question Candidates',
|
|
913
|
+
'',
|
|
914
|
+
'Deterministic candidates only. Rewrite into real open questions during requirements synthesis before planning.',
|
|
579
915
|
'',
|
|
580
|
-
|
|
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
|
-
'#
|
|
925
|
+
'# Assumption Candidates',
|
|
588
926
|
'',
|
|
589
|
-
'
|
|
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 });
|
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
|
|
|
@@ -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