@vibecompass/vibecompass 0.6.0 → 0.7.0
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 +5 -0
- package/README.md +79 -697
- package/package.json +1 -1
- package/src/cli.js +50 -1
- package/src/docs-review.js +619 -8
- package/src/status.js +41 -1
- package/src/workflows/registry.js +9 -5
package/src/docs-review.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
import { access, mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { access, mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { createInterface } from 'node:readline/promises';
|
|
4
4
|
import { parseFrontmatter } from './frontmatter.js';
|
|
5
|
+
import { sha256Text } from './hash.js';
|
|
5
6
|
import { writeStateManifest } from './manifest.js';
|
|
6
7
|
import { parseSimpleYaml } from './simple-yaml.js';
|
|
7
8
|
|
|
8
9
|
const DOCS_REVIEW_PROMPT_VERSION = 'VibeCompass Docs Review Prompt v2';
|
|
9
10
|
const ARCHITECTURE_DOC_SOFT_SIZE_LIMIT_BYTES = 12000;
|
|
11
|
+
const COVERAGE_PLAN_OUTPUT_VERSION = 1;
|
|
12
|
+
const COVERAGE_PLAN_STATUSES = new Set(['accepted', 'deferred', 'missing']);
|
|
13
|
+
const COVERAGE_LEVELS = new Set(['comprehensive', 'partial', 'initial', 'missing']);
|
|
14
|
+
const REBUILD_STALE_POLICIES = new Set(['keep', 'archive']);
|
|
10
15
|
|
|
11
16
|
export async function preflightDocsReview(options = {}, environment = {}) {
|
|
12
17
|
validateDocsReviewMode(options);
|
|
@@ -21,6 +26,18 @@ export async function preflightDocsReview(options = {}, environment = {}) {
|
|
|
21
26
|
outputPath: options.outputPath,
|
|
22
27
|
llm: options.llm,
|
|
23
28
|
model: options.model,
|
|
29
|
+
refreshIndex: options.refreshIndex,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (options.rebuild) {
|
|
34
|
+
return rebuildDocsReview({
|
|
35
|
+
rootDir,
|
|
36
|
+
statePath,
|
|
37
|
+
apply: options.apply,
|
|
38
|
+
dryRun: options.dryRun,
|
|
39
|
+
scopePath: options.scopePath,
|
|
40
|
+
stalePolicy: options.stalePolicy,
|
|
24
41
|
});
|
|
25
42
|
}
|
|
26
43
|
|
|
@@ -183,8 +200,10 @@ async function applyDocsReviewOutput(options) {
|
|
|
183
200
|
: path.join(path.dirname(options.statePath), 'docs-review-output.md');
|
|
184
201
|
const source = await readFile(outputPath, 'utf8');
|
|
185
202
|
const blocks = parseArchitectureDocBlocks(source);
|
|
203
|
+
const coverageBlocks = parseCoveragePlanBlocks(source);
|
|
204
|
+
const decisionRecommendationBlocks = parseDecisionRecommendationBlocks(source);
|
|
186
205
|
|
|
187
|
-
if (blocks.length === 0) {
|
|
206
|
+
if (blocks.length === 0 && coverageBlocks.length === 0 && decisionRecommendationBlocks.length === 0) {
|
|
188
207
|
const malformedFenceCount = countMalformedArchitectureDocFences(source);
|
|
189
208
|
if (malformedFenceCount > 0) {
|
|
190
209
|
throw new Error(
|
|
@@ -192,13 +211,35 @@ async function applyDocsReviewOutput(options) {
|
|
|
192
211
|
);
|
|
193
212
|
}
|
|
194
213
|
|
|
214
|
+
const malformedCoveragePlanFenceCount = countMalformedCoveragePlanFences(source);
|
|
215
|
+
if (malformedCoveragePlanFenceCount > 0) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`Malformed coverage-plan fence found in ${outputPath}. Expected fence openings like \`\`\`vibecompass-coverage-plan version=1\`.`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const malformedDecisionRecommendationFenceCount = countMalformedDecisionRecommendationFences(source);
|
|
222
|
+
if (malformedDecisionRecommendationFenceCount > 0) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
`Malformed decision recommendation fence found in ${outputPath}. Expected fence openings like \`\`\`vibecompass-decision-recommendation target=decisions/domain.md\`.`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
195
228
|
throw new Error(
|
|
196
|
-
`No accepted
|
|
229
|
+
`No accepted docs-review output blocks found in ${outputPath}. Expected fenced blocks like \`\`\`vibecompass-architecture-doc path=architecture/domain/feature/component.md\`, \`\`\`vibecompass-coverage-plan version=1\`, or \`\`\`vibecompass-decision-recommendation target=decisions/domain.md\`.`,
|
|
197
230
|
);
|
|
198
231
|
}
|
|
199
232
|
|
|
200
233
|
validateArchitectureDocBlocks(blocks);
|
|
201
|
-
const
|
|
234
|
+
const coverageProjection = buildCoverageProjection(coverageBlocks, {
|
|
235
|
+
outputPath,
|
|
236
|
+
outputHash: sha256Text(source),
|
|
237
|
+
});
|
|
238
|
+
const decisionRecommendations = validateDecisionRecommendationBlocks(decisionRecommendationBlocks);
|
|
239
|
+
const warnings = [
|
|
240
|
+
...createArchitectureDocSizeWarnings(blocks),
|
|
241
|
+
...createArchitectureDocQualityWarnings(blocks),
|
|
242
|
+
];
|
|
202
243
|
|
|
203
244
|
const applied = [];
|
|
204
245
|
for (const block of blocks) {
|
|
@@ -209,8 +250,31 @@ async function applyDocsReviewOutput(options) {
|
|
|
209
250
|
applied.push({ path: block.path, status: existed ? 'overwritten' : 'created' });
|
|
210
251
|
}
|
|
211
252
|
|
|
253
|
+
let appliedCoverage = null;
|
|
254
|
+
if (coverageProjection) {
|
|
255
|
+
const coveragePath = path.join(path.dirname(options.statePath), 'docs-review-coverage.json');
|
|
256
|
+
await writeFile(coveragePath, `${JSON.stringify(coverageProjection, null, 2)}\n`, 'utf8');
|
|
257
|
+
appliedCoverage = {
|
|
258
|
+
path: coveragePath,
|
|
259
|
+
area_count: coverageProjection.area_count,
|
|
260
|
+
coverage_score: coverageProjection.coverage_score,
|
|
261
|
+
statuses: coverageProjection.statuses,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const appliedDecisionCandidates = decisionRecommendations.length > 0
|
|
266
|
+
? await applyDecisionRecommendations({
|
|
267
|
+
rootDir: options.rootDir,
|
|
268
|
+
recommendations: decisionRecommendations,
|
|
269
|
+
refreshIndex: options.refreshIndex,
|
|
270
|
+
})
|
|
271
|
+
: { applied: [], warnings: [] };
|
|
272
|
+
warnings.push(...appliedDecisionCandidates.warnings);
|
|
273
|
+
|
|
274
|
+
const manifestResult = await writeStateManifest(options.rootDir);
|
|
212
275
|
const llm = normalizeOptionalString(options.llm) ?? current.llm ?? null;
|
|
213
276
|
const model = normalizeOptionalString(options.model) ?? current.model ?? null;
|
|
277
|
+
const appliedAt = new Date().toISOString();
|
|
214
278
|
const marker = {
|
|
215
279
|
...current,
|
|
216
280
|
status: 'completed',
|
|
@@ -218,11 +282,16 @@ async function applyDocsReviewOutput(options) {
|
|
|
218
282
|
...(model ? { model } : {}),
|
|
219
283
|
applied: {
|
|
220
284
|
output_path: outputPath,
|
|
285
|
+
output_hash: sha256Text(source),
|
|
286
|
+
local_root_revision: manifestResult.manifest.canonical.local_root_revision,
|
|
287
|
+
manifest_hash: manifestResult.manifest.canonical.manifest_hash,
|
|
221
288
|
architecture_docs: applied,
|
|
289
|
+
...(appliedCoverage ? { coverage: appliedCoverage } : {}),
|
|
290
|
+
...(appliedDecisionCandidates.applied.length > 0 ? { decision_candidates: appliedDecisionCandidates.applied } : {}),
|
|
222
291
|
...(warnings.length > 0 ? { warnings } : {}),
|
|
223
|
-
applied_at:
|
|
292
|
+
applied_at: appliedAt,
|
|
224
293
|
},
|
|
225
|
-
completed_at:
|
|
294
|
+
completed_at: appliedAt,
|
|
226
295
|
};
|
|
227
296
|
|
|
228
297
|
await writeDocsReviewMarker(options.statePath, marker);
|
|
@@ -238,7 +307,11 @@ async function applyDocsReviewOutput(options) {
|
|
|
238
307
|
applied: marker.applied,
|
|
239
308
|
reviewPrompt: null,
|
|
240
309
|
warnings,
|
|
241
|
-
message:
|
|
310
|
+
message: renderApplyOutputMessage({
|
|
311
|
+
architectureCount: applied.length,
|
|
312
|
+
coverageApplied: Boolean(appliedCoverage),
|
|
313
|
+
decisionCount: appliedDecisionCandidates.applied.length,
|
|
314
|
+
}),
|
|
242
315
|
};
|
|
243
316
|
}
|
|
244
317
|
|
|
@@ -340,6 +413,92 @@ async function applyDecisionArtifact(options) {
|
|
|
340
413
|
};
|
|
341
414
|
}
|
|
342
415
|
|
|
416
|
+
async function rebuildDocsReview(options) {
|
|
417
|
+
const scopePath = normalizeRebuildScopePath(options.scopePath);
|
|
418
|
+
const stalePolicy = normalizeRebuildStalePolicy(options.stalePolicy);
|
|
419
|
+
const apply = Boolean(options.apply);
|
|
420
|
+
const architectureDocs = await listArchitectureDocs(options.rootDir, scopePath);
|
|
421
|
+
const requestedAt = new Date();
|
|
422
|
+
const archiveRoot = stalePolicy === 'archive'
|
|
423
|
+
? `state/docs-review-archive/${formatArchiveTimestamp(requestedAt)}`
|
|
424
|
+
: null;
|
|
425
|
+
const rebuildEntries = architectureDocs.map((docPath) => ({
|
|
426
|
+
path: docPath,
|
|
427
|
+
action: stalePolicy === 'archive' ? 'archive' : 'keep',
|
|
428
|
+
...(archiveRoot ? { archive_path: `${archiveRoot}/${docPath}` } : {}),
|
|
429
|
+
}));
|
|
430
|
+
|
|
431
|
+
if (!apply) {
|
|
432
|
+
return {
|
|
433
|
+
rootDir: options.rootDir,
|
|
434
|
+
statePath: options.statePath,
|
|
435
|
+
status: 'rebuild-preview',
|
|
436
|
+
recorded: false,
|
|
437
|
+
runtime: null,
|
|
438
|
+
hosted: null,
|
|
439
|
+
applied: null,
|
|
440
|
+
reviewPrompt: null,
|
|
441
|
+
rebuild: {
|
|
442
|
+
scope_path: scopePath,
|
|
443
|
+
stale_policy: stalePolicy,
|
|
444
|
+
architecture_docs: rebuildEntries,
|
|
445
|
+
architecture_doc_count: rebuildEntries.length,
|
|
446
|
+
archive_root: archiveRoot,
|
|
447
|
+
},
|
|
448
|
+
warnings: [],
|
|
449
|
+
message: 'Docs-review rebuild preview complete. Re-run with --apply to record the rebuild marker and perform the selected stale-doc action.',
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
for (const entry of rebuildEntries) {
|
|
454
|
+
if (entry.action !== 'archive') {
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
const sourcePath = path.join(options.rootDir, entry.path);
|
|
458
|
+
const targetPath = path.join(options.rootDir, entry.archive_path);
|
|
459
|
+
await mkdir(path.dirname(targetPath), { recursive: true });
|
|
460
|
+
await rename(sourcePath, targetPath);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const manifestResult = await writeStateManifest(options.rootDir);
|
|
464
|
+
const appliedAt = new Date().toISOString();
|
|
465
|
+
const marker = {
|
|
466
|
+
status: 'rebuild-ready',
|
|
467
|
+
requested_at: requestedAt.toISOString(),
|
|
468
|
+
runtime: {
|
|
469
|
+
provider: 'local',
|
|
470
|
+
operation: 'rebuild',
|
|
471
|
+
},
|
|
472
|
+
rebuild: {
|
|
473
|
+
scope_path: scopePath,
|
|
474
|
+
stale_policy: stalePolicy,
|
|
475
|
+
architecture_docs: rebuildEntries,
|
|
476
|
+
architecture_doc_count: rebuildEntries.length,
|
|
477
|
+
...(archiveRoot ? { archive_root: path.join(options.rootDir, archiveRoot) } : {}),
|
|
478
|
+
local_root_revision: manifestResult.manifest.canonical.local_root_revision,
|
|
479
|
+
manifest_hash: manifestResult.manifest.canonical.manifest_hash,
|
|
480
|
+
applied_at: appliedAt,
|
|
481
|
+
},
|
|
482
|
+
completed_at: null,
|
|
483
|
+
};
|
|
484
|
+
await writeDocsReviewMarker(options.statePath, marker);
|
|
485
|
+
|
|
486
|
+
return {
|
|
487
|
+
rootDir: options.rootDir,
|
|
488
|
+
statePath: options.statePath,
|
|
489
|
+
status: 'rebuild-ready',
|
|
490
|
+
runtime: marker.runtime,
|
|
491
|
+
hosted: null,
|
|
492
|
+
applied: null,
|
|
493
|
+
reviewPrompt: null,
|
|
494
|
+
rebuild: marker.rebuild,
|
|
495
|
+
warnings: [],
|
|
496
|
+
message: stalePolicy === 'archive'
|
|
497
|
+
? 'Archived selected architecture docs and recorded a rebuild marker. Run docs-review again, then apply accepted output.'
|
|
498
|
+
: 'Recorded a rebuild marker without moving architecture docs. Run docs-review again, then apply accepted output.',
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
343
502
|
async function readDocsReviewMarker(statePath) {
|
|
344
503
|
try {
|
|
345
504
|
return JSON.parse(await readFile(statePath, 'utf8'));
|
|
@@ -360,12 +519,28 @@ function validateDocsReviewMode(options) {
|
|
|
360
519
|
options.runLocalAnthropic ? '--run-local-anthropic' : null,
|
|
361
520
|
options.applyOutput ? '--apply-output' : null,
|
|
362
521
|
options.applyDecisionArtifact ? '--apply-decision-artifact' : null,
|
|
522
|
+
options.rebuild ? '--rebuild' : null,
|
|
363
523
|
options.complete ? '--complete' : null,
|
|
364
524
|
].filter(Boolean);
|
|
365
525
|
|
|
366
526
|
if (enabled.length > 1) {
|
|
367
527
|
throw new Error(`docs-review accepts only one execution mode at a time. Conflicting flags: ${enabled.join(', ')}.`);
|
|
368
528
|
}
|
|
529
|
+
if (options.apply && !options.rebuild) {
|
|
530
|
+
throw new Error('docs-review --apply is only valid with --rebuild.');
|
|
531
|
+
}
|
|
532
|
+
if (options.dryRun && !options.rebuild) {
|
|
533
|
+
throw new Error('docs-review --dry-run is only valid with --rebuild.');
|
|
534
|
+
}
|
|
535
|
+
if (options.stalePolicy && !options.rebuild) {
|
|
536
|
+
throw new Error('docs-review --stale-policy is only valid with --rebuild.');
|
|
537
|
+
}
|
|
538
|
+
if (options.scopePath && !options.rebuild) {
|
|
539
|
+
throw new Error('docs-review --path is only valid with --rebuild.');
|
|
540
|
+
}
|
|
541
|
+
if (options.apply && options.dryRun) {
|
|
542
|
+
throw new Error('docs-review --rebuild accepts only one of --dry-run or --apply.');
|
|
543
|
+
}
|
|
369
544
|
}
|
|
370
545
|
|
|
371
546
|
async function promptForReviewConfig(options, environment) {
|
|
@@ -624,6 +799,36 @@ function parseArchitectureDocBlocks(source) {
|
|
|
624
799
|
return blocks;
|
|
625
800
|
}
|
|
626
801
|
|
|
802
|
+
function parseCoveragePlanBlocks(source) {
|
|
803
|
+
const blocks = [];
|
|
804
|
+
const pattern = /^```vibecompass-coverage-plan\s+version=1\s*\n([\s\S]*?)^```[ \t]*$/gm;
|
|
805
|
+
let match;
|
|
806
|
+
|
|
807
|
+
while ((match = pattern.exec(source)) !== null) {
|
|
808
|
+
blocks.push({
|
|
809
|
+
version: COVERAGE_PLAN_OUTPUT_VERSION,
|
|
810
|
+
content: match[1].replace(/\n$/, ''),
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
return blocks;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function parseDecisionRecommendationBlocks(source) {
|
|
818
|
+
const blocks = [];
|
|
819
|
+
const pattern = /^```vibecompass-decision-recommendation\s+target=([^\s`]+)\s*\n([\s\S]*?)^```[ \t]*$/gm;
|
|
820
|
+
let match;
|
|
821
|
+
|
|
822
|
+
while ((match = pattern.exec(source)) !== null) {
|
|
823
|
+
blocks.push({
|
|
824
|
+
targetPath: normalizeDecisionTargetPath(match[1]),
|
|
825
|
+
content: match[2].replace(/\n$/, ''),
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
return blocks;
|
|
830
|
+
}
|
|
831
|
+
|
|
627
832
|
function countMalformedArchitectureDocFences(source) {
|
|
628
833
|
const fencePattern = /^```vibecompass-architecture-doc\b.*$/gm;
|
|
629
834
|
const acceptedOpeningPattern = /^```vibecompass-architecture-doc\s+path=[^\s`]+\s*$/;
|
|
@@ -639,6 +844,36 @@ function countMalformedArchitectureDocFences(source) {
|
|
|
639
844
|
return count;
|
|
640
845
|
}
|
|
641
846
|
|
|
847
|
+
function countMalformedCoveragePlanFences(source) {
|
|
848
|
+
const fencePattern = /^```vibecompass-coverage-plan\b.*$/gm;
|
|
849
|
+
const acceptedOpeningPattern = /^```vibecompass-coverage-plan\s+version=1\s*$/;
|
|
850
|
+
let count = 0;
|
|
851
|
+
let match;
|
|
852
|
+
|
|
853
|
+
while ((match = fencePattern.exec(source)) !== null) {
|
|
854
|
+
if (!acceptedOpeningPattern.test(match[0])) {
|
|
855
|
+
count += 1;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
return count;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function countMalformedDecisionRecommendationFences(source) {
|
|
863
|
+
const fencePattern = /^```vibecompass-decision-recommendation\b.*$/gm;
|
|
864
|
+
const acceptedOpeningPattern = /^```vibecompass-decision-recommendation\s+target=[^\s`]+\s*$/;
|
|
865
|
+
let count = 0;
|
|
866
|
+
let match;
|
|
867
|
+
|
|
868
|
+
while ((match = fencePattern.exec(source)) !== null) {
|
|
869
|
+
if (!acceptedOpeningPattern.test(match[0])) {
|
|
870
|
+
count += 1;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
return count;
|
|
875
|
+
}
|
|
876
|
+
|
|
642
877
|
function validateArchitectureDocBlocks(blocks) {
|
|
643
878
|
const seenPaths = new Set();
|
|
644
879
|
for (const block of blocks) {
|
|
@@ -650,6 +885,115 @@ function validateArchitectureDocBlocks(blocks) {
|
|
|
650
885
|
}
|
|
651
886
|
}
|
|
652
887
|
|
|
888
|
+
function buildCoverageProjection(blocks, options) {
|
|
889
|
+
if (blocks.length === 0) {
|
|
890
|
+
return null;
|
|
891
|
+
}
|
|
892
|
+
if (blocks.length > 1) {
|
|
893
|
+
throw new Error('Accepted docs-review output contains multiple coverage-plan blocks. Include at most one `vibecompass-coverage-plan version=1` block.');
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
let plan;
|
|
897
|
+
try {
|
|
898
|
+
plan = JSON.parse(blocks[0].content);
|
|
899
|
+
} catch (error) {
|
|
900
|
+
throw new Error(`Accepted coverage plan must contain valid JSON. ${error instanceof Error ? error.message : ''}`.trim());
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
if (!plan || typeof plan !== 'object' || Array.isArray(plan)) {
|
|
904
|
+
throw new Error('Accepted coverage plan must contain a JSON object.');
|
|
905
|
+
}
|
|
906
|
+
if (!Array.isArray(plan.areas)) {
|
|
907
|
+
throw new Error('Accepted coverage plan requires an "areas" array.');
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const ids = new Set();
|
|
911
|
+
const areas = plan.areas.map((area, index) => normalizeCoverageArea(area, index, ids));
|
|
912
|
+
const statuses = countBy(areas, 'status');
|
|
913
|
+
const coverage_levels = countBy(areas, 'coverage');
|
|
914
|
+
const denominator = areas.length;
|
|
915
|
+
const acceptedCount = statuses.accepted ?? 0;
|
|
916
|
+
|
|
917
|
+
return {
|
|
918
|
+
version: COVERAGE_PLAN_OUTPUT_VERSION,
|
|
919
|
+
source: {
|
|
920
|
+
output_path: options.outputPath,
|
|
921
|
+
output_hash: options.outputHash,
|
|
922
|
+
},
|
|
923
|
+
projected_at: new Date().toISOString(),
|
|
924
|
+
summary: normalizeOptionalString(plan.summary),
|
|
925
|
+
area_count: areas.length,
|
|
926
|
+
coverage_score: denominator === 0 ? null : Number((acceptedCount / denominator).toFixed(4)),
|
|
927
|
+
statuses,
|
|
928
|
+
coverage_levels,
|
|
929
|
+
areas,
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function normalizeCoverageArea(area, index, ids) {
|
|
934
|
+
if (!area || typeof area !== 'object' || Array.isArray(area)) {
|
|
935
|
+
throw new Error(`Coverage plan area at index ${index} must be an object.`);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const id = normalizeOptionalString(area.id);
|
|
939
|
+
if (!id) {
|
|
940
|
+
throw new Error(`Coverage plan area at index ${index} requires non-empty "id".`);
|
|
941
|
+
}
|
|
942
|
+
if (ids.has(id)) {
|
|
943
|
+
throw new Error(`Coverage plan contains duplicate area id: ${id}`);
|
|
944
|
+
}
|
|
945
|
+
ids.add(id);
|
|
946
|
+
|
|
947
|
+
const status = normalizeOptionalString(area.status);
|
|
948
|
+
if (!COVERAGE_PLAN_STATUSES.has(status)) {
|
|
949
|
+
throw new Error(`Coverage plan area "${id}" requires status to be accepted, deferred, or missing.`);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const coverage = normalizeOptionalString(area.coverage);
|
|
953
|
+
if (!COVERAGE_LEVELS.has(coverage)) {
|
|
954
|
+
throw new Error(`Coverage plan area "${id}" requires coverage to be comprehensive, partial, initial, or missing.`);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const proposedPath = normalizeOptionalString(area.proposed_path);
|
|
958
|
+
if (proposedPath) {
|
|
959
|
+
const normalizedPath = normalizeBlockPath(proposedPath);
|
|
960
|
+
if (
|
|
961
|
+
!normalizedPath.startsWith('architecture/') ||
|
|
962
|
+
!normalizedPath.endsWith('.md') ||
|
|
963
|
+
path.posix.isAbsolute(normalizedPath) ||
|
|
964
|
+
normalizedPath.split('/').includes('..')
|
|
965
|
+
) {
|
|
966
|
+
throw new Error(`Coverage plan area "${id}" has invalid proposed_path: ${proposedPath}`);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
return {
|
|
971
|
+
id,
|
|
972
|
+
domain: normalizeOptionalString(area.domain),
|
|
973
|
+
feature: normalizeOptionalString(area.feature),
|
|
974
|
+
component: normalizeOptionalString(area.component),
|
|
975
|
+
status,
|
|
976
|
+
coverage,
|
|
977
|
+
...(proposedPath ? { proposed_path: normalizeBlockPath(proposedPath) } : {}),
|
|
978
|
+
evidence: normalizeStringArray(area.evidence),
|
|
979
|
+
blindspots: normalizeStringArray(area.blindspots),
|
|
980
|
+
...(normalizeOptionalString(area.reason) ? { reason: normalizeOptionalString(area.reason) } : {}),
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function countBy(items, key) {
|
|
985
|
+
return items.reduce((counts, item) => {
|
|
986
|
+
counts[item[key]] = (counts[item[key]] ?? 0) + 1;
|
|
987
|
+
return counts;
|
|
988
|
+
}, {});
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function normalizeStringArray(value) {
|
|
992
|
+
return Array.isArray(value)
|
|
993
|
+
? value.filter((item) => typeof item === 'string').map((item) => item.trim()).filter(Boolean)
|
|
994
|
+
: [];
|
|
995
|
+
}
|
|
996
|
+
|
|
653
997
|
function normalizeBlockPath(value) {
|
|
654
998
|
return String(value ?? '').trim().replaceAll('\\', '/');
|
|
655
999
|
}
|
|
@@ -697,6 +1041,49 @@ function createArchitectureDocSizeWarnings(blocks) {
|
|
|
697
1041
|
.filter(Boolean);
|
|
698
1042
|
}
|
|
699
1043
|
|
|
1044
|
+
function createArchitectureDocQualityWarnings(blocks) {
|
|
1045
|
+
const warnings = [];
|
|
1046
|
+
|
|
1047
|
+
for (const block of blocks) {
|
|
1048
|
+
const frontmatter = parseFrontmatter(block.content, { sourceName: block.path });
|
|
1049
|
+
const body = frontmatter.body ?? '';
|
|
1050
|
+
const sections = extractLevelTwoSections(body);
|
|
1051
|
+
const data = frontmatter.data ?? {};
|
|
1052
|
+
|
|
1053
|
+
if (!sections.has('review metadata')) {
|
|
1054
|
+
warnings.push(`missing_review_metadata: ${block.path} is missing "## Review metadata".`);
|
|
1055
|
+
}
|
|
1056
|
+
if (!/^\s*-\s*Evidence:\s+\S/im.test(body)) {
|
|
1057
|
+
warnings.push(`missing_evidence_metadata: ${block.path} is missing review metadata "Evidence".`);
|
|
1058
|
+
}
|
|
1059
|
+
if (!/^\s*-\s*Blindspots:\s+\S/im.test(body)) {
|
|
1060
|
+
warnings.push(`missing_blindspots_metadata: ${block.path} is missing review metadata "Blindspots".`);
|
|
1061
|
+
}
|
|
1062
|
+
if (!sections.has('retrieval guidance')) {
|
|
1063
|
+
warnings.push(`missing_retrieval_guidance: ${block.path} is missing "## Retrieval guidance".`);
|
|
1064
|
+
}
|
|
1065
|
+
if (!sections.has('involved files')) {
|
|
1066
|
+
warnings.push(`missing_involved_files: ${block.path} is missing "## Involved files".`);
|
|
1067
|
+
}
|
|
1068
|
+
if (
|
|
1069
|
+
block.path !== 'architecture/overview/project-shape.md' &&
|
|
1070
|
+
data.repo === undefined &&
|
|
1071
|
+
data.repos === undefined
|
|
1072
|
+
) {
|
|
1073
|
+
warnings.push(`missing_repo_scope: ${block.path} should declare repo or repos in frontmatter when implementation-scoped.`);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
return warnings;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function extractLevelTwoSections(markdown) {
|
|
1081
|
+
return new Set(
|
|
1082
|
+
[...String(markdown ?? '').matchAll(/^##\s+(.+)$/gm)]
|
|
1083
|
+
.map((match) => match[1].trim().toLowerCase()),
|
|
1084
|
+
);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
700
1087
|
async function submitHostedDocsReview(options) {
|
|
701
1088
|
if (options.runtime.provider !== 'hosted') {
|
|
702
1089
|
throw new Error('docs-review --submit-hosted requires a hosted sync binding in project.yaml.');
|
|
@@ -902,6 +1289,83 @@ async function pathExists(targetPath) {
|
|
|
902
1289
|
}
|
|
903
1290
|
}
|
|
904
1291
|
|
|
1292
|
+
async function listArchitectureDocs(rootDir, scopePath) {
|
|
1293
|
+
const scopeAbsolutePath = path.join(rootDir, scopePath);
|
|
1294
|
+
|
|
1295
|
+
if (!await pathExists(scopeAbsolutePath)) {
|
|
1296
|
+
return [];
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
const docs = [];
|
|
1300
|
+
await collectArchitectureDocs({
|
|
1301
|
+
rootDir,
|
|
1302
|
+
currentPath: scopeAbsolutePath,
|
|
1303
|
+
docs,
|
|
1304
|
+
});
|
|
1305
|
+
return docs.sort();
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
async function collectArchitectureDocs(options) {
|
|
1309
|
+
const entries = await readdir(options.currentPath, { withFileTypes: true });
|
|
1310
|
+
for (const entry of entries) {
|
|
1311
|
+
const absolutePath = path.join(options.currentPath, entry.name);
|
|
1312
|
+
if (entry.isDirectory()) {
|
|
1313
|
+
await collectArchitectureDocs({
|
|
1314
|
+
rootDir: options.rootDir,
|
|
1315
|
+
currentPath: absolutePath,
|
|
1316
|
+
docs: options.docs,
|
|
1317
|
+
});
|
|
1318
|
+
continue;
|
|
1319
|
+
}
|
|
1320
|
+
if (!entry.isFile() || !entry.name.endsWith('.md')) {
|
|
1321
|
+
continue;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
const relativePath = normalizeBlockPath(path.relative(options.rootDir, absolutePath));
|
|
1325
|
+
if (isProtectedArchitectureDoc(relativePath)) {
|
|
1326
|
+
continue;
|
|
1327
|
+
}
|
|
1328
|
+
options.docs.push(relativePath);
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
function isProtectedArchitectureDoc(relativePath) {
|
|
1333
|
+
return relativePath === 'architecture/README.md' ||
|
|
1334
|
+
relativePath === 'architecture/overview/project-shape.md';
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
function normalizeRebuildScopePath(value) {
|
|
1338
|
+
const normalized = normalizeBlockPath(value ?? 'architecture');
|
|
1339
|
+
if (
|
|
1340
|
+
normalized !== 'architecture' &&
|
|
1341
|
+
!normalized.startsWith('architecture/')
|
|
1342
|
+
) {
|
|
1343
|
+
throw new Error(`docs-review --rebuild --path must point under architecture/. Invalid path: ${value}`);
|
|
1344
|
+
}
|
|
1345
|
+
if (
|
|
1346
|
+
path.posix.isAbsolute(normalized) ||
|
|
1347
|
+
normalized.split('/').some((part) => part === '..' || part === '') ||
|
|
1348
|
+
normalized.endsWith('.md')
|
|
1349
|
+
) {
|
|
1350
|
+
throw new Error(`docs-review --rebuild --path must be a relative architecture directory. Invalid path: ${value}`);
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
return normalized;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
function normalizeRebuildStalePolicy(value) {
|
|
1357
|
+
const normalized = normalizeOptionalString(value) ?? 'keep';
|
|
1358
|
+
if (!REBUILD_STALE_POLICIES.has(normalized)) {
|
|
1359
|
+
throw new Error('docs-review --rebuild --stale-policy must be keep or archive.');
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
return normalized;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
function formatArchiveTimestamp(date) {
|
|
1366
|
+
return date.toISOString().replaceAll(':', '-').replaceAll('.', '-');
|
|
1367
|
+
}
|
|
1368
|
+
|
|
905
1369
|
function normalizeOptionalString(value) {
|
|
906
1370
|
if (typeof value !== 'string') {
|
|
907
1371
|
return null;
|
|
@@ -937,6 +1401,106 @@ function normalizeDecisionTargetPath(value) {
|
|
|
937
1401
|
return normalized;
|
|
938
1402
|
}
|
|
939
1403
|
|
|
1404
|
+
function validateDecisionRecommendationBlocks(blocks) {
|
|
1405
|
+
return blocks.map((block, index) => {
|
|
1406
|
+
const fields = parseDecisionRecommendationFields(block.content);
|
|
1407
|
+
const title = normalizeOptionalString(fields.title);
|
|
1408
|
+
const decision = normalizeOptionalString(fields.decision);
|
|
1409
|
+
const rationale = normalizeOptionalString(fields.rationale);
|
|
1410
|
+
const context = normalizeOptionalString(fields.context);
|
|
1411
|
+
|
|
1412
|
+
if (!title) {
|
|
1413
|
+
throw new Error(`Decision recommendation ${index + 1} must include a non-empty Title.`);
|
|
1414
|
+
}
|
|
1415
|
+
if (!decision) {
|
|
1416
|
+
throw new Error(`Decision recommendation ${index + 1} must include non-empty Decision content.`);
|
|
1417
|
+
}
|
|
1418
|
+
if (!rationale) {
|
|
1419
|
+
throw new Error(`Decision recommendation ${index + 1} must include a non-empty Rationale.`);
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
return {
|
|
1423
|
+
targetPath: block.targetPath,
|
|
1424
|
+
title,
|
|
1425
|
+
decision,
|
|
1426
|
+
rationale,
|
|
1427
|
+
context,
|
|
1428
|
+
};
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
function parseDecisionRecommendationFields(content) {
|
|
1433
|
+
const fields = {};
|
|
1434
|
+
let currentKey = null;
|
|
1435
|
+
|
|
1436
|
+
for (const line of String(content ?? '').split(/\r?\n/)) {
|
|
1437
|
+
const labelMatch = line.match(/^\*\*(Title|Decision|Rationale|Context):\*\*\s*(.*)$/i);
|
|
1438
|
+
if (labelMatch) {
|
|
1439
|
+
currentKey = labelMatch[1].toLowerCase();
|
|
1440
|
+
fields[currentKey] = labelMatch[2].trim();
|
|
1441
|
+
continue;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
if (currentKey && line.trim()) {
|
|
1445
|
+
fields[currentKey] = `${fields[currentKey] ? `${fields[currentKey]}\n` : ''}${line.trim()}`;
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
return fields;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
async function applyDecisionRecommendations(options) {
|
|
1453
|
+
const applied = [];
|
|
1454
|
+
for (const recommendation of options.recommendations) {
|
|
1455
|
+
const nextDecisionId = await readNextDecisionId(options.rootDir);
|
|
1456
|
+
const targetFilePath = path.join(options.rootDir, recommendation.targetPath);
|
|
1457
|
+
await mkdir(path.dirname(targetFilePath), { recursive: true });
|
|
1458
|
+
|
|
1459
|
+
let existingContent = '';
|
|
1460
|
+
try {
|
|
1461
|
+
existingContent = await readFile(targetFilePath, 'utf8');
|
|
1462
|
+
} catch (error) {
|
|
1463
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
1464
|
+
existingContent = `# ${path.basename(recommendation.targetPath, '.md')} decisions\n`;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
const entry = renderDecisionEntry({
|
|
1468
|
+
decisionId: nextDecisionId,
|
|
1469
|
+
title: recommendation.title,
|
|
1470
|
+
decision: recommendation.decision,
|
|
1471
|
+
rationale: recommendation.rationale,
|
|
1472
|
+
context: recommendation.context,
|
|
1473
|
+
});
|
|
1474
|
+
await writeFile(targetFilePath, `${existingContent.trimEnd()}\n\n${entry}`, 'utf8');
|
|
1475
|
+
applied.push({
|
|
1476
|
+
decision_id: nextDecisionId,
|
|
1477
|
+
target_path: recommendation.targetPath,
|
|
1478
|
+
title: recommendation.title,
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
if (applied.length === 0) {
|
|
1483
|
+
return { applied, warnings: [] };
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
const indexResult = options.refreshIndex
|
|
1487
|
+
? await refreshDecisionIndex(options.rootDir)
|
|
1488
|
+
: {
|
|
1489
|
+
refreshed: false,
|
|
1490
|
+
warnings: [
|
|
1491
|
+
'decisions/INDEX.md was not refreshed after applying docs-review decision recommendations. Run again with --refresh-index if this root uses the package-generated flat index, or update the project-specific index manually.',
|
|
1492
|
+
],
|
|
1493
|
+
};
|
|
1494
|
+
|
|
1495
|
+
return {
|
|
1496
|
+
applied: applied.map((item) => ({
|
|
1497
|
+
...item,
|
|
1498
|
+
refreshed_index: indexResult.refreshed,
|
|
1499
|
+
})),
|
|
1500
|
+
warnings: indexResult.warnings,
|
|
1501
|
+
};
|
|
1502
|
+
}
|
|
1503
|
+
|
|
940
1504
|
async function readNextDecisionId(rootDir) {
|
|
941
1505
|
const decisionsDir = path.join(rootDir, 'decisions');
|
|
942
1506
|
let max = 0;
|
|
@@ -1097,6 +1661,21 @@ function renderDocsReviewMessage(options) {
|
|
|
1097
1661
|
return 'Docs-review preflight passed. Run the generated architecture-review prompt in the selected LLM, then record completion after applying accepted docs changes.';
|
|
1098
1662
|
}
|
|
1099
1663
|
|
|
1664
|
+
function renderApplyOutputMessage(options) {
|
|
1665
|
+
const parts = [];
|
|
1666
|
+
if (options.architectureCount > 0) {
|
|
1667
|
+
parts.push(`${options.architectureCount} accepted architecture doc${options.architectureCount === 1 ? '' : 's'}`);
|
|
1668
|
+
}
|
|
1669
|
+
if (options.coverageApplied) {
|
|
1670
|
+
parts.push('coverage plan');
|
|
1671
|
+
}
|
|
1672
|
+
if (options.decisionCount > 0) {
|
|
1673
|
+
parts.push(`${options.decisionCount} decision recommendation${options.decisionCount === 1 ? '' : 's'}`);
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
return `Applied ${parts.length > 0 ? parts.join(', ') : 'accepted docs-review output'} and completed the docs-review marker.`;
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1100
1679
|
function renderReviewPrompt(options) {
|
|
1101
1680
|
return [
|
|
1102
1681
|
`# ${DOCS_REVIEW_PROMPT_VERSION}`,
|
|
@@ -1119,6 +1698,7 @@ function renderReviewPrompt(options) {
|
|
|
1119
1698
|
'## Staged Review Protocol',
|
|
1120
1699
|
'Stage 1 — Evidence inventory:',
|
|
1121
1700
|
'- Build a compact repo inventory from file paths, manifests/config files, route/job/test directories, and existing architecture frontmatter before reading large file bodies.',
|
|
1701
|
+
'- Mine finalized session notes for decision candidates and product constraints, but cap proposed decisions to the five highest-value architecture choices.',
|
|
1122
1702
|
'- Identify candidate domains, features, components, and ownership boundaries with concrete `repo:path` evidence.',
|
|
1123
1703
|
'- Do not fetch or summarize broad source bodies until the coverage plan says a file is needed.',
|
|
1124
1704
|
'',
|
|
@@ -1126,6 +1706,7 @@ function renderReviewPrompt(options) {
|
|
|
1126
1706
|
'- Propose the smallest useful architecture-doc set that will let future agents understand the project and retrieve targeted context.',
|
|
1127
1707
|
'- Prioritize entry points, data ownership, external integrations, jobs/async boundaries, auth/security boundaries, and test surfaces.',
|
|
1128
1708
|
'- Mark each proposed doc as `comprehensive`, `partial`, or `initial`, with the reason and blindspots.',
|
|
1709
|
+
'- Emit the accepted plan as one `vibecompass-coverage-plan version=1` JSON fence before architecture-doc blocks.',
|
|
1129
1710
|
'- Ask for user acceptance before emitting fenced architecture-doc blocks.',
|
|
1130
1711
|
'',
|
|
1131
1712
|
'Stage 3 — Bounded doc generation:',
|
|
@@ -1177,13 +1758,43 @@ function renderReviewPrompt(options) {
|
|
|
1177
1758
|
'- token-budget risks or oversized-doc candidates',
|
|
1178
1759
|
'- proposed decisions, if any',
|
|
1179
1760
|
'',
|
|
1761
|
+
'Then output one machine-readable coverage-plan block for the accepted plan:',
|
|
1762
|
+
'',
|
|
1763
|
+
'```vibecompass-coverage-plan version=1',
|
|
1764
|
+
'{',
|
|
1765
|
+
' "summary": "Short coverage plan summary",',
|
|
1766
|
+
' "areas": [',
|
|
1767
|
+
' {',
|
|
1768
|
+
' "id": "stable-area-id",',
|
|
1769
|
+
' "domain": "Domain",',
|
|
1770
|
+
' "feature": "Feature",',
|
|
1771
|
+
' "component": "Component",',
|
|
1772
|
+
' "status": "accepted | deferred | missing",',
|
|
1773
|
+
' "coverage": "comprehensive | partial | initial | missing",',
|
|
1774
|
+
' "proposed_path": "architecture/domain/feature/component.md",',
|
|
1775
|
+
' "evidence": ["repo:path"],',
|
|
1776
|
+
' "blindspots": ["explicit blindspot"]',
|
|
1777
|
+
' }',
|
|
1778
|
+
' ]',
|
|
1779
|
+
'}',
|
|
1780
|
+
'```',
|
|
1781
|
+
'',
|
|
1180
1782
|
'Then, for each architecture doc the user accepts, output one fenced block exactly like this:',
|
|
1181
1783
|
'',
|
|
1182
1784
|
'```vibecompass-architecture-doc path=architecture/domain/feature/component.md',
|
|
1183
1785
|
'<complete markdown file content>',
|
|
1184
1786
|
'```',
|
|
1185
1787
|
'',
|
|
1186
|
-
'
|
|
1788
|
+
'If the review surfaced accepted decision candidates, output each candidate as a separate block without assigning a D-number:',
|
|
1789
|
+
'',
|
|
1790
|
+
'```vibecompass-decision-recommendation target=decisions/cross-cutting.md',
|
|
1791
|
+
'**Title:** Short decision title',
|
|
1792
|
+
'**Decision:** Accepted decision statement to append after local review.',
|
|
1793
|
+
'**Rationale:** Why this choice matters.',
|
|
1794
|
+
'**Context:** Optional supporting context.',
|
|
1795
|
+
'```',
|
|
1796
|
+
'',
|
|
1797
|
+
'Only include accepted coverage, architecture, and decision-recommendation blocks. After accepted output is applied, `vibecompass docs-review --apply-output` records completion in `state/docs-review.json`.',
|
|
1187
1798
|
].join('\n');
|
|
1188
1799
|
}
|
|
1189
1800
|
|