@vibecompass/vibecompass 0.3.0 → 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.
- package/README.md +61 -22
- package/package.json +1 -1
- package/src/cli.js +399 -3
- package/src/docs-review.js +408 -1
- package/src/index.js +6 -0
- package/src/init.js +64 -24
- package/src/manifest.js +18 -1
- package/src/project-memory.js +21 -2
- package/src/read-model.js +1 -1
- package/src/setup.js +149 -34
- package/src/sync.js +395 -0
package/src/project-memory.js
CHANGED
|
@@ -468,8 +468,23 @@ function parseDecisionDocument(options) {
|
|
|
468
468
|
const warnings = [];
|
|
469
469
|
const errors = [];
|
|
470
470
|
const entries = extractDecisionEntries(options.content);
|
|
471
|
+
const malformedHeadings = extractMalformedDecisionHeadings(options.content);
|
|
472
|
+
|
|
473
|
+
for (const heading of malformedHeadings) {
|
|
474
|
+
errors.push(
|
|
475
|
+
createError(
|
|
476
|
+
options.relativePath,
|
|
477
|
+
'decision-invalid-id',
|
|
478
|
+
`Decision heading "${heading}" must use zero-padded D-NNN or higher.`,
|
|
479
|
+
),
|
|
480
|
+
);
|
|
481
|
+
}
|
|
471
482
|
|
|
472
483
|
if (entries.length === 0) {
|
|
484
|
+
if (malformedHeadings.length > 0) {
|
|
485
|
+
return { kind: 'decision', extracted: null, warnings, errors };
|
|
486
|
+
}
|
|
487
|
+
|
|
473
488
|
errors.push(
|
|
474
489
|
createError(options.relativePath, 'decision-missing-entries', 'Decision files must contain at least one resolvable decision entry.'),
|
|
475
490
|
);
|
|
@@ -479,7 +494,7 @@ function parseDecisionDocument(options) {
|
|
|
479
494
|
const decisionIds = [];
|
|
480
495
|
for (const entry of entries) {
|
|
481
496
|
if (!entry.decisionId || entry.decisionId <= 0) {
|
|
482
|
-
errors.push(createError(options.relativePath, 'decision-invalid-id', 'Decision heading must include D-<
|
|
497
|
+
errors.push(createError(options.relativePath, 'decision-invalid-id', 'Decision heading must include zero-padded D-<NNN>.'));
|
|
483
498
|
continue;
|
|
484
499
|
}
|
|
485
500
|
|
|
@@ -576,7 +591,7 @@ function parseSessionDocument(options) {
|
|
|
576
591
|
}
|
|
577
592
|
|
|
578
593
|
function extractDecisionEntries(content) {
|
|
579
|
-
const matches = [...content.matchAll(/^###\s+D-(\d
|
|
594
|
+
const matches = [...content.matchAll(/^###\s+D-(\d{3,})\b.*$/gm)];
|
|
580
595
|
|
|
581
596
|
return matches.map((match, index) => {
|
|
582
597
|
const bodyStart = match.index ?? 0;
|
|
@@ -590,6 +605,10 @@ function extractDecisionEntries(content) {
|
|
|
590
605
|
});
|
|
591
606
|
}
|
|
592
607
|
|
|
608
|
+
function extractMalformedDecisionHeadings(content) {
|
|
609
|
+
return [...content.matchAll(/^###\s+(D-\d{1,2})\b.*$/gm)].map((match) => match[1]);
|
|
610
|
+
}
|
|
611
|
+
|
|
593
612
|
function findDuplicateDecisionErrors(documents) {
|
|
594
613
|
const seen = new Map();
|
|
595
614
|
const errors = [];
|
package/src/read-model.js
CHANGED
|
@@ -5,7 +5,7 @@ import { generateStateManifest } from './manifest.js';
|
|
|
5
5
|
import { scanProjectMemory } from './project-memory.js';
|
|
6
6
|
|
|
7
7
|
const SECTION_PATTERN = /^##\s+(.+)$/gm;
|
|
8
|
-
const DECISION_HEADING_PATTERN = /^###\s+D-(\d
|
|
8
|
+
const DECISION_HEADING_PATTERN = /^###\s+D-(\d{3,})\s+—\s+(.+)$/gm;
|
|
9
9
|
|
|
10
10
|
export async function loadProjectReadModel(rootDir) {
|
|
11
11
|
const scanResult = await scanProjectMemory(rootDir);
|
package/src/setup.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { access, readFile } from 'node:fs/promises';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { createInterface } from 'node:readline/promises';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
4
8
|
|
|
5
9
|
export const PLACEMENT_PATTERNS = {
|
|
6
10
|
WORKSPACE_ROOT: 'workspace-root',
|
|
@@ -73,6 +77,40 @@ export async function resolveInitCliOptions(options, environment = {}) {
|
|
|
73
77
|
};
|
|
74
78
|
}
|
|
75
79
|
|
|
80
|
+
export async function resolveConnectHostedCliOptions(options, environment = {}) {
|
|
81
|
+
const cwd = environment.cwd ? path.resolve(environment.cwd) : process.cwd();
|
|
82
|
+
let resolved = {
|
|
83
|
+
...options,
|
|
84
|
+
sync: isPlainObject(options?.sync) ? { ...options.sync } : {},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
if (!resolved.sync.apiUrl || !resolved.sync.projectId || !resolved.sync.credentialEnvVar) {
|
|
88
|
+
const prompter = createPrompter(environment.io, environment.runtime);
|
|
89
|
+
try {
|
|
90
|
+
if (!resolved.sync.apiUrl) {
|
|
91
|
+
resolved.sync.apiUrl = await askInput(prompter, 'Hosted sync API URL', {
|
|
92
|
+
defaultValue: 'https://vibecompass.dev',
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (!resolved.sync.projectId) {
|
|
96
|
+
resolved.sync.projectId = await askInput(prompter, 'Hosted project id');
|
|
97
|
+
}
|
|
98
|
+
if (!resolved.sync.credentialEnvVar) {
|
|
99
|
+
resolved.sync.credentialEnvVar = await askInput(prompter, 'Credential env var', {
|
|
100
|
+
defaultValue: 'VIBECOMPASS_SYNC_TOKEN',
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
} finally {
|
|
104
|
+
await prompter.close();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
rootDir: resolved.rootDir ? toRelativeIfInsideCwd(resolved.rootDir, cwd) : undefined,
|
|
110
|
+
sync: resolved.sync,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
76
114
|
export function applyPlacementDefaults(options, environment = {}) {
|
|
77
115
|
const cwd = environment.cwd ? path.resolve(environment.cwd) : process.cwd();
|
|
78
116
|
const resolved = cloneInitOptions(options);
|
|
@@ -153,7 +191,8 @@ async function completeGuidedInitOptions(options, environment) {
|
|
|
153
191
|
let resolved = cloneInitOptions(options);
|
|
154
192
|
const cwd = environment.cwd;
|
|
155
193
|
const prompter = environment.prompter;
|
|
156
|
-
const
|
|
194
|
+
const inferredProject = await inferProjectFromCwd(cwd);
|
|
195
|
+
const projectNameDefault = inferredProject.projectName ?? path.basename(cwd);
|
|
157
196
|
|
|
158
197
|
if (!resolved.name) {
|
|
159
198
|
resolved.name = await askInput(prompter, 'Project name', {
|
|
@@ -162,63 +201,82 @@ async function completeGuidedInitOptions(options, environment) {
|
|
|
162
201
|
}
|
|
163
202
|
|
|
164
203
|
if (!resolved.mode) {
|
|
165
|
-
|
|
204
|
+
const setupGoal = await askChoice(prompter, 'What should VibeCompass set up?', [
|
|
166
205
|
{
|
|
167
|
-
value: '
|
|
168
|
-
description: 'Local
|
|
206
|
+
value: 'share-later',
|
|
207
|
+
description: 'Recommended. Local notes, with the option to share or collaborate later.',
|
|
169
208
|
},
|
|
170
209
|
{
|
|
171
|
-
value: 'local-
|
|
172
|
-
description: 'Local files
|
|
210
|
+
value: 'local-only',
|
|
211
|
+
description: 'Local files only. No hosted sync or hosted docs-review prompts.',
|
|
173
212
|
},
|
|
174
213
|
{
|
|
175
214
|
value: 'hosted-only',
|
|
176
|
-
description: '
|
|
215
|
+
description: 'Hosted VibeCompass is already the source of truth for this project.',
|
|
177
216
|
},
|
|
178
217
|
], {
|
|
179
|
-
defaultValue: '
|
|
218
|
+
defaultValue: 'share-later',
|
|
180
219
|
});
|
|
220
|
+
resolved.mode = setupGoal === 'share-later' ? 'local-primary' : setupGoal;
|
|
181
221
|
}
|
|
182
222
|
|
|
183
223
|
if (!Array.isArray(resolved.repos) || resolved.repos.length === 0) {
|
|
184
|
-
|
|
224
|
+
if (inferredProject.repo) {
|
|
225
|
+
const useDetectedRepo = await askConfirm(
|
|
226
|
+
prompter,
|
|
227
|
+
`Use detected Git repo ${inferredProject.repo.remote}?`,
|
|
228
|
+
true,
|
|
229
|
+
);
|
|
230
|
+
resolved.repos = useDetectedRepo ? [inferredProject.repo] : await promptForRepos(prompter, cwd);
|
|
231
|
+
} else {
|
|
232
|
+
resolved.repos = await promptForRepos(prompter, cwd);
|
|
233
|
+
}
|
|
185
234
|
}
|
|
186
235
|
|
|
187
236
|
let recommendation = null;
|
|
188
237
|
if (!resolved.placementPattern) {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
193
|
-
|
|
238
|
+
if (inferredProject.repo && resolved.repos.length === 1) {
|
|
239
|
+
recommendation = recommendPlacementPattern({ repoCount: 1 });
|
|
240
|
+
resolved.placementPattern = recommendation.pattern;
|
|
241
|
+
} else {
|
|
242
|
+
recommendation = await promptForPlacement(prompter, {
|
|
243
|
+
cwd,
|
|
244
|
+
repoCount: resolved.repos.length,
|
|
245
|
+
});
|
|
246
|
+
resolved.placementPattern = recommendation.pattern;
|
|
247
|
+
}
|
|
194
248
|
}
|
|
195
249
|
|
|
196
250
|
if (!resolved.toolingRootDir) {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
251
|
+
if (inferredProject.repo && resolved.repos.length === 1 && resolved.placementPattern === PLACEMENT_PATTERNS.PRIMARY_REPO) {
|
|
252
|
+
resolved.toolingRootDir = '.';
|
|
253
|
+
} else {
|
|
254
|
+
const ownerLabel = describePlacementOwner(resolved.placementPattern);
|
|
255
|
+
const defaultOwnerDir = await suggestOwnerDirectory({
|
|
256
|
+
cwd,
|
|
257
|
+
placementPattern: resolved.placementPattern,
|
|
258
|
+
repos: resolved.repos,
|
|
259
|
+
});
|
|
260
|
+
resolved.toolingRootDir = await askInput(prompter, ownerLabel, {
|
|
261
|
+
defaultValue: defaultOwnerDir,
|
|
262
|
+
validate: async (value) => {
|
|
263
|
+
if (!(await directoryExists(path.resolve(cwd, value)))) {
|
|
264
|
+
return `${ownerLabel} must already exist relative to the current working directory.`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return null;
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
}
|
|
213
271
|
}
|
|
214
272
|
|
|
215
273
|
resolved = applyPlacementDefaults(resolved, { cwd });
|
|
216
274
|
|
|
217
|
-
if (!resolved.sync && resolved.mode === '
|
|
275
|
+
if (!resolved.sync && resolved.mode === 'hosted-only') {
|
|
218
276
|
const configureSync = await askConfirm(
|
|
219
277
|
prompter,
|
|
220
|
-
'
|
|
221
|
-
|
|
278
|
+
'Connect hosted VibeCompass now?',
|
|
279
|
+
resolved.mode === 'hosted-only',
|
|
222
280
|
);
|
|
223
281
|
|
|
224
282
|
if (configureSync) {
|
|
@@ -322,6 +380,63 @@ async function completeGuidedInitOptions(options, environment) {
|
|
|
322
380
|
};
|
|
323
381
|
}
|
|
324
382
|
|
|
383
|
+
async function inferProjectFromCwd(cwd) {
|
|
384
|
+
const [packageName, gitInfo] = await Promise.all([
|
|
385
|
+
readPackageName(cwd),
|
|
386
|
+
readGitInfo(cwd),
|
|
387
|
+
]);
|
|
388
|
+
const projectName = packageName ?? path.basename(cwd);
|
|
389
|
+
|
|
390
|
+
if (!gitInfo?.remote) {
|
|
391
|
+
return { projectName, repo: null };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const remoteSlug = extractRemoteSlug(gitInfo.remote);
|
|
395
|
+
const repoId = suggestRepoId(remoteSlug ?? packageName ?? path.basename(gitInfo.root ?? cwd));
|
|
396
|
+
|
|
397
|
+
return {
|
|
398
|
+
projectName,
|
|
399
|
+
repo: {
|
|
400
|
+
id: repoId,
|
|
401
|
+
remote: gitInfo.remote,
|
|
402
|
+
...(gitInfo.branch ? { defaultBranch: gitInfo.branch } : {}),
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function readPackageName(cwd) {
|
|
408
|
+
try {
|
|
409
|
+
const packageJson = JSON.parse(await readFile(path.join(cwd, 'package.json'), 'utf8'));
|
|
410
|
+
return typeof packageJson.name === 'string' && packageJson.name.trim()
|
|
411
|
+
? packageJson.name.trim()
|
|
412
|
+
: null;
|
|
413
|
+
} catch {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function readGitInfo(cwd) {
|
|
419
|
+
try {
|
|
420
|
+
const [{ stdout: remote }, { stdout: root }, branchResult] = await Promise.all([
|
|
421
|
+
execFileAsync('git', ['remote', 'get-url', 'origin'], { cwd }),
|
|
422
|
+
execFileAsync('git', ['rev-parse', '--show-toplevel'], { cwd }),
|
|
423
|
+
execFileAsync('git', ['branch', '--show-current'], { cwd }).catch(() => ({ stdout: '' })),
|
|
424
|
+
]);
|
|
425
|
+
const normalizedRemote = remote.trim();
|
|
426
|
+
if (!normalizedRemote) {
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return {
|
|
431
|
+
remote: normalizedRemote,
|
|
432
|
+
root: root.trim() || null,
|
|
433
|
+
branch: branchResult.stdout.trim() || null,
|
|
434
|
+
};
|
|
435
|
+
} catch {
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
325
440
|
async function promptForRepos(prompter, cwd) {
|
|
326
441
|
const repoCount = await askInteger(prompter, 'How many repos belong to this logical project?', {
|
|
327
442
|
defaultValue: 1,
|
package/src/sync.js
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { sha256Text } from './hash.js';
|
|
4
|
+
import { writeStateManifest } from './manifest.js';
|
|
5
|
+
import { parseSimpleYaml } from './simple-yaml.js';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_EXPORT_PATH = 'state/pull-export.json';
|
|
8
|
+
|
|
9
|
+
export async function pushProjectMemory(options = {}, environment = {}) {
|
|
10
|
+
const context = await loadSyncContext(options, environment);
|
|
11
|
+
const existingManifest = await readJsonIfPresent(context.manifestPath);
|
|
12
|
+
const manifestResult = await writeStateManifest(context.rootDir, {
|
|
13
|
+
sync: existingManifest?.sync,
|
|
14
|
+
});
|
|
15
|
+
const manifest = manifestResult.manifest;
|
|
16
|
+
const documents = await readPushDocuments(context.rootDir, manifest.documents);
|
|
17
|
+
const baseRemoteRevisionId = normalizeUuid(manifest.sync?.last_successful_remote_revision);
|
|
18
|
+
const body = {
|
|
19
|
+
...(baseRemoteRevisionId ? { base_remote_revision_id: baseRemoteRevisionId } : {}),
|
|
20
|
+
local_root_revision: manifest.canonical.local_root_revision,
|
|
21
|
+
manifest_hash: manifest.canonical.manifest_hash,
|
|
22
|
+
format_version: manifest.canonical.format_version,
|
|
23
|
+
documents,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const response = await postJson(context, 'push', body);
|
|
27
|
+
const now = new Date().toISOString();
|
|
28
|
+
const sync = {
|
|
29
|
+
...(manifest.sync ?? {}),
|
|
30
|
+
last_successful_remote_revision: response.remote_revision_id,
|
|
31
|
+
last_successful_local_root_revision: manifest.canonical.local_root_revision,
|
|
32
|
+
last_successful_manifest_hash: manifest.canonical.manifest_hash,
|
|
33
|
+
last_sync_direction: 'push',
|
|
34
|
+
last_sync_at: now,
|
|
35
|
+
pending_previews: [],
|
|
36
|
+
};
|
|
37
|
+
const updatedManifest = await writeStateManifest(context.rootDir, { sync });
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
status: response.status ?? 'completed',
|
|
41
|
+
rootDir: context.rootDir,
|
|
42
|
+
manifestPath: updatedManifest.manifestPath,
|
|
43
|
+
runId: response.run_id,
|
|
44
|
+
remoteRevisionId: response.remote_revision_id,
|
|
45
|
+
appliedProposalIds: response.applied_proposal_ids ?? [],
|
|
46
|
+
staleProposalIds: response.stale_proposal_ids ?? [],
|
|
47
|
+
message: `Pushed ${documents.length} canonical document${documents.length === 1 ? '' : 's'} to hosted project memory.`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function pullPreviewProjectMemory(options = {}, environment = {}) {
|
|
52
|
+
const context = await loadSyncContext(options, environment);
|
|
53
|
+
const existingManifest = await readJsonIfPresent(context.manifestPath);
|
|
54
|
+
const manifestResult = await writeStateManifest(context.rootDir, {
|
|
55
|
+
sync: existingManifest?.sync,
|
|
56
|
+
});
|
|
57
|
+
const manifest = manifestResult.manifest;
|
|
58
|
+
|
|
59
|
+
const response = await postJson(context, 'pull-preview', {
|
|
60
|
+
state_version: manifest.state_version,
|
|
61
|
+
base_remote_revision_id: normalizeUuid(manifest.sync?.last_successful_remote_revision),
|
|
62
|
+
local_root_revision: manifest.canonical.local_root_revision,
|
|
63
|
+
local_manifest_hash: manifest.canonical.manifest_hash,
|
|
64
|
+
local_documents: buildLocalDocumentSummaries(manifest.documents),
|
|
65
|
+
include_pending_proposals: options.includePendingProposals !== false,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const previewRecord = {
|
|
69
|
+
preview_token: response.preview_token,
|
|
70
|
+
base_remote_revision: response.base_remote_revision_id,
|
|
71
|
+
target_remote_revision: response.target_remote_revision_id,
|
|
72
|
+
local_root_revision: manifest.canonical.local_root_revision,
|
|
73
|
+
local_manifest_hash: manifest.canonical.manifest_hash,
|
|
74
|
+
state_version: manifest.state_version,
|
|
75
|
+
authoritative_change_set_hash: response.authoritative_change_set_hash,
|
|
76
|
+
include_pending_proposals: options.includePendingProposals !== false,
|
|
77
|
+
available_proposal_ids: Array.isArray(response.proposals)
|
|
78
|
+
? response.proposals.map((proposal) => proposal.proposal_id)
|
|
79
|
+
: [],
|
|
80
|
+
conflicts: Array.isArray(response.conflicts) ? response.conflicts : [],
|
|
81
|
+
created_at: response.created_at ?? new Date().toISOString(),
|
|
82
|
+
expires_at: response.expires_at,
|
|
83
|
+
};
|
|
84
|
+
const sync = {
|
|
85
|
+
...(manifest.sync ?? {}),
|
|
86
|
+
pending_previews: [
|
|
87
|
+
...(Array.isArray(manifest.sync?.pending_previews) ? manifest.sync.pending_previews : []),
|
|
88
|
+
previewRecord,
|
|
89
|
+
].slice(-5),
|
|
90
|
+
};
|
|
91
|
+
const updatedManifest = await writeStateManifest(context.rootDir, { sync });
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
status: 'preview-created',
|
|
95
|
+
rootDir: context.rootDir,
|
|
96
|
+
manifestPath: updatedManifest.manifestPath,
|
|
97
|
+
previewToken: response.preview_token,
|
|
98
|
+
proposals: response.proposals ?? [],
|
|
99
|
+
conflicts: response.conflicts ?? [],
|
|
100
|
+
warnings: response.warnings ?? [],
|
|
101
|
+
message: `Created pull preview with ${(response.proposals ?? []).length} pending proposal${(response.proposals ?? []).length === 1 ? '' : 's'}.`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function pullExportProjectMemory(options = {}, environment = {}) {
|
|
106
|
+
const context = await loadSyncContext(options, environment);
|
|
107
|
+
const manifest = await readRequiredManifest(context.manifestPath);
|
|
108
|
+
const preview = resolvePreview(manifest, options.previewToken);
|
|
109
|
+
const proposalIds = options.proposalIds?.length
|
|
110
|
+
? options.proposalIds
|
|
111
|
+
: preview.available_proposal_ids ?? [];
|
|
112
|
+
|
|
113
|
+
if (proposalIds.length === 0) {
|
|
114
|
+
throw new Error('pull-export requires at least one --proposal or a preview with available proposals.');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const response = await postJson(context, 'pull-export', {
|
|
118
|
+
preview_token: preview.preview_token,
|
|
119
|
+
proposal_ids: proposalIds,
|
|
120
|
+
});
|
|
121
|
+
const outputPath = path.resolve(context.rootDir, options.outputPath ?? DEFAULT_EXPORT_PATH);
|
|
122
|
+
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
123
|
+
await writeFile(outputPath, `${JSON.stringify(response, null, 2)}\n`, 'utf8');
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
status: 'exported',
|
|
127
|
+
rootDir: context.rootDir,
|
|
128
|
+
outputPath,
|
|
129
|
+
runId: response.run_id,
|
|
130
|
+
operationCount: Array.isArray(response.operations) ? response.operations.length : 0,
|
|
131
|
+
proposalIds: response.proposal_ids ?? proposalIds,
|
|
132
|
+
message: `Exported ${response.operations?.length ?? 0} proposal operation${(response.operations?.length ?? 0) === 1 ? '' : 's'} to ${outputPath}.`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function applyPullExport(options = {}, environment = {}) {
|
|
137
|
+
const cwd = environment.cwd ? path.resolve(environment.cwd) : process.cwd();
|
|
138
|
+
const rootDir = path.resolve(cwd, options.rootDir ?? '.compass');
|
|
139
|
+
const outputPath = path.resolve(rootDir, options.outputPath ?? DEFAULT_EXPORT_PATH);
|
|
140
|
+
const bundle = await readJsonRequired(outputPath, 'pull-export bundle');
|
|
141
|
+
const operations = Array.isArray(bundle.operations) ? bundle.operations : [];
|
|
142
|
+
if (operations.length === 0) {
|
|
143
|
+
throw new Error(`Pull-export bundle at ${outputPath} has no operations.`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const applied = [];
|
|
147
|
+
for (const operation of operations) {
|
|
148
|
+
const relativePath = normalizeCanonicalPath(operation.path);
|
|
149
|
+
const targetPath = path.join(rootDir, relativePath);
|
|
150
|
+
const currentHash = await readCurrentHash(targetPath);
|
|
151
|
+
const expectedBefore = operation.before_content_hash ?? null;
|
|
152
|
+
if (currentHash !== expectedBefore) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`Cannot apply ${relativePath}: local content hash ${currentHash ?? 'missing'} does not match expected ${expectedBefore ?? 'missing'}. Run pull-preview again or resolve locally.`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (operation.op === 'delete') {
|
|
159
|
+
await unlink(targetPath);
|
|
160
|
+
applied.push({ path: relativePath, status: 'deleted' });
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (operation.op !== 'write' || typeof operation.raw_text !== 'string') {
|
|
165
|
+
throw new Error(`Unsupported operation for ${relativePath}.`);
|
|
166
|
+
}
|
|
167
|
+
const afterHash = sha256Text(operation.raw_text);
|
|
168
|
+
if (afterHash !== operation.after_content_hash) {
|
|
169
|
+
throw new Error(`Exported content hash does not match raw_text for ${relativePath}.`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
await mkdir(path.dirname(targetPath), { recursive: true });
|
|
173
|
+
await writeFile(targetPath, ensureTrailingNewline(operation.raw_text), 'utf8');
|
|
174
|
+
applied.push({ path: relativePath, status: 'written' });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
bundle.applied_at = new Date().toISOString();
|
|
178
|
+
bundle.applied = applied;
|
|
179
|
+
await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, 'utf8');
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
status: 'applied',
|
|
183
|
+
rootDir,
|
|
184
|
+
outputPath,
|
|
185
|
+
applied,
|
|
186
|
+
proposalIds: bundle.proposal_ids ?? [],
|
|
187
|
+
message: `Applied ${applied.length} exported proposal operation${applied.length === 1 ? '' : 's'}. Run vibecompass push to confirm applied proposals.`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function loadSyncContext(options, environment) {
|
|
192
|
+
const cwd = environment.cwd ? path.resolve(environment.cwd) : process.cwd();
|
|
193
|
+
const rootDir = path.resolve(cwd, options.rootDir ?? '.compass');
|
|
194
|
+
const projectFilePath = path.join(rootDir, 'project.yaml');
|
|
195
|
+
const manifestPath = path.join(rootDir, 'state', 'manifest.json');
|
|
196
|
+
const project = parseSimpleYaml(await readFile(projectFilePath, 'utf8'), {
|
|
197
|
+
sourceName: projectFilePath,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
if (project.mode !== 'local-primary') {
|
|
201
|
+
throw new Error('Hosted sync commands require project.yaml mode: local-primary.');
|
|
202
|
+
}
|
|
203
|
+
if (
|
|
204
|
+
!project.sync ||
|
|
205
|
+
typeof project.sync.api_url !== 'string' ||
|
|
206
|
+
typeof project.sync.project_id !== 'string' ||
|
|
207
|
+
typeof project.sync.credential_env_var !== 'string'
|
|
208
|
+
) {
|
|
209
|
+
throw new Error('Hosted sync commands require sync.api_url, sync.project_id, and sync.credential_env_var in project.yaml.');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const credential = normalizeOptionalString((environment.env ?? process.env)[project.sync.credential_env_var]);
|
|
213
|
+
if (!credential) {
|
|
214
|
+
throw new Error(`Hosted sync command requires ${project.sync.credential_env_var}.`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const fetchImpl = environment.runtime?.fetch ?? globalThis.fetch;
|
|
218
|
+
if (typeof fetchImpl !== 'function') {
|
|
219
|
+
throw new Error('Hosted sync command requires a fetch implementation.');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
rootDir,
|
|
224
|
+
project,
|
|
225
|
+
manifestPath,
|
|
226
|
+
fetch: fetchImpl,
|
|
227
|
+
credential,
|
|
228
|
+
apiUrl: project.sync.api_url,
|
|
229
|
+
projectId: project.sync.project_id,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function postJson(context, routeName, body) {
|
|
234
|
+
const endpoint = new URL(
|
|
235
|
+
`api/sync/projects/${encodeURIComponent(context.projectId)}/${routeName}`,
|
|
236
|
+
ensureTrailingSlash(context.apiUrl),
|
|
237
|
+
);
|
|
238
|
+
const response = await context.fetch(endpoint.href, {
|
|
239
|
+
method: 'POST',
|
|
240
|
+
headers: {
|
|
241
|
+
accept: 'application/json',
|
|
242
|
+
authorization: `Bearer ${context.credential}`,
|
|
243
|
+
'content-type': 'application/json',
|
|
244
|
+
},
|
|
245
|
+
body: JSON.stringify(body),
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
if (!response.ok) {
|
|
249
|
+
const responseText = typeof response.text === 'function' ? await response.text() : '';
|
|
250
|
+
throw new Error(`Hosted ${routeName} failed with ${response.status}${responseText ? `: ${responseText}` : ''}`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return typeof response.json === 'function' ? response.json() : {};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function readPushDocuments(rootDir, documents) {
|
|
257
|
+
const result = [];
|
|
258
|
+
for (const [relativePath, metadata] of Object.entries(documents)) {
|
|
259
|
+
const normalizedPath = normalizeCanonicalPath(relativePath);
|
|
260
|
+
const rawText = await readFile(path.join(rootDir, normalizedPath), 'utf8');
|
|
261
|
+
result.push({
|
|
262
|
+
path: normalizedPath,
|
|
263
|
+
kind: metadata.kind,
|
|
264
|
+
content_hash: metadata.content_hash,
|
|
265
|
+
byte_length: metadata.byte_length,
|
|
266
|
+
raw_text: rawText,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
result.sort((left, right) => left.path.localeCompare(right.path));
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function buildLocalDocumentSummaries(documents) {
|
|
275
|
+
return Object.entries(documents ?? {}).map(([relativePath, metadata]) => ({
|
|
276
|
+
path: normalizeCanonicalPath(relativePath),
|
|
277
|
+
kind: metadata.kind,
|
|
278
|
+
content_hash: metadata.content_hash,
|
|
279
|
+
extracted: buildLocalDocumentExtractedSummary(metadata.kind, metadata.extracted),
|
|
280
|
+
}));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function buildLocalDocumentExtractedSummary(kind, extracted = {}) {
|
|
284
|
+
if (!extracted || typeof extracted !== 'object') return {};
|
|
285
|
+
|
|
286
|
+
if (kind === 'session') {
|
|
287
|
+
return {
|
|
288
|
+
...(typeof extracted.title === 'string' ? { title: extracted.title } : {}),
|
|
289
|
+
...(typeof extracted.session_date === 'string' ? { session_date: extracted.session_date } : {}),
|
|
290
|
+
...(Number.isInteger(extracted.session_number)
|
|
291
|
+
? { session_number: extracted.session_number }
|
|
292
|
+
: {}),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (kind === 'decision') {
|
|
297
|
+
return {
|
|
298
|
+
...(Array.isArray(extracted.decision_ids)
|
|
299
|
+
? {
|
|
300
|
+
decision_ids: extracted.decision_ids.filter(
|
|
301
|
+
(value) => Number.isInteger(value) && value > 0,
|
|
302
|
+
),
|
|
303
|
+
}
|
|
304
|
+
: {}),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return {};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function resolvePreview(manifest, previewToken) {
|
|
312
|
+
const previews = Array.isArray(manifest.sync?.pending_previews)
|
|
313
|
+
? manifest.sync.pending_previews
|
|
314
|
+
: [];
|
|
315
|
+
if (previewToken) {
|
|
316
|
+
const match = previews.find((preview) => preview.preview_token === previewToken);
|
|
317
|
+
if (!match) {
|
|
318
|
+
throw new Error(`Preview token ${previewToken} was not found in state/manifest.json.`);
|
|
319
|
+
}
|
|
320
|
+
return match;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const latest = previews[previews.length - 1];
|
|
324
|
+
if (!latest) {
|
|
325
|
+
throw new Error('pull-export requires a pending preview. Run vibecompass pull-preview first.');
|
|
326
|
+
}
|
|
327
|
+
return latest;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function readRequiredManifest(manifestPath) {
|
|
331
|
+
return readJsonRequired(manifestPath, 'state manifest');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function readJsonRequired(filePath, label) {
|
|
335
|
+
try {
|
|
336
|
+
return JSON.parse(await readFile(filePath, 'utf8'));
|
|
337
|
+
} catch (error) {
|
|
338
|
+
throw new Error(`Could not read ${label} at ${filePath}: ${error.message}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function readJsonIfPresent(filePath) {
|
|
343
|
+
try {
|
|
344
|
+
return JSON.parse(await readFile(filePath, 'utf8'));
|
|
345
|
+
} catch (error) {
|
|
346
|
+
if (error?.code === 'ENOENT') return null;
|
|
347
|
+
throw error;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function readCurrentHash(filePath) {
|
|
352
|
+
try {
|
|
353
|
+
return sha256Text(await readFile(filePath, 'utf8'));
|
|
354
|
+
} catch (error) {
|
|
355
|
+
if (error?.code === 'ENOENT') return null;
|
|
356
|
+
throw error;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function normalizeCanonicalPath(value) {
|
|
361
|
+
if (
|
|
362
|
+
typeof value !== 'string' ||
|
|
363
|
+
value.startsWith('/') ||
|
|
364
|
+
value.includes('\\') ||
|
|
365
|
+
value.split('/').some((part) => part === '..' || part === '') ||
|
|
366
|
+
value.startsWith('state/') ||
|
|
367
|
+
value.startsWith('sessions/active/')
|
|
368
|
+
) {
|
|
369
|
+
throw new Error(`Invalid canonical path: ${value}`);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return value;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function ensureTrailingNewline(value) {
|
|
376
|
+
return value.endsWith('\n') ? value : `${value}\n`;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function normalizeOptionalString(value) {
|
|
380
|
+
if (typeof value !== 'string') return null;
|
|
381
|
+
const trimmed = value.trim();
|
|
382
|
+
return trimmed ? trimmed : null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function normalizeUuid(value) {
|
|
386
|
+
const normalized = normalizeOptionalString(value);
|
|
387
|
+
if (!normalized) return null;
|
|
388
|
+
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)
|
|
389
|
+
? normalized
|
|
390
|
+
: null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function ensureTrailingSlash(value) {
|
|
394
|
+
return value.endsWith('/') ? value : `${value}/`;
|
|
395
|
+
}
|