@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.
- package/README.md +64 -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 +282 -34
- package/src/sync.js +395 -0
package/src/docs-review.js
CHANGED
|
@@ -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
|
|
95
|
-
|
|
96
|
-
|
|
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) {
|