@vibecompass/vibecompass 0.10.8 → 0.11.1
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 +36 -1
- package/README.md +32 -0
- package/package.json +1 -1
- package/src/cli.js +602 -8
- package/src/compatibility.js +34 -1
- package/src/decision-index.js +386 -0
- package/src/decisions.js +195 -0
- package/src/docs-review.js +27 -27
- package/src/docs-update.js +459 -24
- package/src/generators/agent-files/index.js +8 -0
- package/src/generators/agent-files/template.js +6 -3
- package/src/git-binding.js +676 -0
- package/src/init.js +9 -0
- package/src/lane-marker.js +339 -0
- package/src/lane-runtime.js +254 -0
- package/src/manifest.js +19 -0
- package/src/project-memory.js +27 -1
- package/src/refresh-workflow.js +8 -0
- package/src/scaffold.js +19 -11
- package/src/serialization.js +244 -0
- package/src/session.js +1000 -75
- package/src/simple-yaml.js +24 -4
- package/src/status.js +43 -5
- package/src/sync.js +5 -0
- package/src/workflows/registry.js +1 -1
package/src/init.js
CHANGED
|
@@ -2,6 +2,7 @@ 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';
|
|
5
|
+
import { withMemoryRootLock } from './serialization.js';
|
|
5
6
|
import { scaffoldInitFiles } from './scaffold.js';
|
|
6
7
|
import { applyPlacementDefaults } from './setup.js';
|
|
7
8
|
import { buildWorkflowMetadata } from './workflow.js';
|
|
@@ -57,6 +58,10 @@ export async function initializeProjectMemory(options) {
|
|
|
57
58
|
export async function connectHostedProjectMemory(options) {
|
|
58
59
|
const cwd = options?.cwd ? path.resolve(options.cwd) : process.cwd();
|
|
59
60
|
const rootDir = path.resolve(cwd, options?.rootDir ?? '.compass');
|
|
61
|
+
return withMemoryRootLock(rootDir, 'connect-hosted', () => connectHostedProjectMemoryLocked(options, rootDir));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function connectHostedProjectMemoryLocked(options, rootDir) {
|
|
60
65
|
const projectFilePath = path.join(rootDir, 'project.yaml');
|
|
61
66
|
let projectConfig;
|
|
62
67
|
try {
|
|
@@ -171,6 +176,10 @@ async function updateManifestSyncState(rootDir, updater) {
|
|
|
171
176
|
export async function setDefaultSyncTarget(options) {
|
|
172
177
|
const cwd = options?.cwd ? path.resolve(options.cwd) : process.cwd();
|
|
173
178
|
const rootDir = path.resolve(cwd, options?.rootDir ?? '.compass');
|
|
179
|
+
return withMemoryRootLock(rootDir, 'sync-target', () => setDefaultSyncTargetLocked(options, rootDir));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function setDefaultSyncTargetLocked(options, rootDir) {
|
|
174
183
|
const projectFilePath = path.join(rootDir, 'project.yaml');
|
|
175
184
|
const projectConfig = parseSimpleYaml(await readFile(projectFilePath, 'utf8'), {
|
|
176
185
|
sourceName: projectFilePath,
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { access, readFile, realpath } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseSimpleYaml } from './simple-yaml.js';
|
|
4
|
+
|
|
5
|
+
// D-280: the worktree-local lane marker is a single hand-inspectable YAML file
|
|
6
|
+
// written and removed only by package commands. This module owns the marker
|
|
7
|
+
// contract plus the shared lane-identity primitives (lane-ID validation and
|
|
8
|
+
// the D-277 selection order) so session.js and docs-update.js cannot diverge.
|
|
9
|
+
export const LANE_MARKER_FILENAME = '.vibecompass-lane.yaml';
|
|
10
|
+
export const LANE_MARKER_FORMAT_VERSION = 1;
|
|
11
|
+
|
|
12
|
+
export const LANE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/;
|
|
13
|
+
export const RESERVED_LANE_IDS = new Set([
|
|
14
|
+
'active',
|
|
15
|
+
'current',
|
|
16
|
+
'default',
|
|
17
|
+
'false',
|
|
18
|
+
'handoff',
|
|
19
|
+
'index',
|
|
20
|
+
'new',
|
|
21
|
+
'no',
|
|
22
|
+
'null',
|
|
23
|
+
'off',
|
|
24
|
+
'on',
|
|
25
|
+
'sessions',
|
|
26
|
+
'state',
|
|
27
|
+
'true',
|
|
28
|
+
'wip',
|
|
29
|
+
'yes',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
export function validateLaneId(value) {
|
|
33
|
+
const normalized = value.trim();
|
|
34
|
+
if (!LANE_ID_PATTERN.test(normalized)) {
|
|
35
|
+
throw new Error('Session lane ID must be a lowercase slug 3-64 characters long using letters, numbers, and hyphens.');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (RESERVED_LANE_IDS.has(normalized)) {
|
|
39
|
+
throw new Error(`Session lane ID "${normalized}" is reserved.`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return normalized;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Walks up from `startDir` to the filesystem root and returns the nearest
|
|
47
|
+
* directory containing a lane marker file, or null. Nearest-marker-wins keeps
|
|
48
|
+
* nested contexts coherent (D-280); no parsing happens here.
|
|
49
|
+
*/
|
|
50
|
+
export async function findNearestLaneMarker(startDir) {
|
|
51
|
+
let dir = path.resolve(startDir);
|
|
52
|
+
|
|
53
|
+
for (;;) {
|
|
54
|
+
const markerPath = path.join(dir, LANE_MARKER_FILENAME);
|
|
55
|
+
if (await fileExists(markerPath)) {
|
|
56
|
+
return { markerDir: dir, markerPath };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const parent = path.dirname(dir);
|
|
60
|
+
if (parent === dir) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
dir = parent;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Reads and validates a lane marker file. Corrupt or invalid markers fail
|
|
70
|
+
* closed with an actionable error naming the marker path — a marker that
|
|
71
|
+
* cannot be validated is broken session context, not something to guess past.
|
|
72
|
+
*/
|
|
73
|
+
export async function readLaneMarker(markerPath) {
|
|
74
|
+
let source;
|
|
75
|
+
try {
|
|
76
|
+
source = await readFile(markerPath, 'utf8');
|
|
77
|
+
} catch (error) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`Lane marker at ${markerPath} exists but cannot be read (${error instanceof Error ? error.message : String(error)}). ` +
|
|
80
|
+
'Fix the file permissions or remove the marker, or pass both --root and --session to bypass it.',
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let data;
|
|
85
|
+
try {
|
|
86
|
+
data = parseSimpleYaml(source, { sourceName: markerPath });
|
|
87
|
+
} catch (error) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`Lane marker at ${markerPath} is not valid marker YAML (${error instanceof Error ? error.message : String(error)}). ` +
|
|
90
|
+
'Remove the marker or recreate it with `vibecompass write-lane-marker`, or pass both --root and --session to bypass it.',
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const formatVersion = typeof data.format_version === 'number' ? data.format_version : Number(data.format_version);
|
|
95
|
+
if (formatVersion !== LANE_MARKER_FORMAT_VERSION) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Lane marker at ${markerPath} has unsupported format_version ${data.format_version ?? '(missing)'}; this package understands version ${LANE_MARKER_FORMAT_VERSION}. ` +
|
|
98
|
+
'Recreate the marker with `vibecompass write-lane-marker`, upgrade the vibecompass package, or pass both --root and --session to bypass it.',
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const laneId = typeof data.lane_id === 'string' ? data.lane_id.trim() : '';
|
|
103
|
+
if (!laneId || !LANE_ID_PATTERN.test(laneId) || RESERVED_LANE_IDS.has(laneId)) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`Lane marker at ${markerPath} has a missing or invalid lane_id. ` +
|
|
106
|
+
'Recreate the marker with `vibecompass write-lane-marker`, or pass both --root and --session to bypass it.',
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const memoryRoot = typeof data.memory_root === 'string' ? data.memory_root.trim() : '';
|
|
111
|
+
if (!memoryRoot || !path.isAbsolute(memoryRoot)) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`Lane marker at ${markerPath} must record memory_root as an absolute path (found ${JSON.stringify(data.memory_root ?? null)}). ` +
|
|
114
|
+
'Recreate the marker with `vibecompass write-lane-marker`, or pass both --root and --session to bypass it.',
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const token = typeof data.token === 'string' ? data.token.trim() : '';
|
|
119
|
+
if (!token) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`Lane marker at ${markerPath} is missing its token. ` +
|
|
122
|
+
'Recreate the marker with `vibecompass write-lane-marker`, or pass both --root and --session to bypass it.',
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
markerPath,
|
|
128
|
+
markerDir: path.dirname(markerPath),
|
|
129
|
+
laneId,
|
|
130
|
+
memoryRoot,
|
|
131
|
+
token,
|
|
132
|
+
createdAt: typeof data.created_at === 'string' ? data.created_at : null,
|
|
133
|
+
createdBy: typeof data.created_by === 'string' ? data.created_by : null,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Pre-lock context resolution (D-280): finds the nearest marker from cwd,
|
|
139
|
+
* fail-closes on corrupt markers, applies root binding, and returns the
|
|
140
|
+
* memory root the command should target. An explicit --root always wins;
|
|
141
|
+
* otherwise a valid marker supplies the root; only when no marker resolves
|
|
142
|
+
* does the `cwd/.compass` default apply.
|
|
143
|
+
*/
|
|
144
|
+
export async function resolveLaneMarkerContext(options) {
|
|
145
|
+
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
146
|
+
const explicitRootDir = options.explicitRootDir ? path.resolve(cwd, options.explicitRootDir) : null;
|
|
147
|
+
const explicitSessionId = options.explicitSessionId ?? null;
|
|
148
|
+
const warnings = [];
|
|
149
|
+
|
|
150
|
+
const found = await findNearestLaneMarker(cwd);
|
|
151
|
+
let marker = null;
|
|
152
|
+
if (found) {
|
|
153
|
+
try {
|
|
154
|
+
marker = await readLaneMarker(found.markerPath);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
// Corrupt markers fail closed unless the context is fully explicit:
|
|
157
|
+
// passing both --root and --session is the documented escape hatch,
|
|
158
|
+
// since the marker could then contribute nothing but warnings.
|
|
159
|
+
if (explicitRootDir && explicitSessionId) {
|
|
160
|
+
warnings.push(
|
|
161
|
+
`${error instanceof Error ? error.message : String(error)} Ignored because --root and --session were provided; fix or remove the marker.`,
|
|
162
|
+
);
|
|
163
|
+
} else {
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (marker && explicitRootDir) {
|
|
170
|
+
const markerRoot = await canonicalizePath(marker.memoryRoot);
|
|
171
|
+
const explicitRoot = await canonicalizePath(explicitRootDir);
|
|
172
|
+
if (markerRoot !== explicitRoot) {
|
|
173
|
+
warnings.push(
|
|
174
|
+
`Lane marker at ${marker.markerPath} binds to memory root ${marker.memoryRoot}, not ${explicitRootDir}; ignoring the marker for this command.`,
|
|
175
|
+
);
|
|
176
|
+
marker = null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const rootDir = explicitRootDir ?? marker?.memoryRoot ?? path.resolve(cwd, '.compass');
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
cwd,
|
|
184
|
+
rootDir,
|
|
185
|
+
rootSource: explicitRootDir ? 'flag' : marker ? 'marker' : 'default',
|
|
186
|
+
marker,
|
|
187
|
+
warnings,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The D-277 lane selection order, shared by every lane-scoped resolver:
|
|
193
|
+
* explicit --session wins (warning when it contradicts a resolvable marker,
|
|
194
|
+
* naming both lanes) > marker lane (fail closed when stale, even if a
|
|
195
|
+
* single-lane fallback would have succeeded) > single active lane > null at
|
|
196
|
+
* zero lanes > actionable error at 2+ lanes. Pure function; the caller
|
|
197
|
+
* enumerates lanes (inside its lock for mutating commands).
|
|
198
|
+
*/
|
|
199
|
+
export function resolveLaneSelection(options) {
|
|
200
|
+
// Validating here (not only in callers) keeps the two resolvers from
|
|
201
|
+
// diverging on the explicit-flag contract.
|
|
202
|
+
const explicitSessionId = options.explicitSessionId ? validateLaneId(options.explicitSessionId) : null;
|
|
203
|
+
const marker = options.marker ?? null;
|
|
204
|
+
const laneIds = options.laneIds ?? [];
|
|
205
|
+
const purpose = options.purpose ?? 'use';
|
|
206
|
+
const warnings = [];
|
|
207
|
+
|
|
208
|
+
if (explicitSessionId) {
|
|
209
|
+
// An explicit selection still fails closed when it names a lane that is
|
|
210
|
+
// not active — otherwise a typo'd --session silently plans or writes
|
|
211
|
+
// against a lane that does not exist (the mis-selection D-277 removes).
|
|
212
|
+
if (!laneIds.includes(explicitSessionId)) {
|
|
213
|
+
throw new Error(
|
|
214
|
+
`Session lane "${explicitSessionId}" is not an active lane in ${options.rootDir ?? 'the project-memory root'}. ` +
|
|
215
|
+
'Run `vibecompass list-sessions` to see active lanes.',
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (marker && marker.laneId !== explicitSessionId && !options.suppressLaneWarnings) {
|
|
220
|
+
warnings.push(
|
|
221
|
+
`--session ${explicitSessionId} overrides the lane marker at ${marker.markerPath} (marker lane: ${marker.laneId}). ` +
|
|
222
|
+
'If that is unintentional, run the command from a directory bound to the intended lane.',
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { sessionId: explicitSessionId, source: 'flag', warnings };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (marker) {
|
|
230
|
+
if (!laneIds.includes(marker.laneId)) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`Lane marker at ${marker.markerPath} names lane "${marker.laneId}", which is not an active lane in ${options.rootDir ?? 'the project-memory root'}. ` +
|
|
233
|
+
'Remove the stale marker or recreate it with `vibecompass write-lane-marker`, or pass --session explicitly (D-280: stale markers fail closed).',
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return { sessionId: marker.laneId, source: 'marker', warnings };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (laneIds.length === 1) {
|
|
241
|
+
return { sessionId: laneIds[0], source: 'single-lane', warnings };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (laneIds.length > 1) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
`Multiple active session lanes exist. Pass --session to choose which lane to ${purpose}, ` +
|
|
247
|
+
'or run the command from a directory with a worktree lane marker (`vibecompass write-lane-marker`).',
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return { sessionId: null, source: 'none', warnings };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* D-280 guard: a marker target must be path-disjoint from the memory root.
|
|
256
|
+
* An ancestor target would turn the shared workspace tree into a de facto
|
|
257
|
+
* global current pointer; an equal-or-descendant target would put marker
|
|
258
|
+
* state inside the memory root D-278 keeps local-only.
|
|
259
|
+
*/
|
|
260
|
+
export async function assertMarkerTargetDisjoint(targetDir, rootDir) {
|
|
261
|
+
const target = await canonicalizePath(path.resolve(targetDir));
|
|
262
|
+
const root = await canonicalizePath(path.resolve(rootDir));
|
|
263
|
+
|
|
264
|
+
if (target === root) {
|
|
265
|
+
throw new Error(
|
|
266
|
+
`write-lane-marker refuses ${targetDir}: the target equals the project-memory root. Markers must be path-disjoint from the memory root (D-280).`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (isPathInside(root, target)) {
|
|
271
|
+
throw new Error(
|
|
272
|
+
`write-lane-marker refuses ${targetDir}: the target contains the project-memory root, so the marker would govern the shared workspace tree (D-280).`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (isPathInside(target, root)) {
|
|
277
|
+
throw new Error(
|
|
278
|
+
`write-lane-marker refuses ${targetDir}: the target is inside the project-memory root; lane markers are local working state and never live in package memory (D-278/D-280).`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Renders the marker file body. String fields that can hold arbitrary text
|
|
285
|
+
* are quoted with JSON.stringify to match the codebase's hand-rendered YAML
|
|
286
|
+
* convention; values needing escape sequences are out of contract for the
|
|
287
|
+
* parseSimpleYaml subset.
|
|
288
|
+
*/
|
|
289
|
+
export function renderLaneMarker(options) {
|
|
290
|
+
return [
|
|
291
|
+
`format_version: ${LANE_MARKER_FORMAT_VERSION}`,
|
|
292
|
+
`lane_id: ${options.laneId}`,
|
|
293
|
+
`memory_root: ${JSON.stringify(options.memoryRoot)}`,
|
|
294
|
+
`token: ${JSON.stringify(options.token)}`,
|
|
295
|
+
`created_at: ${options.createdAt}`,
|
|
296
|
+
`created_by: ${JSON.stringify(options.createdBy)}`,
|
|
297
|
+
'',
|
|
298
|
+
].join('\n');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Walks up from `dir` looking for a `.git` entry (directory or file). */
|
|
302
|
+
export async function findEnclosingGitDir(dir) {
|
|
303
|
+
let current = path.resolve(dir);
|
|
304
|
+
|
|
305
|
+
for (;;) {
|
|
306
|
+
if (await fileExists(path.join(current, '.git'))) {
|
|
307
|
+
return current;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const parent = path.dirname(current);
|
|
311
|
+
if (parent === current) {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
current = parent;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function isPathInside(child, parent) {
|
|
320
|
+
const relative = path.relative(parent, child);
|
|
321
|
+
return relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function canonicalizePath(value) {
|
|
325
|
+
try {
|
|
326
|
+
return await realpath(value);
|
|
327
|
+
} catch {
|
|
328
|
+
return path.resolve(value);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function fileExists(filePath) {
|
|
333
|
+
try {
|
|
334
|
+
await access(filePath);
|
|
335
|
+
return true;
|
|
336
|
+
} catch {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lstat, realpath, rm } from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Per-lane runtime assignment (D-282).
|
|
8
|
+
*
|
|
9
|
+
* Every lane started on a package-managed root gets a lane port and a lane
|
|
10
|
+
* temp directory, computed during start-session preflight inside the D-276
|
|
11
|
+
* memory-root lock. Assignment is lane coordination on one root — the package
|
|
12
|
+
* never probes or binds the port and never supervises processes (the Phase 8
|
|
13
|
+
* scope fence). Consumption goes through `lane-env` config/env export.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_LANE_PORT_BASE = 3100;
|
|
17
|
+
export const DEFAULT_LANE_PORT_STEP = 1;
|
|
18
|
+
export const LANE_PORT_MIN = 1024;
|
|
19
|
+
export const LANE_PORT_MAX = 65535;
|
|
20
|
+
const LANE_TMP_NAMESPACE = 'vibecompass-lanes';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The default lane temp base is `<os.tmpdir()>/vibecompass-lanes`, but
|
|
24
|
+
* `os.tmpdir()` reads `TMPDIR` at call time and D-282's own `lane-env` export
|
|
25
|
+
* sets `TMPDIR=<laneTmpDir>` (the conventional alias). Starting a new lane from
|
|
26
|
+
* inside a `lane-env` shell would therefore place it *nested inside* the
|
|
27
|
+
* exporting lane's temp dir — a dangerous layout, because closing the outer
|
|
28
|
+
* lane recursively removes the inner lane's active temp dir. Strip any
|
|
29
|
+
* `vibecompass-lanes` segment out of the resolved temp root first, so a lane
|
|
30
|
+
* started from a `lane-env` shell is a sibling of the exporting lane under the
|
|
31
|
+
* real OS temp root, never nested (D-284).
|
|
32
|
+
*/
|
|
33
|
+
export function stripLaneTmpNamespace(dir) {
|
|
34
|
+
const segments = dir.split(path.sep);
|
|
35
|
+
const index = segments.indexOf(LANE_TMP_NAMESPACE);
|
|
36
|
+
if (index === -1) {
|
|
37
|
+
return dir;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const rootSegments = segments.slice(0, index);
|
|
41
|
+
return rootSegments.join(path.sep) || path.sep;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function defaultLaneTmpBase() {
|
|
45
|
+
return path.join(stripLaneTmpNamespace(os.tmpdir()), LANE_TMP_NAMESPACE);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolves `project.yaml` `runtime:` overrides (`port_base`, `port_step`,
|
|
50
|
+
* `tmp_base`) against the D-282 defaults. Invalid values warn and fall back —
|
|
51
|
+
* a misconfigured runtime block must not stop a session from starting.
|
|
52
|
+
*/
|
|
53
|
+
export function resolveRuntimeSettings(projectConfig) {
|
|
54
|
+
const warnings = [];
|
|
55
|
+
const runtimeValue = projectConfig?.runtime;
|
|
56
|
+
const runtime = isPlainObject(runtimeValue) ? runtimeValue : {};
|
|
57
|
+
if (runtimeValue !== undefined && runtimeValue !== null && !isPlainObject(runtimeValue)) {
|
|
58
|
+
warnings.push('project.yaml "runtime" is not a mapping; the D-282 runtime defaults apply.');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const key of Object.keys(runtime)) {
|
|
62
|
+
if (!['port_base', 'port_step', 'tmp_base'].includes(key)) {
|
|
63
|
+
warnings.push(`Unknown project.yaml runtime field "${key}" (D-282 supports port_base, port_step, tmp_base).`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let portBase = DEFAULT_LANE_PORT_BASE;
|
|
68
|
+
if (runtime.port_base !== undefined) {
|
|
69
|
+
if (Number.isInteger(runtime.port_base) && runtime.port_base >= LANE_PORT_MIN && runtime.port_base <= LANE_PORT_MAX) {
|
|
70
|
+
portBase = runtime.port_base;
|
|
71
|
+
} else {
|
|
72
|
+
warnings.push(
|
|
73
|
+
`project.yaml runtime.port_base must be an integer between ${LANE_PORT_MIN} and ${LANE_PORT_MAX}; using the default ${DEFAULT_LANE_PORT_BASE}.`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let portStep = DEFAULT_LANE_PORT_STEP;
|
|
79
|
+
if (runtime.port_step !== undefined) {
|
|
80
|
+
if (Number.isInteger(runtime.port_step) && runtime.port_step >= 1) {
|
|
81
|
+
portStep = runtime.port_step;
|
|
82
|
+
} else {
|
|
83
|
+
warnings.push(`project.yaml runtime.port_step must be a positive integer; using the default ${DEFAULT_LANE_PORT_STEP}.`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let tmpBase = defaultLaneTmpBase();
|
|
88
|
+
if (runtime.tmp_base !== undefined) {
|
|
89
|
+
const candidate = typeof runtime.tmp_base === 'string' ? runtime.tmp_base.trim() : '';
|
|
90
|
+
if (candidate && path.isAbsolute(candidate)) {
|
|
91
|
+
tmpBase = candidate;
|
|
92
|
+
} else {
|
|
93
|
+
warnings.push('project.yaml runtime.tmp_base must be an absolute path; using the OS temp namespace default.');
|
|
94
|
+
}
|
|
95
|
+
} else if (stripLaneTmpNamespace(os.tmpdir()) !== os.tmpdir()) {
|
|
96
|
+
// A lane-env-polluted TMPDIR was detected and un-nested (D-284). Surface it
|
|
97
|
+
// so the user knows this lane's temp dir sits beside — not inside — the
|
|
98
|
+
// lane whose lane-env they evaluated, and that it needs its own lane-env.
|
|
99
|
+
warnings.push(
|
|
100
|
+
'Detected a VibeCompass lane temp dir in TMPDIR (you are likely in a `lane-env` shell); placing this lane\'s temp dir as a sibling under the real OS temp root, not nested. Run `eval "$(vibecompass lane-env)"` for the new lane before using it.',
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { portBase, portStep, tmpBase, warnings };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Lowest `port_base + k * port_step` not recorded by any sibling lane.
|
|
109
|
+
* Unparseable sibling lanes contribute no port — their metadata warnings
|
|
110
|
+
* already surface at start — so assignment degrades to a possible collision
|
|
111
|
+
* warning path rather than blocking the start (D-282: coordination, not an
|
|
112
|
+
* OS-level reservation).
|
|
113
|
+
*/
|
|
114
|
+
export function assignLanePort(options) {
|
|
115
|
+
const portBase = options.portBase ?? DEFAULT_LANE_PORT_BASE;
|
|
116
|
+
const portStep = options.portStep ?? DEFAULT_LANE_PORT_STEP;
|
|
117
|
+
const used = new Set();
|
|
118
|
+
for (const lane of options.existingLanes ?? []) {
|
|
119
|
+
const port = lane?.runtime?.port;
|
|
120
|
+
if (Number.isInteger(port)) {
|
|
121
|
+
used.add(port);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (let candidate = portBase; candidate <= LANE_PORT_MAX; candidate += portStep) {
|
|
126
|
+
if (!used.has(candidate)) {
|
|
127
|
+
return candidate;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
throw new Error(
|
|
132
|
+
`No free lane port at or above ${portBase} (step ${portStep}, max ${LANE_PORT_MAX}); ` +
|
|
133
|
+
'close unused lanes or configure project.yaml runtime.port_base / runtime.port_step (D-282).',
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Root key: first 12 hex chars of the SHA-256 of the memory root's canonical
|
|
139
|
+
* realpath. Disambiguates lane temp dirs across roots that share lane ids
|
|
140
|
+
* without leaking the full root path into the shared OS temp namespace.
|
|
141
|
+
*/
|
|
142
|
+
export async function computeLaneTmpRootKey(rootDir) {
|
|
143
|
+
const canonical = await realpath(rootDir).catch(() => path.resolve(rootDir));
|
|
144
|
+
return createHash('sha256').update(canonical).digest('hex').slice(0, 12);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function buildLaneTmpDir(options) {
|
|
148
|
+
return path.join(options.tmpBase, options.rootKey, options.laneId);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* D-282 guarded recursive removal at close, mirroring D-279/D-281
|
|
153
|
+
* conservatism: the recorded path must be absolute, its parent segment must be
|
|
154
|
+
* this root's `rootKey` (the package-owned lane temp namespace for this root),
|
|
155
|
+
* its final segment must be the closing lane's id, and the process cwd must
|
|
156
|
+
* not sit inside it. Any guard failure keeps the directory with guidance; a
|
|
157
|
+
* missing directory is benign crash residue.
|
|
158
|
+
*
|
|
159
|
+
* The namespace is verified from the recorded path's own `<root-key>/<lane-id>`
|
|
160
|
+
* tail rather than a close-time recomputation of `<tmp_base>/<root-key>`: the
|
|
161
|
+
* default `tmp_base` is `os.tmpdir()`, which reads `TMPDIR` at call time, and
|
|
162
|
+
* D-282's own documented consumption path (`eval "$(vibecompass lane-env)"`)
|
|
163
|
+
* exports `TMPDIR=<laneTmpDir>`. Recomputing the namespace from live env would
|
|
164
|
+
* falsely reject the lane's own recorded dir whenever close runs in a lane-env
|
|
165
|
+
* shell (or after `runtime.tmp_base` changed), stranding the temp dir.
|
|
166
|
+
* `rootKey` is the SHA of the memory root's realpath and is env-independent,
|
|
167
|
+
* so the recorded tail is a stable package signature.
|
|
168
|
+
*/
|
|
169
|
+
export async function removeLaneTmpDirAtClose(options) {
|
|
170
|
+
const { recordedTmpDir, laneId, rootKey, cwd } = options;
|
|
171
|
+
const base = { tmpDir: recordedTmpDir, removed: false };
|
|
172
|
+
|
|
173
|
+
if (!path.isAbsolute(recordedTmpDir)) {
|
|
174
|
+
return {
|
|
175
|
+
...base,
|
|
176
|
+
reason: 'not-absolute',
|
|
177
|
+
warnings: [
|
|
178
|
+
`Lane temp dir ${recordedTmpDir} was left in place: the recorded path is not absolute (D-282 guarded removal). Inspect and delete it manually.`,
|
|
179
|
+
],
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!(await pathExists(recordedTmpDir))) {
|
|
184
|
+
// Benign crash residue, matching recorded-but-missing worktrees.
|
|
185
|
+
return { ...base, reason: 'missing', warnings: [] };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const realTmp = await canonicalizePathBestEffort(recordedTmpDir);
|
|
189
|
+
if (path.basename(path.dirname(realTmp)) !== rootKey) {
|
|
190
|
+
return {
|
|
191
|
+
...base,
|
|
192
|
+
reason: 'outside-namespace',
|
|
193
|
+
warnings: [
|
|
194
|
+
`Lane temp dir ${recordedTmpDir} was left in place: it is not inside this root's lane temp namespace (its parent segment is not the root key ${rootKey}) (D-282 guarded removal). Inspect and delete it manually.`,
|
|
195
|
+
],
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (path.basename(realTmp) !== laneId) {
|
|
200
|
+
return {
|
|
201
|
+
...base,
|
|
202
|
+
reason: 'lane-id-mismatch',
|
|
203
|
+
warnings: [
|
|
204
|
+
`Lane temp dir ${recordedTmpDir} was left in place: it does not end in the closing lane's id "${laneId}" (D-282 guarded removal). Inspect and delete it manually.`,
|
|
205
|
+
],
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const realCwd = await canonicalizePathBestEffort(cwd ?? process.cwd());
|
|
210
|
+
const cwdRelative = path.relative(realTmp, realCwd);
|
|
211
|
+
if (cwdRelative === '' || (!cwdRelative.startsWith('..') && !path.isAbsolute(cwdRelative))) {
|
|
212
|
+
return {
|
|
213
|
+
...base,
|
|
214
|
+
reason: 'cwd-inside',
|
|
215
|
+
warnings: [
|
|
216
|
+
`Lane temp dir ${recordedTmpDir} was left in place: the current working directory is inside it (D-282). cd out of the temp dir, then delete it manually.`,
|
|
217
|
+
],
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
await rm(recordedTmpDir, { recursive: true, force: true });
|
|
223
|
+
return { ...base, removed: true, reason: null, warnings: [] };
|
|
224
|
+
} catch (error) {
|
|
225
|
+
return {
|
|
226
|
+
...base,
|
|
227
|
+
reason: 'remove-failed',
|
|
228
|
+
warnings: [
|
|
229
|
+
`Lane temp dir ${recordedTmpDir} could not be removed: ${error instanceof Error ? error.message : String(error)}. Inspect and delete it manually.`,
|
|
230
|
+
],
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function pathExists(value) {
|
|
236
|
+
try {
|
|
237
|
+
await lstat(value);
|
|
238
|
+
return true;
|
|
239
|
+
} catch {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function canonicalizePathBestEffort(value) {
|
|
245
|
+
try {
|
|
246
|
+
return await realpath(value);
|
|
247
|
+
} catch {
|
|
248
|
+
return path.resolve(value);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function isPlainObject(value) {
|
|
253
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
254
|
+
}
|
package/src/manifest.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
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
|
+
import { withMemoryRootLock } from './serialization.js';
|
|
4
5
|
import { scanProjectMemory } from './project-memory.js';
|
|
5
6
|
import { listProjectSessions } from './session.js';
|
|
6
7
|
import { PACKAGE_VERSION } from './version.js';
|
|
@@ -83,6 +84,10 @@ export async function prepareStateManifest(rootDir, options = {}) {
|
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
export async function writeStateManifest(rootDir, options = {}) {
|
|
87
|
+
return withMemoryRootLock(rootDir, 'state-manifest-refresh', () => writeStateManifestLocked(rootDir, options));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function writeStateManifestLocked(rootDir, options = {}) {
|
|
86
91
|
const prepared = await prepareStateManifest(rootDir, options);
|
|
87
92
|
const stateDir = path.dirname(prepared.manifestPath);
|
|
88
93
|
|
|
@@ -121,6 +126,20 @@ async function readActiveSessionsForManifest(rootDir) {
|
|
|
121
126
|
repos: lane.repos ?? [],
|
|
122
127
|
claimed_paths: lane.claims ?? [],
|
|
123
128
|
started_at: lane.startedAt ?? null,
|
|
129
|
+
// D-281/D-278: git-binding presence projection — omitted for unbound
|
|
130
|
+
// lanes so pre-S3 manifest shapes are unchanged.
|
|
131
|
+
...(lane.branch ? { branch: lane.branch } : {}),
|
|
132
|
+
...(lane.worktreeContainer ? { worktree_container: lane.worktreeContainer } : {}),
|
|
133
|
+
// D-282: runtime assignment presence projection — omitted for pre-S4
|
|
134
|
+
// lanes so their manifest shapes are unchanged.
|
|
135
|
+
...(lane.runtime && (Number.isInteger(lane.runtime.port) || lane.runtime.tmpDir)
|
|
136
|
+
? {
|
|
137
|
+
runtime: {
|
|
138
|
+
...(Number.isInteger(lane.runtime.port) ? { port: lane.runtime.port } : {}),
|
|
139
|
+
...(lane.runtime.tmpDir ? { tmp_dir: lane.runtime.tmpDir } : {}),
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
: {}),
|
|
124
143
|
...(lane.decisionSnapshot
|
|
125
144
|
? {
|
|
126
145
|
decision_snapshot: {
|