@besales/ops-framework 0.1.8 → 0.1.9

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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.9
4
+
5
+ - Added `--phase` and `--include` filters to initiative intake/requirements flows.
6
+ - Added `requirements-review.md` human approval cards for requirement candidates.
7
+ - Added phase metadata to extracted requirement candidates and requirements map rows.
8
+
3
9
  ## 0.1.8
4
10
 
5
11
  - Added `initiative-intake` for source-doc indexing, source SHA tracking and deterministic requirement candidates.
package/README.md CHANGED
@@ -232,8 +232,8 @@ Initiatives are the program-level layer above tasks. Use them for MVPs, large fe
232
232
  ```bash
233
233
  ops-agent initiative-create delivery-os-mvp --title "Delivery OS MVP" --mode fast_mvp
234
234
  ops-agent initiative-add-work-package delivery-os-mvp WP-001-foundation --title "Foundation"
235
- ops-agent initiative-intake delivery-os-mvp docs/delivery-os
236
- ops-agent initiative-requirements delivery-os-mvp
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
+ ops-agent initiative-requirements delivery-os-mvp --phase phase-1
237
237
  ops-agent initiative-status delivery-os-mvp
238
238
  ops-agent initiative-next delivery-os-mvp --materialize-task
239
239
  ```
@@ -258,7 +258,9 @@ Raw docs
258
258
  -> tasks
259
259
  ```
260
260
 
261
- `initiative-intake <initiative> <docs-dir>` indexes supported source documents, writes source SHA hashes and deterministic requirement candidates into `intake/`. `initiative-requirements <initiative>` turns those candidates into `REQ-*` rows in `requirements-map.md` and writes `coverage.md`. Human review is still required before treating candidates as approved or planned.
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
+
263
+ `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.
262
264
 
263
265
  ## Feedback Intake
264
266
 
@@ -70,6 +70,8 @@ export function main() {
70
70
  projectRoot: process.cwd(),
71
71
  initiativeId: args.positional[0],
72
72
  docsDir: args.positional[1],
73
+ phase: normalizePhaseFilter(getFlag(args, 'phase', null)),
74
+ include: parseIncludeFilter(getFlag(args, 'include', null)),
73
75
  force: args.flags.has('force'),
74
76
  });
75
77
  printChangeSummary(`Initiative intake written: ${result.initiativeId}`, result.changes);
@@ -81,6 +83,7 @@ export function main() {
81
83
  const result = initiativeRequirements({
82
84
  projectRoot: process.cwd(),
83
85
  initiativeId: args.positional[0],
86
+ phase: normalizePhaseFilter(getFlag(args, 'phase', null)),
84
87
  force: args.flags.has('force'),
85
88
  });
86
89
  printChangeSummary(`Initiative requirements map written: ${result.initiativeId}`, result.changes);
@@ -221,6 +224,8 @@ export function initiativeIntake({
221
224
  projectRoot = process.cwd(),
222
225
  initiativeId,
223
226
  docsDir,
227
+ phase = null,
228
+ include = [],
224
229
  force = false,
225
230
  } = {}) {
226
231
  const initiative = readInitiative({ projectRoot, initiativeId });
@@ -234,12 +239,14 @@ export function initiativeIntake({
234
239
  const intakeDir = path.join(initiative.initiativeDir, 'intake');
235
240
  const changes = [];
236
241
  ensureDirectory(intakeDir, changes);
237
- const sources = collectSourceDocuments({ projectRoot, docsDir: absoluteDocsDir });
238
- const candidates = collectRequirementCandidates({ projectRoot, sources });
242
+ const sources = collectSourceDocuments({ projectRoot, docsDir: absoluteDocsDir, include });
243
+ const candidates = collectRequirementCandidates({ projectRoot, sources, phase });
239
244
  writeFileIfAllowed(path.join(intakeDir, 'source-index.json'), JSON.stringify({
240
245
  schemaVersion: 1,
241
246
  generatedAt: new Date().toISOString(),
242
247
  docsDir: path.relative(projectRoot, absoluteDocsDir),
248
+ phaseFilter: phase,
249
+ include,
243
250
  sources,
244
251
  }, null, 2), { force: true, changes });
245
252
  writeFileIfAllowed(path.join(intakeDir, 'sources.md'), renderSourcesMarkdown({ docsDir: absoluteDocsDir, projectRoot, sources }), { force, changes });
@@ -258,6 +265,7 @@ export function initiativeIntake({
258
265
  export function initiativeRequirements({
259
266
  projectRoot = process.cwd(),
260
267
  initiativeId,
268
+ phase = null,
261
269
  force = false,
262
270
  } = {}) {
263
271
  const initiative = readInitiative({ projectRoot, initiativeId });
@@ -265,19 +273,24 @@ export function initiativeRequirements({
265
273
  if (!fs.existsSync(candidatesPath)) {
266
274
  throw new Error('Missing intake/extracted-requirements.md. Run initiative-intake first.');
267
275
  }
268
- const candidates = parseRequirementCandidates(fs.readFileSync(candidatesPath, 'utf8'));
276
+ const candidates = parseRequirementCandidates(fs.readFileSync(candidatesPath, 'utf8'))
277
+ .filter((candidate) => !phase || candidate.phase === phase);
269
278
  const requirements = candidates.map((candidate, index) => ({
270
279
  id: `REQ-${String(index + 1).padStart(3, '0')}`,
271
280
  status: 'candidate',
272
281
  title: summarizeRequirementTitle(candidate.text),
273
282
  source: candidate.source,
274
283
  sourceHash: candidate.sourceHash,
284
+ phase: candidate.phase || 'unknown',
275
285
  text: candidate.text,
276
286
  workPackage: '',
277
287
  acceptance: '',
288
+ decision: 'pending',
289
+ notes: '',
278
290
  }));
279
291
  const changes = [];
280
292
  writeFileIfAllowed(path.join(initiative.initiativeDir, 'requirements-map.md'), renderRequirementsMap(requirements), { force: true, changes });
293
+ writeFileIfAllowed(path.join(initiative.initiativeDir, 'requirements-review.md'), renderRequirementsReview(requirements), { force: true, changes });
281
294
  writeFileIfAllowed(path.join(initiative.initiativeDir, 'coverage.md'), renderCoverage({ requirements }), { force, changes });
282
295
  return {
283
296
  initiativeId,
@@ -310,11 +323,12 @@ function materializeWorkPackageTask({ projectRoot, initiativeId, workPackage, ta
310
323
  return task;
311
324
  }
312
325
 
313
- function collectSourceDocuments({ projectRoot, docsDir }) {
326
+ function collectSourceDocuments({ projectRoot, docsDir, include = [] }) {
314
327
  const files = [];
315
328
  walkDocs(docsDir, files);
316
329
  return files
317
330
  .sort()
331
+ .filter((filePath) => sourceIncluded({ projectRoot, filePath, include }))
318
332
  .map((filePath) => {
319
333
  const content = fs.readFileSync(filePath, 'utf8');
320
334
  return {
@@ -342,17 +356,26 @@ function walkDocs(directory, files) {
342
356
  }
343
357
  }
344
358
 
345
- function collectRequirementCandidates({ projectRoot, sources }) {
359
+ function collectRequirementCandidates({ projectRoot, sources, phase = null }) {
346
360
  const candidates = [];
347
361
  const seen = new Set();
348
362
  for (const source of sources) {
349
363
  const sourcePath = path.join(projectRoot, source.path);
350
364
  const lines = fs.readFileSync(sourcePath, 'utf8').split(/\r?\n/);
365
+ let headingContext = '';
351
366
  lines.forEach((line, index) => {
367
+ const heading = /^#{1,6}\s+(.+)$/.exec(line);
368
+ if (heading) {
369
+ headingContext = heading[1].trim();
370
+ }
352
371
  const normalized = normalizeRequirementCandidate(line);
353
372
  if (!isRequirementCandidate(normalized)) {
354
373
  return;
355
374
  }
375
+ const candidatePhase = inferPhase(`${source.path} ${headingContext} ${normalized}`);
376
+ if (phase && candidatePhase !== phase) {
377
+ return;
378
+ }
356
379
  const key = sha256(`${source.path}:${normalized}`).slice(0, 16);
357
380
  if (seen.has(key)) {
358
381
  return;
@@ -362,6 +385,7 @@ function collectRequirementCandidates({ projectRoot, sources }) {
362
385
  id: `RC-${String(candidates.length + 1).padStart(3, '0')}`,
363
386
  source: `${source.path}:${index + 1}`,
364
387
  sourceHash: source.sha256,
388
+ phase: candidatePhase,
365
389
  text: normalized,
366
390
  });
367
391
  });
@@ -369,6 +393,14 @@ function collectRequirementCandidates({ projectRoot, sources }) {
369
393
  return candidates;
370
394
  }
371
395
 
396
+ function sourceIncluded({ projectRoot, filePath, include }) {
397
+ if (!include.length) {
398
+ return true;
399
+ }
400
+ const relative = path.relative(projectRoot, filePath);
401
+ return include.some((pattern) => relative === pattern || relative.endsWith(pattern) || relative.includes(pattern));
402
+ }
403
+
372
404
  function normalizeRequirementCandidate(line) {
373
405
  return line
374
406
  .replace(/^#{1,6}\s+/, '')
@@ -409,6 +441,7 @@ function renderRequirementCandidatesMarkdown(candidates) {
409
441
  '',
410
442
  `- Source: \`${candidate.source}\``,
411
443
  `- Source hash: \`${candidate.sourceHash}\``,
444
+ `- Phase: \`${candidate.phase || 'unknown'}\``,
412
445
  `- Candidate: ${candidate.text}`,
413
446
  '',
414
447
  ].join('\n')),
@@ -423,6 +456,7 @@ function parseRequirementCandidates(content) {
423
456
  id: rawId.trim(),
424
457
  source: readMarkdownListField(body, 'Source'),
425
458
  sourceHash: readMarkdownListField(body, 'Source hash'),
459
+ phase: readMarkdownListField(body, 'Phase') || 'unknown',
426
460
  text: readMarkdownListField(body, 'Candidate'),
427
461
  };
428
462
  }).filter((candidate) => candidate.id && candidate.source && candidate.text);
@@ -434,9 +468,9 @@ function renderRequirementsMap(requirements) {
434
468
  '',
435
469
  'Statuses: `candidate | approved | planned | implemented | rejected`.',
436
470
  '',
437
- '| ID | Status | Requirement | Source | Work package | Acceptance |',
438
- '| --- | --- | --- | --- | --- | --- |',
439
- ...requirements.map((req) => `| ${req.id} | ${req.status} | ${escapeTable(req.title)} | \`${req.source}\` | ${req.workPackage || ''} | ${req.acceptance || ''} |`),
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 || ''} |`),
440
474
  '',
441
475
  '## Requirement Details',
442
476
  '',
@@ -446,14 +480,46 @@ function renderRequirementsMap(requirements) {
446
480
  `- Status: \`${req.status}\``,
447
481
  `- Source: \`${req.source}\``,
448
482
  `- Source hash: \`${req.sourceHash}\``,
483
+ `- Phase: \`${req.phase || 'unknown'}\``,
449
484
  `- Work package: ${req.workPackage || '(unassigned)'}`,
450
485
  `- Acceptance: ${req.acceptance || '(fill before implementation)'}`,
486
+ `- Decision: \`${req.decision || 'pending'}\``,
487
+ `- Notes: ${req.notes || '(empty)'}`,
451
488
  `- Requirement: ${req.text}`,
452
489
  '',
453
490
  ].join('\n')),
454
491
  ].join('\n');
455
492
  }
456
493
 
494
+ function renderRequirementsReview(requirements) {
495
+ return [
496
+ '# Requirements Review',
497
+ '',
498
+ 'Human approval pack. Review each requirement before planning work packages.',
499
+ '',
500
+ 'Allowed decisions: `approve | defer | reject | rewrite`.',
501
+ '',
502
+ '## Pending Requirement Decisions',
503
+ '',
504
+ ...requirements.map((req) => [
505
+ `### ${req.id}: ${req.title}`,
506
+ '',
507
+ `- Source: \`${req.source}\``,
508
+ `- Source hash: \`${req.sourceHash}\``,
509
+ `- Phase: \`${req.phase || 'unknown'}\``,
510
+ `- Suggested status: \`${req.status}\``,
511
+ `- Requirement: ${req.text}`,
512
+ `- Proposed work package: ${req.workPackage || '(unassigned)'}`,
513
+ `- Acceptance: ${req.acceptance || '(fill before implementation)'}`,
514
+ `- Decision: \`${req.decision || 'pending'}\``,
515
+ `- Human notes: ${req.notes || '(empty)'}`,
516
+ '',
517
+ 'Decision to set in `requirements-map.md`: `approve | defer | reject | rewrite`',
518
+ '',
519
+ ].join('\n')),
520
+ ].join('\n');
521
+ }
522
+
457
523
  function renderCoverage({ requirements }) {
458
524
  const counts = countBy(requirements, (req) => req.status || 'candidate');
459
525
  return [
@@ -474,6 +540,39 @@ function renderCoverage({ requirements }) {
474
540
  ].join('\n');
475
541
  }
476
542
 
543
+ function parseIncludeFilter(value) {
544
+ if (!value) {
545
+ return [];
546
+ }
547
+ return String(value).split(',').map((item) => item.trim()).filter(Boolean);
548
+ }
549
+
550
+ function normalizePhaseFilter(value) {
551
+ if (!value) {
552
+ return null;
553
+ }
554
+ return normalizePhaseLabel(value);
555
+ }
556
+
557
+ function inferPhase(text) {
558
+ return normalizePhaseLabel(text) || 'unknown';
559
+ }
560
+
561
+ function normalizePhaseLabel(text) {
562
+ const value = String(text || '').toLowerCase();
563
+ if (/future|later|deferred|not in phase 1|не делаем|отложен/.test(value)) {
564
+ return 'future';
565
+ }
566
+ const match = /phase\s*[- ]?(\d+)|фаз[аы]\s*[- ]?(\d+)/i.exec(value);
567
+ if (match) {
568
+ return `phase-${match[1] || match[2]}`;
569
+ }
570
+ if (/\bmvp\b|meeting loop/.test(value)) {
571
+ return 'phase-1';
572
+ }
573
+ return null;
574
+ }
575
+
477
576
  function buildOpenQuestionsMarkdown() {
478
577
  return [
479
578
  '# Open Questions',
@@ -112,6 +112,51 @@ describe('initiative framework', () => {
112
112
  expect(requirementsMap).toContain('REQ-001');
113
113
  expect(requirementsMap).toContain('User must create delivery orders');
114
114
  expect(requirementsMap).toContain('REQ-002');
115
+ expect(requirementsMap).toContain('| REQ-001 | candidate | phase-1 |');
116
+ const review = fs.readFileSync(path.join(initiativeDir, 'requirements-review.md'), 'utf8');
117
+ expect(review).toContain('### REQ-001: User must create delivery orders from the admin UI.');
118
+ expect(review).toContain('Decision to set in `requirements-map.md`: `approve | defer | reject | rewrite`');
119
+ });
120
+
121
+ it('filters source docs and requirements by include and phase', () => {
122
+ const root = makeProject();
123
+ const docsDir = path.join(root, 'docs', 'delivery-os');
124
+ fs.mkdirSync(docsDir, { recursive: true });
125
+ fs.writeFileSync(path.join(docsDir, 'phase1.md'), [
126
+ '# Phase 1',
127
+ '',
128
+ '- System must import meeting transcripts.',
129
+ ].join('\n'));
130
+ fs.writeFileSync(path.join(docsDir, 'future.md'), [
131
+ '# Phase 3',
132
+ '',
133
+ '- System must send Telegram digests.',
134
+ ].join('\n'));
135
+ createInitiative({
136
+ projectRoot: root,
137
+ initiativeId: 'delivery-os-mvp',
138
+ title: 'Delivery OS MVP',
139
+ });
140
+
141
+ const intake = initiativeIntake({
142
+ projectRoot: root,
143
+ initiativeId: 'delivery-os-mvp',
144
+ docsDir: 'docs/delivery-os',
145
+ phase: 'phase-1',
146
+ include: ['phase1.md'],
147
+ });
148
+ const requirements = initiativeRequirements({
149
+ projectRoot: root,
150
+ initiativeId: 'delivery-os-mvp',
151
+ phase: 'phase-1',
152
+ });
153
+
154
+ expect(intake.sources).toHaveLength(1);
155
+ expect(intake.sources[0].path).toBe('docs/delivery-os/phase1.md');
156
+ expect(intake.candidates).toHaveLength(1);
157
+ expect(intake.candidates[0].phase).toBe('phase-1');
158
+ expect(requirements.requirements).toHaveLength(1);
159
+ expect(requirements.requirements[0].text).toContain('meeting transcripts');
115
160
  });
116
161
  });
117
162
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@besales/ops-framework",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "ops-agent": "bin/ops-agent.mjs"