@vibecompass/vibecompass 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +157 -13
- package/package.json +1 -1
- package/src/cli.js +161 -2
- package/src/docs-review.js +813 -0
- package/src/generators/agent-files/template.js +3 -3
- package/src/index.js +1 -0
- package/src/init.js +3 -1
- package/src/manifest.js +34 -1
- package/src/project-memory.js +1 -1
- package/src/scaffold.js +220 -14
- package/src/session.js +256 -25
- package/src/setup.js +54 -0
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
import { access, mkdir, readFile, 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 { parseSimpleYaml } from './simple-yaml.js';
|
|
6
|
+
|
|
7
|
+
export async function preflightDocsReview(options = {}, environment = {}) {
|
|
8
|
+
validateDocsReviewMode(options);
|
|
9
|
+
const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd();
|
|
10
|
+
const rootDir = path.resolve(cwd, options.rootDir ?? '.compass');
|
|
11
|
+
const statePath = path.join(rootDir, 'state', 'docs-review.json');
|
|
12
|
+
|
|
13
|
+
if (options.applyOutput) {
|
|
14
|
+
return applyDocsReviewOutput({
|
|
15
|
+
rootDir,
|
|
16
|
+
statePath,
|
|
17
|
+
outputPath: options.outputPath,
|
|
18
|
+
llm: options.llm,
|
|
19
|
+
model: options.model,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (options.complete) {
|
|
24
|
+
return completeDocsReview({
|
|
25
|
+
rootDir,
|
|
26
|
+
statePath,
|
|
27
|
+
llm: options.llm,
|
|
28
|
+
model: options.model,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const projectFilePath = path.join(rootDir, 'project.yaml');
|
|
33
|
+
const project = parseSimpleYaml(await readFile(projectFilePath, 'utf8'), {
|
|
34
|
+
sourceName: projectFilePath,
|
|
35
|
+
});
|
|
36
|
+
const reviewConfig = options.guided
|
|
37
|
+
? await promptForReviewConfig(options, environment)
|
|
38
|
+
: normalizeReviewConfig(options);
|
|
39
|
+
const env = environment.env ?? process.env;
|
|
40
|
+
const localProvider = resolveLocalProvider(options);
|
|
41
|
+
const runtime = resolveDocsReviewRuntime(project, {
|
|
42
|
+
env,
|
|
43
|
+
localProvider,
|
|
44
|
+
runLocal: shouldRunLocal(options),
|
|
45
|
+
});
|
|
46
|
+
const reviewPrompt = renderReviewPrompt({
|
|
47
|
+
project,
|
|
48
|
+
rootDir,
|
|
49
|
+
llm: reviewConfig.llm,
|
|
50
|
+
model: reviewConfig.model,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const status = options.submitHosted
|
|
54
|
+
? 'hosted-review-requested'
|
|
55
|
+
: shouldRunLocal(options)
|
|
56
|
+
? 'local-review-generated'
|
|
57
|
+
: 'external-review-requested';
|
|
58
|
+
const hosted = options.submitHosted
|
|
59
|
+
? await submitHostedDocsReview({
|
|
60
|
+
env,
|
|
61
|
+
project,
|
|
62
|
+
reviewConfig,
|
|
63
|
+
reviewPrompt,
|
|
64
|
+
rootDir,
|
|
65
|
+
runtime,
|
|
66
|
+
fetch: environment.runtime?.fetch ?? globalThis.fetch,
|
|
67
|
+
})
|
|
68
|
+
: null;
|
|
69
|
+
const localReview = shouldRunLocal(options)
|
|
70
|
+
? await runLocalDocsReview({
|
|
71
|
+
env,
|
|
72
|
+
maxTokens: options.maxTokens,
|
|
73
|
+
model: reviewConfig.model,
|
|
74
|
+
reviewPrompt,
|
|
75
|
+
rootDir,
|
|
76
|
+
statePath,
|
|
77
|
+
runtime,
|
|
78
|
+
fetch: environment.runtime?.fetch ?? globalThis.fetch,
|
|
79
|
+
})
|
|
80
|
+
: null;
|
|
81
|
+
|
|
82
|
+
await writeDocsReviewMarker(statePath, {
|
|
83
|
+
status,
|
|
84
|
+
requested_at: new Date().toISOString(),
|
|
85
|
+
llm: reviewConfig.llm,
|
|
86
|
+
model: reviewConfig.model,
|
|
87
|
+
runtime,
|
|
88
|
+
...(hosted ? { hosted } : {}),
|
|
89
|
+
...(localReview ? { local_review: localReview } : {}),
|
|
90
|
+
completed_at: null,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
rootDir,
|
|
95
|
+
projectFilePath,
|
|
96
|
+
statePath,
|
|
97
|
+
mode: project.mode,
|
|
98
|
+
llm: reviewConfig.llm,
|
|
99
|
+
model: reviewConfig.model,
|
|
100
|
+
runtime,
|
|
101
|
+
status,
|
|
102
|
+
hosted,
|
|
103
|
+
localReview,
|
|
104
|
+
reviewPrompt,
|
|
105
|
+
warnings: [
|
|
106
|
+
...(localReview?.truncated ? [`Local ${localReview.provider} docs-review stopped at max_tokens; saved output may be partial.`] : []),
|
|
107
|
+
],
|
|
108
|
+
message: renderDocsReviewMessage(options),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function completeDocsReview(options) {
|
|
113
|
+
let current = {};
|
|
114
|
+
try {
|
|
115
|
+
current = JSON.parse(await readFile(options.statePath, 'utf8'));
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (error?.code === 'ENOENT') {
|
|
118
|
+
throw new Error(`No docs-review marker found at ${options.statePath}. Run docs-review before marking it complete.`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (error?.code !== 'ENOENT') {
|
|
122
|
+
throw new Error(`Could not read docs-review marker at ${options.statePath}: ${error.message}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const llm = normalizeOptionalString(options.llm) ?? current.llm ?? null;
|
|
127
|
+
const model = normalizeOptionalString(options.model) ?? current.model ?? null;
|
|
128
|
+
const marker = {
|
|
129
|
+
...current,
|
|
130
|
+
status: 'completed',
|
|
131
|
+
...(llm ? { llm } : {}),
|
|
132
|
+
...(model ? { model } : {}),
|
|
133
|
+
completed_at: new Date().toISOString(),
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
await writeDocsReviewMarker(options.statePath, marker);
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
rootDir: options.rootDir,
|
|
140
|
+
statePath: options.statePath,
|
|
141
|
+
status: 'completed',
|
|
142
|
+
llm,
|
|
143
|
+
model,
|
|
144
|
+
runtime: current.runtime ?? null,
|
|
145
|
+
hosted: current.hosted ?? null,
|
|
146
|
+
applied: current.applied ?? null,
|
|
147
|
+
reviewPrompt: null,
|
|
148
|
+
warnings: [],
|
|
149
|
+
message: 'Docs-review marker completed. Future start-session warnings can treat this root as reviewed.',
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function applyDocsReviewOutput(options) {
|
|
154
|
+
const current = await readDocsReviewMarker(options.statePath);
|
|
155
|
+
const outputPath = options.outputPath
|
|
156
|
+
? path.resolve(options.rootDir, options.outputPath)
|
|
157
|
+
: path.join(path.dirname(options.statePath), 'docs-review-output.md');
|
|
158
|
+
const source = await readFile(outputPath, 'utf8');
|
|
159
|
+
const blocks = parseArchitectureDocBlocks(source);
|
|
160
|
+
|
|
161
|
+
if (blocks.length === 0) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`No accepted architecture doc blocks found in ${outputPath}. Expected fenced blocks like \`\`\`vibecompass-architecture-doc path=architecture/domain/feature/component.md\`.`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
for (const block of blocks) {
|
|
168
|
+
validateArchitectureDocBlock(block);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const applied = [];
|
|
172
|
+
for (const block of blocks) {
|
|
173
|
+
const absoluteTargetPath = path.join(options.rootDir, block.path);
|
|
174
|
+
await mkdir(path.dirname(absoluteTargetPath), { recursive: true });
|
|
175
|
+
const existed = await pathExists(absoluteTargetPath);
|
|
176
|
+
await writeFile(absoluteTargetPath, ensureTrailingNewline(block.content), 'utf8');
|
|
177
|
+
applied.push({ path: block.path, status: existed ? 'overwritten' : 'created' });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const llm = normalizeOptionalString(options.llm) ?? current.llm ?? null;
|
|
181
|
+
const model = normalizeOptionalString(options.model) ?? current.model ?? null;
|
|
182
|
+
const marker = {
|
|
183
|
+
...current,
|
|
184
|
+
status: 'completed',
|
|
185
|
+
...(llm ? { llm } : {}),
|
|
186
|
+
...(model ? { model } : {}),
|
|
187
|
+
applied: {
|
|
188
|
+
output_path: outputPath,
|
|
189
|
+
architecture_docs: applied,
|
|
190
|
+
applied_at: new Date().toISOString(),
|
|
191
|
+
},
|
|
192
|
+
completed_at: new Date().toISOString(),
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
await writeDocsReviewMarker(options.statePath, marker);
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
rootDir: options.rootDir,
|
|
199
|
+
statePath: options.statePath,
|
|
200
|
+
status: 'completed',
|
|
201
|
+
llm,
|
|
202
|
+
model,
|
|
203
|
+
runtime: current.runtime ?? null,
|
|
204
|
+
hosted: current.hosted ?? null,
|
|
205
|
+
applied: marker.applied,
|
|
206
|
+
reviewPrompt: null,
|
|
207
|
+
warnings: [],
|
|
208
|
+
message: `Applied ${applied.length} accepted architecture doc${applied.length === 1 ? '' : 's'} and completed the docs-review marker.`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function readDocsReviewMarker(statePath) {
|
|
213
|
+
try {
|
|
214
|
+
return JSON.parse(await readFile(statePath, 'utf8'));
|
|
215
|
+
} catch (error) {
|
|
216
|
+
if (error?.code === 'ENOENT') {
|
|
217
|
+
throw new Error(`No docs-review marker found at ${statePath}. Run docs-review before applying output.`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
throw new Error(`Could not read docs-review marker at ${statePath}: ${error.message}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function validateDocsReviewMode(options) {
|
|
225
|
+
const enabled = [
|
|
226
|
+
options.submitHosted ? '--submit-hosted' : null,
|
|
227
|
+
options.runLocal ? '--run-local' : null,
|
|
228
|
+
options.runLocalAnthropic ? '--run-local-anthropic' : null,
|
|
229
|
+
options.applyOutput ? '--apply-output' : null,
|
|
230
|
+
options.complete ? '--complete' : null,
|
|
231
|
+
].filter(Boolean);
|
|
232
|
+
|
|
233
|
+
if (enabled.length > 1) {
|
|
234
|
+
throw new Error(`docs-review accepts only one execution mode at a time. Conflicting flags: ${enabled.join(', ')}.`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function promptForReviewConfig(options, environment) {
|
|
239
|
+
const prompter = createPrompter(environment.io, environment.runtime);
|
|
240
|
+
try {
|
|
241
|
+
if (!options.llm) {
|
|
242
|
+
prompter.write('Common choices: claude, codex, gemini. Custom provider names are allowed.\n');
|
|
243
|
+
}
|
|
244
|
+
const llm = options.llm ?? await askInput(prompter, 'Preferred LLM for architecture review', {
|
|
245
|
+
defaultValue: 'claude',
|
|
246
|
+
});
|
|
247
|
+
const model = options.model ?? await askInput(prompter, 'Model name to record', {
|
|
248
|
+
defaultValue: defaultModelForLlm(llm),
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
return normalizeReviewConfig({ llm, model });
|
|
252
|
+
} finally {
|
|
253
|
+
await prompter.close();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function normalizeReviewConfig(options) {
|
|
258
|
+
const llm = String(options.llm ?? options.provider ?? 'claude').trim();
|
|
259
|
+
if (!llm) {
|
|
260
|
+
throw new Error('docs-review requires a non-empty LLM provider name.');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const model = String(options.model ?? defaultModelForLlm(llm)).trim();
|
|
264
|
+
if (!model) {
|
|
265
|
+
throw new Error('docs-review requires a non-empty model name.');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return { llm, model };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function shouldRunLocal(options) {
|
|
272
|
+
return Boolean(options.runLocal || options.runLocalAnthropic);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function resolveLocalProvider(options) {
|
|
276
|
+
if (options.runLocalAnthropic) {
|
|
277
|
+
return {
|
|
278
|
+
provider: 'anthropic',
|
|
279
|
+
credentialEnvVar: options.anthropicEnvVar ?? 'ANTHROPIC_API_KEY',
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const provider = normalizeOptionalString(options.provider);
|
|
284
|
+
if (!provider) {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (provider !== 'anthropic') {
|
|
289
|
+
throw new Error(`Unsupported local docs-review provider "${provider}". Supported providers: anthropic.`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
provider,
|
|
294
|
+
credentialEnvVar: options.anthropicEnvVar ?? 'ANTHROPIC_API_KEY',
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function resolveDocsReviewRuntime(project, options) {
|
|
299
|
+
const mode = project?.mode;
|
|
300
|
+
const localProvider = options.localProvider;
|
|
301
|
+
const hasLocalKey =
|
|
302
|
+
localProvider &&
|
|
303
|
+
typeof options.env?.[localProvider.credentialEnvVar] === 'string' &&
|
|
304
|
+
options.env[localProvider.credentialEnvVar].trim() !== '';
|
|
305
|
+
const hasHostedBinding =
|
|
306
|
+
project?.sync &&
|
|
307
|
+
typeof project.sync === 'object' &&
|
|
308
|
+
typeof project.sync.api_url === 'string' &&
|
|
309
|
+
typeof project.sync.project_id === 'string';
|
|
310
|
+
|
|
311
|
+
if (mode === 'local-only') {
|
|
312
|
+
if (options.runLocal && !localProvider) {
|
|
313
|
+
throw new Error('docs-review --run-local requires --provider <name>. Supported providers: anthropic.');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (options.runLocal && !hasLocalKey) {
|
|
317
|
+
throw new Error(
|
|
318
|
+
`docs-review --run-local --provider ${localProvider.provider} requires ${localProvider.credentialEnvVar}.`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return localProvider
|
|
323
|
+
? {
|
|
324
|
+
provider: 'local',
|
|
325
|
+
local_provider: localProvider.provider,
|
|
326
|
+
credential_env_var: localProvider.credentialEnvVar,
|
|
327
|
+
}
|
|
328
|
+
: { provider: 'external' };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (mode === 'local-primary') {
|
|
332
|
+
if (options.runLocal) {
|
|
333
|
+
if (!localProvider) {
|
|
334
|
+
throw new Error('docs-review --run-local requires --provider <name>. Supported providers: anthropic.');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (!hasLocalKey) {
|
|
338
|
+
throw new Error(`docs-review --run-local --provider ${localProvider.provider} requires ${localProvider.credentialEnvVar}.`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
provider: 'local',
|
|
343
|
+
local_provider: localProvider.provider,
|
|
344
|
+
credential_env_var: localProvider.credentialEnvVar,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (hasHostedBinding) {
|
|
349
|
+
return {
|
|
350
|
+
provider: 'hosted',
|
|
351
|
+
api_url: project.sync.api_url,
|
|
352
|
+
project_id: project.sync.project_id,
|
|
353
|
+
...(typeof project.sync.credential_env_var === 'string'
|
|
354
|
+
? { credential_env_var: project.sync.credential_env_var }
|
|
355
|
+
: {}),
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (hasLocalKey) {
|
|
360
|
+
return {
|
|
361
|
+
provider: 'local',
|
|
362
|
+
local_provider: localProvider.provider,
|
|
363
|
+
credential_env_var: localProvider.credentialEnvVar,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
throw new Error(
|
|
368
|
+
`docs-review for local-primary projects requires a hosted sync binding or ${options.anthropicEnvVar}.`,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (mode === 'hosted-only') {
|
|
373
|
+
if (options.runLocal) {
|
|
374
|
+
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.');
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (!hasHostedBinding) {
|
|
378
|
+
throw new Error('docs-review for hosted-only projects requires a hosted sync binding in project.yaml.');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
provider: 'hosted',
|
|
383
|
+
api_url: project.sync.api_url,
|
|
384
|
+
project_id: project.sync.project_id,
|
|
385
|
+
...(typeof project.sync.credential_env_var === 'string'
|
|
386
|
+
? { credential_env_var: project.sync.credential_env_var }
|
|
387
|
+
: {}),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
throw new Error('docs-review requires project.yaml mode to be local-only, local-primary, or hosted-only.');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function runLocalDocsReview(options) {
|
|
395
|
+
if (options.runtime.provider !== 'local') {
|
|
396
|
+
throw new Error('docs-review --run-local requires local runtime.');
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (options.runtime.local_provider !== 'anthropic') {
|
|
400
|
+
throw new Error(`Unsupported local docs-review provider "${options.runtime.local_provider}". Supported providers: anthropic.`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return runLocalAnthropicDocsReview(options);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function runLocalAnthropicDocsReview(options) {
|
|
407
|
+
if (typeof options.fetch !== 'function') {
|
|
408
|
+
throw new Error('docs-review --run-local --provider anthropic requires a fetch implementation.');
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const credential = normalizeOptionalString(options.env?.[options.runtime.credential_env_var]);
|
|
412
|
+
if (!credential) {
|
|
413
|
+
throw new Error(`docs-review --run-local --provider anthropic requires ${options.runtime.credential_env_var}.`);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const response = await options.fetch('https://api.anthropic.com/v1/messages', {
|
|
417
|
+
method: 'POST',
|
|
418
|
+
headers: {
|
|
419
|
+
'anthropic-version': '2023-06-01',
|
|
420
|
+
'content-type': 'application/json',
|
|
421
|
+
'x-api-key': credential,
|
|
422
|
+
},
|
|
423
|
+
body: JSON.stringify({
|
|
424
|
+
model: options.model,
|
|
425
|
+
max_tokens: normalizeMaxTokens(options.maxTokens),
|
|
426
|
+
messages: [
|
|
427
|
+
{
|
|
428
|
+
role: 'user',
|
|
429
|
+
content: options.reviewPrompt,
|
|
430
|
+
},
|
|
431
|
+
],
|
|
432
|
+
}),
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
if (!response.ok) {
|
|
436
|
+
const body = typeof response.text === 'function' ? await response.text() : '';
|
|
437
|
+
throw new Error(`Local Anthropic docs-review failed with ${response.status}${body ? `: ${body}` : ''}`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const body = typeof response.json === 'function' ? await response.json() : {};
|
|
441
|
+
const text = extractAnthropicText(body);
|
|
442
|
+
if (!text) {
|
|
443
|
+
throw new Error('Local Anthropic docs-review response did not include text content.');
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const outputPath = path.join(path.dirname(options.statePath), 'docs-review-output.md');
|
|
447
|
+
await writeFile(outputPath, `${text.trim()}\n`, 'utf8');
|
|
448
|
+
|
|
449
|
+
return {
|
|
450
|
+
provider: 'anthropic',
|
|
451
|
+
output_path: outputPath,
|
|
452
|
+
truncated: body.stop_reason === 'max_tokens',
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function extractAnthropicText(body) {
|
|
457
|
+
if (!Array.isArray(body?.content)) {
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return body.content
|
|
462
|
+
.filter((item) => item?.type === 'text' && typeof item.text === 'string')
|
|
463
|
+
.map((item) => item.text)
|
|
464
|
+
.join('\n\n')
|
|
465
|
+
.trim() || null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function parseArchitectureDocBlocks(source) {
|
|
469
|
+
const blocks = [];
|
|
470
|
+
const pattern = /^```vibecompass-architecture-doc\s+path=([^\s`]+)\s*\n([\s\S]*?)^```[ \t]*$/gm;
|
|
471
|
+
let match;
|
|
472
|
+
|
|
473
|
+
while ((match = pattern.exec(source)) !== null) {
|
|
474
|
+
blocks.push({
|
|
475
|
+
path: normalizeBlockPath(match[1]),
|
|
476
|
+
content: match[2].replace(/\n$/, ''),
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return blocks;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function normalizeBlockPath(value) {
|
|
484
|
+
return String(value ?? '').trim().replaceAll('\\', '/');
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function validateArchitectureDocBlock(block) {
|
|
488
|
+
if (!block.path.startsWith('architecture/') || !block.path.endsWith('.md')) {
|
|
489
|
+
throw new Error(`Accepted docs-review output can only write architecture/*.md files. Invalid path: ${block.path}`);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (path.posix.isAbsolute(block.path) || block.path.split('/').includes('..')) {
|
|
493
|
+
throw new Error(`Accepted docs-review output path must stay inside architecture/. Invalid path: ${block.path}`);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (block.path.split('/').length < 3) {
|
|
497
|
+
throw new Error(
|
|
498
|
+
`Accepted docs-review output path must include at least one subdirectory under architecture/. Invalid path: ${block.path}`,
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const frontmatter = parseFrontmatter(block.content, { sourceName: block.path });
|
|
503
|
+
if (!frontmatter.hasFrontmatter || !frontmatter.data) {
|
|
504
|
+
throw new Error(`Accepted architecture doc ${block.path} must include frontmatter.`);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
for (const field of ['domain', 'feature', 'component', 'status']) {
|
|
508
|
+
if (typeof frontmatter.data[field] !== 'string' || frontmatter.data[field].trim() === '') {
|
|
509
|
+
throw new Error(`Accepted architecture doc ${block.path} requires non-empty frontmatter field "${field}".`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function submitHostedDocsReview(options) {
|
|
515
|
+
if (options.runtime.provider !== 'hosted') {
|
|
516
|
+
throw new Error('docs-review --submit-hosted requires a hosted sync binding in project.yaml.');
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (typeof options.fetch !== 'function') {
|
|
520
|
+
throw new Error('docs-review --submit-hosted requires a fetch implementation.');
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const credentialEnvVar = options.runtime.credential_env_var;
|
|
524
|
+
if (!credentialEnvVar) {
|
|
525
|
+
throw new Error('docs-review --submit-hosted requires sync.credential_env_var in project.yaml.');
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const credential = normalizeOptionalString(options.env?.[credentialEnvVar]);
|
|
529
|
+
if (!credential) {
|
|
530
|
+
throw new Error(`docs-review --submit-hosted requires ${credentialEnvVar}.`);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const endpoint = new URL(
|
|
534
|
+
`api/sync/projects/${encodeURIComponent(options.runtime.project_id)}/docs-review`,
|
|
535
|
+
ensureTrailingSlash(options.runtime.api_url),
|
|
536
|
+
);
|
|
537
|
+
const response = await options.fetch(endpoint.href, {
|
|
538
|
+
method: 'POST',
|
|
539
|
+
headers: {
|
|
540
|
+
accept: 'application/json',
|
|
541
|
+
authorization: `Bearer ${credential}`,
|
|
542
|
+
'content-type': 'application/json',
|
|
543
|
+
},
|
|
544
|
+
body: JSON.stringify({
|
|
545
|
+
llm: options.reviewConfig.llm,
|
|
546
|
+
model: options.reviewConfig.model,
|
|
547
|
+
prompt: options.reviewPrompt,
|
|
548
|
+
project: {
|
|
549
|
+
name: options.project.name,
|
|
550
|
+
mode: options.project.mode,
|
|
551
|
+
repos: Array.isArray(options.project.repos) ? options.project.repos : [],
|
|
552
|
+
},
|
|
553
|
+
}),
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
if (!response.ok) {
|
|
557
|
+
const body = typeof response.text === 'function' ? await response.text() : '';
|
|
558
|
+
throw new Error(`Hosted docs-review request failed with ${response.status}${body ? `: ${body}` : ''}`);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const body = typeof response.json === 'function' ? await response.json() : {};
|
|
562
|
+
const runId = normalizeOptionalString(body.run_id ?? body.runId);
|
|
563
|
+
|
|
564
|
+
if (!runId) {
|
|
565
|
+
throw new Error('Hosted docs-review response did not include run_id.');
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return {
|
|
569
|
+
endpoint: endpoint.href,
|
|
570
|
+
run_id: runId,
|
|
571
|
+
status: normalizeOptionalString(body.status) ?? 'accepted',
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async function writeDocsReviewMarker(statePath, marker) {
|
|
576
|
+
await mkdir(path.dirname(statePath), { recursive: true });
|
|
577
|
+
await writeFile(statePath, `${JSON.stringify(marker, null, 2)}\n`, 'utf8');
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async function pathExists(targetPath) {
|
|
581
|
+
try {
|
|
582
|
+
await access(targetPath);
|
|
583
|
+
return true;
|
|
584
|
+
} catch {
|
|
585
|
+
return false;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function normalizeOptionalString(value) {
|
|
590
|
+
if (typeof value !== 'string') {
|
|
591
|
+
return null;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const trimmed = value.trim();
|
|
595
|
+
return trimmed ? trimmed : null;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function normalizeMaxTokens(value) {
|
|
599
|
+
if (value === undefined || value === null) {
|
|
600
|
+
return 16000;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const number = typeof value === 'number' ? value : Number(value);
|
|
604
|
+
if (!Number.isInteger(number) || number < 1024 || number > 32000) {
|
|
605
|
+
throw new Error('docs-review --max-tokens must be an integer from 1024 to 32000.');
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return number;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function ensureTrailingSlash(value) {
|
|
612
|
+
return value.endsWith('/') ? value : `${value}/`;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function renderDocsReviewMessage(options) {
|
|
616
|
+
if (options.submitHosted) {
|
|
617
|
+
return 'Hosted docs-review request submitted. Poll the hosted run, apply accepted docs changes locally, then run docs-review --complete.';
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (shouldRunLocal(options)) {
|
|
621
|
+
const provider = options.provider ?? 'anthropic';
|
|
622
|
+
return `Local ${provider} docs-review completed. Review the generated output, then run docs-review --apply-output after accepting the architecture docs.`;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return 'Docs-review preflight passed. Run the generated architecture-review prompt in the selected LLM, then record completion after applying accepted docs changes.';
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function renderReviewPrompt(options) {
|
|
629
|
+
return [
|
|
630
|
+
'# VibeCompass Docs Review Prompt v1',
|
|
631
|
+
'',
|
|
632
|
+
'Run a comprehensive VibeCompass architecture documentation review for the project-memory root below. Follow this prompt exactly; do not invent a different document structure.',
|
|
633
|
+
'',
|
|
634
|
+
'## Review Context',
|
|
635
|
+
`- Project memory root: ${options.rootDir}`,
|
|
636
|
+
`- Project: ${options.project.name}`,
|
|
637
|
+
`- Project mode: ${options.project.mode}`,
|
|
638
|
+
`- Review provider to record: ${options.llm}`,
|
|
639
|
+
`- Review model/version to record: ${options.model}`,
|
|
640
|
+
'',
|
|
641
|
+
'## Required Inputs',
|
|
642
|
+
'1. Read `project.yaml`.',
|
|
643
|
+
'2. Read `context.md` if present.',
|
|
644
|
+
'3. Read all canonical files under `architecture/`, `decisions/`, and finalized `sessions/`.',
|
|
645
|
+
'4. Inspect the declared repositories and cite concrete source, config, and test files before making implementation claims.',
|
|
646
|
+
'',
|
|
647
|
+
'## Architecture Doc Contract',
|
|
648
|
+
'When creating or updating architecture docs, use the existing VibeCompass structure:',
|
|
649
|
+
'- path: `architecture/<domain-slug>/<feature-slug>/<component-slug>.md` for component docs',
|
|
650
|
+
'- frontmatter fields: `domain`, `feature`, `component`, `status`, plus `repo` or `repos` when implementation-scoped',
|
|
651
|
+
'- body sections: `## Description`, `## Review metadata`, `## Details`, `## Next steps`, `## Involved files`',
|
|
652
|
+
'',
|
|
653
|
+
'Use this exact Review metadata sub-header in every generated architecture doc:',
|
|
654
|
+
'',
|
|
655
|
+
'## Review metadata',
|
|
656
|
+
`- Review provider: ${options.llm}`,
|
|
657
|
+
`- Review model/version: ${options.model}`,
|
|
658
|
+
`- Project mode: ${options.project.mode}`,
|
|
659
|
+
'- Confidence: high | medium | low',
|
|
660
|
+
'- Coverage: comprehensive | partial | initial',
|
|
661
|
+
'- Evidence: repo:path references inspected before making implementation claims',
|
|
662
|
+
'- Blindspots: explicit list, or "None identified" only when evidence is comprehensive',
|
|
663
|
+
'',
|
|
664
|
+
'## Review Rules',
|
|
665
|
+
'1. Do not delete `architecture/overview/project-shape.md`.',
|
|
666
|
+
'2. Add evidence-backed component docs alongside the overview.',
|
|
667
|
+
'3. Update `architecture/overview/project-shape.md` coverage and blindspot sections to summarize what has now been mapped.',
|
|
668
|
+
'4. Keep all implementation claims source-backed with `repo:path` evidence.',
|
|
669
|
+
'5. Use only the exact confidence and coverage enum values listed above.',
|
|
670
|
+
'6. Put uncertainty in `Blindspots`; do not fill gaps with guesses.',
|
|
671
|
+
'7. Do not append real `D-NNN` decisions without explicit user acceptance. Propose candidate decisions separately.',
|
|
672
|
+
'8. Do not edit `decisions/INDEX.md` directly unless a canonical decision file was explicitly accepted and appended.',
|
|
673
|
+
'',
|
|
674
|
+
'## Output Contract',
|
|
675
|
+
'First provide a concise findings summary with:',
|
|
676
|
+
'- mapped areas',
|
|
677
|
+
'- gaps/blindspots',
|
|
678
|
+
'- proposed architecture docs',
|
|
679
|
+
'- proposed decisions, if any',
|
|
680
|
+
'',
|
|
681
|
+
'Then, for each architecture doc the user accepts, output one fenced block exactly like this:',
|
|
682
|
+
'',
|
|
683
|
+
'```vibecompass-architecture-doc path=architecture/domain/feature/component.md',
|
|
684
|
+
'<complete markdown file content>',
|
|
685
|
+
'```',
|
|
686
|
+
'',
|
|
687
|
+
'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`.',
|
|
688
|
+
].join('\n');
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function ensureTrailingNewline(value) {
|
|
692
|
+
return value.endsWith('\n') ? value : `${value}\n`;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function defaultModelForLlm(llm) {
|
|
696
|
+
const normalized = String(llm ?? '').trim().toLowerCase();
|
|
697
|
+
|
|
698
|
+
if (normalized === 'codex') {
|
|
699
|
+
return 'gpt-5-codex';
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
if (normalized === 'gemini') {
|
|
703
|
+
return 'gemini-2.5-pro';
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
if (normalized === 'claude') {
|
|
707
|
+
return 'claude-sonnet-4-6';
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
return 'record-the-model-version';
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function createPrompter(io = {}, runtime = {}) {
|
|
714
|
+
if (typeof runtime?.prompt === 'function') {
|
|
715
|
+
return {
|
|
716
|
+
async ask(spec) {
|
|
717
|
+
return runtime.prompt(spec);
|
|
718
|
+
},
|
|
719
|
+
write(text) {
|
|
720
|
+
(io.stdout ?? process.stdout).write(text);
|
|
721
|
+
},
|
|
722
|
+
async close() {},
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const input = runtime?.stdin ?? io.stdin ?? process.stdin;
|
|
727
|
+
const output = io.stdout ?? process.stdout;
|
|
728
|
+
|
|
729
|
+
if (!input?.isTTY) {
|
|
730
|
+
throw new Error('Guided docs-review requires an interactive TTY or a custom prompt adapter.');
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const rl = createInterface({ input, output });
|
|
734
|
+
return {
|
|
735
|
+
async ask(spec) {
|
|
736
|
+
return rl.question(`${spec.message}${renderPromptSuffix(spec)}: `);
|
|
737
|
+
},
|
|
738
|
+
write(text) {
|
|
739
|
+
output.write(text);
|
|
740
|
+
},
|
|
741
|
+
async close() {
|
|
742
|
+
rl.close();
|
|
743
|
+
},
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
async function askInput(prompter, message, options = {}) {
|
|
748
|
+
while (true) {
|
|
749
|
+
const raw = await prompter.ask({
|
|
750
|
+
type: 'input',
|
|
751
|
+
message,
|
|
752
|
+
defaultValue: options.defaultValue ?? null,
|
|
753
|
+
});
|
|
754
|
+
const value = normalizePromptAnswer(raw, options.defaultValue ?? null);
|
|
755
|
+
if (value) {
|
|
756
|
+
return value;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
prompter.write('A value is required.\n');
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
async function askChoice(prompter, message, choices, options = {}) {
|
|
764
|
+
const defaultValue = options.defaultValue ?? choices[0]?.value ?? null;
|
|
765
|
+
prompter.write(
|
|
766
|
+
`${choices
|
|
767
|
+
.map((choice, index) => `${index + 1}. ${choice.value} — ${choice.description}`)
|
|
768
|
+
.join('\n')}\n`,
|
|
769
|
+
);
|
|
770
|
+
|
|
771
|
+
while (true) {
|
|
772
|
+
const raw = await prompter.ask({
|
|
773
|
+
type: 'choice',
|
|
774
|
+
message,
|
|
775
|
+
defaultValue,
|
|
776
|
+
choices,
|
|
777
|
+
});
|
|
778
|
+
const normalized = normalizePromptAnswer(raw, defaultValue)?.toLowerCase();
|
|
779
|
+
|
|
780
|
+
const byIndex = Number.parseInt(normalized, 10);
|
|
781
|
+
if (!Number.isNaN(byIndex) && choices[byIndex - 1]) {
|
|
782
|
+
return choices[byIndex - 1].value;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const byValue = choices.find((choice) => choice.value === normalized);
|
|
786
|
+
if (byValue) {
|
|
787
|
+
return byValue.value;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
prompter.write('Please choose one of the listed options.\n');
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function renderPromptSuffix(spec) {
|
|
795
|
+
if (spec.defaultValue) {
|
|
796
|
+
return ` [${spec.defaultValue}]`;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
return '';
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function normalizePromptAnswer(raw, defaultValue) {
|
|
803
|
+
const trimmed = String(raw ?? '').trim();
|
|
804
|
+
if (!trimmed) {
|
|
805
|
+
if (defaultValue === null || defaultValue === undefined) {
|
|
806
|
+
return null;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
return String(defaultValue);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
return trimmed;
|
|
813
|
+
}
|