@besales/ops-framework 0.1.7 → 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 +12 -0
- package/README.md +18 -0
- package/bin/initiative.mjs +393 -1
- package/bin/initiative.test.mjs +86 -0
- package/bin/lib/bootstrap-utils.mjs +2 -0
- package/bin/lib/bootstrap-utils.test.mjs +2 -0
- package/bin/ops-agent.mjs +2 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
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
|
+
|
|
9
|
+
## 0.1.8
|
|
10
|
+
|
|
11
|
+
- Added `initiative-intake` for source-doc indexing, source SHA tracking and deterministic requirement candidates.
|
|
12
|
+
- Added `initiative-requirements` to create `requirements-map.md` and `coverage.md` from intake candidates.
|
|
13
|
+
- Added generated project scripts for initiative intake and requirements mapping.
|
|
14
|
+
|
|
3
15
|
## 0.1.7
|
|
4
16
|
|
|
5
17
|
- Added the first Initiative Framework layer above tasks for MVPs, large feature trains and multi-phase work.
|
package/README.md
CHANGED
|
@@ -184,6 +184,8 @@ Do not commit that `file:` dependency to production projects. It is only for pac
|
|
|
184
184
|
- `initiative-add-work-package`
|
|
185
185
|
- `initiative-status`
|
|
186
186
|
- `initiative-next`
|
|
187
|
+
- `initiative-intake`
|
|
188
|
+
- `initiative-requirements`
|
|
187
189
|
- `test/self-test`
|
|
188
190
|
|
|
189
191
|
## Learning Loop
|
|
@@ -230,6 +232,8 @@ Initiatives are the program-level layer above tasks. Use them for MVPs, large fe
|
|
|
230
232
|
```bash
|
|
231
233
|
ops-agent initiative-create delivery-os-mvp --title "Delivery OS MVP" --mode fast_mvp
|
|
232
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 --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
|
|
233
237
|
ops-agent initiative-status delivery-os-mvp
|
|
234
238
|
ops-agent initiative-next delivery-os-mvp --materialize-task
|
|
235
239
|
```
|
|
@@ -244,6 +248,20 @@ Initiative
|
|
|
244
248
|
|
|
245
249
|
Work packages are materialized as normal tasks, so `brief/research/plan/check/execute/verify/retrospective/learning` still works at the audit boundary. Slices are not separate tasks; they use fast execution, micro-verify and slice evidence inside the work-package task. Create a separate task only when a slice triggers scope, risk, architecture or human-approval escalation.
|
|
246
250
|
|
|
251
|
+
For source docs, use intake before work-package planning:
|
|
252
|
+
|
|
253
|
+
```text
|
|
254
|
+
Raw docs
|
|
255
|
+
-> initiative intake/source-index
|
|
256
|
+
-> requirements-map.md
|
|
257
|
+
-> work packages
|
|
258
|
+
-> tasks
|
|
259
|
+
```
|
|
260
|
+
|
|
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.
|
|
264
|
+
|
|
247
265
|
## Feedback Intake
|
|
248
266
|
|
|
249
267
|
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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
3
4
|
import { fileURLToPath } from 'node:url';
|
|
4
5
|
import {
|
|
5
6
|
getFlag,
|
|
@@ -14,6 +15,7 @@ import { resolveProjectContext } from './lib/project-config.mjs';
|
|
|
14
15
|
|
|
15
16
|
const INITIATIVE_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
16
17
|
const WORK_PACKAGE_ID_PATTERN = /^WP-\d{3}-[a-z0-9][a-z0-9-]*$/;
|
|
18
|
+
const SUPPORTED_SOURCE_EXTENSIONS = new Set(['.md', '.markdown', '.txt', '.json', '.yaml', '.yml']);
|
|
17
19
|
|
|
18
20
|
export function main() {
|
|
19
21
|
const command = process.argv[2];
|
|
@@ -63,7 +65,32 @@ export function main() {
|
|
|
63
65
|
printInitiativeNext(result);
|
|
64
66
|
return;
|
|
65
67
|
}
|
|
66
|
-
|
|
68
|
+
if (command === 'initiative-intake') {
|
|
69
|
+
const result = initiativeIntake({
|
|
70
|
+
projectRoot: process.cwd(),
|
|
71
|
+
initiativeId: args.positional[0],
|
|
72
|
+
docsDir: args.positional[1],
|
|
73
|
+
phase: normalizePhaseFilter(getFlag(args, 'phase', null)),
|
|
74
|
+
include: parseIncludeFilter(getFlag(args, 'include', null)),
|
|
75
|
+
force: args.flags.has('force'),
|
|
76
|
+
});
|
|
77
|
+
printChangeSummary(`Initiative intake written: ${result.initiativeId}`, result.changes);
|
|
78
|
+
console.log(`- sources: ${result.sources.length}`);
|
|
79
|
+
console.log(`- requirement candidates: ${result.candidates.length}`);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (command === 'initiative-requirements') {
|
|
83
|
+
const result = initiativeRequirements({
|
|
84
|
+
projectRoot: process.cwd(),
|
|
85
|
+
initiativeId: args.positional[0],
|
|
86
|
+
phase: normalizePhaseFilter(getFlag(args, 'phase', null)),
|
|
87
|
+
force: args.flags.has('force'),
|
|
88
|
+
});
|
|
89
|
+
printChangeSummary(`Initiative requirements map written: ${result.initiativeId}`, result.changes);
|
|
90
|
+
console.log(`- requirements: ${result.requirements.length}`);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
fail('Usage: ops-agent initiative-create|initiative-add-work-package|initiative-status|initiative-next|initiative-intake|initiative-requirements ...');
|
|
67
94
|
} catch (error) {
|
|
68
95
|
fail(error.message);
|
|
69
96
|
}
|
|
@@ -193,6 +220,85 @@ export function initiativeNext({
|
|
|
193
220
|
};
|
|
194
221
|
}
|
|
195
222
|
|
|
223
|
+
export function initiativeIntake({
|
|
224
|
+
projectRoot = process.cwd(),
|
|
225
|
+
initiativeId,
|
|
226
|
+
docsDir,
|
|
227
|
+
phase = null,
|
|
228
|
+
include = [],
|
|
229
|
+
force = false,
|
|
230
|
+
} = {}) {
|
|
231
|
+
const initiative = readInitiative({ projectRoot, initiativeId });
|
|
232
|
+
if (!docsDir) {
|
|
233
|
+
throw new Error('Usage: ops-agent initiative-intake <initiative> <docs-dir>');
|
|
234
|
+
}
|
|
235
|
+
const absoluteDocsDir = path.resolve(projectRoot, docsDir);
|
|
236
|
+
if (!fs.existsSync(absoluteDocsDir) || !fs.statSync(absoluteDocsDir).isDirectory()) {
|
|
237
|
+
throw new Error(`Docs directory not found: ${docsDir}`);
|
|
238
|
+
}
|
|
239
|
+
const intakeDir = path.join(initiative.initiativeDir, 'intake');
|
|
240
|
+
const changes = [];
|
|
241
|
+
ensureDirectory(intakeDir, changes);
|
|
242
|
+
const sources = collectSourceDocuments({ projectRoot, docsDir: absoluteDocsDir, include });
|
|
243
|
+
const candidates = collectRequirementCandidates({ projectRoot, sources, phase });
|
|
244
|
+
writeFileIfAllowed(path.join(intakeDir, 'source-index.json'), JSON.stringify({
|
|
245
|
+
schemaVersion: 1,
|
|
246
|
+
generatedAt: new Date().toISOString(),
|
|
247
|
+
docsDir: path.relative(projectRoot, absoluteDocsDir),
|
|
248
|
+
phaseFilter: phase,
|
|
249
|
+
include,
|
|
250
|
+
sources,
|
|
251
|
+
}, null, 2), { force: true, changes });
|
|
252
|
+
writeFileIfAllowed(path.join(intakeDir, 'sources.md'), renderSourcesMarkdown({ docsDir: absoluteDocsDir, projectRoot, sources }), { force, changes });
|
|
253
|
+
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 });
|
|
256
|
+
return {
|
|
257
|
+
initiativeId,
|
|
258
|
+
intakeDir,
|
|
259
|
+
sources,
|
|
260
|
+
candidates,
|
|
261
|
+
changes,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function initiativeRequirements({
|
|
266
|
+
projectRoot = process.cwd(),
|
|
267
|
+
initiativeId,
|
|
268
|
+
phase = null,
|
|
269
|
+
force = false,
|
|
270
|
+
} = {}) {
|
|
271
|
+
const initiative = readInitiative({ projectRoot, initiativeId });
|
|
272
|
+
const candidatesPath = path.join(initiative.initiativeDir, 'intake', 'extracted-requirements.md');
|
|
273
|
+
if (!fs.existsSync(candidatesPath)) {
|
|
274
|
+
throw new Error('Missing intake/extracted-requirements.md. Run initiative-intake first.');
|
|
275
|
+
}
|
|
276
|
+
const candidates = parseRequirementCandidates(fs.readFileSync(candidatesPath, 'utf8'))
|
|
277
|
+
.filter((candidate) => !phase || candidate.phase === phase);
|
|
278
|
+
const requirements = candidates.map((candidate, index) => ({
|
|
279
|
+
id: `REQ-${String(index + 1).padStart(3, '0')}`,
|
|
280
|
+
status: 'candidate',
|
|
281
|
+
title: summarizeRequirementTitle(candidate.text),
|
|
282
|
+
source: candidate.source,
|
|
283
|
+
sourceHash: candidate.sourceHash,
|
|
284
|
+
phase: candidate.phase || 'unknown',
|
|
285
|
+
text: candidate.text,
|
|
286
|
+
workPackage: '',
|
|
287
|
+
acceptance: '',
|
|
288
|
+
decision: 'pending',
|
|
289
|
+
notes: '',
|
|
290
|
+
}));
|
|
291
|
+
const changes = [];
|
|
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 });
|
|
294
|
+
writeFileIfAllowed(path.join(initiative.initiativeDir, 'coverage.md'), renderCoverage({ requirements }), { force, changes });
|
|
295
|
+
return {
|
|
296
|
+
initiativeId,
|
|
297
|
+
requirements,
|
|
298
|
+
changes,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
196
302
|
function materializeWorkPackageTask({ projectRoot, initiativeId, workPackage, taskId, force }) {
|
|
197
303
|
const task = createTask({
|
|
198
304
|
projectRoot,
|
|
@@ -217,6 +323,274 @@ function materializeWorkPackageTask({ projectRoot, initiativeId, workPackage, ta
|
|
|
217
323
|
return task;
|
|
218
324
|
}
|
|
219
325
|
|
|
326
|
+
function collectSourceDocuments({ projectRoot, docsDir, include = [] }) {
|
|
327
|
+
const files = [];
|
|
328
|
+
walkDocs(docsDir, files);
|
|
329
|
+
return files
|
|
330
|
+
.sort()
|
|
331
|
+
.filter((filePath) => sourceIncluded({ projectRoot, filePath, include }))
|
|
332
|
+
.map((filePath) => {
|
|
333
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
334
|
+
return {
|
|
335
|
+
path: path.relative(projectRoot, filePath),
|
|
336
|
+
bytes: Buffer.byteLength(content),
|
|
337
|
+
sha256: sha256(content),
|
|
338
|
+
title: inferSourceTitle(content, filePath),
|
|
339
|
+
};
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function walkDocs(directory, files) {
|
|
344
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
345
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') {
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const entryPath = path.join(directory, entry.name);
|
|
349
|
+
if (entry.isDirectory()) {
|
|
350
|
+
walkDocs(entryPath, files);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (entry.isFile() && SUPPORTED_SOURCE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
|
|
354
|
+
files.push(entryPath);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function collectRequirementCandidates({ projectRoot, sources, phase = null }) {
|
|
360
|
+
const candidates = [];
|
|
361
|
+
const seen = new Set();
|
|
362
|
+
for (const source of sources) {
|
|
363
|
+
const sourcePath = path.join(projectRoot, source.path);
|
|
364
|
+
const lines = fs.readFileSync(sourcePath, 'utf8').split(/\r?\n/);
|
|
365
|
+
let headingContext = '';
|
|
366
|
+
lines.forEach((line, index) => {
|
|
367
|
+
const heading = /^#{1,6}\s+(.+)$/.exec(line);
|
|
368
|
+
if (heading) {
|
|
369
|
+
headingContext = heading[1].trim();
|
|
370
|
+
}
|
|
371
|
+
const normalized = normalizeRequirementCandidate(line);
|
|
372
|
+
if (!isRequirementCandidate(normalized)) {
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const candidatePhase = inferPhase(`${source.path} ${headingContext} ${normalized}`);
|
|
376
|
+
if (phase && candidatePhase !== phase) {
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const key = sha256(`${source.path}:${normalized}`).slice(0, 16);
|
|
380
|
+
if (seen.has(key)) {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
seen.add(key);
|
|
384
|
+
candidates.push({
|
|
385
|
+
id: `RC-${String(candidates.length + 1).padStart(3, '0')}`,
|
|
386
|
+
source: `${source.path}:${index + 1}`,
|
|
387
|
+
sourceHash: source.sha256,
|
|
388
|
+
phase: candidatePhase,
|
|
389
|
+
text: normalized,
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
return candidates;
|
|
394
|
+
}
|
|
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
|
+
|
|
404
|
+
function normalizeRequirementCandidate(line) {
|
|
405
|
+
return line
|
|
406
|
+
.replace(/^#{1,6}\s+/, '')
|
|
407
|
+
.replace(/^[-*]\s+/, '')
|
|
408
|
+
.replace(/^\d+[.)]\s+/, '')
|
|
409
|
+
.replace(/\s+/g, ' ')
|
|
410
|
+
.trim();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function isRequirementCandidate(text) {
|
|
414
|
+
if (text.length < 20 || text.length > 500) {
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
return /(must|should|required|requirement|user can|user must|system must|needs to|нужно|должен|должна|должны|требован|пользователь может|пользователь должен|система должна|необходимо|важно)/i.test(text);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function renderSourcesMarkdown({ docsDir, projectRoot, sources }) {
|
|
421
|
+
return [
|
|
422
|
+
'# Initiative Sources',
|
|
423
|
+
'',
|
|
424
|
+
`Docs directory: \`${path.relative(projectRoot, docsDir)}\``,
|
|
425
|
+
'',
|
|
426
|
+
'| Source | Title | SHA-256 | Bytes |',
|
|
427
|
+
'| --- | --- | --- | --- |',
|
|
428
|
+
...sources.map((source) => `| \`${source.path}\` | ${escapeTable(source.title)} | \`${source.sha256}\` | ${source.bytes} |`),
|
|
429
|
+
'',
|
|
430
|
+
].join('\n');
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function renderRequirementCandidatesMarkdown(candidates) {
|
|
434
|
+
return [
|
|
435
|
+
'# Extracted Requirement Candidates',
|
|
436
|
+
'',
|
|
437
|
+
'These are deterministic candidates from source docs. Human review is required before planning work packages.',
|
|
438
|
+
'',
|
|
439
|
+
...candidates.map((candidate) => [
|
|
440
|
+
`## ${candidate.id}`,
|
|
441
|
+
'',
|
|
442
|
+
`- Source: \`${candidate.source}\``,
|
|
443
|
+
`- Source hash: \`${candidate.sourceHash}\``,
|
|
444
|
+
`- Phase: \`${candidate.phase || 'unknown'}\``,
|
|
445
|
+
`- Candidate: ${candidate.text}`,
|
|
446
|
+
'',
|
|
447
|
+
].join('\n')),
|
|
448
|
+
].join('\n');
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function parseRequirementCandidates(content) {
|
|
452
|
+
return content.split(/^##\s+/m).slice(1).map((section) => {
|
|
453
|
+
const [rawId, ...bodyLines] = section.split('\n');
|
|
454
|
+
const body = bodyLines.join('\n');
|
|
455
|
+
return {
|
|
456
|
+
id: rawId.trim(),
|
|
457
|
+
source: readMarkdownListField(body, 'Source'),
|
|
458
|
+
sourceHash: readMarkdownListField(body, 'Source hash'),
|
|
459
|
+
phase: readMarkdownListField(body, 'Phase') || 'unknown',
|
|
460
|
+
text: readMarkdownListField(body, 'Candidate'),
|
|
461
|
+
};
|
|
462
|
+
}).filter((candidate) => candidate.id && candidate.source && candidate.text);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function renderRequirementsMap(requirements) {
|
|
466
|
+
return [
|
|
467
|
+
'# Requirements Map',
|
|
468
|
+
'',
|
|
469
|
+
'Statuses: `candidate | approved | planned | implemented | rejected`.',
|
|
470
|
+
'',
|
|
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 || ''} |`),
|
|
474
|
+
'',
|
|
475
|
+
'## Requirement Details',
|
|
476
|
+
'',
|
|
477
|
+
...requirements.map((req) => [
|
|
478
|
+
`### ${req.id}`,
|
|
479
|
+
'',
|
|
480
|
+
`- Status: \`${req.status}\``,
|
|
481
|
+
`- Source: \`${req.source}\``,
|
|
482
|
+
`- Source hash: \`${req.sourceHash}\``,
|
|
483
|
+
`- Phase: \`${req.phase || 'unknown'}\``,
|
|
484
|
+
`- Work package: ${req.workPackage || '(unassigned)'}`,
|
|
485
|
+
`- Acceptance: ${req.acceptance || '(fill before implementation)'}`,
|
|
486
|
+
`- Decision: \`${req.decision || 'pending'}\``,
|
|
487
|
+
`- Notes: ${req.notes || '(empty)'}`,
|
|
488
|
+
`- Requirement: ${req.text}`,
|
|
489
|
+
'',
|
|
490
|
+
].join('\n')),
|
|
491
|
+
].join('\n');
|
|
492
|
+
}
|
|
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
|
+
|
|
523
|
+
function renderCoverage({ requirements }) {
|
|
524
|
+
const counts = countBy(requirements, (req) => req.status || 'candidate');
|
|
525
|
+
return [
|
|
526
|
+
'# Initiative Coverage',
|
|
527
|
+
'',
|
|
528
|
+
`- Total requirements: ${requirements.length}`,
|
|
529
|
+
`- Candidate: ${counts.candidate || 0}`,
|
|
530
|
+
`- Approved: ${counts.approved || 0}`,
|
|
531
|
+
`- Planned: ${counts.planned || 0}`,
|
|
532
|
+
`- Implemented: ${counts.implemented || 0}`,
|
|
533
|
+
`- Rejected: ${counts.rejected || 0}`,
|
|
534
|
+
'',
|
|
535
|
+
'## Unassigned Requirements',
|
|
536
|
+
'',
|
|
537
|
+
...requirements.filter((req) => !req.workPackage).map((req) => `- ${req.id}: ${req.title}`),
|
|
538
|
+
...(requirements.some((req) => !req.workPackage) ? [] : ['- None.']),
|
|
539
|
+
'',
|
|
540
|
+
].join('\n');
|
|
541
|
+
}
|
|
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
|
+
|
|
576
|
+
function buildOpenQuestionsMarkdown() {
|
|
577
|
+
return [
|
|
578
|
+
'# Open Questions',
|
|
579
|
+
'',
|
|
580
|
+
'- Add human decisions discovered during initiative intake.',
|
|
581
|
+
'',
|
|
582
|
+
].join('\n');
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function buildAssumptionsMarkdown() {
|
|
586
|
+
return [
|
|
587
|
+
'# Assumptions',
|
|
588
|
+
'',
|
|
589
|
+
'- Add assumptions that were required to interpret source docs.',
|
|
590
|
+
'',
|
|
591
|
+
].join('\n');
|
|
592
|
+
}
|
|
593
|
+
|
|
220
594
|
function readInitiative({ projectRoot, initiativeId }) {
|
|
221
595
|
assertInitiativeId(initiativeId);
|
|
222
596
|
const context = resolveProjectContext({ cwd: projectRoot });
|
|
@@ -407,6 +781,11 @@ function readInlineField(content, field) {
|
|
|
407
781
|
return match ? match[1].trim() : '';
|
|
408
782
|
}
|
|
409
783
|
|
|
784
|
+
function readMarkdownListField(content, field) {
|
|
785
|
+
const match = new RegExp(`^- ${escapeRegExp(field)}:\\s*(.*)$`, 'm').exec(content);
|
|
786
|
+
return match ? match[1].replace(/^`|`$/g, '').trim() : '';
|
|
787
|
+
}
|
|
788
|
+
|
|
410
789
|
function readSection(content, heading) {
|
|
411
790
|
const match = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*\\n([\\s\\S]*?)(?=^##\\s+|\\s*$)`, 'm').exec(content);
|
|
412
791
|
return match ? match[1].trim() : '';
|
|
@@ -516,6 +895,19 @@ function escapeTable(value) {
|
|
|
516
895
|
return String(value).replace(/\|/g, '\\|');
|
|
517
896
|
}
|
|
518
897
|
|
|
898
|
+
function inferSourceTitle(content, filePath) {
|
|
899
|
+
const heading = /^#\s+(.+)$/m.exec(content);
|
|
900
|
+
return heading ? heading[1].trim() : path.basename(filePath);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function summarizeRequirementTitle(text) {
|
|
904
|
+
return text.length > 120 ? `${text.slice(0, 117)}...` : text;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function sha256(value) {
|
|
908
|
+
return crypto.createHash('sha256').update(value).digest('hex');
|
|
909
|
+
}
|
|
910
|
+
|
|
519
911
|
function escapeRegExp(value) {
|
|
520
912
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
521
913
|
}
|
package/bin/initiative.test.mjs
CHANGED
|
@@ -5,7 +5,9 @@ import { describe, expect, it } from 'vitest';
|
|
|
5
5
|
import {
|
|
6
6
|
addWorkPackage,
|
|
7
7
|
createInitiative,
|
|
8
|
+
initiativeIntake,
|
|
8
9
|
initiativeNext,
|
|
10
|
+
initiativeRequirements,
|
|
9
11
|
initiativeStatus,
|
|
10
12
|
} from './initiative.mjs';
|
|
11
13
|
|
|
@@ -72,6 +74,90 @@ describe('initiative framework', () => {
|
|
|
72
74
|
expect(workPackage).toContain('Status: in_progress');
|
|
73
75
|
expect(workPackage).toContain('Task: TASK-001-foundation');
|
|
74
76
|
});
|
|
77
|
+
|
|
78
|
+
it('indexes source docs and builds a requirements map', () => {
|
|
79
|
+
const root = makeProject();
|
|
80
|
+
const docsDir = path.join(root, 'docs', 'delivery-os');
|
|
81
|
+
fs.mkdirSync(docsDir, { recursive: true });
|
|
82
|
+
fs.writeFileSync(path.join(docsDir, 'mvp.md'), [
|
|
83
|
+
'# Delivery OS MVP',
|
|
84
|
+
'',
|
|
85
|
+
'- User must create delivery orders from the admin UI.',
|
|
86
|
+
'- Система должна показывать статус доставки оператору.',
|
|
87
|
+
'- Nice prose for background context only.',
|
|
88
|
+
].join('\n'));
|
|
89
|
+
createInitiative({
|
|
90
|
+
projectRoot: root,
|
|
91
|
+
initiativeId: 'delivery-os-mvp',
|
|
92
|
+
title: 'Delivery OS MVP',
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const intake = initiativeIntake({
|
|
96
|
+
projectRoot: root,
|
|
97
|
+
initiativeId: 'delivery-os-mvp',
|
|
98
|
+
docsDir: 'docs/delivery-os',
|
|
99
|
+
});
|
|
100
|
+
const requirements = initiativeRequirements({
|
|
101
|
+
projectRoot: root,
|
|
102
|
+
initiativeId: 'delivery-os-mvp',
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(intake.sources).toHaveLength(1);
|
|
106
|
+
expect(intake.candidates).toHaveLength(2);
|
|
107
|
+
expect(requirements.requirements).toHaveLength(2);
|
|
108
|
+
const initiativeDir = path.join(root, 'ops', 'agent-pipeline', 'initiatives', 'delivery-os-mvp');
|
|
109
|
+
const sourceIndex = JSON.parse(fs.readFileSync(path.join(initiativeDir, 'intake', 'source-index.json'), 'utf8'));
|
|
110
|
+
const requirementsMap = fs.readFileSync(path.join(initiativeDir, 'requirements-map.md'), 'utf8');
|
|
111
|
+
expect(sourceIndex.sources[0].path).toBe('docs/delivery-os/mvp.md');
|
|
112
|
+
expect(requirementsMap).toContain('REQ-001');
|
|
113
|
+
expect(requirementsMap).toContain('User must create delivery orders');
|
|
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');
|
|
160
|
+
});
|
|
75
161
|
});
|
|
76
162
|
|
|
77
163
|
function makeProject() {
|
|
@@ -125,6 +125,8 @@ export function buildOpsScripts(packageSpec) {
|
|
|
125
125
|
'agent:initiative-add-work-package': run('initiative-add-work-package'),
|
|
126
126
|
'agent:initiative-status': run('initiative-status'),
|
|
127
127
|
'agent:initiative-next': run('initiative-next'),
|
|
128
|
+
'agent:initiative-intake': run('initiative-intake'),
|
|
129
|
+
'agent:initiative-requirements': run('initiative-requirements'),
|
|
128
130
|
'agent:test': run('test/self-test'),
|
|
129
131
|
};
|
|
130
132
|
}
|
|
@@ -142,6 +142,8 @@ describe('buildOpsScripts', () => {
|
|
|
142
142
|
expect(scripts['agent:closeout']).toBe('ops-agent closeout');
|
|
143
143
|
expect(scripts['agent:initiative-create']).toBe('ops-agent initiative-create');
|
|
144
144
|
expect(scripts['agent:initiative-next']).toBe('ops-agent initiative-next');
|
|
145
|
+
expect(scripts['agent:initiative-intake']).toBe('ops-agent initiative-intake');
|
|
146
|
+
expect(scripts['agent:initiative-requirements']).toBe('ops-agent initiative-requirements');
|
|
145
147
|
expect(scripts['agent:test']).toBe('ops-agent test/self-test');
|
|
146
148
|
});
|
|
147
149
|
});
|
package/bin/ops-agent.mjs
CHANGED
|
@@ -38,6 +38,8 @@ const COMMANDS = new Map([
|
|
|
38
38
|
['initiative-add-work-package', 'initiative.mjs'],
|
|
39
39
|
['initiative-status', 'initiative.mjs'],
|
|
40
40
|
['initiative-next', 'initiative.mjs'],
|
|
41
|
+
['initiative-intake', 'initiative.mjs'],
|
|
42
|
+
['initiative-requirements', 'initiative.mjs'],
|
|
41
43
|
['test/self-test', null],
|
|
42
44
|
]);
|
|
43
45
|
|