@vibecompass/vibecompass 0.3.0 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,8 @@
1
- import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
1
+ import { access, mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { createInterface } from 'node:readline/promises';
4
4
  import { parseFrontmatter } from './frontmatter.js';
5
+ import { writeStateManifest } from './manifest.js';
5
6
  import { parseSimpleYaml } from './simple-yaml.js';
6
7
 
7
8
  export async function preflightDocsReview(options = {}, environment = {}) {
@@ -20,6 +21,15 @@ export async function preflightDocsReview(options = {}, environment = {}) {
20
21
  });
21
22
  }
22
23
 
24
+ if (options.applyDecisionArtifact) {
25
+ return applyDecisionArtifact({
26
+ rootDir,
27
+ statePath,
28
+ artifactId: options.artifactId,
29
+ refreshIndex: options.refreshIndex,
30
+ });
31
+ }
32
+
23
33
  if (options.complete) {
24
34
  return completeDocsReview({
25
35
  rootDir,
@@ -29,6 +39,15 @@ export async function preflightDocsReview(options = {}, environment = {}) {
29
39
  });
30
40
  }
31
41
 
42
+ if (options.pollHosted) {
43
+ return pollHostedDocsReview({
44
+ rootDir,
45
+ statePath,
46
+ env: environment.env ?? process.env,
47
+ fetch: environment.runtime?.fetch ?? globalThis.fetch,
48
+ });
49
+ }
50
+
32
51
  const projectFilePath = path.join(rootDir, 'project.yaml');
33
52
  const project = parseSimpleYaml(await readFile(projectFilePath, 'utf8'), {
34
53
  sourceName: projectFilePath,
@@ -63,6 +82,7 @@ export async function preflightDocsReview(options = {}, environment = {}) {
63
82
  reviewPrompt,
64
83
  rootDir,
65
84
  runtime,
85
+ manifest: await writeStateManifest(rootDir),
66
86
  fetch: environment.runtime?.fetch ?? globalThis.fetch,
67
87
  })
68
88
  : null;
@@ -144,6 +164,7 @@ async function completeDocsReview(options) {
144
164
  runtime: current.runtime ?? null,
145
165
  hosted: current.hosted ?? null,
146
166
  applied: current.applied ?? null,
167
+ appliedDecisionArtifact: current.applied_decision_artifact ?? null,
147
168
  reviewPrompt: null,
148
169
  warnings: [],
149
170
  message: 'Docs-review marker completed. Future start-session warnings can treat this root as reviewed.',
@@ -209,6 +230,104 @@ async function applyDocsReviewOutput(options) {
209
230
  };
210
231
  }
211
232
 
233
+ async function applyDecisionArtifact(options) {
234
+ const current = await readDocsReviewMarker(options.statePath);
235
+ const artifactId = normalizeOptionalString(options.artifactId);
236
+ if (!artifactId) {
237
+ throw new Error('docs-review --apply-decision-artifact requires --artifact <artifact-id>.');
238
+ }
239
+
240
+ const artifact = (current.hosted?.artifacts ?? []).find(
241
+ (item) => item?.artifact_id === artifactId,
242
+ );
243
+ if (!artifact) {
244
+ throw new Error(`Decision artifact ${artifactId} was not found in ${options.statePath}. Run docs-review --poll-hosted first.`);
245
+ }
246
+ if (artifact.artifact_type !== 'decision_recommendation') {
247
+ throw new Error(`Artifact ${artifactId} is ${artifact.artifact_type}, not decision_recommendation.`);
248
+ }
249
+
250
+ const content = artifact.content ?? {};
251
+ const targetPath = normalizeDecisionTargetPath(
252
+ content.target_path ?? artifact.target_path ?? 'decisions/cross-cutting.md',
253
+ );
254
+ const title = normalizeOptionalString(content.title ?? artifact.title);
255
+ const decision = normalizeOptionalString(content.decision ?? artifact.summary);
256
+ const rationale = normalizeOptionalString(content.rationale);
257
+ const context = normalizeOptionalString(content.context);
258
+
259
+ if (!title || !decision) {
260
+ throw new Error(`Decision artifact ${artifactId} must include title and decision content.`);
261
+ }
262
+ if (!rationale) {
263
+ throw new Error(`Decision artifact ${artifactId} must include a non-empty rationale before it can be appended locally.`);
264
+ }
265
+
266
+ const nextDecisionId = await readNextDecisionId(options.rootDir);
267
+ const targetFilePath = path.join(options.rootDir, targetPath);
268
+ await mkdir(path.dirname(targetFilePath), { recursive: true });
269
+
270
+ let existingContent = '';
271
+ try {
272
+ existingContent = await readFile(targetFilePath, 'utf8');
273
+ } catch (error) {
274
+ if (error?.code !== 'ENOENT') throw error;
275
+ existingContent = `# ${path.basename(targetPath, '.md')} decisions\n`;
276
+ }
277
+
278
+ const entry = renderDecisionEntry({
279
+ decisionId: nextDecisionId,
280
+ title,
281
+ decision,
282
+ rationale,
283
+ context,
284
+ artifactId,
285
+ });
286
+ await writeFile(targetFilePath, `${existingContent.trimEnd()}\n\n${entry}`, 'utf8');
287
+ const indexResult = options.refreshIndex
288
+ ? await refreshDecisionIndex(options.rootDir)
289
+ : {
290
+ refreshed: false,
291
+ warnings: [
292
+ 'decisions/INDEX.md was not refreshed. Run again with --refresh-index if this root uses the package-generated flat index, or update the project-specific index manually.',
293
+ ],
294
+ };
295
+
296
+ const appliedDecisionArtifact = {
297
+ artifact_id: artifactId,
298
+ decision_id: nextDecisionId,
299
+ target_path: targetPath,
300
+ applied_at: new Date().toISOString(),
301
+ refreshed_index: indexResult.refreshed,
302
+ };
303
+ await writeDocsReviewMarker(options.statePath, {
304
+ ...current,
305
+ applied_decision_artifact: appliedDecisionArtifact,
306
+ hosted: {
307
+ ...current.hosted,
308
+ artifacts: (current.hosted?.artifacts ?? []).map((item) =>
309
+ item?.artifact_id === artifactId
310
+ ? { ...item, local_status: 'applied', local_decision_id: nextDecisionId }
311
+ : item,
312
+ ),
313
+ },
314
+ });
315
+
316
+ return {
317
+ rootDir: options.rootDir,
318
+ statePath: options.statePath,
319
+ status: current.status ?? 'hosted-review-completed',
320
+ llm: current.llm ?? null,
321
+ model: current.model ?? null,
322
+ runtime: current.runtime ?? null,
323
+ hosted: current.hosted ?? null,
324
+ appliedDecisionArtifact,
325
+ reviewPrompt: null,
326
+ warnings: indexResult.warnings,
327
+ message: `Applied decision artifact ${artifactId} as D-${String(nextDecisionId).padStart(3, '0')} in ${targetPath}.`,
328
+ };
329
+ }
330
+
212
331
  async function readDocsReviewMarker(statePath) {
213
332
  try {
214
333
  return JSON.parse(await readFile(statePath, 'utf8'));
@@ -224,9 +343,11 @@ async function readDocsReviewMarker(statePath) {
224
343
  function validateDocsReviewMode(options) {
225
344
  const enabled = [
226
345
  options.submitHosted ? '--submit-hosted' : null,
346
+ options.pollHosted ? '--poll-hosted' : null,
227
347
  options.runLocal ? '--run-local' : null,
228
348
  options.runLocalAnthropic ? '--run-local-anthropic' : null,
229
349
  options.applyOutput ? '--apply-output' : null,
350
+ options.applyDecisionArtifact ? '--apply-decision-artifact' : null,
230
351
  options.complete ? '--complete' : null,
231
352
  ].filter(Boolean);
232
353
 
@@ -534,6 +655,9 @@ async function submitHostedDocsReview(options) {
534
655
  `api/sync/projects/${encodeURIComponent(options.runtime.project_id)}/docs-review`,
535
656
  ensureTrailingSlash(options.runtime.api_url),
536
657
  );
658
+ const baselineRemoteRevisionId = normalizeUuid(
659
+ options.manifest?.manifest?.sync?.last_successful_remote_revision,
660
+ );
537
661
  const response = await options.fetch(endpoint.href, {
538
662
  method: 'POST',
539
663
  headers: {
@@ -550,11 +674,28 @@ async function submitHostedDocsReview(options) {
550
674
  mode: options.project.mode,
551
675
  repos: Array.isArray(options.project.repos) ? options.project.repos : [],
552
676
  },
677
+ ...(baselineRemoteRevisionId
678
+ ? { baseline_remote_revision_id: baselineRemoteRevisionId }
679
+ : {}),
680
+ local_root_revision: options.manifest?.manifest?.canonical?.local_root_revision,
681
+ evidence_scope: {
682
+ manifest_hash: options.manifest?.manifest?.canonical?.manifest_hash,
683
+ document_count: options.manifest?.manifest?.canonical?.document_count,
684
+ warning_count: options.manifest?.manifest?.canonical?.warning_count,
685
+ },
553
686
  }),
554
687
  });
555
688
 
556
689
  if (!response.ok) {
557
690
  const body = typeof response.text === 'function' ? await response.text() : '';
691
+ const details = parseJsonObject(body);
692
+ if (details?.error) {
693
+ const nextStep = details.next_step ? ` Next step: ${details.next_step}` : '';
694
+ throw new Error(
695
+ `Hosted docs-review request failed with ${response.status}: ${details.error}${nextStep}`,
696
+ );
697
+ }
698
+
558
699
  throw new Error(`Hosted docs-review request failed with ${response.status}${body ? `: ${body}` : ''}`);
559
700
  }
560
701
 
@@ -569,6 +710,116 @@ async function submitHostedDocsReview(options) {
569
710
  endpoint: endpoint.href,
570
711
  run_id: runId,
571
712
  status: normalizeOptionalString(body.status) ?? 'accepted',
713
+ phase: normalizeOptionalString(body.phase),
714
+ };
715
+ }
716
+
717
+ function parseJsonObject(value) {
718
+ if (!value) return null;
719
+ try {
720
+ const parsed = JSON.parse(value);
721
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
722
+ } catch {
723
+ return null;
724
+ }
725
+ }
726
+
727
+ async function pollHostedDocsReview(options) {
728
+ const marker = await readDocsReviewMarker(options.statePath);
729
+ const runtime = marker.runtime;
730
+ const hosted = marker.hosted;
731
+
732
+ if (runtime?.provider !== 'hosted' || !runtime.api_url || !runtime.project_id) {
733
+ throw new Error('docs-review --poll-hosted requires a hosted docs-review marker.');
734
+ }
735
+
736
+ const runId = normalizeOptionalString(hosted?.run_id);
737
+ if (!runId) {
738
+ throw new Error('docs-review --poll-hosted requires hosted.run_id in state/docs-review.json.');
739
+ }
740
+
741
+ const credentialEnvVar = runtime.credential_env_var;
742
+ if (!credentialEnvVar) {
743
+ throw new Error('docs-review --poll-hosted requires runtime.credential_env_var in state/docs-review.json.');
744
+ }
745
+
746
+ const credential = normalizeOptionalString(options.env?.[credentialEnvVar]);
747
+ if (!credential) {
748
+ throw new Error(`docs-review --poll-hosted requires ${credentialEnvVar}.`);
749
+ }
750
+
751
+ const endpoint = new URL(
752
+ `api/sync/projects/${encodeURIComponent(runtime.project_id)}/runs/${encodeURIComponent(runId)}`,
753
+ ensureTrailingSlash(runtime.api_url),
754
+ );
755
+ const response = await options.fetch(endpoint.href, {
756
+ method: 'GET',
757
+ headers: {
758
+ accept: 'application/json',
759
+ authorization: `Bearer ${credential}`,
760
+ },
761
+ });
762
+
763
+ if (!response.ok) {
764
+ const body = typeof response.text === 'function' ? await response.text() : '';
765
+ throw new Error(`Hosted docs-review poll failed with ${response.status}${body ? `: ${body}` : ''}`);
766
+ }
767
+
768
+ const body = typeof response.json === 'function' ? await response.json() : {};
769
+ const {
770
+ phase: _oldPhase,
771
+ prompt_version: _oldPromptVersion,
772
+ parser_version: _oldParserVersion,
773
+ proposal_ids: _oldProposalIds,
774
+ artifact_ids: _oldArtifactIds,
775
+ artifacts: _oldArtifacts,
776
+ warnings: _oldWarnings,
777
+ error_code: _oldErrorCode,
778
+ error_message: _oldErrorMessage,
779
+ ...hostedBase
780
+ } = hosted;
781
+ const hostedStatus = normalizeOptionalString(body.status) ?? hosted.status ?? 'accepted';
782
+ const hostedErrorMessage = normalizeOptionalString(body.error_message);
783
+ const updatedHosted = {
784
+ ...hostedBase,
785
+ endpoint: hosted.endpoint,
786
+ run_id: runId,
787
+ status: hostedStatus,
788
+ ...(normalizeOptionalString(body.phase) ? { phase: normalizeOptionalString(body.phase) } : {}),
789
+ ...(normalizeOptionalString(body.prompt_version) ? { prompt_version: normalizeOptionalString(body.prompt_version) } : {}),
790
+ ...(normalizeOptionalString(body.parser_version) ? { parser_version: normalizeOptionalString(body.parser_version) } : {}),
791
+ ...(Array.isArray(body.proposal_ids) ? { proposal_ids: body.proposal_ids } : {}),
792
+ ...(Array.isArray(body.artifact_ids) ? { artifact_ids: body.artifact_ids } : {}),
793
+ ...(Array.isArray(body.artifacts) ? { artifacts: body.artifacts } : {}),
794
+ ...(Array.isArray(body.warnings) ? { warnings: body.warnings } : {}),
795
+ ...(normalizeOptionalString(body.error_code) ? { error_code: normalizeOptionalString(body.error_code) } : {}),
796
+ ...(hostedErrorMessage ? { error_message: hostedErrorMessage } : {}),
797
+ };
798
+ const markerStatus = markerStatusForHostedRunStatus(hostedStatus);
799
+ const message = hostedStatus === 'failed' && hostedErrorMessage
800
+ ? `Hosted docs-review run ${runId} is failed: ${hostedErrorMessage}`
801
+ : `Hosted docs-review run ${runId} is ${updatedHosted.status}.`;
802
+
803
+ const updatedMarker = {
804
+ ...marker,
805
+ status: markerStatus,
806
+ hosted: updatedHosted,
807
+ };
808
+ await writeDocsReviewMarker(options.statePath, updatedMarker);
809
+
810
+ return {
811
+ rootDir: options.rootDir,
812
+ statePath: options.statePath,
813
+ status: markerStatus,
814
+ llm: marker.llm ?? null,
815
+ model: marker.model ?? null,
816
+ runtime,
817
+ hosted: updatedHosted,
818
+ reviewPrompt: null,
819
+ warnings: (updatedHosted.warnings ?? []).map((warning) =>
820
+ warning?.message ? `${warning.code ?? 'hosted_warning'}: ${warning.message}` : String(warning),
821
+ ),
822
+ message,
572
823
  };
573
824
  }
574
825
 
@@ -595,6 +846,162 @@ function normalizeOptionalString(value) {
595
846
  return trimmed ? trimmed : null;
596
847
  }
597
848
 
849
+ function normalizeUuid(value) {
850
+ const normalized = normalizeOptionalString(value);
851
+ if (!normalized) {
852
+ return null;
853
+ }
854
+
855
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(normalized)
856
+ ? normalized
857
+ : null;
858
+ }
859
+
860
+ function normalizeDecisionTargetPath(value) {
861
+ const normalized = normalizeBlockPath(value);
862
+ if (
863
+ !normalized.startsWith('decisions/') ||
864
+ !normalized.endsWith('.md') ||
865
+ normalized.split('/').length !== 2 ||
866
+ normalized.split('/').some((part) => part === '..' || part === '') ||
867
+ ['decisions/INDEX.md', 'decisions/README.md', 'decisions/EXAMPLE.md'].includes(normalized)
868
+ ) {
869
+ throw new Error(`Decision artifact target must be a decisions/*.md domain file. Invalid path: ${value}`);
870
+ }
871
+
872
+ return normalized;
873
+ }
874
+
875
+ async function readNextDecisionId(rootDir) {
876
+ const decisionsDir = path.join(rootDir, 'decisions');
877
+ let max = 0;
878
+
879
+ for (const fileName of await listDecisionFileNames(decisionsDir)) {
880
+ const content = await readFile(path.join(decisionsDir, fileName), 'utf8');
881
+ for (const match of content.matchAll(/^###\s+D-(\d+)\b/gm)) {
882
+ max = Math.max(max, Number(match[1]));
883
+ }
884
+ }
885
+
886
+ return max + 1;
887
+ }
888
+
889
+ async function listDecisionFileNames(decisionsDir) {
890
+ try {
891
+ return (await readdir(decisionsDir))
892
+ .filter((fileName) =>
893
+ fileName.endsWith('.md') &&
894
+ !['INDEX.md', 'README.md', 'EXAMPLE.md'].includes(fileName),
895
+ )
896
+ .sort((left, right) => left.localeCompare(right));
897
+ } catch (error) {
898
+ if (error?.code === 'ENOENT') return [];
899
+ throw error;
900
+ }
901
+ }
902
+
903
+ function renderDecisionEntry(options) {
904
+ const decisionNumber = `D-${String(options.decisionId).padStart(3, '0')}`;
905
+ return [
906
+ `### ${decisionNumber} — ${options.title}`,
907
+ '',
908
+ `**Timestamp:** ${formatDecisionTimestamp(new Date())}`,
909
+ `**Decision:** ${options.decision}`,
910
+ `**Rationale:** ${options.rationale}`,
911
+ ...(options.context ? [`**Context:** ${options.context}`] : []),
912
+ '',
913
+ ].join('\n');
914
+ }
915
+
916
+ function formatDecisionTimestamp(date) {
917
+ const offsetMinutes = -date.getTimezoneOffset();
918
+ const sign = offsetMinutes >= 0 ? '+' : '-';
919
+ const absoluteOffset = Math.abs(offsetMinutes);
920
+ const offsetHours = String(Math.floor(absoluteOffset / 60)).padStart(2, '0');
921
+ const offsetRemainder = String(absoluteOffset % 60).padStart(2, '0');
922
+ const yyyy = date.getFullYear();
923
+ const mm = String(date.getMonth() + 1).padStart(2, '0');
924
+ const dd = String(date.getDate()).padStart(2, '0');
925
+ const hh = String(date.getHours()).padStart(2, '0');
926
+ const min = String(date.getMinutes()).padStart(2, '0');
927
+
928
+ return `${yyyy}-${mm}-${dd} ${hh}:${min} ${formatTimeZoneAbbreviation(date) ?? `UTC${sign}${offsetHours}:${offsetRemainder}`}`;
929
+ }
930
+
931
+ function formatTimeZoneAbbreviation(date) {
932
+ try {
933
+ const parts = new Intl.DateTimeFormat('en-US', {
934
+ timeZoneName: 'short',
935
+ }).formatToParts(date);
936
+ const zone = parts.find((part) => part.type === 'timeZoneName')?.value;
937
+ return zone && !/^GMT[+-]/.test(zone) ? zone : null;
938
+ } catch {
939
+ return null;
940
+ }
941
+ }
942
+
943
+ async function refreshDecisionIndex(rootDir) {
944
+ const decisionsDir = path.join(rootDir, 'decisions');
945
+ const indexPath = path.join(decisionsDir, 'INDEX.md');
946
+ const warnings = [];
947
+ const rows = [];
948
+
949
+ for (const fileName of await listDecisionFileNames(decisionsDir)) {
950
+ const relativePath = `decisions/${fileName}`;
951
+ const content = await readFile(path.join(decisionsDir, fileName), 'utf8');
952
+ for (const match of content.matchAll(/^###\s+D-(\d+)\s+—\s+(.+)$/gm)) {
953
+ rows.push({
954
+ id: Number(match[1]),
955
+ title: match[2].trim(),
956
+ domainFile: fileName,
957
+ });
958
+ }
959
+ if (!content.match(/^###\s+D-(\d+)\s+—\s+(.+)$/m)) {
960
+ warnings.push(`No indexable decisions found in ${relativePath}.`);
961
+ }
962
+ }
963
+
964
+ rows.sort((left, right) => left.id - right.id);
965
+ await mkdir(decisionsDir, { recursive: true });
966
+ await writeFile(indexPath, renderDecisionIndex(rows), 'utf8');
967
+ return { refreshed: true, warnings };
968
+ }
969
+
970
+ function renderDecisionIndex(rows) {
971
+ return [
972
+ '# Decision Index',
973
+ '',
974
+ 'All decisions in chronological order. Each entry links to its domain file.',
975
+ '',
976
+ '> **Rule:** Decisions are append-only — never modify existing entries.',
977
+ '',
978
+ '| # | Decision | Domain |',
979
+ '|---|----------|--------|',
980
+ ...rows.map((row) => {
981
+ const decisionNumber = `D-${String(row.id).padStart(3, '0')}`;
982
+ const domain = path.basename(row.domainFile, '.md');
983
+ return `| ${decisionNumber} | ${row.title} | [${domain}](${row.domainFile}) |`;
984
+ }),
985
+ '',
986
+ ].join('\n');
987
+ }
988
+
989
+ function markerStatusForHostedRunStatus(status) {
990
+ if (status === 'completed') {
991
+ return 'hosted-review-completed';
992
+ }
993
+
994
+ if (status === 'failed') {
995
+ return 'hosted-review-failed';
996
+ }
997
+
998
+ if (status === 'needs_rebase') {
999
+ return 'hosted-review-needs-rebase';
1000
+ }
1001
+
1002
+ return 'hosted-review-requested';
1003
+ }
1004
+
598
1005
  function normalizeMaxTokens(value) {
599
1006
  if (value === undefined || value === null) {
600
1007
  return 16000;
package/src/index.js CHANGED
@@ -1,6 +1,12 @@
1
1
  export { STATE_VERSION, generateStateManifest, writeStateManifest } from './manifest.js';
2
2
  export { initializeProjectMemory } from './init.js';
3
3
  export { preflightDocsReview } from './docs-review.js';
4
+ export {
5
+ applyPullExport,
6
+ pullExportProjectMemory,
7
+ pullPreviewProjectMemory,
8
+ pushProjectMemory,
9
+ } from './sync.js';
4
10
  export { scanProjectMemory } from './project-memory.js';
5
11
  export {
6
12
  closeProjectSession,
package/src/init.js CHANGED
@@ -5,6 +5,7 @@ import { writeStateManifest } from './manifest.js';
5
5
  import { scaffoldInitFiles } from './scaffold.js';
6
6
  import { applyPlacementDefaults } from './setup.js';
7
7
  import { buildWorkflowMetadata } from './workflow.js';
8
+ import { parseSimpleYaml } from './simple-yaml.js';
8
9
 
9
10
  const VALID_MODES = new Set(['local-only', 'local-primary', 'hosted-only']);
10
11
 
@@ -41,6 +42,39 @@ export async function initializeProjectMemory(options) {
41
42
  };
42
43
  }
43
44
 
45
+ export async function connectHostedProjectMemory(options) {
46
+ const cwd = options?.cwd ? path.resolve(options.cwd) : process.cwd();
47
+ const rootDir = path.resolve(cwd, options?.rootDir ?? '.compass');
48
+ const projectFilePath = path.join(rootDir, 'project.yaml');
49
+ let projectConfig;
50
+ try {
51
+ projectConfig = parseSimpleYaml(await readFile(projectFilePath, 'utf8'), {
52
+ sourceName: projectFilePath,
53
+ });
54
+ } catch (error) {
55
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
56
+ throw new Error(`No project.yaml found in ${rootDir}. Run "vibecompass init" first.`);
57
+ }
58
+
59
+ throw error;
60
+ }
61
+
62
+ if (!['local-primary', 'hosted-only'].includes(projectConfig.mode)) {
63
+ throw new Error('connect-hosted requires project.yaml mode to be local-primary or hosted-only.');
64
+ }
65
+
66
+ const sync = normalizeSyncOptions(options?.sync, projectConfig.mode);
67
+ projectConfig.sync = sync;
68
+ await writeFile(projectFilePath, serializeProjectConfig(projectConfig), 'utf8');
69
+
70
+ return {
71
+ rootDir,
72
+ projectFilePath,
73
+ mode: projectConfig.mode,
74
+ syncEnvVar: sync.credential_env_var,
75
+ };
76
+ }
77
+
44
78
  function normalizeInitOptions(options) {
45
79
  if (!options || typeof options !== 'object') {
46
80
  throw new Error('initializeProjectMemory requires an options object.');
@@ -91,30 +125,9 @@ function normalizeInitOptions(options) {
91
125
  };
92
126
  });
93
127
 
94
- const syncOptions = preparedOptions.sync ?? null;
95
- let sync = undefined;
96
- if (syncOptions) {
97
- if (preparedOptions.mode !== 'local-primary') {
98
- throw new Error('sync configuration is only supported when mode is local-primary.');
99
- }
100
-
101
- const requiredFields = ['apiUrl', 'projectId', 'credentialEnvVar'];
102
- const missing = requiredFields.filter((field) => typeof syncOptions[field] !== 'string' || syncOptions[field].trim() === '');
103
-
104
- if (missing.length > 0) {
105
- throw new Error(
106
- `sync configuration is incomplete. Missing: ${missing.join(', ')}.`,
107
- );
108
- }
109
-
110
- sync = {
111
- provider: 'vibecompass',
112
- api_url: syncOptions.apiUrl,
113
- project_id: syncOptions.projectId,
114
- credential_source: 'env',
115
- credential_env_var: syncOptions.credentialEnvVar,
116
- };
117
- }
128
+ const sync = preparedOptions.sync
129
+ ? normalizeSyncOptions(preparedOptions.sync, preparedOptions.mode)
130
+ : undefined;
118
131
 
119
132
  const projectConfig = {
120
133
  format_version: 1,
@@ -149,6 +162,33 @@ function normalizeInitOptions(options) {
149
162
  };
150
163
  }
151
164
 
165
+ function normalizeSyncOptions(syncOptions, mode) {
166
+ if (!syncOptions || typeof syncOptions !== 'object') {
167
+ throw new Error('sync configuration must be an object.');
168
+ }
169
+
170
+ if (!['local-primary', 'hosted-only'].includes(mode)) {
171
+ throw new Error('sync configuration is only supported when mode is local-primary or hosted-only.');
172
+ }
173
+
174
+ const requiredFields = ['apiUrl', 'projectId', 'credentialEnvVar'];
175
+ const missing = requiredFields.filter((field) => typeof syncOptions[field] !== 'string' || syncOptions[field].trim() === '');
176
+
177
+ if (missing.length > 0) {
178
+ throw new Error(
179
+ `sync configuration is incomplete. Missing: ${missing.join(', ')}.`,
180
+ );
181
+ }
182
+
183
+ return {
184
+ provider: 'vibecompass',
185
+ api_url: syncOptions.apiUrl,
186
+ project_id: syncOptions.projectId,
187
+ credential_source: 'env',
188
+ credential_env_var: syncOptions.credentialEnvVar,
189
+ };
190
+ }
191
+
152
192
  function buildAgentFilesMetadata(metadata) {
153
193
  const existingAgentFiles = metadata?.agent_files && typeof metadata.agent_files === 'object'
154
194
  ? metadata.agent_files
package/src/manifest.js CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdir, writeFile } from 'node:fs/promises';
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { localRevisionFromManifestHash, stableHash } from './hash.js';
4
4
  import { scanProjectMemory } from './project-memory.js';
@@ -59,8 +59,12 @@ export function generateStateManifest(scanResult, options = {}) {
59
59
  export async function writeStateManifest(rootDir, options = {}) {
60
60
  const scanResult = await scanProjectMemory(rootDir);
61
61
  const activeSessions = await readActiveSessionsForManifest(rootDir);
62
+ const existingSync = Object.hasOwn(options, 'sync')
63
+ ? options.sync
64
+ : await readExistingSyncState(rootDir);
62
65
  const manifest = generateStateManifest(scanResult, {
63
66
  ...options,
67
+ ...(existingSync ? { sync: existingSync } : {}),
64
68
  ...(activeSessions ? { activeSessions } : {}),
65
69
  });
66
70
  const stateDir = path.join(rootDir, 'state');
@@ -76,6 +80,19 @@ export async function writeStateManifest(rootDir, options = {}) {
76
80
  };
77
81
  }
78
82
 
83
+ async function readExistingSyncState(rootDir) {
84
+ try {
85
+ const existing = JSON.parse(
86
+ await readFile(path.join(rootDir, 'state', 'manifest.json'), 'utf8'),
87
+ );
88
+ return existing?.sync && typeof existing.sync === 'object'
89
+ ? existing.sync
90
+ : null;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
79
96
  async function readActiveSessionsForManifest(rootDir) {
80
97
  const sessions = await listProjectSessions({ rootDir });
81
98
  if (sessions.lanes.length === 0 && !sessions.current) {