@vibecompass/vibecompass 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,147 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { parseSimpleYaml } from '../../simple-yaml.js';
4
+ import { loadProjectReadModel } from '../../read-model.js';
5
+ import { applyManagedBlock } from './markers.js';
6
+ import { buildAgentContext } from './template.js';
7
+ import { claudeMdFormat } from './claude-md.js';
8
+ import { agentsMdFormat } from './agents-md.js';
9
+ import { cursorRulesFormat } from './cursor-rules.js';
10
+ import { copilotInstructionsFormat } from './copilot-instructions.js';
11
+
12
+ const FORMATS = [
13
+ claudeMdFormat,
14
+ agentsMdFormat,
15
+ cursorRulesFormat,
16
+ copilotInstructionsFormat,
17
+ ];
18
+
19
+ const FORMAT_MAP = new Map(FORMATS.map((format) => [format.name, format]));
20
+
21
+ export async function syncAgentInstructionFiles(options = {}) {
22
+ const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd();
23
+ const rootDir = path.resolve(cwd, options.rootDir ?? '.compass');
24
+ const toolingRootDir = options.toolingRootDir
25
+ ? path.resolve(cwd, options.toolingRootDir)
26
+ : cwd;
27
+ const requestedFormat = normalizeOptionalString(options.format);
28
+ const dryRun = Boolean(options.dryRun);
29
+
30
+ const projectConfig = await readProjectConfig(rootDir);
31
+ const selection = resolveEnabledFormats(projectConfig, requestedFormat);
32
+
33
+ if (selection.disabledFormat) {
34
+ const outputPath = path.join(toolingRootDir, selection.disabledFormat.path);
35
+ return {
36
+ rootDir,
37
+ toolingRootDir,
38
+ dryRun,
39
+ results: [
40
+ {
41
+ format: selection.disabledFormat.name,
42
+ path: outputPath,
43
+ relativePath: toPosix(path.relative(toolingRootDir, outputPath)),
44
+ status: 'disabled',
45
+ warning: `Format "${selection.disabledFormat.name}" is disabled in metadata.agent_files; left untouched.`,
46
+ changed: false,
47
+ },
48
+ ],
49
+ };
50
+ }
51
+
52
+ const readModel = await loadProjectReadModel(rootDir);
53
+ const context = buildAgentContext(readModel);
54
+ const results = [];
55
+
56
+ for (const format of selection.formats) {
57
+ const outputPath = path.join(toolingRootDir, format.path);
58
+ const existingContent = await readExistingFile(outputPath);
59
+ const generatedContent = format.render(context);
60
+ const applied = applyManagedBlock(existingContent, generatedContent);
61
+
62
+ if (!dryRun && applied.warning === null && applied.content !== existingContent) {
63
+ await mkdir(path.dirname(outputPath), { recursive: true });
64
+ await writeFile(outputPath, applied.content, 'utf8');
65
+ }
66
+
67
+ results.push({
68
+ format: format.name,
69
+ path: outputPath,
70
+ relativePath: toPosix(path.relative(toolingRootDir, outputPath)),
71
+ status: dryRun && applied.warning === null ? `dry-run-${applied.status}` : applied.status,
72
+ warning: applied.warning,
73
+ changed: applied.warning === null && applied.content !== existingContent,
74
+ });
75
+ }
76
+
77
+ return {
78
+ rootDir,
79
+ toolingRootDir,
80
+ dryRun,
81
+ results,
82
+ };
83
+ }
84
+
85
+ export function getSupportedAgentFormats() {
86
+ return FORMATS.map((format) => format.name);
87
+ }
88
+
89
+ async function readProjectConfig(rootDir) {
90
+ const source = await readFile(path.join(rootDir, 'project.yaml'), 'utf8');
91
+ return parseSimpleYaml(source, { sourceName: 'project.yaml' });
92
+ }
93
+
94
+ async function readExistingFile(filePath) {
95
+ try {
96
+ return await readFile(filePath, 'utf8');
97
+ } catch (error) {
98
+ if (error && typeof error === 'object' && error.code === 'ENOENT') {
99
+ return null;
100
+ }
101
+
102
+ throw error;
103
+ }
104
+ }
105
+
106
+ function resolveEnabledFormats(projectConfig, requestedFormat) {
107
+ const configured = projectConfig?.metadata?.agent_files;
108
+ const enabledNames = FORMATS
109
+ .filter((format) => configured?.[format.name] !== false)
110
+ .map((format) => format.name);
111
+
112
+ if (requestedFormat) {
113
+ if (!FORMAT_MAP.has(requestedFormat)) {
114
+ throw new Error(`Unknown agent file format "${requestedFormat}". Supported formats: ${getSupportedAgentFormats().join(', ')}.`);
115
+ }
116
+
117
+ if (!enabledNames.includes(requestedFormat)) {
118
+ return {
119
+ formats: [],
120
+ disabledFormat: FORMAT_MAP.get(requestedFormat),
121
+ };
122
+ }
123
+
124
+ return {
125
+ formats: [FORMAT_MAP.get(requestedFormat)],
126
+ disabledFormat: null,
127
+ };
128
+ }
129
+
130
+ return {
131
+ formats: enabledNames.map((name) => FORMAT_MAP.get(name)),
132
+ disabledFormat: null,
133
+ };
134
+ }
135
+
136
+ function normalizeOptionalString(value) {
137
+ if (typeof value !== 'string') {
138
+ return null;
139
+ }
140
+
141
+ const trimmed = value.trim();
142
+ return trimmed ? trimmed : null;
143
+ }
144
+
145
+ function toPosix(value) {
146
+ return value.split(path.sep).join('/');
147
+ }
@@ -0,0 +1,49 @@
1
+ export const START_MARKER = '<!-- vibecompass:start - managed by VibeCompass, do not edit -->';
2
+ export const END_MARKER = '<!-- vibecompass:end -->';
3
+
4
+ export function renderManagedBlock(content) {
5
+ const trimmed = content.trim();
6
+ return `${START_MARKER}\n${trimmed}\n${END_MARKER}\n`;
7
+ }
8
+
9
+ export function applyManagedBlock(existingContent, generatedContent) {
10
+ const block = renderManagedBlock(generatedContent);
11
+
12
+ if (existingContent === null) {
13
+ return {
14
+ status: 'create',
15
+ content: block,
16
+ warning: null,
17
+ };
18
+ }
19
+
20
+ const startIndex = existingContent.indexOf(START_MARKER);
21
+ const endIndex = existingContent.indexOf(END_MARKER);
22
+
23
+ if (startIndex === -1 && endIndex === -1) {
24
+ return {
25
+ status: 'warning',
26
+ content: existingContent,
27
+ warning: 'Existing file has no VibeCompass managed markers; left untouched.',
28
+ };
29
+ }
30
+
31
+ if (startIndex === -1 || endIndex === -1 || endIndex < startIndex) {
32
+ return {
33
+ status: 'warning',
34
+ content: existingContent,
35
+ warning: 'Existing file has incomplete VibeCompass managed markers; left untouched.',
36
+ };
37
+ }
38
+
39
+ const before = existingContent.slice(0, startIndex);
40
+ const after = existingContent.slice(endIndex + END_MARKER.length);
41
+ const beforeSeparator = before === '' || before.endsWith('\n') ? '' : '\n';
42
+ const afterSeparator = after === '' || after.startsWith('\n') ? '' : '\n';
43
+
44
+ return {
45
+ status: 'update',
46
+ content: `${before}${beforeSeparator}${block}${afterSeparator}${after}`,
47
+ warning: null,
48
+ };
49
+ }
@@ -0,0 +1,75 @@
1
+ export function buildAgentContext(readModel) {
2
+ return {
3
+ projectName: readModel.project.name ?? 'Unnamed project',
4
+ description: readModel.project.description ?? null,
5
+ mode: readModel.project.mode ?? null,
6
+ rootDir: readModel.project.root_dir,
7
+ repos: readModel.project.repos ?? [],
8
+ domains: readModel.domains ?? [],
9
+ recentDecisions: readModel.decisions.slice(0, 8),
10
+ recentSessions: readModel.sessions.slice(0, 3),
11
+ };
12
+ }
13
+
14
+ export function renderSharedInstructionBody(context, options = {}) {
15
+ const heading = options.heading ?? `${context.projectName} Project Instructions`;
16
+ const intro =
17
+ options.intro ??
18
+ 'Use VibeCompass project memory as the source of truth before making code changes.';
19
+
20
+ return [
21
+ `# ${heading}`,
22
+ '',
23
+ intro,
24
+ '',
25
+ '## Read First',
26
+ `- Project memory root: \`${context.rootDir}\``,
27
+ '- Read `project.yaml` for project identity, repos, mode, and workflow defaults.',
28
+ '- Read the latest finalized session note under `sessions/` before resuming work.',
29
+ '- Read `sessions/wip.md` and `sessions/handoff.md` when they exist; they are the active builder/reviewer scratch files.',
30
+ '- Read relevant `architecture/` and `decisions/` files before changing implementation.',
31
+ '',
32
+ '## Project Shape',
33
+ ...renderProjectShape(context),
34
+ '',
35
+ '## Workflow Rules',
36
+ '- Use `start session` for builder work and `join as reviewer` for review work.',
37
+ '- Use `review handoff` to ask a reviewer to inspect the active handoff.',
38
+ '- Use `address review` to process reviewer feedback from `sessions/wip.md` and `sessions/handoff.md`.',
39
+ '- Treat generated agent files as views. Update canonical VibeCompass project memory instead of editing managed regions.',
40
+ '',
41
+ '## Recent Decisions',
42
+ ...renderDecisionLines(context.recentDecisions),
43
+ ].join('\n');
44
+ }
45
+
46
+ function renderProjectShape(context) {
47
+ const lines = [];
48
+
49
+ if (context.description) {
50
+ lines.push(`- Description: ${context.description}`);
51
+ }
52
+
53
+ if (context.mode) {
54
+ lines.push(`- Mode: ${context.mode}`);
55
+ }
56
+
57
+ for (const repo of context.repos) {
58
+ lines.push(`- Repo \`${repo.id}\`: ${repo.remote}`);
59
+ }
60
+
61
+ for (const domain of context.domains.slice(0, 8)) {
62
+ const features = domain.features.map((feature) => feature.feature).join(', ');
63
+ lines.push(`- ${domain.domain}: ${features || 'No features recorded yet.'}`);
64
+ }
65
+
66
+ return lines.length > 0 ? lines : ['- No architecture domains recorded yet.'];
67
+ }
68
+
69
+ function renderDecisionLines(decisions) {
70
+ if (!decisions || decisions.length === 0) {
71
+ return ['- No decisions recorded yet.'];
72
+ }
73
+
74
+ return decisions.map((decision) => `- D-${String(decision.decision_id).padStart(3, '0')} — ${decision.title}`);
75
+ }
package/src/hash.js ADDED
@@ -0,0 +1,29 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ export function sha256Text(value) {
4
+ return `sha256:${createHash('sha256').update(value).digest('hex')}`;
5
+ }
6
+
7
+ export function stableStringify(value) {
8
+ if (Array.isArray(value)) {
9
+ return `[${value.map((item) => stableStringify(item)).join(',')}]`;
10
+ }
11
+
12
+ if (value && typeof value === 'object') {
13
+ const entries = Object.entries(value)
14
+ .sort(([left], [right]) => left.localeCompare(right))
15
+ .map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`);
16
+
17
+ return `{${entries.join(',')}}`;
18
+ }
19
+
20
+ return JSON.stringify(value);
21
+ }
22
+
23
+ export function stableHash(value) {
24
+ return sha256Text(stableStringify(value));
25
+ }
26
+
27
+ export function localRevisionFromManifestHash(manifestHash) {
28
+ return `loc_${manifestHash.slice('sha256:'.length, 'sha256:'.length + 24)}`;
29
+ }
package/src/index.js ADDED
@@ -0,0 +1,15 @@
1
+ export { STATE_VERSION, generateStateManifest, writeStateManifest } from './manifest.js';
2
+ export { initializeProjectMemory } from './init.js';
3
+ export { scanProjectMemory } from './project-memory.js';
4
+ export { startProjectSession, closeProjectSession } from './session.js';
5
+ export {
6
+ syncAgentInstructionFiles,
7
+ getSupportedAgentFormats,
8
+ } from './generators/agent-files/index.js';
9
+ export {
10
+ loadProjectReadModel,
11
+ getProjectContext,
12
+ getFeatureContext,
13
+ getDecisionLog,
14
+ getFileContext,
15
+ } from './read-model.js';
package/src/init.js ADDED
@@ -0,0 +1,227 @@
1
+ import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { serializeProjectConfig } from './project-yaml.js';
4
+ import { writeStateManifest } from './manifest.js';
5
+ import { scaffoldInitFiles } from './scaffold.js';
6
+ import { applyPlacementDefaults } from './setup.js';
7
+ import { buildWorkflowMetadata } from './workflow.js';
8
+
9
+ const VALID_MODES = new Set(['local-only', 'local-primary', 'hosted-only']);
10
+
11
+ export async function initializeProjectMemory(options) {
12
+ const normalized = normalizeInitOptions(options);
13
+
14
+ await mkdir(normalized.rootDir, { recursive: true });
15
+ await mkdir(path.join(normalized.rootDir, 'architecture'), { recursive: true });
16
+ await mkdir(path.join(normalized.rootDir, 'decisions'), { recursive: true });
17
+ await mkdir(path.join(normalized.rootDir, 'sessions'), { recursive: true });
18
+
19
+ const projectFilePath = path.join(normalized.rootDir, 'project.yaml');
20
+ const gitignorePath = path.join(normalized.rootDir, '.gitignore');
21
+
22
+ await writeProjectFile(projectFilePath, normalized.projectConfig, normalized.force);
23
+ const gitignoreUpdated = await ensureStateIgnored(gitignorePath);
24
+ const manifestResult = await writeStateManifest(normalized.rootDir, {
25
+ generatedAt: normalized.generatedAt,
26
+ });
27
+ const scaffoldResult = await scaffoldInitFiles(normalized.bootstrap);
28
+
29
+ return {
30
+ rootDir: normalized.rootDir,
31
+ projectFilePath,
32
+ gitignorePath,
33
+ gitignoreUpdated,
34
+ manifestPath: manifestResult.manifestPath,
35
+ manifest: manifestResult.manifest,
36
+ scanResult: manifestResult.scanResult,
37
+ syncEnvVar: normalized.projectConfig.sync?.credential_env_var ?? null,
38
+ contextFilePath: scaffoldResult.contextFilePath,
39
+ scaffoldCreatedFiles: scaffoldResult.createdFiles,
40
+ scaffoldSkippedFiles: scaffoldResult.skippedFiles,
41
+ };
42
+ }
43
+
44
+ function normalizeInitOptions(options) {
45
+ if (!options || typeof options !== 'object') {
46
+ throw new Error('initializeProjectMemory requires an options object.');
47
+ }
48
+
49
+ const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd();
50
+ const preparedOptions = applyPlacementDefaults(options, { cwd });
51
+ const rootDir = path.resolve(cwd, preparedOptions.rootDir ?? '.compass');
52
+ const toolingRootDir = preparedOptions.toolingRootDir
53
+ ? path.resolve(cwd, preparedOptions.toolingRootDir)
54
+ : cwd;
55
+
56
+ if (typeof preparedOptions.name !== 'string' || preparedOptions.name.trim() === '') {
57
+ throw new Error('init requires a non-empty project name.');
58
+ }
59
+
60
+ if (typeof preparedOptions.mode !== 'string' || !VALID_MODES.has(preparedOptions.mode)) {
61
+ throw new Error('init requires mode to be local-only, local-primary, or hosted-only.');
62
+ }
63
+
64
+ if (!Array.isArray(preparedOptions.repos) || preparedOptions.repos.length === 0) {
65
+ throw new Error('init requires at least one repo descriptor.');
66
+ }
67
+
68
+ const seenRepoIds = new Set();
69
+ const repos = preparedOptions.repos.map((repo, index) => {
70
+ if (!repo || typeof repo !== 'object') {
71
+ throw new Error(`repo ${index + 1} must be an object.`);
72
+ }
73
+
74
+ if (typeof repo.id !== 'string' || repo.id.trim() === '') {
75
+ throw new Error(`repo ${index + 1} requires a non-empty id.`);
76
+ }
77
+
78
+ if (seenRepoIds.has(repo.id)) {
79
+ throw new Error(`repo id "${repo.id}" was provided more than once.`);
80
+ }
81
+ seenRepoIds.add(repo.id);
82
+
83
+ if (typeof repo.remote !== 'string' || repo.remote.trim() === '') {
84
+ throw new Error(`repo "${repo.id}" requires a non-empty remote.`);
85
+ }
86
+
87
+ return {
88
+ id: repo.id,
89
+ remote: repo.remote,
90
+ ...(repo.defaultBranch ? { default_branch: repo.defaultBranch } : {}),
91
+ };
92
+ });
93
+
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
+ }
118
+
119
+ const projectConfig = {
120
+ format_version: 1,
121
+ name: preparedOptions.name.trim(),
122
+ ...(preparedOptions.slug ? { slug: preparedOptions.slug } : {}),
123
+ ...(preparedOptions.description ? { description: preparedOptions.description } : {}),
124
+ mode: preparedOptions.mode,
125
+ repos,
126
+ ...(sync ? { sync } : {}),
127
+ metadata: buildAgentFilesMetadata(buildWorkflowMetadata(preparedOptions.metadata, {
128
+ gitPublish:
129
+ typeof preparedOptions.closeSessionGitPublish === 'boolean'
130
+ ? preparedOptions.closeSessionGitPublish
131
+ : undefined,
132
+ gitRemote: preparedOptions.closeSessionGitRemote,
133
+ })),
134
+ };
135
+
136
+ const bootstrapOptions = normalizeBootstrapOptions(preparedOptions.bootstrap, {
137
+ rootDir,
138
+ toolingRootDir,
139
+ projectConfig,
140
+ });
141
+
142
+ return {
143
+ rootDir,
144
+ force: Boolean(preparedOptions.force),
145
+ generatedAt: preparedOptions.generatedAt,
146
+ projectConfig,
147
+ bootstrap: bootstrapOptions,
148
+ };
149
+ }
150
+
151
+ function buildAgentFilesMetadata(metadata) {
152
+ const existingAgentFiles = metadata?.agent_files && typeof metadata.agent_files === 'object'
153
+ ? metadata.agent_files
154
+ : {};
155
+
156
+ return {
157
+ ...metadata,
158
+ agent_files: {
159
+ claude_md: existingAgentFiles.claude_md !== false,
160
+ agents_md: existingAgentFiles.agents_md !== false,
161
+ cursor_rules: existingAgentFiles.cursor_rules !== false,
162
+ copilot_instructions: existingAgentFiles.copilot_instructions !== false,
163
+ windsurf_rules: existingAgentFiles.windsurf_rules === true,
164
+ gemini_md: existingAgentFiles.gemini_md === true,
165
+ },
166
+ };
167
+ }
168
+
169
+ function normalizeBootstrapOptions(bootstrap, context) {
170
+ const requested = bootstrap && typeof bootstrap === 'object' ? bootstrap : {};
171
+ const workflow = Boolean(requested.workflow || requested.claude || requested.agents);
172
+ const contextFilePath = path.join(context.rootDir, 'context.md');
173
+
174
+ return {
175
+ workflow,
176
+ claude: Boolean(requested.claude),
177
+ agents: Boolean(requested.agents),
178
+ rootDir: context.rootDir,
179
+ toolingRootDir: context.toolingRootDir,
180
+ projectConfig: context.projectConfig,
181
+ rootRelativePath: toPosix(path.relative(context.toolingRootDir, context.rootDir) || '.'),
182
+ contextRelativeToToolingRoot: toPosix(
183
+ path.relative(context.toolingRootDir, contextFilePath) || 'context.md',
184
+ ),
185
+ };
186
+ }
187
+
188
+ async function writeProjectFile(projectFilePath, projectConfig, force) {
189
+ if (!force && (await fileExists(projectFilePath))) {
190
+ throw new Error(`project.yaml already exists at ${projectFilePath}. Use --force to overwrite it.`);
191
+ }
192
+
193
+ const source = serializeProjectConfig(projectConfig);
194
+ await writeFile(projectFilePath, source, 'utf8');
195
+ }
196
+
197
+ async function ensureStateIgnored(gitignorePath) {
198
+ const stateEntry = 'state/';
199
+
200
+ if (!(await fileExists(gitignorePath))) {
201
+ await writeFile(gitignorePath, `${stateEntry}\n`, 'utf8');
202
+ return true;
203
+ }
204
+
205
+ const current = await readFile(gitignorePath, 'utf8');
206
+ const entries = current.split(/\r?\n/).map((line) => line.trim());
207
+ if (entries.includes(stateEntry)) {
208
+ return false;
209
+ }
210
+
211
+ const separator = current.length > 0 && !current.endsWith('\n') ? '\n' : '';
212
+ await writeFile(gitignorePath, `${current}${separator}${stateEntry}\n`, 'utf8');
213
+ return true;
214
+ }
215
+
216
+ async function fileExists(filePath) {
217
+ try {
218
+ await access(filePath);
219
+ return true;
220
+ } catch {
221
+ return false;
222
+ }
223
+ }
224
+
225
+ function toPosix(value) {
226
+ return value.split(path.sep).join('/');
227
+ }
@@ -0,0 +1,75 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { localRevisionFromManifestHash, stableHash } from './hash.js';
4
+ import { scanProjectMemory } from './project-memory.js';
5
+
6
+ export const STATE_VERSION = 1;
7
+
8
+ export function generateStateManifest(scanResult, options = {}) {
9
+ if (scanResult.errors.length > 0) {
10
+ const details = scanResult.errors.map((error) => `${error.path}: ${error.message}`).join('\n');
11
+ throw new Error(`Cannot generate state manifest with canonical parse errors.\n${details}`);
12
+ }
13
+
14
+ const generatedAt = toIsoString(options.generatedAt ?? new Date());
15
+ const manifestDocuments = {};
16
+ const manifestInventory = [];
17
+ let warningCount = 0;
18
+
19
+ for (const document of scanResult.documents) {
20
+ manifestInventory.push({
21
+ path: document.path,
22
+ kind: document.kind,
23
+ content_hash: document.contentHash,
24
+ byte_length: document.byteLength,
25
+ });
26
+
27
+ warningCount += document.warnings.length;
28
+
29
+ manifestDocuments[document.path] = {
30
+ kind: document.kind,
31
+ content_hash: document.contentHash,
32
+ byte_length: document.byteLength,
33
+ warnings: document.warnings,
34
+ extracted: document.extracted,
35
+ };
36
+ }
37
+
38
+ const manifestHash = stableHash(manifestInventory);
39
+ const projectExtracted = scanResult.project.extracted ?? {};
40
+
41
+ return {
42
+ state_version: STATE_VERSION,
43
+ generated_at: generatedAt,
44
+ canonical: {
45
+ format_version: projectExtracted.format_version,
46
+ mode: projectExtracted.mode,
47
+ local_root_revision: localRevisionFromManifestHash(manifestHash),
48
+ manifest_hash: manifestHash,
49
+ document_count: manifestInventory.length,
50
+ warning_count: warningCount,
51
+ },
52
+ documents: manifestDocuments,
53
+ ...(options.sync ? { sync: options.sync } : {}),
54
+ };
55
+ }
56
+
57
+ export async function writeStateManifest(rootDir, options = {}) {
58
+ const scanResult = await scanProjectMemory(rootDir);
59
+ const manifest = generateStateManifest(scanResult, options);
60
+ const stateDir = path.join(rootDir, 'state');
61
+ const manifestPath = path.join(stateDir, 'manifest.json');
62
+
63
+ await mkdir(stateDir, { recursive: true });
64
+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
65
+
66
+ return {
67
+ manifest,
68
+ manifestPath,
69
+ scanResult,
70
+ };
71
+ }
72
+
73
+ function toIsoString(value) {
74
+ return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
75
+ }