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