@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.
@@ -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-<positive integer>.'));
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+)\b.*$/gm)];
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+)\s+—\s+(.+)$/gm;
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 { access } from 'node:fs/promises';
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 projectNameDefault = path.basename(cwd);
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
- resolved.mode = await askChoice(prompter, 'Project mode', [
204
+ const setupGoal = await askChoice(prompter, 'What should VibeCompass set up?', [
166
205
  {
167
- value: 'local-only',
168
- description: 'Local files only. No hosted dependency.',
206
+ value: 'share-later',
207
+ description: 'Recommended. Local notes, with the option to share or collaborate later.',
169
208
  },
170
209
  {
171
- value: 'local-primary',
172
- description: 'Local files stay authoritative, with optional hosted sync/projection.',
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: 'Compatibility mode when hosted state remains authoritative.',
215
+ description: 'Hosted VibeCompass is already the source of truth for this project.',
177
216
  },
178
217
  ], {
179
- defaultValue: 'local-only',
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
- resolved.repos = await promptForRepos(prompter, cwd);
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
- recommendation = await promptForPlacement(prompter, {
190
- cwd,
191
- repoCount: resolved.repos.length,
192
- });
193
- resolved.placementPattern = recommendation.pattern;
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
- const ownerLabel = describePlacementOwner(resolved.placementPattern);
198
- const defaultOwnerDir = await suggestOwnerDirectory({
199
- cwd,
200
- placementPattern: resolved.placementPattern,
201
- repos: resolved.repos,
202
- });
203
- resolved.toolingRootDir = await askInput(prompter, ownerLabel, {
204
- defaultValue: defaultOwnerDir,
205
- validate: async (value) => {
206
- if (!(await directoryExists(path.resolve(cwd, value)))) {
207
- return `${ownerLabel} must already exist relative to the current working directory.`;
208
- }
209
-
210
- return null;
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 === 'local-primary') {
275
+ if (!resolved.sync && resolved.mode === 'hosted-only') {
218
276
  const configureSync = await askConfirm(
219
277
  prompter,
220
- 'Configure hosted sync binding now?',
221
- false,
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
+ }