@vibecompass/vibecompass 0.2.1 → 0.3.1

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.
@@ -0,0 +1,1220 @@
1
+ import { access, mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { createInterface } from 'node:readline/promises';
4
+ import { parseFrontmatter } from './frontmatter.js';
5
+ import { writeStateManifest } from './manifest.js';
6
+ import { parseSimpleYaml } from './simple-yaml.js';
7
+
8
+ export async function preflightDocsReview(options = {}, environment = {}) {
9
+ validateDocsReviewMode(options);
10
+ const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd();
11
+ const rootDir = path.resolve(cwd, options.rootDir ?? '.compass');
12
+ const statePath = path.join(rootDir, 'state', 'docs-review.json');
13
+
14
+ if (options.applyOutput) {
15
+ return applyDocsReviewOutput({
16
+ rootDir,
17
+ statePath,
18
+ outputPath: options.outputPath,
19
+ llm: options.llm,
20
+ model: options.model,
21
+ });
22
+ }
23
+
24
+ if (options.applyDecisionArtifact) {
25
+ return applyDecisionArtifact({
26
+ rootDir,
27
+ statePath,
28
+ artifactId: options.artifactId,
29
+ refreshIndex: options.refreshIndex,
30
+ });
31
+ }
32
+
33
+ if (options.complete) {
34
+ return completeDocsReview({
35
+ rootDir,
36
+ statePath,
37
+ llm: options.llm,
38
+ model: options.model,
39
+ });
40
+ }
41
+
42
+ if (options.pollHosted) {
43
+ return pollHostedDocsReview({
44
+ rootDir,
45
+ statePath,
46
+ env: environment.env ?? process.env,
47
+ fetch: environment.runtime?.fetch ?? globalThis.fetch,
48
+ });
49
+ }
50
+
51
+ const projectFilePath = path.join(rootDir, 'project.yaml');
52
+ const project = parseSimpleYaml(await readFile(projectFilePath, 'utf8'), {
53
+ sourceName: projectFilePath,
54
+ });
55
+ const reviewConfig = options.guided
56
+ ? await promptForReviewConfig(options, environment)
57
+ : normalizeReviewConfig(options);
58
+ const env = environment.env ?? process.env;
59
+ const localProvider = resolveLocalProvider(options);
60
+ const runtime = resolveDocsReviewRuntime(project, {
61
+ env,
62
+ localProvider,
63
+ runLocal: shouldRunLocal(options),
64
+ });
65
+ const reviewPrompt = renderReviewPrompt({
66
+ project,
67
+ rootDir,
68
+ llm: reviewConfig.llm,
69
+ model: reviewConfig.model,
70
+ });
71
+
72
+ const status = options.submitHosted
73
+ ? 'hosted-review-requested'
74
+ : shouldRunLocal(options)
75
+ ? 'local-review-generated'
76
+ : 'external-review-requested';
77
+ const hosted = options.submitHosted
78
+ ? await submitHostedDocsReview({
79
+ env,
80
+ project,
81
+ reviewConfig,
82
+ reviewPrompt,
83
+ rootDir,
84
+ runtime,
85
+ manifest: await writeStateManifest(rootDir),
86
+ fetch: environment.runtime?.fetch ?? globalThis.fetch,
87
+ })
88
+ : null;
89
+ const localReview = shouldRunLocal(options)
90
+ ? await runLocalDocsReview({
91
+ env,
92
+ maxTokens: options.maxTokens,
93
+ model: reviewConfig.model,
94
+ reviewPrompt,
95
+ rootDir,
96
+ statePath,
97
+ runtime,
98
+ fetch: environment.runtime?.fetch ?? globalThis.fetch,
99
+ })
100
+ : null;
101
+
102
+ await writeDocsReviewMarker(statePath, {
103
+ status,
104
+ requested_at: new Date().toISOString(),
105
+ llm: reviewConfig.llm,
106
+ model: reviewConfig.model,
107
+ runtime,
108
+ ...(hosted ? { hosted } : {}),
109
+ ...(localReview ? { local_review: localReview } : {}),
110
+ completed_at: null,
111
+ });
112
+
113
+ return {
114
+ rootDir,
115
+ projectFilePath,
116
+ statePath,
117
+ mode: project.mode,
118
+ llm: reviewConfig.llm,
119
+ model: reviewConfig.model,
120
+ runtime,
121
+ status,
122
+ hosted,
123
+ localReview,
124
+ reviewPrompt,
125
+ warnings: [
126
+ ...(localReview?.truncated ? [`Local ${localReview.provider} docs-review stopped at max_tokens; saved output may be partial.`] : []),
127
+ ],
128
+ message: renderDocsReviewMessage(options),
129
+ };
130
+ }
131
+
132
+ async function completeDocsReview(options) {
133
+ let current = {};
134
+ try {
135
+ current = JSON.parse(await readFile(options.statePath, 'utf8'));
136
+ } catch (error) {
137
+ if (error?.code === 'ENOENT') {
138
+ throw new Error(`No docs-review marker found at ${options.statePath}. Run docs-review before marking it complete.`);
139
+ }
140
+
141
+ if (error?.code !== 'ENOENT') {
142
+ throw new Error(`Could not read docs-review marker at ${options.statePath}: ${error.message}`);
143
+ }
144
+ }
145
+
146
+ const llm = normalizeOptionalString(options.llm) ?? current.llm ?? null;
147
+ const model = normalizeOptionalString(options.model) ?? current.model ?? null;
148
+ const marker = {
149
+ ...current,
150
+ status: 'completed',
151
+ ...(llm ? { llm } : {}),
152
+ ...(model ? { model } : {}),
153
+ completed_at: new Date().toISOString(),
154
+ };
155
+
156
+ await writeDocsReviewMarker(options.statePath, marker);
157
+
158
+ return {
159
+ rootDir: options.rootDir,
160
+ statePath: options.statePath,
161
+ status: 'completed',
162
+ llm,
163
+ model,
164
+ runtime: current.runtime ?? null,
165
+ hosted: current.hosted ?? null,
166
+ applied: current.applied ?? null,
167
+ appliedDecisionArtifact: current.applied_decision_artifact ?? null,
168
+ reviewPrompt: null,
169
+ warnings: [],
170
+ message: 'Docs-review marker completed. Future start-session warnings can treat this root as reviewed.',
171
+ };
172
+ }
173
+
174
+ async function applyDocsReviewOutput(options) {
175
+ const current = await readDocsReviewMarker(options.statePath);
176
+ const outputPath = options.outputPath
177
+ ? path.resolve(options.rootDir, options.outputPath)
178
+ : path.join(path.dirname(options.statePath), 'docs-review-output.md');
179
+ const source = await readFile(outputPath, 'utf8');
180
+ const blocks = parseArchitectureDocBlocks(source);
181
+
182
+ if (blocks.length === 0) {
183
+ throw new Error(
184
+ `No accepted architecture doc blocks found in ${outputPath}. Expected fenced blocks like \`\`\`vibecompass-architecture-doc path=architecture/domain/feature/component.md\`.`,
185
+ );
186
+ }
187
+
188
+ for (const block of blocks) {
189
+ validateArchitectureDocBlock(block);
190
+ }
191
+
192
+ const applied = [];
193
+ for (const block of blocks) {
194
+ const absoluteTargetPath = path.join(options.rootDir, block.path);
195
+ await mkdir(path.dirname(absoluteTargetPath), { recursive: true });
196
+ const existed = await pathExists(absoluteTargetPath);
197
+ await writeFile(absoluteTargetPath, ensureTrailingNewline(block.content), 'utf8');
198
+ applied.push({ path: block.path, status: existed ? 'overwritten' : 'created' });
199
+ }
200
+
201
+ const llm = normalizeOptionalString(options.llm) ?? current.llm ?? null;
202
+ const model = normalizeOptionalString(options.model) ?? current.model ?? null;
203
+ const marker = {
204
+ ...current,
205
+ status: 'completed',
206
+ ...(llm ? { llm } : {}),
207
+ ...(model ? { model } : {}),
208
+ applied: {
209
+ output_path: outputPath,
210
+ architecture_docs: applied,
211
+ applied_at: new Date().toISOString(),
212
+ },
213
+ completed_at: new Date().toISOString(),
214
+ };
215
+
216
+ await writeDocsReviewMarker(options.statePath, marker);
217
+
218
+ return {
219
+ rootDir: options.rootDir,
220
+ statePath: options.statePath,
221
+ status: 'completed',
222
+ llm,
223
+ model,
224
+ runtime: current.runtime ?? null,
225
+ hosted: current.hosted ?? null,
226
+ applied: marker.applied,
227
+ reviewPrompt: null,
228
+ warnings: [],
229
+ message: `Applied ${applied.length} accepted architecture doc${applied.length === 1 ? '' : 's'} and completed the docs-review marker.`,
230
+ };
231
+ }
232
+
233
+ async function applyDecisionArtifact(options) {
234
+ const current = await readDocsReviewMarker(options.statePath);
235
+ const artifactId = normalizeOptionalString(options.artifactId);
236
+ if (!artifactId) {
237
+ throw new Error('docs-review --apply-decision-artifact requires --artifact <artifact-id>.');
238
+ }
239
+
240
+ const artifact = (current.hosted?.artifacts ?? []).find(
241
+ (item) => item?.artifact_id === artifactId,
242
+ );
243
+ if (!artifact) {
244
+ throw new Error(`Decision artifact ${artifactId} was not found in ${options.statePath}. Run docs-review --poll-hosted first.`);
245
+ }
246
+ if (artifact.artifact_type !== 'decision_recommendation') {
247
+ throw new Error(`Artifact ${artifactId} is ${artifact.artifact_type}, not decision_recommendation.`);
248
+ }
249
+
250
+ const content = artifact.content ?? {};
251
+ const targetPath = normalizeDecisionTargetPath(
252
+ content.target_path ?? artifact.target_path ?? 'decisions/cross-cutting.md',
253
+ );
254
+ const title = normalizeOptionalString(content.title ?? artifact.title);
255
+ const decision = normalizeOptionalString(content.decision ?? artifact.summary);
256
+ const rationale = normalizeOptionalString(content.rationale);
257
+ const context = normalizeOptionalString(content.context);
258
+
259
+ if (!title || !decision) {
260
+ throw new Error(`Decision artifact ${artifactId} must include title and decision content.`);
261
+ }
262
+ if (!rationale) {
263
+ throw new Error(`Decision artifact ${artifactId} must include a non-empty rationale before it can be appended locally.`);
264
+ }
265
+
266
+ const nextDecisionId = await readNextDecisionId(options.rootDir);
267
+ const targetFilePath = path.join(options.rootDir, targetPath);
268
+ await mkdir(path.dirname(targetFilePath), { recursive: true });
269
+
270
+ let existingContent = '';
271
+ try {
272
+ existingContent = await readFile(targetFilePath, 'utf8');
273
+ } catch (error) {
274
+ if (error?.code !== 'ENOENT') throw error;
275
+ existingContent = `# ${path.basename(targetPath, '.md')} decisions\n`;
276
+ }
277
+
278
+ const entry = renderDecisionEntry({
279
+ decisionId: nextDecisionId,
280
+ title,
281
+ decision,
282
+ rationale,
283
+ context,
284
+ artifactId,
285
+ });
286
+ await writeFile(targetFilePath, `${existingContent.trimEnd()}\n\n${entry}`, 'utf8');
287
+ const indexResult = options.refreshIndex
288
+ ? await refreshDecisionIndex(options.rootDir)
289
+ : {
290
+ refreshed: false,
291
+ warnings: [
292
+ 'decisions/INDEX.md was not refreshed. Run again with --refresh-index if this root uses the package-generated flat index, or update the project-specific index manually.',
293
+ ],
294
+ };
295
+
296
+ const appliedDecisionArtifact = {
297
+ artifact_id: artifactId,
298
+ decision_id: nextDecisionId,
299
+ target_path: targetPath,
300
+ applied_at: new Date().toISOString(),
301
+ refreshed_index: indexResult.refreshed,
302
+ };
303
+ await writeDocsReviewMarker(options.statePath, {
304
+ ...current,
305
+ applied_decision_artifact: appliedDecisionArtifact,
306
+ hosted: {
307
+ ...current.hosted,
308
+ artifacts: (current.hosted?.artifacts ?? []).map((item) =>
309
+ item?.artifact_id === artifactId
310
+ ? { ...item, local_status: 'applied', local_decision_id: nextDecisionId }
311
+ : item,
312
+ ),
313
+ },
314
+ });
315
+
316
+ return {
317
+ rootDir: options.rootDir,
318
+ statePath: options.statePath,
319
+ status: current.status ?? 'hosted-review-completed',
320
+ llm: current.llm ?? null,
321
+ model: current.model ?? null,
322
+ runtime: current.runtime ?? null,
323
+ hosted: current.hosted ?? null,
324
+ appliedDecisionArtifact,
325
+ reviewPrompt: null,
326
+ warnings: indexResult.warnings,
327
+ message: `Applied decision artifact ${artifactId} as D-${String(nextDecisionId).padStart(3, '0')} in ${targetPath}.`,
328
+ };
329
+ }
330
+
331
+ async function readDocsReviewMarker(statePath) {
332
+ try {
333
+ return JSON.parse(await readFile(statePath, 'utf8'));
334
+ } catch (error) {
335
+ if (error?.code === 'ENOENT') {
336
+ throw new Error(`No docs-review marker found at ${statePath}. Run docs-review before applying output.`);
337
+ }
338
+
339
+ throw new Error(`Could not read docs-review marker at ${statePath}: ${error.message}`);
340
+ }
341
+ }
342
+
343
+ function validateDocsReviewMode(options) {
344
+ const enabled = [
345
+ options.submitHosted ? '--submit-hosted' : null,
346
+ options.pollHosted ? '--poll-hosted' : null,
347
+ options.runLocal ? '--run-local' : null,
348
+ options.runLocalAnthropic ? '--run-local-anthropic' : null,
349
+ options.applyOutput ? '--apply-output' : null,
350
+ options.applyDecisionArtifact ? '--apply-decision-artifact' : null,
351
+ options.complete ? '--complete' : null,
352
+ ].filter(Boolean);
353
+
354
+ if (enabled.length > 1) {
355
+ throw new Error(`docs-review accepts only one execution mode at a time. Conflicting flags: ${enabled.join(', ')}.`);
356
+ }
357
+ }
358
+
359
+ async function promptForReviewConfig(options, environment) {
360
+ const prompter = createPrompter(environment.io, environment.runtime);
361
+ try {
362
+ if (!options.llm) {
363
+ prompter.write('Common choices: claude, codex, gemini. Custom provider names are allowed.\n');
364
+ }
365
+ const llm = options.llm ?? await askInput(prompter, 'Preferred LLM for architecture review', {
366
+ defaultValue: 'claude',
367
+ });
368
+ const model = options.model ?? await askInput(prompter, 'Model name to record', {
369
+ defaultValue: defaultModelForLlm(llm),
370
+ });
371
+
372
+ return normalizeReviewConfig({ llm, model });
373
+ } finally {
374
+ await prompter.close();
375
+ }
376
+ }
377
+
378
+ function normalizeReviewConfig(options) {
379
+ const llm = String(options.llm ?? options.provider ?? 'claude').trim();
380
+ if (!llm) {
381
+ throw new Error('docs-review requires a non-empty LLM provider name.');
382
+ }
383
+
384
+ const model = String(options.model ?? defaultModelForLlm(llm)).trim();
385
+ if (!model) {
386
+ throw new Error('docs-review requires a non-empty model name.');
387
+ }
388
+
389
+ return { llm, model };
390
+ }
391
+
392
+ function shouldRunLocal(options) {
393
+ return Boolean(options.runLocal || options.runLocalAnthropic);
394
+ }
395
+
396
+ function resolveLocalProvider(options) {
397
+ if (options.runLocalAnthropic) {
398
+ return {
399
+ provider: 'anthropic',
400
+ credentialEnvVar: options.anthropicEnvVar ?? 'ANTHROPIC_API_KEY',
401
+ };
402
+ }
403
+
404
+ const provider = normalizeOptionalString(options.provider);
405
+ if (!provider) {
406
+ return null;
407
+ }
408
+
409
+ if (provider !== 'anthropic') {
410
+ throw new Error(`Unsupported local docs-review provider "${provider}". Supported providers: anthropic.`);
411
+ }
412
+
413
+ return {
414
+ provider,
415
+ credentialEnvVar: options.anthropicEnvVar ?? 'ANTHROPIC_API_KEY',
416
+ };
417
+ }
418
+
419
+ function resolveDocsReviewRuntime(project, options) {
420
+ const mode = project?.mode;
421
+ const localProvider = options.localProvider;
422
+ const hasLocalKey =
423
+ localProvider &&
424
+ typeof options.env?.[localProvider.credentialEnvVar] === 'string' &&
425
+ options.env[localProvider.credentialEnvVar].trim() !== '';
426
+ const hasHostedBinding =
427
+ project?.sync &&
428
+ typeof project.sync === 'object' &&
429
+ typeof project.sync.api_url === 'string' &&
430
+ typeof project.sync.project_id === 'string';
431
+
432
+ if (mode === 'local-only') {
433
+ if (options.runLocal && !localProvider) {
434
+ throw new Error('docs-review --run-local requires --provider <name>. Supported providers: anthropic.');
435
+ }
436
+
437
+ if (options.runLocal && !hasLocalKey) {
438
+ throw new Error(
439
+ `docs-review --run-local --provider ${localProvider.provider} requires ${localProvider.credentialEnvVar}.`,
440
+ );
441
+ }
442
+
443
+ return localProvider
444
+ ? {
445
+ provider: 'local',
446
+ local_provider: localProvider.provider,
447
+ credential_env_var: localProvider.credentialEnvVar,
448
+ }
449
+ : { provider: 'external' };
450
+ }
451
+
452
+ if (mode === 'local-primary') {
453
+ if (options.runLocal) {
454
+ if (!localProvider) {
455
+ throw new Error('docs-review --run-local requires --provider <name>. Supported providers: anthropic.');
456
+ }
457
+
458
+ if (!hasLocalKey) {
459
+ throw new Error(`docs-review --run-local --provider ${localProvider.provider} requires ${localProvider.credentialEnvVar}.`);
460
+ }
461
+
462
+ return {
463
+ provider: 'local',
464
+ local_provider: localProvider.provider,
465
+ credential_env_var: localProvider.credentialEnvVar,
466
+ };
467
+ }
468
+
469
+ if (hasHostedBinding) {
470
+ return {
471
+ provider: 'hosted',
472
+ api_url: project.sync.api_url,
473
+ project_id: project.sync.project_id,
474
+ ...(typeof project.sync.credential_env_var === 'string'
475
+ ? { credential_env_var: project.sync.credential_env_var }
476
+ : {}),
477
+ };
478
+ }
479
+
480
+ if (hasLocalKey) {
481
+ return {
482
+ provider: 'local',
483
+ local_provider: localProvider.provider,
484
+ credential_env_var: localProvider.credentialEnvVar,
485
+ };
486
+ }
487
+
488
+ throw new Error(
489
+ `docs-review for local-primary projects requires a hosted sync binding or ${options.anthropicEnvVar}.`,
490
+ );
491
+ }
492
+
493
+ if (mode === 'hosted-only') {
494
+ if (options.runLocal) {
495
+ throw new Error('docs-review --run-local is not supported for hosted-only projects. Use --submit-hosted, or switch the project to local-only/local-primary mode for direct local execution.');
496
+ }
497
+
498
+ if (!hasHostedBinding) {
499
+ throw new Error('docs-review for hosted-only projects requires a hosted sync binding in project.yaml.');
500
+ }
501
+
502
+ return {
503
+ provider: 'hosted',
504
+ api_url: project.sync.api_url,
505
+ project_id: project.sync.project_id,
506
+ ...(typeof project.sync.credential_env_var === 'string'
507
+ ? { credential_env_var: project.sync.credential_env_var }
508
+ : {}),
509
+ };
510
+ }
511
+
512
+ throw new Error('docs-review requires project.yaml mode to be local-only, local-primary, or hosted-only.');
513
+ }
514
+
515
+ async function runLocalDocsReview(options) {
516
+ if (options.runtime.provider !== 'local') {
517
+ throw new Error('docs-review --run-local requires local runtime.');
518
+ }
519
+
520
+ if (options.runtime.local_provider !== 'anthropic') {
521
+ throw new Error(`Unsupported local docs-review provider "${options.runtime.local_provider}". Supported providers: anthropic.`);
522
+ }
523
+
524
+ return runLocalAnthropicDocsReview(options);
525
+ }
526
+
527
+ async function runLocalAnthropicDocsReview(options) {
528
+ if (typeof options.fetch !== 'function') {
529
+ throw new Error('docs-review --run-local --provider anthropic requires a fetch implementation.');
530
+ }
531
+
532
+ const credential = normalizeOptionalString(options.env?.[options.runtime.credential_env_var]);
533
+ if (!credential) {
534
+ throw new Error(`docs-review --run-local --provider anthropic requires ${options.runtime.credential_env_var}.`);
535
+ }
536
+
537
+ const response = await options.fetch('https://api.anthropic.com/v1/messages', {
538
+ method: 'POST',
539
+ headers: {
540
+ 'anthropic-version': '2023-06-01',
541
+ 'content-type': 'application/json',
542
+ 'x-api-key': credential,
543
+ },
544
+ body: JSON.stringify({
545
+ model: options.model,
546
+ max_tokens: normalizeMaxTokens(options.maxTokens),
547
+ messages: [
548
+ {
549
+ role: 'user',
550
+ content: options.reviewPrompt,
551
+ },
552
+ ],
553
+ }),
554
+ });
555
+
556
+ if (!response.ok) {
557
+ const body = typeof response.text === 'function' ? await response.text() : '';
558
+ throw new Error(`Local Anthropic docs-review failed with ${response.status}${body ? `: ${body}` : ''}`);
559
+ }
560
+
561
+ const body = typeof response.json === 'function' ? await response.json() : {};
562
+ const text = extractAnthropicText(body);
563
+ if (!text) {
564
+ throw new Error('Local Anthropic docs-review response did not include text content.');
565
+ }
566
+
567
+ const outputPath = path.join(path.dirname(options.statePath), 'docs-review-output.md');
568
+ await writeFile(outputPath, `${text.trim()}\n`, 'utf8');
569
+
570
+ return {
571
+ provider: 'anthropic',
572
+ output_path: outputPath,
573
+ truncated: body.stop_reason === 'max_tokens',
574
+ };
575
+ }
576
+
577
+ function extractAnthropicText(body) {
578
+ if (!Array.isArray(body?.content)) {
579
+ return null;
580
+ }
581
+
582
+ return body.content
583
+ .filter((item) => item?.type === 'text' && typeof item.text === 'string')
584
+ .map((item) => item.text)
585
+ .join('\n\n')
586
+ .trim() || null;
587
+ }
588
+
589
+ function parseArchitectureDocBlocks(source) {
590
+ const blocks = [];
591
+ const pattern = /^```vibecompass-architecture-doc\s+path=([^\s`]+)\s*\n([\s\S]*?)^```[ \t]*$/gm;
592
+ let match;
593
+
594
+ while ((match = pattern.exec(source)) !== null) {
595
+ blocks.push({
596
+ path: normalizeBlockPath(match[1]),
597
+ content: match[2].replace(/\n$/, ''),
598
+ });
599
+ }
600
+
601
+ return blocks;
602
+ }
603
+
604
+ function normalizeBlockPath(value) {
605
+ return String(value ?? '').trim().replaceAll('\\', '/');
606
+ }
607
+
608
+ function validateArchitectureDocBlock(block) {
609
+ if (!block.path.startsWith('architecture/') || !block.path.endsWith('.md')) {
610
+ throw new Error(`Accepted docs-review output can only write architecture/*.md files. Invalid path: ${block.path}`);
611
+ }
612
+
613
+ if (path.posix.isAbsolute(block.path) || block.path.split('/').includes('..')) {
614
+ throw new Error(`Accepted docs-review output path must stay inside architecture/. Invalid path: ${block.path}`);
615
+ }
616
+
617
+ if (block.path.split('/').length < 3) {
618
+ throw new Error(
619
+ `Accepted docs-review output path must include at least one subdirectory under architecture/. Invalid path: ${block.path}`,
620
+ );
621
+ }
622
+
623
+ const frontmatter = parseFrontmatter(block.content, { sourceName: block.path });
624
+ if (!frontmatter.hasFrontmatter || !frontmatter.data) {
625
+ throw new Error(`Accepted architecture doc ${block.path} must include frontmatter.`);
626
+ }
627
+
628
+ for (const field of ['domain', 'feature', 'component', 'status']) {
629
+ if (typeof frontmatter.data[field] !== 'string' || frontmatter.data[field].trim() === '') {
630
+ throw new Error(`Accepted architecture doc ${block.path} requires non-empty frontmatter field "${field}".`);
631
+ }
632
+ }
633
+ }
634
+
635
+ async function submitHostedDocsReview(options) {
636
+ if (options.runtime.provider !== 'hosted') {
637
+ throw new Error('docs-review --submit-hosted requires a hosted sync binding in project.yaml.');
638
+ }
639
+
640
+ if (typeof options.fetch !== 'function') {
641
+ throw new Error('docs-review --submit-hosted requires a fetch implementation.');
642
+ }
643
+
644
+ const credentialEnvVar = options.runtime.credential_env_var;
645
+ if (!credentialEnvVar) {
646
+ throw new Error('docs-review --submit-hosted requires sync.credential_env_var in project.yaml.');
647
+ }
648
+
649
+ const credential = normalizeOptionalString(options.env?.[credentialEnvVar]);
650
+ if (!credential) {
651
+ throw new Error(`docs-review --submit-hosted requires ${credentialEnvVar}.`);
652
+ }
653
+
654
+ const endpoint = new URL(
655
+ `api/sync/projects/${encodeURIComponent(options.runtime.project_id)}/docs-review`,
656
+ ensureTrailingSlash(options.runtime.api_url),
657
+ );
658
+ const baselineRemoteRevisionId = normalizeUuid(
659
+ options.manifest?.manifest?.sync?.last_successful_remote_revision,
660
+ );
661
+ const response = await options.fetch(endpoint.href, {
662
+ method: 'POST',
663
+ headers: {
664
+ accept: 'application/json',
665
+ authorization: `Bearer ${credential}`,
666
+ 'content-type': 'application/json',
667
+ },
668
+ body: JSON.stringify({
669
+ llm: options.reviewConfig.llm,
670
+ model: options.reviewConfig.model,
671
+ prompt: options.reviewPrompt,
672
+ project: {
673
+ name: options.project.name,
674
+ mode: options.project.mode,
675
+ repos: Array.isArray(options.project.repos) ? options.project.repos : [],
676
+ },
677
+ ...(baselineRemoteRevisionId
678
+ ? { baseline_remote_revision_id: baselineRemoteRevisionId }
679
+ : {}),
680
+ local_root_revision: options.manifest?.manifest?.canonical?.local_root_revision,
681
+ evidence_scope: {
682
+ manifest_hash: options.manifest?.manifest?.canonical?.manifest_hash,
683
+ document_count: options.manifest?.manifest?.canonical?.document_count,
684
+ warning_count: options.manifest?.manifest?.canonical?.warning_count,
685
+ },
686
+ }),
687
+ });
688
+
689
+ if (!response.ok) {
690
+ const body = typeof response.text === 'function' ? await response.text() : '';
691
+ const details = parseJsonObject(body);
692
+ if (details?.error) {
693
+ const nextStep = details.next_step ? ` Next step: ${details.next_step}` : '';
694
+ throw new Error(
695
+ `Hosted docs-review request failed with ${response.status}: ${details.error}${nextStep}`,
696
+ );
697
+ }
698
+
699
+ throw new Error(`Hosted docs-review request failed with ${response.status}${body ? `: ${body}` : ''}`);
700
+ }
701
+
702
+ const body = typeof response.json === 'function' ? await response.json() : {};
703
+ const runId = normalizeOptionalString(body.run_id ?? body.runId);
704
+
705
+ if (!runId) {
706
+ throw new Error('Hosted docs-review response did not include run_id.');
707
+ }
708
+
709
+ return {
710
+ endpoint: endpoint.href,
711
+ run_id: runId,
712
+ status: normalizeOptionalString(body.status) ?? 'accepted',
713
+ phase: normalizeOptionalString(body.phase),
714
+ };
715
+ }
716
+
717
+ function parseJsonObject(value) {
718
+ if (!value) return null;
719
+ try {
720
+ const parsed = JSON.parse(value);
721
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
722
+ } catch {
723
+ return null;
724
+ }
725
+ }
726
+
727
+ async function pollHostedDocsReview(options) {
728
+ const marker = await readDocsReviewMarker(options.statePath);
729
+ const runtime = marker.runtime;
730
+ const hosted = marker.hosted;
731
+
732
+ if (runtime?.provider !== 'hosted' || !runtime.api_url || !runtime.project_id) {
733
+ throw new Error('docs-review --poll-hosted requires a hosted docs-review marker.');
734
+ }
735
+
736
+ const runId = normalizeOptionalString(hosted?.run_id);
737
+ if (!runId) {
738
+ throw new Error('docs-review --poll-hosted requires hosted.run_id in state/docs-review.json.');
739
+ }
740
+
741
+ const credentialEnvVar = runtime.credential_env_var;
742
+ if (!credentialEnvVar) {
743
+ throw new Error('docs-review --poll-hosted requires runtime.credential_env_var in state/docs-review.json.');
744
+ }
745
+
746
+ const credential = normalizeOptionalString(options.env?.[credentialEnvVar]);
747
+ if (!credential) {
748
+ throw new Error(`docs-review --poll-hosted requires ${credentialEnvVar}.`);
749
+ }
750
+
751
+ const endpoint = new URL(
752
+ `api/sync/projects/${encodeURIComponent(runtime.project_id)}/runs/${encodeURIComponent(runId)}`,
753
+ ensureTrailingSlash(runtime.api_url),
754
+ );
755
+ const response = await options.fetch(endpoint.href, {
756
+ method: 'GET',
757
+ headers: {
758
+ accept: 'application/json',
759
+ authorization: `Bearer ${credential}`,
760
+ },
761
+ });
762
+
763
+ if (!response.ok) {
764
+ const body = typeof response.text === 'function' ? await response.text() : '';
765
+ throw new Error(`Hosted docs-review poll failed with ${response.status}${body ? `: ${body}` : ''}`);
766
+ }
767
+
768
+ const body = typeof response.json === 'function' ? await response.json() : {};
769
+ const {
770
+ phase: _oldPhase,
771
+ prompt_version: _oldPromptVersion,
772
+ parser_version: _oldParserVersion,
773
+ proposal_ids: _oldProposalIds,
774
+ artifact_ids: _oldArtifactIds,
775
+ artifacts: _oldArtifacts,
776
+ warnings: _oldWarnings,
777
+ error_code: _oldErrorCode,
778
+ error_message: _oldErrorMessage,
779
+ ...hostedBase
780
+ } = hosted;
781
+ const hostedStatus = normalizeOptionalString(body.status) ?? hosted.status ?? 'accepted';
782
+ const hostedErrorMessage = normalizeOptionalString(body.error_message);
783
+ const updatedHosted = {
784
+ ...hostedBase,
785
+ endpoint: hosted.endpoint,
786
+ run_id: runId,
787
+ status: hostedStatus,
788
+ ...(normalizeOptionalString(body.phase) ? { phase: normalizeOptionalString(body.phase) } : {}),
789
+ ...(normalizeOptionalString(body.prompt_version) ? { prompt_version: normalizeOptionalString(body.prompt_version) } : {}),
790
+ ...(normalizeOptionalString(body.parser_version) ? { parser_version: normalizeOptionalString(body.parser_version) } : {}),
791
+ ...(Array.isArray(body.proposal_ids) ? { proposal_ids: body.proposal_ids } : {}),
792
+ ...(Array.isArray(body.artifact_ids) ? { artifact_ids: body.artifact_ids } : {}),
793
+ ...(Array.isArray(body.artifacts) ? { artifacts: body.artifacts } : {}),
794
+ ...(Array.isArray(body.warnings) ? { warnings: body.warnings } : {}),
795
+ ...(normalizeOptionalString(body.error_code) ? { error_code: normalizeOptionalString(body.error_code) } : {}),
796
+ ...(hostedErrorMessage ? { error_message: hostedErrorMessage } : {}),
797
+ };
798
+ const markerStatus = markerStatusForHostedRunStatus(hostedStatus);
799
+ const message = hostedStatus === 'failed' && hostedErrorMessage
800
+ ? `Hosted docs-review run ${runId} is failed: ${hostedErrorMessage}`
801
+ : `Hosted docs-review run ${runId} is ${updatedHosted.status}.`;
802
+
803
+ const updatedMarker = {
804
+ ...marker,
805
+ status: markerStatus,
806
+ hosted: updatedHosted,
807
+ };
808
+ await writeDocsReviewMarker(options.statePath, updatedMarker);
809
+
810
+ return {
811
+ rootDir: options.rootDir,
812
+ statePath: options.statePath,
813
+ status: markerStatus,
814
+ llm: marker.llm ?? null,
815
+ model: marker.model ?? null,
816
+ runtime,
817
+ hosted: updatedHosted,
818
+ reviewPrompt: null,
819
+ warnings: (updatedHosted.warnings ?? []).map((warning) =>
820
+ warning?.message ? `${warning.code ?? 'hosted_warning'}: ${warning.message}` : String(warning),
821
+ ),
822
+ message,
823
+ };
824
+ }
825
+
826
+ async function writeDocsReviewMarker(statePath, marker) {
827
+ await mkdir(path.dirname(statePath), { recursive: true });
828
+ await writeFile(statePath, `${JSON.stringify(marker, null, 2)}\n`, 'utf8');
829
+ }
830
+
831
+ async function pathExists(targetPath) {
832
+ try {
833
+ await access(targetPath);
834
+ return true;
835
+ } catch {
836
+ return false;
837
+ }
838
+ }
839
+
840
+ function normalizeOptionalString(value) {
841
+ if (typeof value !== 'string') {
842
+ return null;
843
+ }
844
+
845
+ const trimmed = value.trim();
846
+ return trimmed ? trimmed : null;
847
+ }
848
+
849
+ function normalizeUuid(value) {
850
+ const normalized = normalizeOptionalString(value);
851
+ if (!normalized) {
852
+ return null;
853
+ }
854
+
855
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(normalized)
856
+ ? normalized
857
+ : null;
858
+ }
859
+
860
+ function normalizeDecisionTargetPath(value) {
861
+ const normalized = normalizeBlockPath(value);
862
+ if (
863
+ !normalized.startsWith('decisions/') ||
864
+ !normalized.endsWith('.md') ||
865
+ normalized.split('/').length !== 2 ||
866
+ normalized.split('/').some((part) => part === '..' || part === '') ||
867
+ ['decisions/INDEX.md', 'decisions/README.md', 'decisions/EXAMPLE.md'].includes(normalized)
868
+ ) {
869
+ throw new Error(`Decision artifact target must be a decisions/*.md domain file. Invalid path: ${value}`);
870
+ }
871
+
872
+ return normalized;
873
+ }
874
+
875
+ async function readNextDecisionId(rootDir) {
876
+ const decisionsDir = path.join(rootDir, 'decisions');
877
+ let max = 0;
878
+
879
+ for (const fileName of await listDecisionFileNames(decisionsDir)) {
880
+ const content = await readFile(path.join(decisionsDir, fileName), 'utf8');
881
+ for (const match of content.matchAll(/^###\s+D-(\d+)\b/gm)) {
882
+ max = Math.max(max, Number(match[1]));
883
+ }
884
+ }
885
+
886
+ return max + 1;
887
+ }
888
+
889
+ async function listDecisionFileNames(decisionsDir) {
890
+ try {
891
+ return (await readdir(decisionsDir))
892
+ .filter((fileName) =>
893
+ fileName.endsWith('.md') &&
894
+ !['INDEX.md', 'README.md', 'EXAMPLE.md'].includes(fileName),
895
+ )
896
+ .sort((left, right) => left.localeCompare(right));
897
+ } catch (error) {
898
+ if (error?.code === 'ENOENT') return [];
899
+ throw error;
900
+ }
901
+ }
902
+
903
+ function renderDecisionEntry(options) {
904
+ const decisionNumber = `D-${String(options.decisionId).padStart(3, '0')}`;
905
+ return [
906
+ `### ${decisionNumber} — ${options.title}`,
907
+ '',
908
+ `**Timestamp:** ${formatDecisionTimestamp(new Date())}`,
909
+ `**Decision:** ${options.decision}`,
910
+ `**Rationale:** ${options.rationale}`,
911
+ ...(options.context ? [`**Context:** ${options.context}`] : []),
912
+ '',
913
+ ].join('\n');
914
+ }
915
+
916
+ function formatDecisionTimestamp(date) {
917
+ const offsetMinutes = -date.getTimezoneOffset();
918
+ const sign = offsetMinutes >= 0 ? '+' : '-';
919
+ const absoluteOffset = Math.abs(offsetMinutes);
920
+ const offsetHours = String(Math.floor(absoluteOffset / 60)).padStart(2, '0');
921
+ const offsetRemainder = String(absoluteOffset % 60).padStart(2, '0');
922
+ const yyyy = date.getFullYear();
923
+ const mm = String(date.getMonth() + 1).padStart(2, '0');
924
+ const dd = String(date.getDate()).padStart(2, '0');
925
+ const hh = String(date.getHours()).padStart(2, '0');
926
+ const min = String(date.getMinutes()).padStart(2, '0');
927
+
928
+ return `${yyyy}-${mm}-${dd} ${hh}:${min} ${formatTimeZoneAbbreviation(date) ?? `UTC${sign}${offsetHours}:${offsetRemainder}`}`;
929
+ }
930
+
931
+ function formatTimeZoneAbbreviation(date) {
932
+ try {
933
+ const parts = new Intl.DateTimeFormat('en-US', {
934
+ timeZoneName: 'short',
935
+ }).formatToParts(date);
936
+ const zone = parts.find((part) => part.type === 'timeZoneName')?.value;
937
+ return zone && !/^GMT[+-]/.test(zone) ? zone : null;
938
+ } catch {
939
+ return null;
940
+ }
941
+ }
942
+
943
+ async function refreshDecisionIndex(rootDir) {
944
+ const decisionsDir = path.join(rootDir, 'decisions');
945
+ const indexPath = path.join(decisionsDir, 'INDEX.md');
946
+ const warnings = [];
947
+ const rows = [];
948
+
949
+ for (const fileName of await listDecisionFileNames(decisionsDir)) {
950
+ const relativePath = `decisions/${fileName}`;
951
+ const content = await readFile(path.join(decisionsDir, fileName), 'utf8');
952
+ for (const match of content.matchAll(/^###\s+D-(\d+)\s+—\s+(.+)$/gm)) {
953
+ rows.push({
954
+ id: Number(match[1]),
955
+ title: match[2].trim(),
956
+ domainFile: fileName,
957
+ });
958
+ }
959
+ if (!content.match(/^###\s+D-(\d+)\s+—\s+(.+)$/m)) {
960
+ warnings.push(`No indexable decisions found in ${relativePath}.`);
961
+ }
962
+ }
963
+
964
+ rows.sort((left, right) => left.id - right.id);
965
+ await mkdir(decisionsDir, { recursive: true });
966
+ await writeFile(indexPath, renderDecisionIndex(rows), 'utf8');
967
+ return { refreshed: true, warnings };
968
+ }
969
+
970
+ function renderDecisionIndex(rows) {
971
+ return [
972
+ '# Decision Index',
973
+ '',
974
+ 'All decisions in chronological order. Each entry links to its domain file.',
975
+ '',
976
+ '> **Rule:** Decisions are append-only — never modify existing entries.',
977
+ '',
978
+ '| # | Decision | Domain |',
979
+ '|---|----------|--------|',
980
+ ...rows.map((row) => {
981
+ const decisionNumber = `D-${String(row.id).padStart(3, '0')}`;
982
+ const domain = path.basename(row.domainFile, '.md');
983
+ return `| ${decisionNumber} | ${row.title} | [${domain}](${row.domainFile}) |`;
984
+ }),
985
+ '',
986
+ ].join('\n');
987
+ }
988
+
989
+ function markerStatusForHostedRunStatus(status) {
990
+ if (status === 'completed') {
991
+ return 'hosted-review-completed';
992
+ }
993
+
994
+ if (status === 'failed') {
995
+ return 'hosted-review-failed';
996
+ }
997
+
998
+ if (status === 'needs_rebase') {
999
+ return 'hosted-review-needs-rebase';
1000
+ }
1001
+
1002
+ return 'hosted-review-requested';
1003
+ }
1004
+
1005
+ function normalizeMaxTokens(value) {
1006
+ if (value === undefined || value === null) {
1007
+ return 16000;
1008
+ }
1009
+
1010
+ const number = typeof value === 'number' ? value : Number(value);
1011
+ if (!Number.isInteger(number) || number < 1024 || number > 32000) {
1012
+ throw new Error('docs-review --max-tokens must be an integer from 1024 to 32000.');
1013
+ }
1014
+
1015
+ return number;
1016
+ }
1017
+
1018
+ function ensureTrailingSlash(value) {
1019
+ return value.endsWith('/') ? value : `${value}/`;
1020
+ }
1021
+
1022
+ function renderDocsReviewMessage(options) {
1023
+ if (options.submitHosted) {
1024
+ return 'Hosted docs-review request submitted. Poll the hosted run, apply accepted docs changes locally, then run docs-review --complete.';
1025
+ }
1026
+
1027
+ if (shouldRunLocal(options)) {
1028
+ const provider = options.provider ?? 'anthropic';
1029
+ return `Local ${provider} docs-review completed. Review the generated output, then run docs-review --apply-output after accepting the architecture docs.`;
1030
+ }
1031
+
1032
+ return 'Docs-review preflight passed. Run the generated architecture-review prompt in the selected LLM, then record completion after applying accepted docs changes.';
1033
+ }
1034
+
1035
+ function renderReviewPrompt(options) {
1036
+ return [
1037
+ '# VibeCompass Docs Review Prompt v1',
1038
+ '',
1039
+ 'Run a comprehensive VibeCompass architecture documentation review for the project-memory root below. Follow this prompt exactly; do not invent a different document structure.',
1040
+ '',
1041
+ '## Review Context',
1042
+ `- Project memory root: ${options.rootDir}`,
1043
+ `- Project: ${options.project.name}`,
1044
+ `- Project mode: ${options.project.mode}`,
1045
+ `- Review provider to record: ${options.llm}`,
1046
+ `- Review model/version to record: ${options.model}`,
1047
+ '',
1048
+ '## Required Inputs',
1049
+ '1. Read `project.yaml`.',
1050
+ '2. Read `context.md` if present.',
1051
+ '3. Read all canonical files under `architecture/`, `decisions/`, and finalized `sessions/`.',
1052
+ '4. Inspect the declared repositories and cite concrete source, config, and test files before making implementation claims.',
1053
+ '',
1054
+ '## Architecture Doc Contract',
1055
+ 'When creating or updating architecture docs, use the existing VibeCompass structure:',
1056
+ '- path: `architecture/<domain-slug>/<feature-slug>/<component-slug>.md` for component docs',
1057
+ '- frontmatter fields: `domain`, `feature`, `component`, `status`, plus `repo` or `repos` when implementation-scoped',
1058
+ '- body sections: `## Description`, `## Review metadata`, `## Details`, `## Next steps`, `## Involved files`',
1059
+ '',
1060
+ 'Use this exact Review metadata sub-header in every generated architecture doc:',
1061
+ '',
1062
+ '## Review metadata',
1063
+ `- Review provider: ${options.llm}`,
1064
+ `- Review model/version: ${options.model}`,
1065
+ `- Project mode: ${options.project.mode}`,
1066
+ '- Confidence: high | medium | low',
1067
+ '- Coverage: comprehensive | partial | initial',
1068
+ '- Evidence: repo:path references inspected before making implementation claims',
1069
+ '- Blindspots: explicit list, or "None identified" only when evidence is comprehensive',
1070
+ '',
1071
+ '## Review Rules',
1072
+ '1. Do not delete `architecture/overview/project-shape.md`.',
1073
+ '2. Add evidence-backed component docs alongside the overview.',
1074
+ '3. Update `architecture/overview/project-shape.md` coverage and blindspot sections to summarize what has now been mapped.',
1075
+ '4. Keep all implementation claims source-backed with `repo:path` evidence.',
1076
+ '5. Use only the exact confidence and coverage enum values listed above.',
1077
+ '6. Put uncertainty in `Blindspots`; do not fill gaps with guesses.',
1078
+ '7. Do not append real `D-NNN` decisions without explicit user acceptance. Propose candidate decisions separately.',
1079
+ '8. Do not edit `decisions/INDEX.md` directly unless a canonical decision file was explicitly accepted and appended.',
1080
+ '',
1081
+ '## Output Contract',
1082
+ 'First provide a concise findings summary with:',
1083
+ '- mapped areas',
1084
+ '- gaps/blindspots',
1085
+ '- proposed architecture docs',
1086
+ '- proposed decisions, if any',
1087
+ '',
1088
+ 'Then, for each architecture doc the user accepts, output one fenced block exactly like this:',
1089
+ '',
1090
+ '```vibecompass-architecture-doc path=architecture/domain/feature/component.md',
1091
+ '<complete markdown file content>',
1092
+ '```',
1093
+ '',
1094
+ '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`.',
1095
+ ].join('\n');
1096
+ }
1097
+
1098
+ function ensureTrailingNewline(value) {
1099
+ return value.endsWith('\n') ? value : `${value}\n`;
1100
+ }
1101
+
1102
+ function defaultModelForLlm(llm) {
1103
+ const normalized = String(llm ?? '').trim().toLowerCase();
1104
+
1105
+ if (normalized === 'codex') {
1106
+ return 'gpt-5-codex';
1107
+ }
1108
+
1109
+ if (normalized === 'gemini') {
1110
+ return 'gemini-2.5-pro';
1111
+ }
1112
+
1113
+ if (normalized === 'claude') {
1114
+ return 'claude-sonnet-4-6';
1115
+ }
1116
+
1117
+ return 'record-the-model-version';
1118
+ }
1119
+
1120
+ function createPrompter(io = {}, runtime = {}) {
1121
+ if (typeof runtime?.prompt === 'function') {
1122
+ return {
1123
+ async ask(spec) {
1124
+ return runtime.prompt(spec);
1125
+ },
1126
+ write(text) {
1127
+ (io.stdout ?? process.stdout).write(text);
1128
+ },
1129
+ async close() {},
1130
+ };
1131
+ }
1132
+
1133
+ const input = runtime?.stdin ?? io.stdin ?? process.stdin;
1134
+ const output = io.stdout ?? process.stdout;
1135
+
1136
+ if (!input?.isTTY) {
1137
+ throw new Error('Guided docs-review requires an interactive TTY or a custom prompt adapter.');
1138
+ }
1139
+
1140
+ const rl = createInterface({ input, output });
1141
+ return {
1142
+ async ask(spec) {
1143
+ return rl.question(`${spec.message}${renderPromptSuffix(spec)}: `);
1144
+ },
1145
+ write(text) {
1146
+ output.write(text);
1147
+ },
1148
+ async close() {
1149
+ rl.close();
1150
+ },
1151
+ };
1152
+ }
1153
+
1154
+ async function askInput(prompter, message, options = {}) {
1155
+ while (true) {
1156
+ const raw = await prompter.ask({
1157
+ type: 'input',
1158
+ message,
1159
+ defaultValue: options.defaultValue ?? null,
1160
+ });
1161
+ const value = normalizePromptAnswer(raw, options.defaultValue ?? null);
1162
+ if (value) {
1163
+ return value;
1164
+ }
1165
+
1166
+ prompter.write('A value is required.\n');
1167
+ }
1168
+ }
1169
+
1170
+ async function askChoice(prompter, message, choices, options = {}) {
1171
+ const defaultValue = options.defaultValue ?? choices[0]?.value ?? null;
1172
+ prompter.write(
1173
+ `${choices
1174
+ .map((choice, index) => `${index + 1}. ${choice.value} — ${choice.description}`)
1175
+ .join('\n')}\n`,
1176
+ );
1177
+
1178
+ while (true) {
1179
+ const raw = await prompter.ask({
1180
+ type: 'choice',
1181
+ message,
1182
+ defaultValue,
1183
+ choices,
1184
+ });
1185
+ const normalized = normalizePromptAnswer(raw, defaultValue)?.toLowerCase();
1186
+
1187
+ const byIndex = Number.parseInt(normalized, 10);
1188
+ if (!Number.isNaN(byIndex) && choices[byIndex - 1]) {
1189
+ return choices[byIndex - 1].value;
1190
+ }
1191
+
1192
+ const byValue = choices.find((choice) => choice.value === normalized);
1193
+ if (byValue) {
1194
+ return byValue.value;
1195
+ }
1196
+
1197
+ prompter.write('Please choose one of the listed options.\n');
1198
+ }
1199
+ }
1200
+
1201
+ function renderPromptSuffix(spec) {
1202
+ if (spec.defaultValue) {
1203
+ return ` [${spec.defaultValue}]`;
1204
+ }
1205
+
1206
+ return '';
1207
+ }
1208
+
1209
+ function normalizePromptAnswer(raw, defaultValue) {
1210
+ const trimmed = String(raw ?? '').trim();
1211
+ if (!trimmed) {
1212
+ if (defaultValue === null || defaultValue === undefined) {
1213
+ return null;
1214
+ }
1215
+
1216
+ return String(defaultValue);
1217
+ }
1218
+
1219
+ return trimmed;
1220
+ }