@vibecompass/vibecompass 0.11.1 → 0.12.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.
- package/CHANGELOG.md +47 -0
- package/README.md +20 -1
- package/package.json +1 -1
- package/src/cli.js +209 -0
- package/src/docs-review.js +103 -16
- package/src/index.js +3 -0
- package/src/lane-marker.js +39 -1
- package/src/mode-transition.js +265 -0
- package/src/scaffold.js +1 -1
- package/src/serialization.js +26 -0
- package/src/session.js +162 -15
- package/src/status.js +78 -0
- package/src/sync.js +264 -13
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseSimpleYaml } from './simple-yaml.js';
|
|
4
|
+
import { resolveSyncBinding } from './sync-binding.js';
|
|
5
|
+
import { withMemoryRootLock } from './serialization.js';
|
|
6
|
+
import { adoptRemoteHead, pushProjectMemory } from './sync.js';
|
|
7
|
+
|
|
8
|
+
const PROMOTED_MARKER = ['state', 'promoted-root.json'];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* D-288 promote: local-primary -> hosted-only as a crash-safe, resumable
|
|
12
|
+
* two-phase transition. Fresh verified push baseline -> server records the
|
|
13
|
+
* intent (with a completeness report) -> local changes (project.yaml mode,
|
|
14
|
+
* promoted-root marker, agent guidance) -> server confirm. `--resume`
|
|
15
|
+
* continues an interrupted run from whatever state it reached; `--abort`
|
|
16
|
+
* backs the whole thing out on both sides.
|
|
17
|
+
*/
|
|
18
|
+
export async function promoteHosted(options = {}, environment = {}) {
|
|
19
|
+
const cwd = environment.cwd ? path.resolve(environment.cwd) : process.cwd();
|
|
20
|
+
const rootDir = path.resolve(cwd, options.rootDir ?? '.compass');
|
|
21
|
+
return withMemoryRootLock(rootDir, 'promote-hosted', () =>
|
|
22
|
+
promoteHostedLocked(options, environment, rootDir),
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function promoteHostedLocked(options, environment, rootDir) {
|
|
27
|
+
const context = await loadModeAgnosticSyncContext(rootDir, options, environment);
|
|
28
|
+
const server = await getJson(context, 'status');
|
|
29
|
+
const localMode = context.project.mode;
|
|
30
|
+
const notes = [];
|
|
31
|
+
|
|
32
|
+
if (options.abort === true) {
|
|
33
|
+
// A confirmed promotion cannot be aborted: the server is already
|
|
34
|
+
// hosted-only, and rewriting only the local record would create exactly
|
|
35
|
+
// the split-brain the two-phase flow exists to prevent. Reversing a
|
|
36
|
+
// finished cutover is demote-hosted's job.
|
|
37
|
+
if (server.mode === 'hosted-only' && !server.pending_mode_transition) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
'This promotion was already confirmed — the hosted project is hosted-only. '
|
|
40
|
+
+ 'Aborting now would only rewrite the local record and split the two mode records. '
|
|
41
|
+
+ 'Use vibecompass demote-hosted to reverse the cutover on both sides.',
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
await postJson(context, 'mode-transition', { action: 'abort' });
|
|
45
|
+
if (localMode === 'hosted-only') {
|
|
46
|
+
await writeProjectMode(rootDir, 'local-primary');
|
|
47
|
+
await removePromotedMarker(rootDir);
|
|
48
|
+
notes.push('Restored local project.yaml to local-primary and removed the promoted-root marker.');
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
status: 'aborted',
|
|
52
|
+
rootDir,
|
|
53
|
+
warnings: notes,
|
|
54
|
+
message: 'Promotion aborted; the project remains local-primary on both sides.',
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (server.mode === 'hosted-only' && localMode === 'hosted-only' && !server.pending_mode_transition) {
|
|
59
|
+
await ensurePromotedMarker(rootDir, context);
|
|
60
|
+
return {
|
|
61
|
+
status: 'already-promoted',
|
|
62
|
+
rootDir,
|
|
63
|
+
warnings: [],
|
|
64
|
+
message: 'This project is already hosted-only on both sides.',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const resuming = options.resume === true || Boolean(server.pending_mode_transition) || localMode === 'hosted-only';
|
|
69
|
+
let completeness = null;
|
|
70
|
+
|
|
71
|
+
if (localMode === 'local-primary') {
|
|
72
|
+
// Phase 1: fresh verified baseline. Idempotent push makes retries safe.
|
|
73
|
+
const pushResult = await pushProjectMemory(
|
|
74
|
+
{ rootDir, syncTarget: options.syncTarget ?? null },
|
|
75
|
+
environment,
|
|
76
|
+
);
|
|
77
|
+
notes.push(`Baseline push: ${pushResult.status} (${pushResult.remoteRevisionId}).`);
|
|
78
|
+
|
|
79
|
+
const manifest = JSON.parse(await readFile(path.join(rootDir, 'state', 'manifest.json'), 'utf8'));
|
|
80
|
+
const begin = await postJson(context, 'mode-transition', {
|
|
81
|
+
action: 'begin',
|
|
82
|
+
target_mode: 'hosted-only',
|
|
83
|
+
expected_manifest_hash: manifest.canonical.manifest_hash,
|
|
84
|
+
});
|
|
85
|
+
completeness = begin.completeness ?? null;
|
|
86
|
+
|
|
87
|
+
// Phase 2: local changes.
|
|
88
|
+
await writeProjectMode(rootDir, 'hosted-only');
|
|
89
|
+
await ensurePromotedMarker(rootDir, context);
|
|
90
|
+
} else if (resuming) {
|
|
91
|
+
// Crash-window repair: the mode flipped locally but the marker (or the
|
|
92
|
+
// confirm) may be missing.
|
|
93
|
+
await ensurePromotedMarker(rootDir, context);
|
|
94
|
+
} else {
|
|
95
|
+
throw new Error(`promote-hosted requires a local-primary root (found mode: ${localMode ?? 'unknown'}).`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Phase 3: confirm (safe to retry; the server treats a repeated confirm as done).
|
|
99
|
+
await postJson(context, 'mode-transition', { action: 'confirm', target_mode: 'hosted-only' });
|
|
100
|
+
|
|
101
|
+
notes.push(
|
|
102
|
+
'Agent re-pointing: hosted context is now canonical. In your MCP configuration set VIBECOMPASS_API_KEY '
|
|
103
|
+
+ '(create a key on the hosted Setup page), remove VIBECOMPASS_ROOT, and restart your MCP server. '
|
|
104
|
+
+ 'Agent instruction files keep working for reading; canonical writes belong on the hosted dashboard.',
|
|
105
|
+
);
|
|
106
|
+
notes.push(
|
|
107
|
+
'This root is now a promoted working copy: canonical write commands refuse by default '
|
|
108
|
+
+ '(vibecompass demote-hosted reverses the cutover; VIBECOMPASS_ALLOW_PROMOTED_ROOT_WRITES=1 overrides deliberately).',
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
status: resuming && localMode !== 'local-primary' ? 'resumed-and-promoted' : 'promoted',
|
|
113
|
+
rootDir,
|
|
114
|
+
completeness,
|
|
115
|
+
warnings: notes,
|
|
116
|
+
message: 'Promotion complete: the hosted app is now this project\'s source of truth (both mode records agree).',
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* D-289 demote: hosted-only -> local-primary. Run from a local root that
|
|
122
|
+
* holds the hosted content (freshly bootstrapped from the export bundle, or
|
|
123
|
+
* the original promoted root). Flips both records via the same two-phase
|
|
124
|
+
* endpoint, then adopts the hosted head as the sync cursor so the first push
|
|
125
|
+
* works immediately.
|
|
126
|
+
*/
|
|
127
|
+
export async function demoteHosted(options = {}, environment = {}) {
|
|
128
|
+
const cwd = environment.cwd ? path.resolve(environment.cwd) : process.cwd();
|
|
129
|
+
const rootDir = path.resolve(cwd, options.rootDir ?? '.compass');
|
|
130
|
+
return withMemoryRootLock(rootDir, 'demote-hosted', () =>
|
|
131
|
+
demoteHostedLocked(options, environment, rootDir),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function demoteHostedLocked(options, environment, rootDir) {
|
|
136
|
+
const context = await loadModeAgnosticSyncContext(rootDir, options, environment);
|
|
137
|
+
const server = await getJson(context, 'status');
|
|
138
|
+
const notes = [];
|
|
139
|
+
|
|
140
|
+
if (server.mode === 'local-primary' && context.project.mode === 'local-primary') {
|
|
141
|
+
return {
|
|
142
|
+
status: 'already-local-primary',
|
|
143
|
+
rootDir,
|
|
144
|
+
warnings: [],
|
|
145
|
+
message: 'This project is already local-primary on both sides.',
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (server.mode === 'hosted-only') {
|
|
150
|
+
await postJson(context, 'mode-transition', { action: 'begin', target_mode: 'local-primary' });
|
|
151
|
+
}
|
|
152
|
+
if (context.project.mode !== 'local-primary') {
|
|
153
|
+
await writeProjectMode(rootDir, 'local-primary');
|
|
154
|
+
notes.push('Local project.yaml mode set to local-primary.');
|
|
155
|
+
}
|
|
156
|
+
await removePromotedMarker(rootDir);
|
|
157
|
+
await postJson(context, 'mode-transition', { action: 'confirm', target_mode: 'local-primary' });
|
|
158
|
+
|
|
159
|
+
// Adopt the hosted head as this root's baseline so the first push has the
|
|
160
|
+
// correct parent (bundle-restored roots already carry it; this makes the
|
|
161
|
+
// flow correct for any root).
|
|
162
|
+
const adoption = await adoptRemoteHead(
|
|
163
|
+
{
|
|
164
|
+
rootDir,
|
|
165
|
+
syncTarget: options.syncTarget ?? null,
|
|
166
|
+
acceptDivergence: options.acceptDivergence === true,
|
|
167
|
+
},
|
|
168
|
+
environment,
|
|
169
|
+
);
|
|
170
|
+
notes.push(adoption.message);
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
status: 'demoted',
|
|
174
|
+
rootDir,
|
|
175
|
+
warnings: notes,
|
|
176
|
+
message: 'Demotion complete: this local root is canonical again (local-primary on both sides). Local files win; push to publish changes.',
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function loadModeAgnosticSyncContext(rootDir, options, environment) {
|
|
181
|
+
const projectFilePath = path.join(rootDir, 'project.yaml');
|
|
182
|
+
const project = parseSimpleYaml(await readFile(projectFilePath, 'utf8'), {
|
|
183
|
+
sourceName: projectFilePath,
|
|
184
|
+
});
|
|
185
|
+
const binding = resolveSyncBinding(project, options.syncTarget ?? null);
|
|
186
|
+
if (!binding) {
|
|
187
|
+
throw new Error('Mode transitions require a sync binding in project.yaml (run connect-hosted first).');
|
|
188
|
+
}
|
|
189
|
+
const credential = ((environment.env ?? process.env)[binding.credentialEnvVar] ?? '').trim();
|
|
190
|
+
if (!credential) {
|
|
191
|
+
throw new Error(`Mode transitions require ${binding.credentialEnvVar} to be set.`);
|
|
192
|
+
}
|
|
193
|
+
const fetchImpl = environment.runtime?.fetch ?? globalThis.fetch;
|
|
194
|
+
if (typeof fetchImpl !== 'function') {
|
|
195
|
+
throw new Error('Mode transitions require a fetch implementation.');
|
|
196
|
+
}
|
|
197
|
+
return { rootDir, project, binding, credential, fetch: fetchImpl };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function endpointUrl(context, routeName) {
|
|
201
|
+
const base = context.binding.apiUrl.endsWith('/')
|
|
202
|
+
? context.binding.apiUrl
|
|
203
|
+
: `${context.binding.apiUrl}/`;
|
|
204
|
+
return new URL(
|
|
205
|
+
`api/sync/projects/${encodeURIComponent(context.binding.projectId)}/${routeName}`,
|
|
206
|
+
base,
|
|
207
|
+
).href;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function getJson(context, routeName) {
|
|
211
|
+
const response = await context.fetch(endpointUrl(context, routeName), {
|
|
212
|
+
headers: {
|
|
213
|
+
accept: 'application/json',
|
|
214
|
+
authorization: `Bearer ${context.credential}`,
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
if (!response.ok) {
|
|
218
|
+
const text = typeof response.text === 'function' ? await response.text() : '';
|
|
219
|
+
throw new Error(`Hosted ${routeName} failed with ${response.status}${text ? `: ${text}` : ''}`);
|
|
220
|
+
}
|
|
221
|
+
return typeof response.json === 'function' ? response.json() : {};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function postJson(context, routeName, body) {
|
|
225
|
+
const response = await context.fetch(endpointUrl(context, routeName), {
|
|
226
|
+
method: 'POST',
|
|
227
|
+
headers: {
|
|
228
|
+
accept: 'application/json',
|
|
229
|
+
authorization: `Bearer ${context.credential}`,
|
|
230
|
+
'content-type': 'application/json',
|
|
231
|
+
},
|
|
232
|
+
body: JSON.stringify(body),
|
|
233
|
+
});
|
|
234
|
+
if (!response.ok) {
|
|
235
|
+
const text = typeof response.text === 'function' ? await response.text() : '';
|
|
236
|
+
throw new Error(`Hosted ${routeName} failed with ${response.status}${text ? `: ${text}` : ''}`);
|
|
237
|
+
}
|
|
238
|
+
return typeof response.json === 'function' ? response.json() : {};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function writeProjectMode(rootDir, mode) {
|
|
242
|
+
const projectFilePath = path.join(rootDir, 'project.yaml');
|
|
243
|
+
const raw = await readFile(projectFilePath, 'utf8');
|
|
244
|
+
const next = raw.replace(/^mode:\s*\S+\s*$/m, `mode: ${mode}`);
|
|
245
|
+
if (next === raw && !new RegExp(`^mode:\\s*${mode}\\s*$`, 'm').test(raw)) {
|
|
246
|
+
throw new Error(`Could not update the mode line in ${projectFilePath}; edit it to "mode: ${mode}" and re-run with --resume.`);
|
|
247
|
+
}
|
|
248
|
+
await writeFile(projectFilePath, next, 'utf8');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function ensurePromotedMarker(rootDir, context) {
|
|
252
|
+
const markerPath = path.join(rootDir, ...PROMOTED_MARKER);
|
|
253
|
+
await mkdir(path.dirname(markerPath), { recursive: true });
|
|
254
|
+
const marker = {
|
|
255
|
+
promoted_at: new Date().toISOString(),
|
|
256
|
+
api_url: context.binding.apiUrl,
|
|
257
|
+
project_id: context.binding.projectId,
|
|
258
|
+
note: 'This root was promoted to hosted-only (D-288). The hosted app is canonical; local canonical writes refuse by default.',
|
|
259
|
+
};
|
|
260
|
+
await writeFile(markerPath, `${JSON.stringify(marker, null, 2)}\n`, 'utf8');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function removePromotedMarker(rootDir) {
|
|
264
|
+
await rm(path.join(rootDir, ...PROMOTED_MARKER), { force: true });
|
|
265
|
+
}
|
package/src/scaffold.js
CHANGED
|
@@ -276,7 +276,7 @@ ${renderWorkflowDefaults(workflow)}
|
|
|
276
276
|
6. Read the relevant docs under \`${rootRelativePath}/architecture/\` and \`${rootRelativePath}/decisions/\`.
|
|
277
277
|
|
|
278
278
|
## Builder workflow
|
|
279
|
-
At session start, prefer running \`vibecompass start-session --id <lane-id> --working-on "..." \`; add \`--branch <name> --repo <id> [--worktree]\` when the lane should work on its own branch or in isolated worktrees (D-281).
|
|
279
|
+
At session start, prefer running \`vibecompass start-session --id <lane-id> --working-on "..." \`; add \`--feature\`, \`--repo\`, \`--claim\`, \`--architecture-doc\`, and \`--decision-domain-file\` values when the lane's scope is known so overlap warnings can be precise. Add \`--branch <name> --repo <id> [--worktree]\` when the lane should work on its own branch or in isolated worktrees (D-281).
|
|
280
280
|
If you manage files manually, create \`${rootRelativePath}/sessions/active/<lane-id>/session.yaml\` and \`${rootRelativePath}/sessions/active/index.yaml\`, then create \`${rootRelativePath}/sessions/active/<lane-id>/wip.md\` if it does not exist:
|
|
281
281
|
|
|
282
282
|
\`\`\`md
|
package/src/serialization.js
CHANGED
|
@@ -46,7 +46,33 @@ export function memoryRootLockPath(rootDir) {
|
|
|
46
46
|
* Runs `fn` while holding the serialization lock for `rootDir`. Reentrant:
|
|
47
47
|
* a nested call from inside the lock holder runs immediately.
|
|
48
48
|
*/
|
|
49
|
+
// Commands that stay allowed on a promoted root: the transition flows
|
|
50
|
+
// themselves, plus recovery tooling that must be able to repair state.
|
|
51
|
+
const PROMOTED_ROOT_ALLOWED_COMMANDS = new Set([
|
|
52
|
+
'promote-hosted',
|
|
53
|
+
'demote-hosted',
|
|
54
|
+
'bootstrap',
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
async function assertNotPromotedRoot(rootDir, label) {
|
|
58
|
+
if (PROMOTED_ROOT_ALLOWED_COMMANDS.has(label)) return;
|
|
59
|
+
if (process.env.VIBECOMPASS_ALLOW_PROMOTED_ROOT_WRITES === '1') return;
|
|
60
|
+
const markerPath = path.join(rootDir, 'state', 'promoted-root.json');
|
|
61
|
+
let marker;
|
|
62
|
+
try {
|
|
63
|
+
marker = JSON.parse(await readFile(markerPath, 'utf8'));
|
|
64
|
+
} catch {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
throw new Error(
|
|
68
|
+
`This root was promoted to hosted-only${marker?.promoted_at ? ` on ${marker.promoted_at}` : ''}: the hosted app is canonical and local writes here can never sync back (D-288). `
|
|
69
|
+
+ 'Make changes on the hosted dashboard, run vibecompass demote-hosted to make this root canonical again, '
|
|
70
|
+
+ 'or set VIBECOMPASS_ALLOW_PROMOTED_ROOT_WRITES=1 to override deliberately.',
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
49
74
|
export async function withMemoryRootLock(rootDir, label, fn, options = {}) {
|
|
75
|
+
await assertNotPromotedRoot(rootDir, label);
|
|
50
76
|
const key = await canonicalizeMemoryRootKey(rootDir);
|
|
51
77
|
const held = heldRoots.getStore();
|
|
52
78
|
if (await findHeldMemoryRootKey(held, key)) {
|
package/src/session.js
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
} from './lane-runtime.js';
|
|
27
27
|
import {
|
|
28
28
|
LANE_MARKER_FILENAME,
|
|
29
|
+
assertLaneMarkerSnapshotCurrent,
|
|
29
30
|
assertMarkerTargetDisjoint,
|
|
30
31
|
findEnclosingGitDir,
|
|
31
32
|
readLaneMarker,
|
|
@@ -141,6 +142,7 @@ export async function inferToolingRootForMemoryRoot(rootDir) {
|
|
|
141
142
|
}
|
|
142
143
|
|
|
143
144
|
async function startProjectSessionLocked(normalized, options, markerContext) {
|
|
145
|
+
await assertLaneMarkerSnapshotCurrent(markerContext);
|
|
144
146
|
const workingOn = requireNonEmptyString(options?.workingOn, 'start-session requires a non-empty workingOn value.');
|
|
145
147
|
const sessionDate = normalizeSessionDate(options?.date);
|
|
146
148
|
|
|
@@ -151,12 +153,17 @@ async function startProjectSessionLocked(normalized, options, markerContext) {
|
|
|
151
153
|
const features = normalizeStringArray(options?.features);
|
|
152
154
|
const repos = normalizeStringArray(options?.repos);
|
|
153
155
|
const claims = normalizeStringArray(options?.claims);
|
|
156
|
+
const architectureDocs = normalizeStringArray(options?.architectureDocs).map(normalizeArchitectureDocPath);
|
|
157
|
+
const decisionDomainFiles = normalizeStringArray(options?.decisionDomainFiles ?? options?.decisionDomains)
|
|
158
|
+
.map(normalizeDecisionDomainFile);
|
|
154
159
|
const existingLanes = await listActiveSessionLanes(normalized);
|
|
155
160
|
const overlapWarnings = buildOverlapWarnings({
|
|
156
161
|
sessionId,
|
|
157
162
|
features,
|
|
158
163
|
repos,
|
|
159
164
|
claims,
|
|
165
|
+
architectureDocs,
|
|
166
|
+
decisionDomainFiles,
|
|
160
167
|
existingLanes,
|
|
161
168
|
hasWorktree: options?.worktree === true,
|
|
162
169
|
});
|
|
@@ -276,6 +283,8 @@ async function startProjectSessionLocked(normalized, options, markerContext) {
|
|
|
276
283
|
features,
|
|
277
284
|
repos,
|
|
278
285
|
claims,
|
|
286
|
+
architectureDocs,
|
|
287
|
+
decisionDomainFiles,
|
|
279
288
|
baseRevisions: baseRevisionCapture.baseRevisions,
|
|
280
289
|
runtime: laneRuntime,
|
|
281
290
|
// Record-before-create (D-281): the full binding plan — including the
|
|
@@ -426,6 +435,7 @@ export async function closeProjectSession(options) {
|
|
|
426
435
|
}
|
|
427
436
|
|
|
428
437
|
async function closeProjectSessionLocked(normalized, options, markerContext) {
|
|
438
|
+
await assertLaneMarkerSnapshotCurrent(markerContext);
|
|
429
439
|
const title = requireNonEmptyString(options?.title, 'session close requires a non-empty title.');
|
|
430
440
|
const completed = normalizeStringArray(options?.completed);
|
|
431
441
|
const models = normalizeStringArray(options?.models);
|
|
@@ -584,6 +594,17 @@ async function closeProjectSessionLocked(normalized, options, markerContext) {
|
|
|
584
594
|
markerCleanupWarnings.push(...containerCleanup.warnings);
|
|
585
595
|
}
|
|
586
596
|
}
|
|
597
|
+
if (markerContext.marker?.laneId === sessionId) {
|
|
598
|
+
const contextMarkerPath = path.resolve(markerContext.marker.markerPath);
|
|
599
|
+
const recordedMarkerPath = closingLaneMetadata?.laneMarker?.path
|
|
600
|
+
? path.resolve(closingLaneMetadata.laneMarker.path)
|
|
601
|
+
: null;
|
|
602
|
+
if (contextMarkerPath !== recordedMarkerPath && (await fileExists(contextMarkerPath))) {
|
|
603
|
+
markerCleanupWarnings.push(
|
|
604
|
+
`Lane marker at ${contextMarkerPath} names the closed lane "${sessionId}" but was not recorded in session.yaml; close-session left it in place. Remove it manually or recreate it with \`vibecompass write-lane-marker\` before reusing that directory.`,
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
587
608
|
if (removal.attempted) {
|
|
588
609
|
worktreeCleanup = {
|
|
589
610
|
branch: closingLaneMetadata?.branch ?? null,
|
|
@@ -900,6 +921,7 @@ export async function switchProjectSession(options = {}) {
|
|
|
900
921
|
}
|
|
901
922
|
|
|
902
923
|
async function switchProjectSessionLocked(normalized, options, markerContext) {
|
|
924
|
+
await assertLaneMarkerSnapshotCurrent(markerContext);
|
|
903
925
|
const sessionId = validateLaneId(requireNonEmptyString(options?.sessionId, 'switch-session requires a session ID.'));
|
|
904
926
|
const lanes = await listActiveSessionLanes(normalized);
|
|
905
927
|
const lane = lanes.find((item) => item.id === sessionId);
|
|
@@ -1020,6 +1042,7 @@ export async function writeLaneMarkerForSession(options) {
|
|
|
1020
1042
|
}
|
|
1021
1043
|
|
|
1022
1044
|
async function writeLaneMarkerForSessionLocked(normalized, options, markerContext) {
|
|
1045
|
+
await assertLaneMarkerSnapshotCurrent(markerContext);
|
|
1023
1046
|
const sessionId = validateLaneId(
|
|
1024
1047
|
requireNonEmptyString(options?.sessionId, 'write-lane-marker requires --session <lane-id> so the marker binds to an explicit lane.'),
|
|
1025
1048
|
);
|
|
@@ -1506,6 +1529,8 @@ async function listActiveSessionLanes(normalized) {
|
|
|
1506
1529
|
features: metadata.features,
|
|
1507
1530
|
repos: metadata.repos,
|
|
1508
1531
|
claims: metadata.claims,
|
|
1532
|
+
architectureDocs: metadata.architectureDocs,
|
|
1533
|
+
decisionDomainFiles: metadata.decisionDomainFiles,
|
|
1509
1534
|
startedAt: metadata.startedAt,
|
|
1510
1535
|
decisionSnapshot: metadata.decisionSnapshot,
|
|
1511
1536
|
branch: metadata.branch ?? null,
|
|
@@ -1548,6 +1573,8 @@ async function readLaneMetadata(sessionFilePath) {
|
|
|
1548
1573
|
features: normalizeStringArray(data.feature_slugs),
|
|
1549
1574
|
repos: normalizeStringArray(data.repos),
|
|
1550
1575
|
claims: normalizeStringArray(data.claimed_paths),
|
|
1576
|
+
architectureDocs: normalizeStringArray(data.architecture_docs).map(normalizeArchitectureDocPath),
|
|
1577
|
+
decisionDomainFiles: normalizeStringArray(data.decision_domain_files).map(normalizeDecisionDomainFile),
|
|
1551
1578
|
startedAt: normalizeOptionalString(data.started_at),
|
|
1552
1579
|
decisionSnapshot: {
|
|
1553
1580
|
highestDecisionId: parseNullableNumber(data.decision_snapshot?.highest_decision_id),
|
|
@@ -1581,6 +1608,8 @@ async function readLaneMetadata(sessionFilePath) {
|
|
|
1581
1608
|
features: [],
|
|
1582
1609
|
repos: [],
|
|
1583
1610
|
claims: [],
|
|
1611
|
+
architectureDocs: [],
|
|
1612
|
+
decisionDomainFiles: [],
|
|
1584
1613
|
startedAt: null,
|
|
1585
1614
|
decisionSnapshot: {
|
|
1586
1615
|
highestDecisionId: null,
|
|
@@ -1839,6 +1868,8 @@ function renderLaneMetadata(options) {
|
|
|
1839
1868
|
renderYamlArray('feature_slugs', options.features),
|
|
1840
1869
|
renderYamlArray('repos', options.repos),
|
|
1841
1870
|
renderYamlArray('claimed_paths', options.claims),
|
|
1871
|
+
renderYamlArray('architecture_docs', options.architectureDocs),
|
|
1872
|
+
renderYamlArray('decision_domain_files', options.decisionDomainFiles),
|
|
1842
1873
|
`started_at: ${formatLocalDateTime(new Date())}`,
|
|
1843
1874
|
// D-282: the runtime assignment travels with the lane record (local-only
|
|
1844
1875
|
// per D-278) so lane-env and close-time cleanup read it back.
|
|
@@ -2021,13 +2052,32 @@ function buildOverlapWarnings(options) {
|
|
|
2021
2052
|
);
|
|
2022
2053
|
}
|
|
2023
2054
|
|
|
2024
|
-
const claimOverlaps = findClaimOverlaps(
|
|
2055
|
+
const claimOverlaps = findClaimOverlaps(
|
|
2056
|
+
options.claims,
|
|
2057
|
+
options.repos,
|
|
2058
|
+
lane.claims ?? [],
|
|
2059
|
+
lane.repos ?? [],
|
|
2060
|
+
);
|
|
2025
2061
|
for (const overlap of claimOverlaps) {
|
|
2026
2062
|
warnings.push(
|
|
2027
2063
|
`Active session lane "${options.sessionId}" overlaps "${lane.id}" on claimed path ${overlap.repo}:${overlap.path}.`,
|
|
2028
2064
|
);
|
|
2029
2065
|
}
|
|
2030
2066
|
|
|
2067
|
+
const sharedArchitectureDocs = intersect(options.architectureDocs ?? [], lane.architectureDocs ?? []);
|
|
2068
|
+
if (sharedArchitectureDocs.length > 0) {
|
|
2069
|
+
warnings.push(
|
|
2070
|
+
`Active session lane "${options.sessionId}" overlaps "${lane.id}" on architecture doc(s): ${sharedArchitectureDocs.join(', ')}.`,
|
|
2071
|
+
);
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
const sharedDecisionDomainFiles = intersect(options.decisionDomainFiles ?? [], lane.decisionDomainFiles ?? []);
|
|
2075
|
+
if (sharedDecisionDomainFiles.length > 0) {
|
|
2076
|
+
warnings.push(
|
|
2077
|
+
`Active session lane "${options.sessionId}" overlaps "${lane.id}" on decision domain file(s): ${sharedDecisionDomainFiles.join(', ')}.`,
|
|
2078
|
+
);
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2031
2081
|
const sharedRepos = intersect(options.repos, lane.repos ?? []);
|
|
2032
2082
|
warnings.push(...buildSharedCheckoutWarnings({
|
|
2033
2083
|
sessionId: options.sessionId,
|
|
@@ -2038,15 +2088,38 @@ function buildOverlapWarnings(options) {
|
|
|
2038
2088
|
context: 'start-session',
|
|
2039
2089
|
}));
|
|
2040
2090
|
|
|
2091
|
+
const bothLackClaims = options.claims.length === 0 && (lane.claims ?? []).length === 0;
|
|
2041
2092
|
if (
|
|
2042
2093
|
sharedRepos.length > 0 &&
|
|
2043
2094
|
(options.hasWorktree || lane.worktreeContainer) &&
|
|
2044
|
-
|
|
2045
|
-
(lane.claims ?? []).length === 0
|
|
2095
|
+
bothLackClaims
|
|
2046
2096
|
) {
|
|
2047
2097
|
warnings.push(
|
|
2048
2098
|
`Active session lane "${options.sessionId}" shares repo(s) ${sharedRepos.join(', ')} with "${lane.id}" without path claims; add --claim values to clarify ownership.`,
|
|
2049
2099
|
);
|
|
2100
|
+
continue;
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
const disambiguatedByFeatures =
|
|
2104
|
+
options.features.length > 0 &&
|
|
2105
|
+
(lane.features ?? []).length > 0 &&
|
|
2106
|
+
sharedFeatures.length === 0;
|
|
2107
|
+
const disambiguatedByClaims = hasNarrowNonOverlappingClaims({
|
|
2108
|
+
sharedRepos,
|
|
2109
|
+
leftClaims: options.claims,
|
|
2110
|
+
leftRepos: options.repos,
|
|
2111
|
+
rightClaims: lane.claims ?? [],
|
|
2112
|
+
rightRepos: lane.repos ?? [],
|
|
2113
|
+
});
|
|
2114
|
+
if (
|
|
2115
|
+
sharedRepos.length > 0 &&
|
|
2116
|
+
claimOverlaps.length === 0 &&
|
|
2117
|
+
!disambiguatedByFeatures &&
|
|
2118
|
+
!disambiguatedByClaims
|
|
2119
|
+
) {
|
|
2120
|
+
warnings.push(
|
|
2121
|
+
`Active session lane "${options.sessionId}" shares repo(s) ${sharedRepos.join(', ')} with "${lane.id}" without narrower non-overlapping ownership; ownership is ambiguous. Add --claim paths or --feature slugs to both lanes to clarify ownership.`,
|
|
2122
|
+
);
|
|
2050
2123
|
}
|
|
2051
2124
|
}
|
|
2052
2125
|
|
|
@@ -2093,10 +2166,10 @@ function intersect(left, right) {
|
|
|
2093
2166
|
return left.filter((item) => rightSet.has(item));
|
|
2094
2167
|
}
|
|
2095
2168
|
|
|
2096
|
-
function findClaimOverlaps(leftClaims, rightClaims) {
|
|
2169
|
+
function findClaimOverlaps(leftClaims, leftRepos, rightClaims, rightRepos) {
|
|
2097
2170
|
const overlaps = [];
|
|
2098
|
-
for (const left of leftClaims
|
|
2099
|
-
for (const right of rightClaims
|
|
2171
|
+
for (const left of expandClaims(leftClaims, leftRepos)) {
|
|
2172
|
+
for (const right of expandClaims(rightClaims, rightRepos)) {
|
|
2100
2173
|
if (left.repo !== right.repo) {
|
|
2101
2174
|
continue;
|
|
2102
2175
|
}
|
|
@@ -2113,20 +2186,94 @@ function findClaimOverlaps(leftClaims, rightClaims) {
|
|
|
2113
2186
|
return overlaps;
|
|
2114
2187
|
}
|
|
2115
2188
|
|
|
2116
|
-
function
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
return null;
|
|
2189
|
+
function hasNarrowNonOverlappingClaims(options) {
|
|
2190
|
+
if (options.sharedRepos.length === 0) {
|
|
2191
|
+
return false;
|
|
2120
2192
|
}
|
|
2121
2193
|
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2194
|
+
const leftClaims = expandClaims(options.leftClaims, options.leftRepos);
|
|
2195
|
+
const rightClaims = expandClaims(options.rightClaims, options.rightRepos);
|
|
2196
|
+
return options.sharedRepos.every((repo) => {
|
|
2197
|
+
const leftForRepo = leftClaims.filter((claim) => claim.repo === repo);
|
|
2198
|
+
const rightForRepo = rightClaims.filter((claim) => claim.repo === repo);
|
|
2199
|
+
if (leftForRepo.length === 0 || rightForRepo.length === 0) {
|
|
2200
|
+
return false;
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
return leftForRepo.every((left) =>
|
|
2204
|
+
rightForRepo.every((right) => !pathPrefixesOverlap(left.path, right.path)));
|
|
2205
|
+
});
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
function expandClaims(claims, repos) {
|
|
2209
|
+
const normalizedRepos = normalizeStringArray(repos);
|
|
2210
|
+
return normalizeStringArray(claims)
|
|
2211
|
+
.flatMap((claim) => parseClaim(claim, normalizedRepos))
|
|
2212
|
+
.filter(Boolean);
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
function parseClaim(value, repos = []) {
|
|
2216
|
+
const source = String(value ?? '').trim();
|
|
2217
|
+
if (!source) {
|
|
2218
|
+
return [];
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
const normalizedRepos = normalizeStringArray(repos);
|
|
2222
|
+
const separatorIndex = source.indexOf(':');
|
|
2223
|
+
const repoId = separatorIndex > 0 ? source.slice(0, separatorIndex) : '';
|
|
2224
|
+
const rawClaimPath = separatorIndex > 0 ? source.slice(separatorIndex + 1) : '';
|
|
2225
|
+
if (separatorIndex > 0 && !rawClaimPath) {
|
|
2226
|
+
return [];
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
if (
|
|
2230
|
+
separatorIndex > 0 &&
|
|
2231
|
+
rawClaimPath &&
|
|
2232
|
+
(!isWindowsDrivePath(source) || (normalizedRepos.includes(repoId) && rawClaimPath.startsWith('/')))
|
|
2233
|
+
) {
|
|
2234
|
+
const normalizedPath = normalizeClaimPath(rawClaimPath);
|
|
2235
|
+
if (!normalizedPath) {
|
|
2236
|
+
return [];
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
return [{
|
|
2240
|
+
repo: repoId,
|
|
2241
|
+
path: normalizedPath,
|
|
2242
|
+
}];
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
const normalized = normalizeClaimPath(source);
|
|
2246
|
+
if (!normalized) {
|
|
2247
|
+
return [];
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
return normalizedRepos.map((repo) => ({
|
|
2251
|
+
repo,
|
|
2252
|
+
path: normalized,
|
|
2253
|
+
}));
|
|
2126
2254
|
}
|
|
2127
2255
|
|
|
2128
2256
|
function normalizeClaimPath(value) {
|
|
2129
|
-
return value
|
|
2257
|
+
return String(value ?? '')
|
|
2258
|
+
.trim()
|
|
2259
|
+
.replace(/\\/g, '/')
|
|
2260
|
+
.replace(/\/+/g, '/')
|
|
2261
|
+
.split('/')
|
|
2262
|
+
.filter((segment) => segment !== '' && segment !== '.')
|
|
2263
|
+
.join('/');
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
function normalizeArchitectureDocPath(value) {
|
|
2267
|
+
return normalizeClaimPath(value);
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
function normalizeDecisionDomainFile(value) {
|
|
2271
|
+
const normalized = normalizeClaimPath(value);
|
|
2272
|
+
return normalized.startsWith('decisions/') ? normalized : `decisions/${normalized}`;
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
function isWindowsDrivePath(value) {
|
|
2276
|
+
return /^[A-Za-z]:[\\/]/.test(String(value ?? ''));
|
|
2130
2277
|
}
|
|
2131
2278
|
|
|
2132
2279
|
function pathPrefixesOverlap(left, right) {
|