@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/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: {