@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.
@@ -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 architecture doc blocks found in ${outputPath}. Expected fenced blocks like \`\`\`vibecompass-architecture-doc path=architecture/domain/feature/component.md\`.`,
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 warnings = createArchitectureDocSizeWarnings(blocks);
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: new Date().toISOString(),
292
+ applied_at: appliedAt,
224
293
  },
225
- completed_at: new Date().toISOString(),
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: `Applied ${applied.length} accepted architecture doc${applied.length === 1 ? '' : 's'} and completed the docs-review marker.`,
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
- 'Only include docs the user has accepted in fenced `vibecompass-architecture-doc` blocks. After accepted docs are applied, `vibecompass docs-review --apply-output` records completion in `state/docs-review.json`.',
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