@vibecompass/vibecompass 0.3.4 → 0.5.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,308 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { STATE_VERSION } from './manifest.js';
4
+ import { parseSimpleYaml } from './simple-yaml.js';
5
+ import { PACKAGE_VERSION } from './version.js';
6
+
7
+ /**
8
+ * Inspect root compatibility without writing files.
9
+ * `packageVersion` is a test override; production callers should omit it.
10
+ */
11
+ export async function inspectProjectCompatibility(options = {}) {
12
+ const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd();
13
+ const rootDir = path.resolve(cwd, options.rootDir ?? '.compass');
14
+ const cliPackageVersion = normalizePackageVersion(options.packageVersion ?? PACKAGE_VERSION);
15
+ const projectFilePath = path.join(rootDir, 'project.yaml');
16
+ const manifestPath = path.join(rootDir, 'state', 'manifest.json');
17
+ const warnings = [];
18
+
19
+ const project = await readProjectStamp(projectFilePath);
20
+ const manifest = await readManifestState(manifestPath);
21
+ const packageStatus = project.status === 'unreadable'
22
+ ? {
23
+ status: 'unknown',
24
+ rootVersion: null,
25
+ cliVersion: cliPackageVersion,
26
+ errorMessage: project.errorMessage,
27
+ }
28
+ : buildPackageStatus(project.packageVersion, cliPackageVersion);
29
+ const stateStatus = buildStateStatus(manifest);
30
+ // Package and state statuses intentionally use different vocabularies:
31
+ // canonical package-stamp drift is distinct from derived-state drift.
32
+
33
+ if (packageStatus.status === 'unknown') {
34
+ warnings.push(warning({
35
+ code: 'project-yaml-unreadable',
36
+ message: `project.yaml is unreadable. ${packageStatus.errorMessage}`,
37
+ errorMessage: packageStatus.errorMessage,
38
+ }));
39
+ } else if (packageStatus.status === 'legacy') {
40
+ warnings.push(warning({
41
+ code: 'package-version-legacy-root',
42
+ message: 'project.yaml has no metadata.package_version stamp; treating the root as legacy/unknown.',
43
+ cliVersion: cliPackageVersion,
44
+ }));
45
+ } else if (packageStatus.status === 'behind') {
46
+ warnings.push(warning({
47
+ code: 'package-version-behind',
48
+ message: `project.yaml metadata.package_version ${packageStatus.rootVersion} is older than CLI package ${cliPackageVersion}.`,
49
+ rootVersion: packageStatus.rootVersion,
50
+ cliVersion: cliPackageVersion,
51
+ }));
52
+ } else if (packageStatus.status === 'ahead') {
53
+ warnings.push(warning({
54
+ code: 'package-version-ahead',
55
+ message: `project.yaml metadata.package_version ${packageStatus.rootVersion} is newer than CLI package ${cliPackageVersion}.`,
56
+ rootVersion: packageStatus.rootVersion,
57
+ cliVersion: cliPackageVersion,
58
+ }));
59
+ } else if (packageStatus.status === 'invalid') {
60
+ warnings.push(warning({
61
+ code: 'package-version-invalid',
62
+ message: 'project.yaml metadata.package_version is present but is not a non-empty semver string.',
63
+ rootVersion: packageStatus.rootVersion,
64
+ cliVersion: cliPackageVersion,
65
+ }));
66
+ }
67
+
68
+ if (stateStatus.status === 'missing') {
69
+ warnings.push(warning({
70
+ code: 'state-manifest-missing',
71
+ message: 'state/manifest.json is missing; commands that need derived state can regenerate it.',
72
+ }));
73
+ } else if (stateStatus.status === 'unreadable') {
74
+ warnings.push(warning({
75
+ code: 'state-manifest-unreadable',
76
+ message: `state/manifest.json is unreadable; commands that need derived state can discard and regenerate it. ${stateStatus.errorMessage}`,
77
+ errorMessage: stateStatus.errorMessage,
78
+ }));
79
+ } else if (stateStatus.status === 'unsupported') {
80
+ warnings.push(warning({
81
+ code: 'state-version-unsupported',
82
+ message: `state/manifest.json state_version ${stateStatus.observedVersion} is unsupported by this CLI (expected ${STATE_VERSION}); commands that need derived state can regenerate it.`,
83
+ observedVersion: stateStatus.observedVersion,
84
+ expectedVersion: STATE_VERSION,
85
+ packageObservedVersion: stateStatus.packageObservedVersion,
86
+ }));
87
+ }
88
+
89
+ return {
90
+ rootDir,
91
+ projectFilePath,
92
+ manifestPath,
93
+ cliPackageVersion,
94
+ package: packageStatus,
95
+ state: stateStatus,
96
+ warnings,
97
+ };
98
+ }
99
+
100
+ export function formatCompatibilityWarnings(result) {
101
+ return (result?.warnings ?? []).map((warning) => warning.message);
102
+ }
103
+
104
+ async function readProjectStamp(projectFilePath) {
105
+ try {
106
+ const config = parseSimpleYaml(await readFile(projectFilePath, 'utf8'), {
107
+ sourceName: projectFilePath,
108
+ });
109
+ const metadata = isPlainObject(config.metadata) ? config.metadata : {};
110
+ return {
111
+ status: 'ok',
112
+ packageVersion: Object.hasOwn(metadata, 'package_version')
113
+ ? metadata.package_version
114
+ : undefined,
115
+ };
116
+ } catch (error) {
117
+ return {
118
+ status: 'unreadable',
119
+ packageVersion: undefined,
120
+ errorMessage: error instanceof Error ? error.message : 'Unable to read project.yaml.',
121
+ };
122
+ }
123
+ }
124
+
125
+ async function readManifestState(manifestPath) {
126
+ try {
127
+ return {
128
+ status: 'ok',
129
+ manifest: JSON.parse(await readFile(manifestPath, 'utf8')),
130
+ };
131
+ } catch (error) {
132
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
133
+ return { status: 'missing' };
134
+ }
135
+
136
+ return {
137
+ status: 'unreadable',
138
+ errorMessage: error instanceof Error ? error.message : 'Unable to read state/manifest.json.',
139
+ };
140
+ }
141
+ }
142
+
143
+ function buildPackageStatus(rootVersion, cliPackageVersion) {
144
+ if (rootVersion === undefined) {
145
+ return {
146
+ status: 'legacy',
147
+ rootVersion: null,
148
+ cliVersion: cliPackageVersion,
149
+ };
150
+ }
151
+
152
+ if (typeof rootVersion !== 'string' || rootVersion.trim() === '') {
153
+ return {
154
+ status: 'invalid',
155
+ rootVersion,
156
+ cliVersion: cliPackageVersion,
157
+ };
158
+ }
159
+
160
+ const normalizedRootVersion = normalizePackageVersion(rootVersion);
161
+ if (!parseSemver(normalizedRootVersion)) {
162
+ return {
163
+ status: 'invalid',
164
+ rootVersion,
165
+ cliVersion: cliPackageVersion,
166
+ };
167
+ }
168
+
169
+ const comparison = comparePackageVersions(normalizedRootVersion, cliPackageVersion);
170
+
171
+ return {
172
+ status: comparison === 0 ? 'current' : comparison < 0 ? 'behind' : 'ahead',
173
+ rootVersion: normalizedRootVersion,
174
+ cliVersion: cliPackageVersion,
175
+ };
176
+ }
177
+
178
+ function buildStateStatus(manifestResult) {
179
+ if (manifestResult.status === 'missing') {
180
+ return { status: 'missing' };
181
+ }
182
+
183
+ if (manifestResult.status === 'unreadable') {
184
+ return {
185
+ status: 'unreadable',
186
+ errorMessage: manifestResult.errorMessage,
187
+ };
188
+ }
189
+
190
+ const manifest = manifestResult.manifest;
191
+ if (manifest?.state_version !== STATE_VERSION) {
192
+ return {
193
+ status: 'unsupported',
194
+ observedVersion: manifest?.state_version ?? null,
195
+ expectedVersion: STATE_VERSION,
196
+ packageObservedVersion:
197
+ typeof manifest?.package?.observed_version === 'string'
198
+ ? manifest.package.observed_version
199
+ : null,
200
+ };
201
+ }
202
+
203
+ return {
204
+ status: 'current',
205
+ observedVersion: manifest.state_version,
206
+ expectedVersion: STATE_VERSION,
207
+ packageObservedVersion:
208
+ typeof manifest?.package?.observed_version === 'string'
209
+ ? manifest.package.observed_version
210
+ : null,
211
+ };
212
+ }
213
+
214
+ function comparePackageVersions(left, right) {
215
+ const leftSemver = parseSemver(left);
216
+ const rightSemver = parseSemver(right);
217
+ if (!leftSemver || !rightSemver) {
218
+ return 0;
219
+ }
220
+
221
+ const leftParts = [leftSemver.major, leftSemver.minor, leftSemver.patch];
222
+ const rightParts = [rightSemver.major, rightSemver.minor, rightSemver.patch];
223
+ for (let index = 0; index < 3; index += 1) {
224
+ if (leftParts[index] < rightParts[index]) {
225
+ return -1;
226
+ }
227
+ if (leftParts[index] > rightParts[index]) {
228
+ return 1;
229
+ }
230
+ }
231
+
232
+ return comparePrerelease(leftSemver.prerelease, rightSemver.prerelease);
233
+ }
234
+
235
+ function parseSemver(version) {
236
+ const match = String(version).match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
237
+ if (!match) {
238
+ return null;
239
+ }
240
+
241
+ return {
242
+ major: Number(match[1]),
243
+ minor: Number(match[2]),
244
+ patch: Number(match[3]),
245
+ prerelease: match[4] ? match[4].split('.') : [],
246
+ };
247
+ }
248
+
249
+ function comparePrerelease(left, right) {
250
+ if (left.length === 0 && right.length === 0) {
251
+ return 0;
252
+ }
253
+
254
+ if (left.length === 0) {
255
+ return 1;
256
+ }
257
+
258
+ if (right.length === 0) {
259
+ return -1;
260
+ }
261
+
262
+ const length = Math.max(left.length, right.length);
263
+ for (let index = 0; index < length; index += 1) {
264
+ const leftPart = left[index];
265
+ const rightPart = right[index];
266
+ if (leftPart === undefined) {
267
+ return -1;
268
+ }
269
+ if (rightPart === undefined) {
270
+ return 1;
271
+ }
272
+
273
+ const leftNumeric = /^\d+$/.test(leftPart);
274
+ const rightNumeric = /^\d+$/.test(rightPart);
275
+ if (leftNumeric && rightNumeric) {
276
+ const difference = Number(leftPart) - Number(rightPart);
277
+ if (difference !== 0) {
278
+ return difference < 0 ? -1 : 1;
279
+ }
280
+ continue;
281
+ }
282
+
283
+ if (leftNumeric !== rightNumeric) {
284
+ return leftNumeric ? -1 : 1;
285
+ }
286
+
287
+ const comparison = leftPart.localeCompare(rightPart);
288
+ if (comparison !== 0) {
289
+ return comparison < 0 ? -1 : 1;
290
+ }
291
+ }
292
+
293
+ return 0;
294
+ }
295
+
296
+ function normalizePackageVersion(version) {
297
+ return String(version).trim();
298
+ }
299
+
300
+ function isPlainObject(value) {
301
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
302
+ }
303
+
304
+ function warning(fields) {
305
+ return Object.fromEntries(
306
+ Object.entries(fields).filter(([, value]) => value !== undefined),
307
+ );
308
+ }
@@ -59,8 +59,10 @@ export async function preflightDocsReview(options = {}, environment = {}) {
59
59
  const localProvider = resolveLocalProvider(options);
60
60
  const runtime = resolveDocsReviewRuntime(project, {
61
61
  env,
62
+ guided: Boolean(options.guided),
62
63
  localProvider,
63
64
  runLocal: shouldRunLocal(options),
65
+ anthropicEnvVar: options.anthropicEnvVar ?? 'ANTHROPIC_API_KEY',
64
66
  });
65
67
  const reviewPrompt = renderReviewPrompt({
66
68
  project,
@@ -180,14 +182,19 @@ async function applyDocsReviewOutput(options) {
180
182
  const blocks = parseArchitectureDocBlocks(source);
181
183
 
182
184
  if (blocks.length === 0) {
185
+ const malformedFenceCount = countMalformedArchitectureDocFences(source);
186
+ if (malformedFenceCount > 0) {
187
+ throw new Error(
188
+ `Malformed architecture doc fence found in ${outputPath}. Expected fence openings like \`\`\`vibecompass-architecture-doc path=architecture/domain/feature/component.md\` with exactly one path attribute and no extra attributes.`,
189
+ );
190
+ }
191
+
183
192
  throw new Error(
184
193
  `No accepted architecture doc blocks found in ${outputPath}. Expected fenced blocks like \`\`\`vibecompass-architecture-doc path=architecture/domain/feature/component.md\`.`,
185
194
  );
186
195
  }
187
196
 
188
- for (const block of blocks) {
189
- validateArchitectureDocBlock(block);
190
- }
197
+ validateArchitectureDocBlocks(blocks);
191
198
 
192
199
  const applied = [];
193
200
  for (const block of blocks) {
@@ -357,6 +364,13 @@ function validateDocsReviewMode(options) {
357
364
  }
358
365
 
359
366
  async function promptForReviewConfig(options, environment) {
367
+ if (options.llm && options.model) {
368
+ return normalizeReviewConfig({
369
+ llm: options.llm,
370
+ model: options.model,
371
+ });
372
+ }
373
+
360
374
  const prompter = createPrompter(environment.io, environment.runtime);
361
375
  try {
362
376
  if (!options.llm) {
@@ -485,6 +499,10 @@ function resolveDocsReviewRuntime(project, options) {
485
499
  };
486
500
  }
487
501
 
502
+ if (options.guided) {
503
+ return { provider: 'external' };
504
+ }
505
+
488
506
  throw new Error(
489
507
  `docs-review for local-primary projects requires a hosted sync binding or ${options.anthropicEnvVar}.`,
490
508
  );
@@ -601,6 +619,32 @@ function parseArchitectureDocBlocks(source) {
601
619
  return blocks;
602
620
  }
603
621
 
622
+ function countMalformedArchitectureDocFences(source) {
623
+ const fencePattern = /^```vibecompass-architecture-doc\b.*$/gm;
624
+ const acceptedOpeningPattern = /^```vibecompass-architecture-doc\s+path=[^\s`]+\s*$/;
625
+ let count = 0;
626
+ let match;
627
+
628
+ while ((match = fencePattern.exec(source)) !== null) {
629
+ if (!acceptedOpeningPattern.test(match[0])) {
630
+ count += 1;
631
+ }
632
+ }
633
+
634
+ return count;
635
+ }
636
+
637
+ function validateArchitectureDocBlocks(blocks) {
638
+ const seenPaths = new Set();
639
+ for (const block of blocks) {
640
+ if (seenPaths.has(block.path)) {
641
+ throw new Error(`Accepted docs-review output contains duplicate architecture doc path: ${block.path}`);
642
+ }
643
+ seenPaths.add(block.path);
644
+ validateArchitectureDocBlock(block);
645
+ }
646
+ }
647
+
604
648
  function normalizeBlockPath(value) {
605
649
  return String(value ?? '').trim().replaceAll('\\', '/');
606
650
  }
@@ -625,7 +669,10 @@ function validateArchitectureDocBlock(block) {
625
669
  throw new Error(`Accepted architecture doc ${block.path} must include frontmatter.`);
626
670
  }
627
671
 
628
- for (const field of ['domain', 'feature', 'component', 'status']) {
672
+ const requiredFields = block.path === 'architecture/overview/project-shape.md'
673
+ ? ['status']
674
+ : ['domain', 'feature', 'component', 'status'];
675
+ for (const field of requiredFields) {
629
676
  if (typeof frontmatter.data[field] !== 'string' || frontmatter.data[field].trim() === '') {
630
677
  throw new Error(`Accepted architecture doc ${block.path} requires non-empty frontmatter field "${field}".`);
631
678
  }
@@ -8,6 +8,7 @@ import { claudeMdFormat } from './claude-md.js';
8
8
  import { agentsMdFormat } from './agents-md.js';
9
9
  import { cursorRulesFormat } from './cursor-rules.js';
10
10
  import { copilotInstructionsFormat } from './copilot-instructions.js';
11
+ import { getWorkflowConflictScanPatterns } from '../../workflows/registry.js';
11
12
 
12
13
  const FORMATS = [
13
14
  claudeMdFormat,
@@ -17,17 +18,7 @@ const FORMATS = [
17
18
  ];
18
19
 
19
20
  const FORMAT_MAP = new Map(FORMATS.map((format) => [format.name, format]));
20
- const CONFLICT_SCAN_PATTERNS = [
21
- 'session',
22
- 'handoff',
23
- 'decisions',
24
- 'wip.md',
25
- 'review handoff',
26
- 'close-session',
27
- 'address review',
28
- 'builder',
29
- 'reviewer',
30
- ];
21
+ const CONFLICT_SCAN_PATTERNS = getWorkflowConflictScanPatterns();
31
22
 
32
23
  export async function syncAgentInstructionFiles(options = {}) {
33
24
  const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd();
@@ -1,3 +1,5 @@
1
+ import { renderPromptCommandLines } from '../../workflows/registry.js';
2
+
1
3
  export function buildAgentContext(readModel) {
2
4
  return {
3
5
  projectName: readModel.project.name ?? 'Unnamed project',
@@ -16,47 +18,40 @@ export function renderSharedInstructionBody(context, options = {}) {
16
18
  const intro =
17
19
  options.intro ??
18
20
  'Use VibeCompass project memory as the source of truth before making code changes.';
21
+ const contextPath = `${context.rootDir}/context.md`;
19
22
 
20
23
  return [
21
24
  `# ${heading}`,
22
25
  '',
23
26
  intro,
24
27
  '',
25
- `Read \`${context.rootDir}/context.md\` for the full VibeCompass workflow protocol when it exists.`,
28
+ `This managed block is a compact bootloader. Read \`${contextPath}\` for the full VibeCompass workflow protocol; workflow-specific details live under \`${context.rootDir}/workflows/\`.`,
26
29
  '',
27
30
  'Existing project-specific coding, style, framework, and safety instructions outside this managed block remain authoritative. This block owns only VibeCompass project-memory workflow: sessions, handoffs, decisions, docs-review, and close-out.',
28
31
  '',
29
- '## Read First',
32
+ '## Project Snapshot',
30
33
  `- Project memory root: \`${context.rootDir}\``,
31
- '- Read `project.yaml` for project identity, repos, mode, and workflow defaults.',
32
- '- Read the latest finalized session note under `sessions/` before resuming work.',
33
- '- Read `sessions/active/index.yaml` when it exists, then read the selected lane under `sessions/active/<lane-id>/`.',
34
- '- Read the selected lane `wip.md` and `handoff.md` when they exist; they are the active builder/reviewer scratch files.',
35
- '- Read relevant `architecture/` and `decisions/` files before changing implementation.',
34
+ ...renderProjectSnapshot(context),
36
35
  '',
37
- '## Project Shape',
38
- ...renderProjectShape(context),
36
+ '## Read Order',
37
+ `1. Read \`${contextPath}\`.`,
38
+ `2. Read the latest finalized session note under \`${context.rootDir}/sessions/\`.`,
39
+ `3. If present, read \`${context.rootDir}/sessions/active/index.yaml\`, then the selected lane's \`wip.md\` and \`handoff.md\`.`,
40
+ `4. Read relevant \`${context.rootDir}/architecture/\` and \`${context.rootDir}/decisions/\` docs before editing implementation.`,
39
41
  '',
40
- '## Workflow Rules',
41
- '- VibeCompass active builder sessions are named lanes. Use one lane per active feature or workstream; finalized session notes are append-only history under `sessions/YYYY-MM-DD-N-title.md`.',
42
- '- Use `start session` for builder work, `join as reviewer` for review work, and `close session` / `end session` when the builder is ready to close the selected lane.',
43
- '- Use `vibecompass start-session --id <lane-id>` to open a named lane for every active feature or workstream.',
44
- '- Use `vibecompass list-sessions` and `vibecompass switch-session <lane-id>` to inspect or change the current lane.',
45
- '- Treat `sessions/active/index.yaml` as the current lane source of truth; tool-specific Current session blocks are continuity summaries.',
46
- '- Use optional planning mode for risky or ambiguous work: read the same context, propose scope, and record the agreed plan in the selected lane `wip.md` before implementation.',
47
- '- Use `review handoff` to ask a reviewer to inspect the active handoff.',
48
- '- Use `address review` to process reviewer feedback from the selected lane `wip.md` and `handoff.md`.',
49
- '- End the active builder session with the `close session` prompt, which runs close-out and ends with `vibecompass close-session --session <lane-id>`; `end session` and `vibecompass end-session` are accepted aliases.',
50
- '- If stale scratch files block `start-session`, read them first, then close, recover, move, or delete them intentionally before retrying.',
51
- '- Keep decisions append-only in `decisions/`. Session notes may reference decisions, but decisions are the durable source of truth.',
52
- '- Treat generated agent files as views. Update canonical VibeCompass project memory instead of editing managed regions.',
42
+ '## Prompt Commands',
43
+ ...renderPromptCommandLines({ rootRelativePath: context.rootDir }),
44
+ '- The prompt commands above are agent behaviors. The `vibecompass` CLI commands remain the filesystem mechanics behind them.',
53
45
  '',
54
- '## Recent Decisions',
55
- ...renderDecisionLines(context.recentDecisions),
46
+ '## Hard Rules',
47
+ '- Treat `sessions/active/index.yaml` as the current lane source of truth; tool-specific Current session blocks are continuity summaries.',
48
+ '- Keep the selected lane `wip.md` and `handoff.md` current during active builder/reviewer work.',
49
+ '- Keep decisions append-only in `decisions/`; append accepted architectural decisions before implementing them.',
50
+ '- Do not edit this managed block directly. Update canonical VibeCompass memory, then rerun package commands such as `vibecompass sync-agents`.',
56
51
  ].join('\n');
57
52
  }
58
53
 
59
- function renderProjectShape(context) {
54
+ function renderProjectSnapshot(context) {
60
55
  const lines = [];
61
56
 
62
57
  if (context.description) {
@@ -71,18 +66,22 @@ function renderProjectShape(context) {
71
66
  lines.push(`- Repo \`${repo.id}\`: ${repo.remote}`);
72
67
  }
73
68
 
74
- for (const domain of context.domains.slice(0, 8)) {
75
- const features = domain.features.map((feature) => feature.feature).join(', ');
76
- lines.push(`- ${domain.domain}: ${features || 'No features recorded yet.'}`);
69
+ const domainCount = context.domains.length;
70
+ if (domainCount > 0) {
71
+ lines.push(`- Architecture domains recorded: ${domainCount}`);
77
72
  }
78
73
 
79
- return lines.length > 0 ? lines : ['- No architecture domains recorded yet.'];
80
- }
74
+ if (context.recentDecisions.length > 0) {
75
+ lines.push(`- Latest decision: ${renderDecisionLine(context.recentDecisions[0])}`);
76
+ }
81
77
 
82
- function renderDecisionLines(decisions) {
83
- if (!decisions || decisions.length === 0) {
84
- return ['- No decisions recorded yet.'];
78
+ if (context.recentSessions.length > 0) {
79
+ lines.push(`- Latest session: ${context.recentSessions[0].title ?? context.recentSessions[0].path}`);
85
80
  }
86
81
 
87
- return decisions.map((decision) => `- D-${String(decision.decision_id).padStart(3, '0')} — ${decision.title}`);
82
+ return lines.length > 0 ? lines : ['- No project details recorded yet.'];
83
+ }
84
+
85
+ function renderDecisionLine(decision) {
86
+ return `D-${String(decision.decision_id).padStart(3, '0')} — ${decision.title}`;
88
87
  }
package/src/index.js CHANGED
@@ -1,4 +1,8 @@
1
- export { STATE_VERSION, generateStateManifest, writeStateManifest } from './manifest.js';
1
+ export { STATE_VERSION, generateStateManifest, prepareStateManifest, writeStateManifest } from './manifest.js';
2
+ export { inspectProjectCompatibility, formatCompatibilityWarnings } from './compatibility.js';
3
+ export { PACKAGE_VERSION } from './version.js';
4
+ export { refreshWorkflow } from './refresh-workflow.js';
5
+ export { getProjectStatus, renderStatusText, toStatusJson } from './status.js';
2
6
  export { initializeProjectMemory } from './init.js';
3
7
  export { preflightDocsReview } from './docs-review.js';
4
8
  export {
package/src/init.js CHANGED
@@ -1,4 +1,4 @@
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 { serializeProjectConfig } from './project-yaml.js';
4
4
  import { writeStateManifest } from './manifest.js';
@@ -6,12 +6,17 @@ import { scaffoldInitFiles } from './scaffold.js';
6
6
  import { applyPlacementDefaults } from './setup.js';
7
7
  import { buildWorkflowMetadata } from './workflow.js';
8
8
  import { parseSimpleYaml } from './simple-yaml.js';
9
+ import { PACKAGE_VERSION } from './version.js';
9
10
 
10
11
  const VALID_MODES = new Set(['local-only', 'local-primary', 'hosted-only']);
11
12
 
12
13
  export async function initializeProjectMemory(options) {
13
14
  const normalized = normalizeInitOptions(options);
14
15
 
16
+ if (normalized.force && !normalized.replaceActiveLanes) {
17
+ await assertNoActiveSessionLanes(normalized.rootDir);
18
+ }
19
+
15
20
  await mkdir(normalized.rootDir, { recursive: true });
16
21
  await mkdir(path.join(normalized.rootDir, 'architecture'), { recursive: true });
17
22
  await mkdir(path.join(normalized.rootDir, 'decisions'), { recursive: true });
@@ -137,7 +142,10 @@ function normalizeInitOptions(options) {
137
142
  mode: preparedOptions.mode,
138
143
  repos,
139
144
  ...(sync ? { sync } : {}),
140
- metadata: buildAgentFilesMetadata(buildWorkflowMetadata(preparedOptions.metadata, {
145
+ metadata: buildAgentFilesMetadata(buildWorkflowMetadata({
146
+ ...(preparedOptions.metadata && typeof preparedOptions.metadata === 'object' ? preparedOptions.metadata : {}),
147
+ package_version: PACKAGE_VERSION,
148
+ }, {
141
149
  gitPublish:
142
150
  typeof preparedOptions.closeSessionGitPublish === 'boolean'
143
151
  ? preparedOptions.closeSessionGitPublish
@@ -156,12 +164,44 @@ function normalizeInitOptions(options) {
156
164
  return {
157
165
  rootDir,
158
166
  force: Boolean(preparedOptions.force),
167
+ replaceActiveLanes: Boolean(preparedOptions.replaceActiveLanes),
159
168
  generatedAt: preparedOptions.generatedAt,
160
169
  projectConfig,
161
170
  bootstrap: bootstrapOptions,
162
171
  };
163
172
  }
164
173
 
174
+ async function assertNoActiveSessionLanes(rootDir) {
175
+ const activeDir = path.join(rootDir, 'sessions', 'active');
176
+ let entries = [];
177
+ try {
178
+ entries = await readdir(activeDir, { withFileTypes: true });
179
+ } catch {
180
+ return;
181
+ }
182
+
183
+ const activeLaneIds = [];
184
+ for (const entry of entries) {
185
+ if (!entry.isDirectory()) {
186
+ continue;
187
+ }
188
+
189
+ const laneDir = path.join(activeDir, entry.name);
190
+ if (
191
+ (await fileExists(path.join(laneDir, 'session.yaml'))) ||
192
+ (await fileExists(path.join(laneDir, 'wip.md')))
193
+ ) {
194
+ activeLaneIds.push(entry.name);
195
+ }
196
+ }
197
+
198
+ if (activeLaneIds.length > 0) {
199
+ throw new Error(
200
+ `Cannot overwrite project memory while active session lanes exist: ${activeLaneIds.join(', ')}. Close the lanes first or pass --replace-active-lanes.`,
201
+ );
202
+ }
203
+ }
204
+
165
205
  function normalizeSyncOptions(syncOptions, mode) {
166
206
  if (!syncOptions || typeof syncOptions !== 'object') {
167
207
  throw new Error('sync configuration must be an object.');
package/src/manifest.js CHANGED
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { localRevisionFromManifestHash, stableHash } from './hash.js';
4
4
  import { scanProjectMemory } from './project-memory.js';
5
5
  import { listProjectSessions } from './session.js';
6
+ import { PACKAGE_VERSION } from './version.js';
6
7
 
7
8
  export const STATE_VERSION = 1;
8
9
 
@@ -50,13 +51,17 @@ export function generateStateManifest(scanResult, options = {}) {
50
51
  document_count: manifestInventory.length,
51
52
  warning_count: warningCount,
52
53
  },
54
+ package: {
55
+ observed_version: options.packageVersion ?? PACKAGE_VERSION,
56
+ observed_at: generatedAt,
57
+ },
53
58
  documents: manifestDocuments,
54
59
  ...(options.activeSessions ? { active_sessions: options.activeSessions } : {}),
55
60
  ...(options.sync ? { sync: options.sync } : {}),
56
61
  };
57
62
  }
58
63
 
59
- export async function writeStateManifest(rootDir, options = {}) {
64
+ export async function prepareStateManifest(rootDir, options = {}) {
60
65
  const scanResult = await scanProjectMemory(rootDir);
61
66
  const activeSessions = await readActiveSessionsForManifest(rootDir);
62
67
  const existingSync = Object.hasOwn(options, 'sync')
@@ -70,9 +75,6 @@ export async function writeStateManifest(rootDir, options = {}) {
70
75
  const stateDir = path.join(rootDir, 'state');
71
76
  const manifestPath = path.join(stateDir, 'manifest.json');
72
77
 
73
- await mkdir(stateDir, { recursive: true });
74
- await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
75
-
76
78
  return {
77
79
  manifest,
78
80
  manifestPath,
@@ -80,6 +82,16 @@ export async function writeStateManifest(rootDir, options = {}) {
80
82
  };
81
83
  }
82
84
 
85
+ export async function writeStateManifest(rootDir, options = {}) {
86
+ const prepared = await prepareStateManifest(rootDir, options);
87
+ const stateDir = path.dirname(prepared.manifestPath);
88
+
89
+ await mkdir(stateDir, { recursive: true });
90
+ await writeFile(prepared.manifestPath, `${JSON.stringify(prepared.manifest, null, 2)}\n`, 'utf8');
91
+
92
+ return prepared;
93
+ }
94
+
83
95
  async function readExistingSyncState(rootDir) {
84
96
  try {
85
97
  const existing = JSON.parse(