@vibecompass/vibecompass 0.10.8 → 0.11.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 +30 -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 +205 -0
- package/src/session.js +942 -74
- 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/session.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { access, mkdir, readFile, readdir, realpath, rm, rmdir, stat, writeFile } from 'node:fs/promises';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { scanProjectMemory } from './project-memory.js';
|
|
4
5
|
import { parseSimpleYaml } from './simple-yaml.js';
|
|
@@ -6,6 +7,34 @@ import { buildCloseSessionGuidance, resolveWorkflowSettings } from './workflow.j
|
|
|
6
7
|
import { syncAgentInstructionFiles } from './generators/agent-files/index.js';
|
|
7
8
|
import { START_MARKER } from './generators/agent-files/markers.js';
|
|
8
9
|
import { planDocsUpdate } from './docs-update.js';
|
|
10
|
+
import {
|
|
11
|
+
captureBaseRevisions,
|
|
12
|
+
normalizeBranchName,
|
|
13
|
+
preflightGitBinding,
|
|
14
|
+
provisionGitBinding,
|
|
15
|
+
removeLaneWorktreesAtClose,
|
|
16
|
+
rollbackGitBinding,
|
|
17
|
+
} from './git-binding.js';
|
|
18
|
+
import { withMemoryRootLock } from './serialization.js';
|
|
19
|
+
import { findDuplicateDecisionIds, formatDecisionId } from './decisions.js';
|
|
20
|
+
import {
|
|
21
|
+
assignLanePort,
|
|
22
|
+
buildLaneTmpDir,
|
|
23
|
+
computeLaneTmpRootKey,
|
|
24
|
+
removeLaneTmpDirAtClose,
|
|
25
|
+
resolveRuntimeSettings,
|
|
26
|
+
} from './lane-runtime.js';
|
|
27
|
+
import {
|
|
28
|
+
LANE_MARKER_FILENAME,
|
|
29
|
+
assertMarkerTargetDisjoint,
|
|
30
|
+
findEnclosingGitDir,
|
|
31
|
+
readLaneMarker,
|
|
32
|
+
renderLaneMarker,
|
|
33
|
+
resolveLaneMarkerContext,
|
|
34
|
+
resolveLaneSelection,
|
|
35
|
+
validateLaneId,
|
|
36
|
+
} from './lane-marker.js';
|
|
37
|
+
import { PACKAGE_VERSION } from './version.js';
|
|
9
38
|
// This lazy-only cycle is intentional: manifest.js reads lane state from this
|
|
10
39
|
// module, and session command handlers rewrite the derived manifest after
|
|
11
40
|
// lane mutations. Keep both sides free of top-level calls into the other.
|
|
@@ -14,7 +43,6 @@ import { writeStateManifest } from './manifest.js';
|
|
|
14
43
|
const CURRENT_SESSION_REQUIRED_FIELDS = ['Date:', 'Working on:', 'Last thing completed:', 'Blockers:', 'Next session should:'];
|
|
15
44
|
const WIP_HEADER_PATTERN = /^# WIP — (\d{4}-\d{2}-\d{2}) \(session (\d+)\)$/m;
|
|
16
45
|
const SESSION_FILENAME_PATTERN = /^(\d{4}-\d{2}-\d{2})-(\d+)-([a-z0-9-]+)\.md$/i;
|
|
17
|
-
const LANE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/;
|
|
18
46
|
const DOCUMENT_MAINTENANCE_STATUSES = new Set(['updated', 'not-needed', 'deferred']);
|
|
19
47
|
const DOCUMENT_MAINTENANCE_FIELDS = [
|
|
20
48
|
{
|
|
@@ -33,27 +61,86 @@ const DOCUMENT_MAINTENANCE_FIELDS = [
|
|
|
33
61
|
flag: '--session-maintenance',
|
|
34
62
|
},
|
|
35
63
|
];
|
|
36
|
-
const RESERVED_LANE_IDS = new Set([
|
|
37
|
-
'active',
|
|
38
|
-
'current',
|
|
39
|
-
'default',
|
|
40
|
-
'false',
|
|
41
|
-
'handoff',
|
|
42
|
-
'index',
|
|
43
|
-
'new',
|
|
44
|
-
'no',
|
|
45
|
-
'null',
|
|
46
|
-
'off',
|
|
47
|
-
'on',
|
|
48
|
-
'sessions',
|
|
49
|
-
'state',
|
|
50
|
-
'true',
|
|
51
|
-
'wip',
|
|
52
|
-
'yes',
|
|
53
|
-
]);
|
|
54
|
-
|
|
55
64
|
export async function startProjectSession(options) {
|
|
56
|
-
const normalized =
|
|
65
|
+
const { normalized, markerContext } = await resolveSessionCommandContext(options);
|
|
66
|
+
return withMemoryRootLock(normalized.rootDir, 'start-session', () =>
|
|
67
|
+
startProjectSessionLocked(normalized, options, markerContext));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Pre-lock command context (D-280): an explicit --root always wins; otherwise
|
|
72
|
+
* lane-scoped commands adopt the nearest valid worktree lane marker's
|
|
73
|
+
* memory_root, and only when no marker resolves does the `cwd/.compass`
|
|
74
|
+
* default apply. Commands that never resolve a lane from the marker skip the
|
|
75
|
+
* marker walk entirely when --root is explicit, so internal callers that
|
|
76
|
+
* always pass rootDir (manifest/status refresh) keep their existing behavior.
|
|
77
|
+
*/
|
|
78
|
+
async function resolveSessionCommandContext(options, { needMarkerForLane = false } = {}) {
|
|
79
|
+
let markerContext;
|
|
80
|
+
if (options?.rootDir && !needMarkerForLane) {
|
|
81
|
+
const cwd = options?.cwd ? path.resolve(options.cwd) : process.cwd();
|
|
82
|
+
markerContext = { cwd, rootDir: path.resolve(cwd, options.rootDir), rootSource: 'flag', marker: null, warnings: [] };
|
|
83
|
+
} else {
|
|
84
|
+
markerContext = await resolveLaneMarkerContext({
|
|
85
|
+
cwd: options?.cwd,
|
|
86
|
+
explicitRootDir: options?.rootDir ?? null,
|
|
87
|
+
explicitSessionId: normalizeOptionalString(options?.sessionId),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const inferredToolingRootDir = await inferToolingRootDirForContext(options, markerContext);
|
|
92
|
+
const normalized = normalizeSessionPaths({
|
|
93
|
+
...options,
|
|
94
|
+
cwd: markerContext.cwd,
|
|
95
|
+
rootDir: markerContext.rootDir,
|
|
96
|
+
...(inferredToolingRootDir ? { toolingRootDir: inferredToolingRootDir } : {}),
|
|
97
|
+
});
|
|
98
|
+
return { normalized, markerContext };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The tooling root (CLAUDE.md home) defaults to cwd, but follows the memory
|
|
103
|
+
* root's placement in two cases: when the marker supplied the root (a
|
|
104
|
+
* worktree cwd must update the real Current-session block), and when an
|
|
105
|
+
* explicit --root is used from a cwd with no CLAUDE.md (e.g. the corrupt-
|
|
106
|
+
* marker bypass run from a worktree) — a case where the cwd default could
|
|
107
|
+
* only fail at readClaudeFile, so inferring is strictly recovery, never a
|
|
108
|
+
* behavior change for working flows.
|
|
109
|
+
*/
|
|
110
|
+
async function inferToolingRootDirForContext(options, markerContext) {
|
|
111
|
+
if (options?.toolingRootDir) {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (markerContext.rootSource === 'marker') {
|
|
116
|
+
return inferToolingRootForMemoryRoot(markerContext.rootDir);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (markerContext.rootSource === 'flag' && !(await fileExists(path.join(markerContext.cwd, 'CLAUDE.md')))) {
|
|
120
|
+
return inferToolingRootForMemoryRoot(markerContext.rootDir);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The `<owner>/.compass` placement keeps CLAUDE.md one level above the memory
|
|
128
|
+
* root; the dedicated-memory-repo placement keeps it inside the root itself.
|
|
129
|
+
* Prefer whichever directory actually holds a CLAUDE.md, falling back to the
|
|
130
|
+
* parent (the actionable readClaudeFile error and --tooling-root remain the
|
|
131
|
+
* escape hatch for layouts with no CLAUDE.md at either).
|
|
132
|
+
* Exported for read-only surfaces (status) that adopt a marker-supplied root
|
|
133
|
+
* and must follow the same placement rule for the tooling root.
|
|
134
|
+
*/
|
|
135
|
+
export async function inferToolingRootForMemoryRoot(rootDir) {
|
|
136
|
+
if (await fileExists(path.join(rootDir, 'CLAUDE.md'))) {
|
|
137
|
+
return rootDir;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return path.dirname(rootDir);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function startProjectSessionLocked(normalized, options, markerContext) {
|
|
57
144
|
const workingOn = requireNonEmptyString(options?.workingOn, 'start-session requires a non-empty workingOn value.');
|
|
58
145
|
const sessionDate = normalizeSessionDate(options?.date);
|
|
59
146
|
|
|
@@ -73,6 +160,71 @@ export async function startProjectSession(options) {
|
|
|
73
160
|
existingLanes,
|
|
74
161
|
});
|
|
75
162
|
const highestDecisionId = await readHighestDecisionId(normalized.rootDir);
|
|
163
|
+
const projectConfig = await readProjectConfig(normalized.projectFilePath);
|
|
164
|
+
const baseRevisionCapture = await captureBaseRevisions({
|
|
165
|
+
workspaceDir: path.dirname(normalized.rootDir),
|
|
166
|
+
repoIds: repos,
|
|
167
|
+
projectRepos: Array.isArray(projectConfig?.repos) ? projectConfig.repos : [],
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// D-282 per-lane runtime assignment, computed in preflight inside the
|
|
171
|
+
// memory-root lock so sibling lanes' recorded ports are stable while the
|
|
172
|
+
// lowest free slot is picked. Assignment is lane coordination on this root,
|
|
173
|
+
// not an OS-level reservation — no port is probed or bound here.
|
|
174
|
+
const runtimeSettings = resolveRuntimeSettings(projectConfig);
|
|
175
|
+
const laneRuntime = {
|
|
176
|
+
port: assignLanePort({
|
|
177
|
+
existingLanes,
|
|
178
|
+
portBase: runtimeSettings.portBase,
|
|
179
|
+
portStep: runtimeSettings.portStep,
|
|
180
|
+
}),
|
|
181
|
+
tmpDir: buildLaneTmpDir({
|
|
182
|
+
tmpBase: runtimeSettings.tmpBase,
|
|
183
|
+
rootKey: await computeLaneTmpRootKey(normalized.rootDir),
|
|
184
|
+
laneId: sessionId,
|
|
185
|
+
}),
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// D-281 opt-in git binding: normalize + preflight before the first write so
|
|
189
|
+
// a refused binding leaves the root untouched.
|
|
190
|
+
if (options?.worktree && !options?.branch) {
|
|
191
|
+
throw new Error('--worktree requires --branch; a worktree checks out the lane branch.');
|
|
192
|
+
}
|
|
193
|
+
let gitBinding = null;
|
|
194
|
+
if (options?.branch) {
|
|
195
|
+
const branch = await normalizeBranchName(options.branch);
|
|
196
|
+
const workspaceDir = path.dirname(normalized.rootDir);
|
|
197
|
+
const containerDir = path.join(workspaceDir, 'worktrees', sessionId);
|
|
198
|
+
const useWorktree = options.worktree === true;
|
|
199
|
+
const preflight = await preflightGitBinding({
|
|
200
|
+
workspaceDir,
|
|
201
|
+
rootDir: normalized.rootDir,
|
|
202
|
+
branch,
|
|
203
|
+
worktree: useWorktree,
|
|
204
|
+
repoIds: repos,
|
|
205
|
+
projectRepos: Array.isArray(projectConfig?.repos) ? projectConfig.repos : [],
|
|
206
|
+
existingLanes,
|
|
207
|
+
containerDir,
|
|
208
|
+
});
|
|
209
|
+
gitBinding = {
|
|
210
|
+
branch,
|
|
211
|
+
containerDir: useWorktree ? containerDir : null,
|
|
212
|
+
repoPlans: preflight.repoPlans,
|
|
213
|
+
warnings: preflight.warnings,
|
|
214
|
+
marker: useWorktree
|
|
215
|
+
? {
|
|
216
|
+
path: path.join(containerDir, LANE_MARKER_FILENAME),
|
|
217
|
+
token: randomUUID(),
|
|
218
|
+
createdAt: formatLocalDateTime(new Date()),
|
|
219
|
+
}
|
|
220
|
+
: null,
|
|
221
|
+
};
|
|
222
|
+
// Bound repos base on what the binding checks out (create: start point;
|
|
223
|
+
// reuse: existing branch tip), overriding the plain HEAD capture.
|
|
224
|
+
for (const plan of preflight.repoPlans) {
|
|
225
|
+
baseRevisionCapture.baseRevisions[plan.repoId] = plan.baseRevision;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
76
228
|
|
|
77
229
|
const nextSessionNumber = await getNextSessionNumber(normalized.sessionsDir, sessionDate);
|
|
78
230
|
const currentSession = parseCurrentSessionBlock(claude.content, { optional: true });
|
|
@@ -87,6 +239,21 @@ export async function startProjectSession(options) {
|
|
|
87
239
|
nextSessionShould:
|
|
88
240
|
normalizeOptionalString(options?.nextSessionShould) ??
|
|
89
241
|
'Finish the active session and close it with a finalized session note.',
|
|
242
|
+
}, {
|
|
243
|
+
lanes: [
|
|
244
|
+
...existingLanes.map((lane) => ({ id: lane.id, workingOn: lane.workingOn })),
|
|
245
|
+
{ id: sessionId, workingOn },
|
|
246
|
+
],
|
|
247
|
+
selectedId: sessionId,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// D-282: the lane temp dir is created before the first root write, so a
|
|
251
|
+
// creation failure aborts start-session with the root untouched (empty
|
|
252
|
+
// parent namespace dirs left behind by a failure are benign).
|
|
253
|
+
await mkdir(laneRuntime.tmpDir, { recursive: true }).catch((error) => {
|
|
254
|
+
throw new Error(
|
|
255
|
+
`Could not create the lane temp directory ${laneRuntime.tmpDir}: ${error instanceof Error ? error.message : String(error)} (D-282). Configure project.yaml runtime.tmp_base if the default namespace is not writable.`,
|
|
256
|
+
);
|
|
90
257
|
});
|
|
91
258
|
|
|
92
259
|
await mkdir(normalized.activeSessionsDir, { recursive: true });
|
|
@@ -108,6 +275,25 @@ export async function startProjectSession(options) {
|
|
|
108
275
|
features,
|
|
109
276
|
repos,
|
|
110
277
|
claims,
|
|
278
|
+
baseRevisions: baseRevisionCapture.baseRevisions,
|
|
279
|
+
runtime: laneRuntime,
|
|
280
|
+
// Record-before-create (D-281): the full binding plan — including the
|
|
281
|
+
// marker path/token — is in session.yaml before any git artifact or
|
|
282
|
+
// marker file exists, so a crash leaves only benign recorded-but-missing
|
|
283
|
+
// entries.
|
|
284
|
+
...(gitBinding
|
|
285
|
+
? {
|
|
286
|
+
branch: gitBinding.branch,
|
|
287
|
+
worktreeContainer: gitBinding.containerDir,
|
|
288
|
+
worktrees: gitBinding.containerDir
|
|
289
|
+
? Object.fromEntries(gitBinding.repoPlans.map((plan) => [plan.repoId, plan.worktreePath]))
|
|
290
|
+
: null,
|
|
291
|
+
worktreeSources: gitBinding.containerDir
|
|
292
|
+
? Object.fromEntries(gitBinding.repoPlans.map((plan) => [plan.repoId, plan.sourceDir]))
|
|
293
|
+
: null,
|
|
294
|
+
laneMarker: gitBinding.marker,
|
|
295
|
+
}
|
|
296
|
+
: {}),
|
|
111
297
|
highestDecisionId,
|
|
112
298
|
}),
|
|
113
299
|
'utf8',
|
|
@@ -132,11 +318,57 @@ export async function startProjectSession(options) {
|
|
|
132
318
|
}),
|
|
133
319
|
'utf8',
|
|
134
320
|
);
|
|
321
|
+
const priorIndexContent = await readFile(normalized.activeSessionsIndexPath, 'utf8').catch(() => null);
|
|
135
322
|
await upsertActiveSessionIndex(normalized, {
|
|
136
323
|
id: sessionId,
|
|
137
324
|
status: 'active',
|
|
138
325
|
workingOn,
|
|
139
326
|
}, { current: sessionId });
|
|
327
|
+
|
|
328
|
+
if (gitBinding) {
|
|
329
|
+
const progress = { worktrees: [], createdBranches: [] };
|
|
330
|
+
try {
|
|
331
|
+
if (gitBinding.containerDir) {
|
|
332
|
+
await mkdir(gitBinding.containerDir, { recursive: true });
|
|
333
|
+
await writeFile(
|
|
334
|
+
gitBinding.marker.path,
|
|
335
|
+
renderLaneMarker({
|
|
336
|
+
laneId: sessionId,
|
|
337
|
+
memoryRoot: normalized.rootDir,
|
|
338
|
+
token: gitBinding.marker.token,
|
|
339
|
+
createdAt: gitBinding.marker.createdAt,
|
|
340
|
+
createdBy: PACKAGE_VERSION,
|
|
341
|
+
}),
|
|
342
|
+
'utf8',
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
await provisionGitBinding({ branch: gitBinding.branch, repoPlans: gitBinding.repoPlans }, progress);
|
|
346
|
+
} catch (error) {
|
|
347
|
+
// D-281: start-session is atomic — unwind git artifacts created this
|
|
348
|
+
// call, then the lane files, CLAUDE.md, and index this call wrote.
|
|
349
|
+
const rollbackNotes = await rollbackGitBinding(progress, gitBinding.branch);
|
|
350
|
+
if (gitBinding.containerDir) {
|
|
351
|
+
await rm(gitBinding.containerDir, { recursive: true, force: true }).catch(() => {});
|
|
352
|
+
}
|
|
353
|
+
await rm(lanePaths.laneDir, { recursive: true, force: true }).catch(() => {});
|
|
354
|
+
// D-282: the lane temp dir was created this call; unwind it with the rest.
|
|
355
|
+
await rm(laneRuntime.tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
356
|
+
await writeFile(normalized.claudePath, claude.content, 'utf8').catch(() => {});
|
|
357
|
+
if (priorIndexContent === null) {
|
|
358
|
+
await rm(normalized.activeSessionsIndexPath, { force: true }).catch(() => {});
|
|
359
|
+
} else {
|
|
360
|
+
await writeFile(normalized.activeSessionsIndexPath, priorIndexContent, 'utf8').catch(() => {});
|
|
361
|
+
}
|
|
362
|
+
await refreshStateManifestSafely(normalized.rootDir);
|
|
363
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
364
|
+
throw new Error(
|
|
365
|
+
`${message}\nGit binding failed; start-session was rolled back and lane "${sessionId}" was not created.` +
|
|
366
|
+
(rollbackNotes.length > 0 ? `\nRollback notes: ${rollbackNotes.join('; ')}` : '') +
|
|
367
|
+
'\nIf a crash interrupts this rollback, run `vibecompass rebuild-active-index` and close the lane normally.',
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
140
372
|
const manifestRefresh = await refreshStateManifestSafely(normalized.rootDir);
|
|
141
373
|
const auditWarnings = await buildDocsReviewWarnings(normalized);
|
|
142
374
|
const metadataWarnings = collectLaneMetadataWarnings(existingLanes);
|
|
@@ -155,19 +387,44 @@ export async function startProjectSession(options) {
|
|
|
155
387
|
handoffFilePath: lanePaths.handoffFilePath,
|
|
156
388
|
sessionDate,
|
|
157
389
|
sessionNumber: nextSessionNumber,
|
|
390
|
+
runtime: laneRuntime,
|
|
158
391
|
warnings: [
|
|
392
|
+
...markerContext.warnings,
|
|
159
393
|
...metadataWarnings,
|
|
160
394
|
...overlapWarnings,
|
|
395
|
+
...baseRevisionCapture.warnings,
|
|
396
|
+
...runtimeSettings.warnings,
|
|
397
|
+
...(gitBinding?.warnings ?? []),
|
|
161
398
|
...manifestRefresh.warnings,
|
|
162
399
|
...auditWarnings,
|
|
163
400
|
],
|
|
401
|
+
...(gitBinding
|
|
402
|
+
? {
|
|
403
|
+
gitBinding: {
|
|
404
|
+
branch: gitBinding.branch,
|
|
405
|
+
worktreeContainer: gitBinding.containerDir,
|
|
406
|
+
markerPath: gitBinding.marker?.path ?? null,
|
|
407
|
+
repos: gitBinding.repoPlans.map((plan) => ({
|
|
408
|
+
repoId: plan.repoId,
|
|
409
|
+
mode: plan.mode,
|
|
410
|
+
baseRevision: plan.baseRevision,
|
|
411
|
+
worktreePath: plan.worktreePath,
|
|
412
|
+
})),
|
|
413
|
+
},
|
|
414
|
+
}
|
|
415
|
+
: {}),
|
|
164
416
|
manifest: manifestRefresh.manifest,
|
|
165
417
|
agentFileSync,
|
|
166
418
|
};
|
|
167
419
|
}
|
|
168
420
|
|
|
169
421
|
export async function closeProjectSession(options) {
|
|
170
|
-
const normalized =
|
|
422
|
+
const { normalized, markerContext } = await resolveSessionCommandContext(options, { needMarkerForLane: true });
|
|
423
|
+
return withMemoryRootLock(normalized.rootDir, 'close-session', () =>
|
|
424
|
+
closeProjectSessionLocked(normalized, options, markerContext));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function closeProjectSessionLocked(normalized, options, markerContext) {
|
|
171
428
|
const title = requireNonEmptyString(options?.title, 'session close requires a non-empty title.');
|
|
172
429
|
const completed = normalizeStringArray(options?.completed);
|
|
173
430
|
const models = normalizeStringArray(options?.models);
|
|
@@ -184,16 +441,42 @@ export async function closeProjectSession(options) {
|
|
|
184
441
|
}
|
|
185
442
|
|
|
186
443
|
await ensureInitializedProjectMemory(normalized.rootDir, { allowMissingProjectFile: true });
|
|
444
|
+
|
|
445
|
+
const duplicateDecisionIds = await findDuplicateDecisionIds(normalized.rootDir);
|
|
446
|
+
if (duplicateDecisionIds.length > 0) {
|
|
447
|
+
throw new Error(
|
|
448
|
+
`close-session is blocked: duplicate decision IDs detected — ${duplicateDecisionIds
|
|
449
|
+
.map((duplicate) => `${formatDecisionId(duplicate.id)} (${duplicate.occurrences.join(', ')})`)
|
|
450
|
+
.join('; ')}. Repair the canonical decision files by hand before closing (D-276); duplicates are never auto-renumbered.`,
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
187
454
|
const claude = await readClaudeFile(normalized.claudePath);
|
|
188
455
|
const projectConfig = await readProjectConfig(normalized.projectFilePath);
|
|
189
456
|
const workflowSettings = resolveWorkflowSettings(projectConfig);
|
|
190
|
-
const
|
|
457
|
+
const laneSelection = await resolveExistingSessionId(normalized, options, markerContext, 'close');
|
|
458
|
+
const sessionId = laneSelection.sessionId;
|
|
191
459
|
const lanePaths = sessionId ? getLanePaths(normalized, sessionId) : normalized;
|
|
460
|
+
const closingLaneMetadata = sessionId ? await readLaneMetadata(lanePaths.sessionFilePath) : null;
|
|
461
|
+
// Fail closed on a null-degraded lane parse, mirroring the write-lane-marker
|
|
462
|
+
// rebind guard: a degraded parse reads as "no worktrees recorded", so
|
|
463
|
+
// closing would destroy session.yaml — the only record of the worktree
|
|
464
|
+
// paths, sources, container, and marker token — while the worktrees (and
|
|
465
|
+
// any uncommitted work in them) survive unmanaged behind a permanently
|
|
466
|
+
// stale marker (D-281).
|
|
467
|
+
if (closingLaneMetadata && closingLaneMetadata.warnings.length > 0) {
|
|
468
|
+
throw new Error(
|
|
469
|
+
`Lane "${sessionId}"'s session.yaml could not be parsed (${closingLaneMetadata.warnings[0]}); refusing to close — a null-degraded parse would hide recorded worktrees and closing would destroy the removal-guard records while the worktrees survive (D-281). Repair the lane metadata first, then rerun close-session.`,
|
|
470
|
+
);
|
|
471
|
+
}
|
|
192
472
|
const documentMaintenance = normalizeDocumentMaintenanceCheckpoint(options?.documentMaintenance);
|
|
193
473
|
const docsUpdate = await planDocsUpdateSafely({
|
|
194
474
|
rootDir: normalized.rootDir,
|
|
195
475
|
cwd: normalized.cwd,
|
|
196
476
|
sessionId,
|
|
477
|
+
// close-session already resolved (and warned about) the lane selection;
|
|
478
|
+
// the embedded plan must not repeat the flag-vs-marker warning.
|
|
479
|
+
suppressLaneWarnings: true,
|
|
197
480
|
// Test injection seam; production callers should let close-session use the default planner.
|
|
198
481
|
planner: options?.docsUpdatePlanner,
|
|
199
482
|
});
|
|
@@ -227,7 +510,9 @@ export async function closeProjectSession(options) {
|
|
|
227
510
|
const sessionFilePath = path.join(normalized.rootDir, sessionRelativePath);
|
|
228
511
|
|
|
229
512
|
if (await fileExists(sessionFilePath)) {
|
|
230
|
-
throw new Error(
|
|
513
|
+
throw new Error(
|
|
514
|
+
`Session note already exists at ${sessionFilePath}. If a previous close-session was interrupted after writing the note (for example during worktree removal), move or delete that file and rerun close-session — recorded-but-missing worktrees from the interrupted run are benign.`,
|
|
515
|
+
);
|
|
231
516
|
}
|
|
232
517
|
|
|
233
518
|
await writeFile(
|
|
@@ -248,8 +533,86 @@ export async function closeProjectSession(options) {
|
|
|
248
533
|
);
|
|
249
534
|
|
|
250
535
|
const hadHandoff = await fileExists(lanePaths.handoffFilePath);
|
|
536
|
+
const markerCleanupWarnings = [];
|
|
251
537
|
let activeSessionIndex = null;
|
|
538
|
+
let worktreeCleanup = null;
|
|
539
|
+
let runtimeCleanup = null;
|
|
252
540
|
if (sessionId) {
|
|
541
|
+
// D-281 close-side guarded removal (S3-3): runs after the session note is
|
|
542
|
+
// written (a failed note write leaves everything intact) and before the
|
|
543
|
+
// lane records are destroyed. A crash between removal and the lane-dir
|
|
544
|
+
// delete leaves only benign recorded-but-missing entries.
|
|
545
|
+
const removal = closingLaneMetadata
|
|
546
|
+
? await removeLaneWorktreesAtClose({
|
|
547
|
+
laneId: sessionId,
|
|
548
|
+
worktrees: closingLaneMetadata.worktrees,
|
|
549
|
+
worktreeSources: closingLaneMetadata.worktreeSources,
|
|
550
|
+
worktreeContainer: closingLaneMetadata.worktreeContainer,
|
|
551
|
+
recordedMarker: closingLaneMetadata.laneMarker,
|
|
552
|
+
cwd: normalized.cwd,
|
|
553
|
+
})
|
|
554
|
+
: { attempted: false, removed: [], surviving: [], warnings: [] };
|
|
555
|
+
markerCleanupWarnings.push(...removal.warnings);
|
|
556
|
+
|
|
557
|
+
let markerRemoved = false;
|
|
558
|
+
let markerKept = false;
|
|
559
|
+
let containerRemoved = false;
|
|
560
|
+
if (removal.surviving.length > 0) {
|
|
561
|
+
// D-281: keep the marker while any recorded worktree survives, so the
|
|
562
|
+
// leftover worktrees keep an actionable fail-closed breadcrumb instead
|
|
563
|
+
// of a silent wrong-root fallback. Only claim the marker "was kept"
|
|
564
|
+
// when it actually exists — removal may have been refused precisely
|
|
565
|
+
// because the marker is missing.
|
|
566
|
+
if (closingLaneMetadata?.laneMarker?.path && (await fileExists(closingLaneMetadata.laneMarker.path))) {
|
|
567
|
+
markerKept = true;
|
|
568
|
+
markerCleanupWarnings.push(
|
|
569
|
+
`Lane "${sessionId}" closed with ${removal.surviving.length} surviving worktree(s); the container marker at ${closingLaneMetadata.laneMarker.path} was kept as a breadcrumb (D-281). After removing the worktrees, delete the marker and the container directory manually.`,
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
} else {
|
|
573
|
+
if (closingLaneMetadata?.laneMarker?.path) {
|
|
574
|
+
const cleanup = await removeRecordedLaneMarker(closingLaneMetadata.laneMarker);
|
|
575
|
+
markerRemoved = cleanup.removed;
|
|
576
|
+
markerCleanupWarnings.push(...cleanup.warnings);
|
|
577
|
+
}
|
|
578
|
+
if (removal.attempted && closingLaneMetadata?.worktreeContainer) {
|
|
579
|
+
// rmdir-if-empty: never recursive — a stray user file in the
|
|
580
|
+
// container keeps it in place with a note rather than being deleted.
|
|
581
|
+
const containerCleanup = await removeEmptyWorktreeContainer(closingLaneMetadata.worktreeContainer, normalized.cwd);
|
|
582
|
+
containerRemoved = containerCleanup.removed;
|
|
583
|
+
markerCleanupWarnings.push(...containerCleanup.warnings);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (removal.attempted) {
|
|
587
|
+
worktreeCleanup = {
|
|
588
|
+
branch: closingLaneMetadata?.branch ?? null,
|
|
589
|
+
removed: removal.removed,
|
|
590
|
+
surviving: removal.surviving,
|
|
591
|
+
markerRemoved,
|
|
592
|
+
// Explicit kept status (reviewer pass): "kept" requires the marker
|
|
593
|
+
// file to exist — surviving worktrees with a missing marker must not
|
|
594
|
+
// render a phantom breadcrumb line.
|
|
595
|
+
markerKept,
|
|
596
|
+
containerRemoved,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// D-282 guarded lane temp-dir removal: runs before the lane record —
|
|
601
|
+
// the only pointer to the temp dir — is destroyed. The namespace is
|
|
602
|
+
// verified from the recorded path's own <root-key>/<lane-id> tail against
|
|
603
|
+
// the env-independent rootKey, never a close-time recompute of tmp_base
|
|
604
|
+
// (os.tmpdir() reads TMPDIR, which lane-env pollutes). Lanes without a
|
|
605
|
+
// recorded runtime block (pre-S4) close exactly as before.
|
|
606
|
+
if (closingLaneMetadata?.runtime?.tmpDir) {
|
|
607
|
+
runtimeCleanup = await removeLaneTmpDirAtClose({
|
|
608
|
+
recordedTmpDir: closingLaneMetadata.runtime.tmpDir,
|
|
609
|
+
laneId: sessionId,
|
|
610
|
+
rootKey: await computeLaneTmpRootKey(normalized.rootDir),
|
|
611
|
+
cwd: normalized.cwd,
|
|
612
|
+
});
|
|
613
|
+
markerCleanupWarnings.push(...runtimeCleanup.warnings);
|
|
614
|
+
}
|
|
615
|
+
|
|
253
616
|
await rm(lanePaths.laneDir, { recursive: true, force: true });
|
|
254
617
|
activeSessionIndex = await removeActiveSessionFromIndex(normalized, sessionId);
|
|
255
618
|
} else {
|
|
@@ -260,20 +623,34 @@ export async function closeProjectSession(options) {
|
|
|
260
623
|
}
|
|
261
624
|
|
|
262
625
|
const closedSummary = `Closed session ${activeSession.sessionNumber} and wrote \`${sessionRelativePath}\`.`;
|
|
626
|
+
const survivingLanes = activeSessionIndex?.lanes ?? [];
|
|
627
|
+
const survivorListing = { lanes: survivingLanes.map((lane) => ({ id: lane.id, workingOn: lane.workingOn })), selectedId: activeSessionIndex?.current ?? null };
|
|
263
628
|
const updatedClaude = replaceCurrentSessionBlock(
|
|
264
629
|
claude.content,
|
|
265
630
|
activeSessionIndex?.current
|
|
266
|
-
? buildCurrentSessionFieldsForLane(
|
|
631
|
+
? buildCurrentSessionFieldsForLane(survivingLanes.find((lane) => lane.id === activeSessionIndex.current), {
|
|
267
632
|
lastThingCompleted: normalizeOptionalString(options?.lastThingCompleted) ?? closedSummary,
|
|
268
633
|
})
|
|
269
|
-
:
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
634
|
+
: survivingLanes.length >= 2
|
|
635
|
+
? {
|
|
636
|
+
date: `${activeSession.sessionDate} (session ${activeSession.sessionNumber})`,
|
|
637
|
+
workingOn:
|
|
638
|
+
'Multiple lanes remain active. Select one explicitly with `vibecompass switch-session <lane-id>`, --session, or a worktree lane marker.',
|
|
639
|
+
lastThingCompleted: normalizeOptionalString(options?.lastThingCompleted) ?? closedSummary,
|
|
640
|
+
blockers: blockers.length > 0 ? summarizeList(blockers) : 'No blocker remains.',
|
|
641
|
+
nextSessionShould:
|
|
642
|
+
normalizeOptionalString(options?.nextSessionShould) ??
|
|
643
|
+
'Pick the next lane explicitly (D-277: no implicit current-lane fallback while multiple lanes are active).',
|
|
644
|
+
}
|
|
645
|
+
: {
|
|
646
|
+
date: `${activeSession.sessionDate} (session ${activeSession.sessionNumber})`,
|
|
647
|
+
workingOn: 'Session closed. Ready for the next builder session.',
|
|
648
|
+
lastThingCompleted: normalizeOptionalString(options?.lastThingCompleted) ?? closedSummary,
|
|
649
|
+
blockers: blockers.length > 0 ? summarizeList(blockers) : 'No blocker remains.',
|
|
650
|
+
nextSessionShould:
|
|
651
|
+
normalizeOptionalString(options?.nextSessionShould) ?? summarizeOrderedList(nextSteps),
|
|
652
|
+
},
|
|
653
|
+
survivorListing,
|
|
277
654
|
);
|
|
278
655
|
|
|
279
656
|
await writeFile(normalized.claudePath, updatedClaude, 'utf8');
|
|
@@ -299,7 +676,16 @@ export async function closeProjectSession(options) {
|
|
|
299
676
|
projectConfig,
|
|
300
677
|
rootFlag: buildRootFlagForGuidance(normalized),
|
|
301
678
|
}),
|
|
679
|
+
worktreeCleanup,
|
|
680
|
+
runtimeCleanup,
|
|
302
681
|
warnings: [
|
|
682
|
+
...markerContext.warnings,
|
|
683
|
+
...laneSelection.warnings,
|
|
684
|
+
...markerCleanupWarnings,
|
|
685
|
+
// A:192 (D-281): the pre-close staleness set computed by docs-update is
|
|
686
|
+
// re-emitted at close as warnings, so the closer sees it even when the
|
|
687
|
+
// embedded plan scrolls past.
|
|
688
|
+
...collectStalenessWarnings(docsUpdate.plan),
|
|
303
689
|
...docsUpdate.warnings,
|
|
304
690
|
...manifestRefresh.warnings,
|
|
305
691
|
],
|
|
@@ -312,6 +698,62 @@ export async function closeProjectSession(options) {
|
|
|
312
698
|
};
|
|
313
699
|
}
|
|
314
700
|
|
|
701
|
+
/**
|
|
702
|
+
* D-281 rmdir-if-empty: after the worktrees and the container marker are
|
|
703
|
+
* gone, an empty container is removed non-recursively. Anything left inside
|
|
704
|
+
* (stray user files) keeps the container in place with a note — close-out
|
|
705
|
+
* never deletes content it did not create. The D-281 cwd clause applies here
|
|
706
|
+
* too: POSIX happily rmdirs the invoking process's own working directory,
|
|
707
|
+
* which would strand the caller's shell in a deleted path.
|
|
708
|
+
*/
|
|
709
|
+
async function removeEmptyWorktreeContainer(containerDir, cwd) {
|
|
710
|
+
const realContainer = await canonicalizePathBestEffort(containerDir);
|
|
711
|
+
const realCwd = await canonicalizePathBestEffort(cwd ?? process.cwd());
|
|
712
|
+
const cwdRelative = path.relative(realContainer, realCwd);
|
|
713
|
+
if (cwdRelative === '' || (!cwdRelative.startsWith('..') && !path.isAbsolute(cwdRelative))) {
|
|
714
|
+
return {
|
|
715
|
+
removed: false,
|
|
716
|
+
warnings: [
|
|
717
|
+
`Worktree container ${containerDir} was left in place: the current working directory is inside it (D-281). cd out of the container, then delete it manually.`,
|
|
718
|
+
],
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
try {
|
|
723
|
+
await rmdir(containerDir);
|
|
724
|
+
return { removed: true, warnings: [] };
|
|
725
|
+
} catch (error) {
|
|
726
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
|
727
|
+
return { removed: false, warnings: [] };
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
return {
|
|
731
|
+
removed: false,
|
|
732
|
+
warnings: [
|
|
733
|
+
`Worktree container ${containerDir} was left in place: it is not empty (close-session only removes an empty container; D-281). Inspect and delete it manually.`,
|
|
734
|
+
],
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
async function canonicalizePathBestEffort(value) {
|
|
740
|
+
try {
|
|
741
|
+
return await realpath(value);
|
|
742
|
+
} catch {
|
|
743
|
+
return path.resolve(value);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/** Flattens the docs-update pre-close staleness set into close warnings (A:192 re-emission). */
|
|
748
|
+
function collectStalenessWarnings(plan) {
|
|
749
|
+
const staleness = plan?.staleness;
|
|
750
|
+
if (!staleness) {
|
|
751
|
+
return [];
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
return (staleness.entries ?? []).map((entry) => `Pre-close staleness: ${entry}`);
|
|
755
|
+
}
|
|
756
|
+
|
|
315
757
|
function normalizeDocumentMaintenanceCheckpoint(value) {
|
|
316
758
|
const source = value && typeof value === 'object' && !Array.isArray(value)
|
|
317
759
|
? value
|
|
@@ -352,21 +794,100 @@ function normalizeDocumentMaintenanceCheckpoint(value) {
|
|
|
352
794
|
return checkpoint;
|
|
353
795
|
}
|
|
354
796
|
|
|
797
|
+
/**
|
|
798
|
+
* Read-only lane runtime export (D-282): resolves the lane per D-277/D-280
|
|
799
|
+
* (explicit --session, else nearest worktree marker, else single active lane)
|
|
800
|
+
* and returns the recorded runtime assignment plus a ready-to-export env map.
|
|
801
|
+
* The conventional PORT/TMPDIR aliases let unmodified dev servers and tools
|
|
802
|
+
* pick the assignment up; pass conventionalAliases: false to omit them.
|
|
803
|
+
*/
|
|
804
|
+
export async function readLaneEnvironment(options = {}) {
|
|
805
|
+
const { normalized, markerContext } = await resolveSessionCommandContext(options, { needMarkerForLane: true });
|
|
806
|
+
await ensureInitializedProjectMemory(normalized.rootDir, { allowMissingProjectFile: true });
|
|
807
|
+
const lanes = await listActiveSessionLanes(normalized);
|
|
808
|
+
const explicitId = normalizeOptionalString(options?.sessionId);
|
|
809
|
+
const selection = resolveLaneSelection({
|
|
810
|
+
explicitSessionId: explicitId ? validateLaneId(explicitId) : null,
|
|
811
|
+
marker: markerContext.marker,
|
|
812
|
+
laneIds: lanes.map((lane) => lane.id),
|
|
813
|
+
rootDir: normalized.rootDir,
|
|
814
|
+
purpose: 'export runtime env for',
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
if (!selection.sessionId) {
|
|
818
|
+
throw new Error('No active session lane exists. Run `vibecompass start-session` first (D-282: runtime assignments are per-lane).');
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const lane = lanes.find((item) => item.id === selection.sessionId);
|
|
822
|
+
if (lane.warnings.length > 0) {
|
|
823
|
+
throw new Error(
|
|
824
|
+
`Lane "${selection.sessionId}"'s session.yaml could not be parsed (${lane.warnings[0]}); repair the lane metadata before exporting its runtime env.`,
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
if (!lane.runtime || !Number.isInteger(lane.runtime.port) || !lane.runtime.tmpDir) {
|
|
829
|
+
throw new Error(
|
|
830
|
+
`Lane "${selection.sessionId}" has no recorded runtime assignment (lanes started before D-282 have none). Close and re-open the lane to assign one.`,
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const conventionalAliases = options.conventionalAliases !== false;
|
|
835
|
+
const env = {
|
|
836
|
+
VIBECOMPASS_LANE_ID: selection.sessionId,
|
|
837
|
+
VIBECOMPASS_LANE_PORT: String(lane.runtime.port),
|
|
838
|
+
VIBECOMPASS_LANE_TMPDIR: lane.runtime.tmpDir,
|
|
839
|
+
...(conventionalAliases
|
|
840
|
+
? {
|
|
841
|
+
PORT: String(lane.runtime.port),
|
|
842
|
+
TMPDIR: lane.runtime.tmpDir,
|
|
843
|
+
}
|
|
844
|
+
: {}),
|
|
845
|
+
};
|
|
846
|
+
|
|
847
|
+
return {
|
|
848
|
+
rootDir: normalized.rootDir,
|
|
849
|
+
sessionId: selection.sessionId,
|
|
850
|
+
port: lane.runtime.port,
|
|
851
|
+
tmpDir: lane.runtime.tmpDir,
|
|
852
|
+
env,
|
|
853
|
+
warnings: [...markerContext.warnings, ...selection.warnings],
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
|
|
355
857
|
export async function listProjectSessions(options = {}) {
|
|
356
|
-
const normalized =
|
|
858
|
+
const { normalized } = await resolveSessionCommandContext(options);
|
|
357
859
|
await ensureInitializedProjectMemory(normalized.rootDir, { allowMissingProjectFile: true });
|
|
358
860
|
const index = await readActiveSessionIndex(normalized);
|
|
359
861
|
const lanes = await listActiveSessionLanes(normalized);
|
|
360
862
|
|
|
361
863
|
return {
|
|
362
864
|
rootDir: normalized.rootDir,
|
|
363
|
-
current: index.current
|
|
865
|
+
current: resolveValidatedCurrentLane(index.current, lanes),
|
|
364
866
|
lanes,
|
|
365
867
|
};
|
|
366
868
|
}
|
|
367
869
|
|
|
870
|
+
/**
|
|
871
|
+
* D-277: the index pointer is only reported as current when it names an
|
|
872
|
+
* existing active lane; a single active lane may be current implicitly, but
|
|
873
|
+
* with two or more lanes an invalid/missing pointer is never repaired by
|
|
874
|
+
* picking one — current becomes null until an explicit selection.
|
|
875
|
+
*/
|
|
876
|
+
function resolveValidatedCurrentLane(pointer, lanes) {
|
|
877
|
+
if (pointer && lanes.some((lane) => lane.id === pointer)) {
|
|
878
|
+
return pointer;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
return lanes.length === 1 ? lanes[0].id : null;
|
|
882
|
+
}
|
|
883
|
+
|
|
368
884
|
export async function switchProjectSession(options = {}) {
|
|
369
|
-
const normalized =
|
|
885
|
+
const { normalized, markerContext } = await resolveSessionCommandContext(options);
|
|
886
|
+
return withMemoryRootLock(normalized.rootDir, 'switch-session', () =>
|
|
887
|
+
switchProjectSessionLocked(normalized, options, markerContext));
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
async function switchProjectSessionLocked(normalized, options, markerContext) {
|
|
370
891
|
const sessionId = validateLaneId(requireNonEmptyString(options?.sessionId, 'switch-session requires a session ID.'));
|
|
371
892
|
const lanes = await listActiveSessionLanes(normalized);
|
|
372
893
|
const lane = lanes.find((item) => item.id === sessionId);
|
|
@@ -375,6 +896,16 @@ export async function switchProjectSession(options = {}) {
|
|
|
375
896
|
throw new Error(`Active session lane "${sessionId}" does not exist.`);
|
|
376
897
|
}
|
|
377
898
|
|
|
899
|
+
// D-280 disagreement warning: switching to a lane other than the one the
|
|
900
|
+
// enclosing marker binds proceeds, but names both lanes.
|
|
901
|
+
const selection = resolveLaneSelection({
|
|
902
|
+
explicitSessionId: sessionId,
|
|
903
|
+
marker: markerContext?.marker ?? null,
|
|
904
|
+
laneIds: lanes.map((item) => item.id),
|
|
905
|
+
rootDir: normalized.rootDir,
|
|
906
|
+
purpose: 'switch to',
|
|
907
|
+
});
|
|
908
|
+
|
|
378
909
|
await upsertActiveSessionIndex(normalized, null, { current: sessionId });
|
|
379
910
|
const claude = await readClaudeFile(normalized.claudePath);
|
|
380
911
|
await writeFile(
|
|
@@ -384,6 +915,7 @@ export async function switchProjectSession(options = {}) {
|
|
|
384
915
|
buildCurrentSessionFieldsForLane(lane, {
|
|
385
916
|
lastThingCompleted: `Switched current lane to ${sessionId}.`,
|
|
386
917
|
}),
|
|
918
|
+
{ lanes: lanes.map((item) => ({ id: item.id, workingOn: item.workingOn })), selectedId: sessionId },
|
|
387
919
|
),
|
|
388
920
|
'utf8',
|
|
389
921
|
);
|
|
@@ -398,12 +930,244 @@ export async function switchProjectSession(options = {}) {
|
|
|
398
930
|
claudePath: normalized.claudePath,
|
|
399
931
|
current: sessionId,
|
|
400
932
|
lanes,
|
|
401
|
-
warnings: manifestRefresh.warnings,
|
|
933
|
+
warnings: [...markerContext.warnings, ...selection.warnings, ...manifestRefresh.warnings],
|
|
402
934
|
manifest: manifestRefresh.manifest,
|
|
403
935
|
agentFileSync,
|
|
404
936
|
};
|
|
405
937
|
}
|
|
406
938
|
|
|
939
|
+
/**
|
|
940
|
+
* Rebuilds `sessions/active/index.yaml` from the active lane directories plus
|
|
941
|
+
* an explicit current-lane selection (D-266/D-277 recovery path). The current
|
|
942
|
+
* pointer is never derived from enumeration alone: an explicit `current` wins,
|
|
943
|
+
* a still-valid existing pointer is preserved, and only a single surviving
|
|
944
|
+
* lane may become current implicitly.
|
|
945
|
+
*/
|
|
946
|
+
export async function rebuildActiveSessionIndex(options = {}) {
|
|
947
|
+
const { normalized } = await resolveSessionCommandContext(options);
|
|
948
|
+
return withMemoryRootLock(normalized.rootDir, 'rebuild-active-index', async () => {
|
|
949
|
+
await ensureInitializedProjectMemory(normalized.rootDir, { allowMissingProjectFile: true });
|
|
950
|
+
const lanes = await listActiveSessionLanes(normalized);
|
|
951
|
+
const requested = options?.current == null || options.current === ''
|
|
952
|
+
? null
|
|
953
|
+
: validateLaneId(requireNonEmptyString(options.current, 'rebuild-active-index requires a non-empty --current lane ID when provided.'));
|
|
954
|
+
|
|
955
|
+
if (requested && !lanes.some((lane) => lane.id === requested)) {
|
|
956
|
+
throw new Error(`Active session lane "${requested}" does not exist; cannot set it as the current lane.`);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
if (lanes.length === 0) {
|
|
960
|
+
await rm(normalized.activeSessionsIndexPath, { force: true });
|
|
961
|
+
return {
|
|
962
|
+
rootDir: normalized.rootDir,
|
|
963
|
+
indexPath: normalized.activeSessionsIndexPath,
|
|
964
|
+
current: null,
|
|
965
|
+
lanes,
|
|
966
|
+
removed: true,
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const existing = await readActiveSessionIndex(normalized);
|
|
971
|
+
const current =
|
|
972
|
+
requested ??
|
|
973
|
+
(lanes.some((lane) => lane.id === existing.current) ? existing.current : null) ??
|
|
974
|
+
(lanes.length === 1 ? lanes[0].id : null);
|
|
975
|
+
|
|
976
|
+
if (!current) {
|
|
977
|
+
throw new Error(
|
|
978
|
+
'rebuild-active-index needs an explicit --current <lane-id> because more than one lane is active and the existing pointer is not valid (D-277: the current lane is an explicit selection, not a derived default).',
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
await mkdir(normalized.activeSessionsDir, { recursive: true });
|
|
983
|
+
await writeFile(normalized.activeSessionsIndexPath, renderActiveSessionIndex(current, lanes), 'utf8');
|
|
984
|
+
return {
|
|
985
|
+
rootDir: normalized.rootDir,
|
|
986
|
+
indexPath: normalized.activeSessionsIndexPath,
|
|
987
|
+
current,
|
|
988
|
+
lanes,
|
|
989
|
+
removed: false,
|
|
990
|
+
};
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Writes the worktree lane marker for an active lane (D-280). Markers are
|
|
996
|
+
* never written implicitly: this explicit command and S3 worktree
|
|
997
|
+
* provisioning are the only producers. The target must be path-disjoint from
|
|
998
|
+
* the memory root, and the lane's session.yaml records the marker under a
|
|
999
|
+
* `lane_marker:` block map so close-session and S3 removal can token-match.
|
|
1000
|
+
*/
|
|
1001
|
+
export async function writeLaneMarkerForSession(options) {
|
|
1002
|
+
// needMarkerForLane: the enclosing marker is consulted even under an
|
|
1003
|
+
// explicit --root so the D-280 flag-vs-marker disagreement warning fires
|
|
1004
|
+
// when this command binds a different lane than the invoking context.
|
|
1005
|
+
const { normalized, markerContext } = await resolveSessionCommandContext(options, { needMarkerForLane: true });
|
|
1006
|
+
return withMemoryRootLock(normalized.rootDir, 'write-lane-marker', () =>
|
|
1007
|
+
writeLaneMarkerForSessionLocked(normalized, options, markerContext));
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
async function writeLaneMarkerForSessionLocked(normalized, options, markerContext) {
|
|
1011
|
+
const sessionId = validateLaneId(
|
|
1012
|
+
requireNonEmptyString(options?.sessionId, 'write-lane-marker requires --session <lane-id> so the marker binds to an explicit lane.'),
|
|
1013
|
+
);
|
|
1014
|
+
await ensureInitializedProjectMemory(normalized.rootDir, { allowMissingProjectFile: true });
|
|
1015
|
+
const warnings = [...markerContext.warnings];
|
|
1016
|
+
const lanes = await listActiveSessionLanes(normalized);
|
|
1017
|
+
// Shared D-277/D-280 selection: validates the explicit lane is active and
|
|
1018
|
+
// emits the disagreement warning when an enclosing marker names another lane.
|
|
1019
|
+
const selection = resolveLaneSelection({
|
|
1020
|
+
explicitSessionId: sessionId,
|
|
1021
|
+
marker: markerContext.marker,
|
|
1022
|
+
laneIds: lanes.map((lane) => lane.id),
|
|
1023
|
+
rootDir: normalized.rootDir,
|
|
1024
|
+
purpose: 'bind the marker to',
|
|
1025
|
+
});
|
|
1026
|
+
warnings.push(...selection.warnings);
|
|
1027
|
+
|
|
1028
|
+
const lanePaths = getLanePaths(normalized, sessionId);
|
|
1029
|
+
if (!(await fileExists(lanePaths.sessionFilePath))) {
|
|
1030
|
+
throw new Error(
|
|
1031
|
+
`Lane "${sessionId}" has no session.yaml at ${lanePaths.sessionFilePath}; the marker cannot be recorded. Repair the lane (rebuild-active-index) before writing a marker.`,
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const targetDir = path.resolve(normalized.cwd, options?.dir ?? '.');
|
|
1036
|
+
const targetStat = await stat(targetDir).catch(() => null);
|
|
1037
|
+
if (!targetStat) {
|
|
1038
|
+
throw new Error(
|
|
1039
|
+
`write-lane-marker target directory ${targetDir} does not exist. Create it first; markers are never provisioned implicitly (D-280).`,
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
if (!targetStat.isDirectory()) {
|
|
1043
|
+
throw new Error(`write-lane-marker target ${targetDir} is not a directory.`);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
await assertMarkerTargetDisjoint(targetDir, normalized.rootDir);
|
|
1047
|
+
const gitDir = await findEnclosingGitDir(targetDir);
|
|
1048
|
+
if (gitDir) {
|
|
1049
|
+
warnings.push(
|
|
1050
|
+
`Marker target ${targetDir} sits inside a git work tree (${gitDir}); lane markers are local-only (D-278) — make sure ${LANE_MARKER_FILENAME} is git-ignored there.`,
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const markerPath = path.join(targetDir, LANE_MARKER_FILENAME);
|
|
1055
|
+
if (await fileExists(markerPath)) {
|
|
1056
|
+
try {
|
|
1057
|
+
const existing = await readLaneMarker(markerPath);
|
|
1058
|
+
if (existing.laneId !== sessionId) {
|
|
1059
|
+
warnings.push(`Replaced the marker at ${markerPath} previously bound to lane "${existing.laneId}".`);
|
|
1060
|
+
}
|
|
1061
|
+
} catch {
|
|
1062
|
+
warnings.push(`Replaced an unreadable marker at ${markerPath}.`);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
const laneMetadata = await readLaneMetadata(lanePaths.sessionFilePath);
|
|
1067
|
+
// Fail closed when the lane metadata is unreadable: a null-degraded parse
|
|
1068
|
+
// would hide a recorded worktree container and let a rebind orphan the
|
|
1069
|
+
// removal guard (D-281).
|
|
1070
|
+
if (laneMetadata.warnings.length > 0) {
|
|
1071
|
+
throw new Error(
|
|
1072
|
+
`Lane "${sessionId}"'s session.yaml could not be parsed (${laneMetadata.warnings[0]}); refusing to rebind its marker — a provisioned worktree container would be undetectable (D-281). Repair the lane metadata first.`,
|
|
1073
|
+
);
|
|
1074
|
+
}
|
|
1075
|
+
// D-281: a provisioned lane's container marker is what authorizes guarded
|
|
1076
|
+
// worktree removal at close; rebinding would token-delete it and orphan
|
|
1077
|
+
// the worktrees permanently.
|
|
1078
|
+
if (laneMetadata.worktreeContainer) {
|
|
1079
|
+
throw new Error(
|
|
1080
|
+
`Lane "${sessionId}" has a provisioned worktree container at ${laneMetadata.worktreeContainer}; its marker is managed by start/close-session and rebinding it would orphan the worktree-removal guard (D-281). Close the lane to remove the worktrees instead.`,
|
|
1081
|
+
);
|
|
1082
|
+
}
|
|
1083
|
+
if (laneMetadata.laneMarker?.path && path.resolve(laneMetadata.laneMarker.path) !== markerPath) {
|
|
1084
|
+
const cleanup = await removeRecordedLaneMarker(laneMetadata.laneMarker);
|
|
1085
|
+
warnings.push(...cleanup.warnings);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
const token = randomUUID();
|
|
1089
|
+
const createdAt = formatLocalDateTime(new Date());
|
|
1090
|
+
const sessionYaml = await readFile(lanePaths.sessionFilePath, 'utf8');
|
|
1091
|
+
// Record before writing the marker file: a crash between the two leaves the
|
|
1092
|
+
// benign recorded-but-missing state (cleanup is a no-op) instead of an
|
|
1093
|
+
// unrecorded on-disk marker that nothing can remove or report.
|
|
1094
|
+
await writeFile(
|
|
1095
|
+
lanePaths.sessionFilePath,
|
|
1096
|
+
upsertLaneMarkerBlock(sessionYaml, { path: markerPath, token, createdAt }),
|
|
1097
|
+
'utf8',
|
|
1098
|
+
);
|
|
1099
|
+
await writeFile(
|
|
1100
|
+
markerPath,
|
|
1101
|
+
renderLaneMarker({
|
|
1102
|
+
laneId: sessionId,
|
|
1103
|
+
memoryRoot: normalized.rootDir,
|
|
1104
|
+
token,
|
|
1105
|
+
createdAt,
|
|
1106
|
+
createdBy: PACKAGE_VERSION,
|
|
1107
|
+
}),
|
|
1108
|
+
'utf8',
|
|
1109
|
+
);
|
|
1110
|
+
|
|
1111
|
+
return {
|
|
1112
|
+
rootDir: normalized.rootDir,
|
|
1113
|
+
sessionId,
|
|
1114
|
+
markerPath,
|
|
1115
|
+
token,
|
|
1116
|
+
warnings,
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* Removes a lane's recorded marker file only when the on-disk token still
|
|
1122
|
+
* matches the recorded token (D-280 guarded removal); anything else is
|
|
1123
|
+
* reported and left in place.
|
|
1124
|
+
*/
|
|
1125
|
+
async function removeRecordedLaneMarker(recorded) {
|
|
1126
|
+
const warnings = [];
|
|
1127
|
+
if (!recorded?.path) {
|
|
1128
|
+
return { removed: false, warnings };
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
if (!(await fileExists(recorded.path))) {
|
|
1132
|
+
return { removed: false, warnings };
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
try {
|
|
1136
|
+
const marker = await readLaneMarker(recorded.path);
|
|
1137
|
+
if (recorded.token && marker.token === recorded.token) {
|
|
1138
|
+
await rm(recorded.path, { force: true });
|
|
1139
|
+
return { removed: true, warnings };
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
warnings.push(
|
|
1143
|
+
`Lane marker at ${recorded.path} left in place: its token does not match the lane's recorded marker (D-280: only token-matched markers are removed).`,
|
|
1144
|
+
);
|
|
1145
|
+
} catch (error) {
|
|
1146
|
+
warnings.push(
|
|
1147
|
+
`Lane marker at ${recorded.path} left in place: ${error instanceof Error ? error.message : String(error)}`,
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
return { removed: false, warnings };
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/** Replaces (or appends) the `lane_marker:` block map in a session.yaml body. */
|
|
1155
|
+
function upsertLaneMarkerBlock(content, marker) {
|
|
1156
|
+
// CRLF-tolerant to match parseSimpleYaml's /\r?\n/ splitting; a missed
|
|
1157
|
+
// strip here would append a duplicate key and void the whole lane metadata.
|
|
1158
|
+
const stripped = content.replace(/^lane_marker:\r?\n(?:[ \t]+.*\r?\n?)*/m, '');
|
|
1159
|
+
const base = stripped.endsWith('\n') ? stripped : `${stripped}\n`;
|
|
1160
|
+
const block = [
|
|
1161
|
+
'lane_marker:',
|
|
1162
|
+
` path: ${quoteYamlString(marker.path)}`,
|
|
1163
|
+
` token: ${quoteYamlString(marker.token)}`,
|
|
1164
|
+
` created_at: ${marker.createdAt}`,
|
|
1165
|
+
'',
|
|
1166
|
+
].join('\n');
|
|
1167
|
+
|
|
1168
|
+
return `${base}${block}`;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
407
1171
|
async function refreshStateManifestSafely(rootDir) {
|
|
408
1172
|
try {
|
|
409
1173
|
const manifest = await writeStateManifest(rootDir);
|
|
@@ -429,6 +1193,7 @@ async function planDocsUpdateSafely(options) {
|
|
|
429
1193
|
rootDir: options.rootDir,
|
|
430
1194
|
cwd: options.cwd,
|
|
431
1195
|
sessionId: options.sessionId,
|
|
1196
|
+
suppressLaneWarnings: options.suppressLaneWarnings === true,
|
|
432
1197
|
}),
|
|
433
1198
|
warnings: [],
|
|
434
1199
|
};
|
|
@@ -588,9 +1353,9 @@ function parseCurrentSessionBlock(content, options = {}) {
|
|
|
588
1353
|
};
|
|
589
1354
|
}
|
|
590
1355
|
|
|
591
|
-
function replaceCurrentSessionBlock(content, fields) {
|
|
1356
|
+
function replaceCurrentSessionBlock(content, fields, laneListing = null) {
|
|
592
1357
|
const sessionFence = findCurrentSessionFence(content);
|
|
593
|
-
const blockBody = renderCurrentSessionBlockBody(fields);
|
|
1358
|
+
const blockBody = renderCurrentSessionBlockBody(fields, laneListing);
|
|
594
1359
|
|
|
595
1360
|
if (!sessionFence) {
|
|
596
1361
|
return insertCurrentSessionBlock(content, blockBody);
|
|
@@ -599,14 +1364,32 @@ function replaceCurrentSessionBlock(content, fields) {
|
|
|
599
1364
|
return `${content.slice(0, sessionFence.bodyStart)}${blockBody}${content.slice(sessionFence.bodyEnd)}`;
|
|
600
1365
|
}
|
|
601
1366
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
1367
|
+
/**
|
|
1368
|
+
* D-277: with two or more active lanes the block becomes a derived multi-lane
|
|
1369
|
+
* listing (one line per active lane plus the selected lane); with at most one
|
|
1370
|
+
* lane it keeps the exact legacy five-field shape. The five field labels stay
|
|
1371
|
+
* present in both shapes so parseCurrentSessionBlock/findCurrentSessionFence
|
|
1372
|
+
* keep working on either.
|
|
1373
|
+
*/
|
|
1374
|
+
function renderCurrentSessionBlockBody(fields, laneListing = null) {
|
|
1375
|
+
const lines = [`Date: ${fields.date}`];
|
|
1376
|
+
|
|
1377
|
+
if (laneListing && Array.isArray(laneListing.lanes) && laneListing.lanes.length >= 2) {
|
|
1378
|
+
lines.push('Active lanes:');
|
|
1379
|
+
for (const lane of [...laneListing.lanes].sort((left, right) => left.id.localeCompare(right.id))) {
|
|
1380
|
+
const selectedSuffix = laneListing.selectedId === lane.id ? ' [selected]' : '';
|
|
1381
|
+
lines.push(`- ${lane.id} — ${lane.workingOn ?? 'No summary recorded'}${selectedSuffix}`);
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
lines.push(
|
|
605
1386
|
`Working on: ${fields.workingOn}`,
|
|
606
1387
|
`Last thing completed: ${fields.lastThingCompleted}`,
|
|
607
1388
|
`Blockers: ${fields.blockers}`,
|
|
608
1389
|
`Next session should: ${fields.nextSessionShould}`,
|
|
609
|
-
|
|
1390
|
+
);
|
|
1391
|
+
|
|
1392
|
+
return lines.join('\n');
|
|
610
1393
|
}
|
|
611
1394
|
|
|
612
1395
|
function insertCurrentSessionBlock(content, blockBody) {
|
|
@@ -678,22 +1461,17 @@ async function resolveStartSessionId(normalized, options) {
|
|
|
678
1461
|
throw new Error('start-session requires --id <lane-id> so each active lane has a meaningful name.');
|
|
679
1462
|
}
|
|
680
1463
|
|
|
681
|
-
async function resolveExistingSessionId(normalized, options) {
|
|
1464
|
+
async function resolveExistingSessionId(normalized, options, markerContext, purpose) {
|
|
682
1465
|
const explicitId = normalizeOptionalString(options?.sessionId);
|
|
683
|
-
if (explicitId) {
|
|
684
|
-
return validateLaneId(explicitId);
|
|
685
|
-
}
|
|
686
|
-
|
|
687
1466
|
const lanes = await listActiveSessionLanes(normalized);
|
|
688
|
-
if (lanes.length === 1) {
|
|
689
|
-
return lanes[0].id;
|
|
690
|
-
}
|
|
691
1467
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
1468
|
+
return resolveLaneSelection({
|
|
1469
|
+
explicitSessionId: explicitId ? validateLaneId(explicitId) : null,
|
|
1470
|
+
marker: markerContext?.marker ?? null,
|
|
1471
|
+
laneIds: lanes.map((lane) => lane.id),
|
|
1472
|
+
rootDir: normalized.rootDir,
|
|
1473
|
+
purpose,
|
|
1474
|
+
});
|
|
697
1475
|
}
|
|
698
1476
|
|
|
699
1477
|
async function listActiveSessionLanes(normalized) {
|
|
@@ -718,6 +1496,12 @@ async function listActiveSessionLanes(normalized) {
|
|
|
718
1496
|
claims: metadata.claims,
|
|
719
1497
|
startedAt: metadata.startedAt,
|
|
720
1498
|
decisionSnapshot: metadata.decisionSnapshot,
|
|
1499
|
+
branch: metadata.branch ?? null,
|
|
1500
|
+
worktreeContainer: metadata.worktreeContainer ?? null,
|
|
1501
|
+
worktrees: metadata.worktrees ?? {},
|
|
1502
|
+
worktreeSources: metadata.worktreeSources ?? {},
|
|
1503
|
+
baseRevisions: metadata.baseRevisions ?? {},
|
|
1504
|
+
runtime: metadata.runtime ?? null,
|
|
721
1505
|
warnings: metadata.warnings,
|
|
722
1506
|
sessionDate: metadata.sessionDate ?? wipSession?.sessionDate ?? null,
|
|
723
1507
|
sessionNumber: metadata.sessionNumber ?? wipSession?.sessionNumber ?? 0,
|
|
@@ -756,6 +1540,24 @@ async function readLaneMetadata(sessionFilePath) {
|
|
|
756
1540
|
decisionSnapshot: {
|
|
757
1541
|
highestDecisionId: parseNullableNumber(data.decision_snapshot?.highest_decision_id),
|
|
758
1542
|
},
|
|
1543
|
+
laneMarker: data.lane_marker && typeof data.lane_marker === 'object'
|
|
1544
|
+
? {
|
|
1545
|
+
path: normalizeOptionalString(data.lane_marker.path),
|
|
1546
|
+
token: normalizeOptionalString(data.lane_marker.token),
|
|
1547
|
+
createdAt: normalizeOptionalString(data.lane_marker.created_at),
|
|
1548
|
+
}
|
|
1549
|
+
: null,
|
|
1550
|
+
branch: normalizeOptionalString(data.branch),
|
|
1551
|
+
worktreeContainer: normalizeOptionalString(data.worktree_container),
|
|
1552
|
+
worktrees: normalizeStringMap(data.worktrees),
|
|
1553
|
+
worktreeSources: normalizeStringMap(data.worktree_sources),
|
|
1554
|
+
baseRevisions: normalizeStringMap(data.base_revisions),
|
|
1555
|
+
runtime: data.runtime && typeof data.runtime === 'object' && !Array.isArray(data.runtime)
|
|
1556
|
+
? {
|
|
1557
|
+
port: parseNullableNumber(data.runtime.port),
|
|
1558
|
+
tmpDir: normalizeOptionalString(data.runtime.tmp_dir),
|
|
1559
|
+
}
|
|
1560
|
+
: null,
|
|
759
1561
|
sessionDate: normalizeOptionalString(data.session_date),
|
|
760
1562
|
sessionNumber: typeof data.session_number === 'number' ? data.session_number : Number(data.session_number) || null,
|
|
761
1563
|
warnings: [],
|
|
@@ -771,6 +1573,13 @@ async function readLaneMetadata(sessionFilePath) {
|
|
|
771
1573
|
decisionSnapshot: {
|
|
772
1574
|
highestDecisionId: null,
|
|
773
1575
|
},
|
|
1576
|
+
laneMarker: null,
|
|
1577
|
+
branch: null,
|
|
1578
|
+
worktreeContainer: null,
|
|
1579
|
+
worktrees: {},
|
|
1580
|
+
worktreeSources: {},
|
|
1581
|
+
baseRevisions: {},
|
|
1582
|
+
runtime: null,
|
|
774
1583
|
sessionDate: null,
|
|
775
1584
|
sessionNumber: null,
|
|
776
1585
|
warnings: [
|
|
@@ -780,6 +1589,27 @@ async function readLaneMetadata(sessionFilePath) {
|
|
|
780
1589
|
}
|
|
781
1590
|
}
|
|
782
1591
|
|
|
1592
|
+
/**
|
|
1593
|
+
* One-level block map of string values (worktrees, worktree_sources,
|
|
1594
|
+
* base_revisions). Unquoted all-digit values come back numerically coerced
|
|
1595
|
+
* from parseScalar — normalize them back to strings.
|
|
1596
|
+
*/
|
|
1597
|
+
function normalizeStringMap(value) {
|
|
1598
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
1599
|
+
return {};
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
const result = {};
|
|
1603
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
1604
|
+
const normalized = normalizeOptionalString(typeof entry === 'number' ? String(entry) : entry);
|
|
1605
|
+
if (normalized) {
|
|
1606
|
+
result[key] = normalized;
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
return result;
|
|
1611
|
+
}
|
|
1612
|
+
|
|
783
1613
|
async function readActiveSessionIndex(normalized) {
|
|
784
1614
|
try {
|
|
785
1615
|
const data = parseSimpleYaml(await readFile(normalized.activeSessionsIndexPath, 'utf8'), {
|
|
@@ -796,7 +1626,12 @@ async function readActiveSessionIndex(normalized) {
|
|
|
796
1626
|
}
|
|
797
1627
|
|
|
798
1628
|
async function upsertActiveSessionIndex(normalized, lane, options = {}) {
|
|
799
|
-
|
|
1629
|
+
// D-277: callers must select `current` explicitly; the upsert never derives
|
|
1630
|
+
// a current lane from the lane being written or a stale existing pointer.
|
|
1631
|
+
if (!('current' in options)) {
|
|
1632
|
+
throw new Error('upsertActiveSessionIndex requires an explicit current-lane selection.');
|
|
1633
|
+
}
|
|
1634
|
+
|
|
800
1635
|
const lanes = await listActiveSessionLanes(normalized);
|
|
801
1636
|
const laneMap = new Map(lanes.map((item) => [item.id, item]));
|
|
802
1637
|
if (lane) {
|
|
@@ -807,9 +1642,8 @@ async function upsertActiveSessionIndex(normalized, lane, options = {}) {
|
|
|
807
1642
|
});
|
|
808
1643
|
}
|
|
809
1644
|
|
|
810
|
-
const current = options.current ?? currentIndex.current ?? lane?.id ?? null;
|
|
811
1645
|
await mkdir(normalized.activeSessionsDir, { recursive: true });
|
|
812
|
-
await writeFile(normalized.activeSessionsIndexPath, renderActiveSessionIndex(current, [...laneMap.values()]), 'utf8');
|
|
1646
|
+
await writeFile(normalized.activeSessionsIndexPath, renderActiveSessionIndex(options.current, [...laneMap.values()]), 'utf8');
|
|
813
1647
|
}
|
|
814
1648
|
|
|
815
1649
|
/**
|
|
@@ -820,7 +1654,15 @@ async function upsertActiveSessionIndex(normalized, lane, options = {}) {
|
|
|
820
1654
|
async function removeActiveSessionFromIndex(normalized, sessionId) {
|
|
821
1655
|
const index = await readActiveSessionIndex(normalized);
|
|
822
1656
|
const lanes = (await listActiveSessionLanes(normalized)).filter((lane) => lane.id !== sessionId);
|
|
823
|
-
|
|
1657
|
+
// D-277: a surviving pointer is kept only when it names a surviving lane; a
|
|
1658
|
+
// sole survivor may become current implicitly, but with 2+ survivors the
|
|
1659
|
+
// pointer goes null until an explicit switch-session / marker selection.
|
|
1660
|
+
// This also repairs stale pointers that named an already-missing lane.
|
|
1661
|
+
const keptPointer =
|
|
1662
|
+
index.current && index.current !== sessionId && lanes.some((lane) => lane.id === index.current)
|
|
1663
|
+
? index.current
|
|
1664
|
+
: null;
|
|
1665
|
+
const current = keptPointer ?? (lanes.length === 1 ? lanes[0].id : null);
|
|
824
1666
|
if (lanes.length === 0) {
|
|
825
1667
|
await rm(normalized.activeSessionsIndexPath, { force: true });
|
|
826
1668
|
return {
|
|
@@ -876,19 +1718,6 @@ function parseActiveSession(wipContent, wipFilePath = 'sessions/active/<lane-id>
|
|
|
876
1718
|
};
|
|
877
1719
|
}
|
|
878
1720
|
|
|
879
|
-
function validateLaneId(value) {
|
|
880
|
-
const normalized = value.trim();
|
|
881
|
-
if (!LANE_ID_PATTERN.test(normalized)) {
|
|
882
|
-
throw new Error('Session lane ID must be a lowercase slug 3-64 characters long using letters, numbers, and hyphens.');
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
if (RESERVED_LANE_IDS.has(normalized)) {
|
|
886
|
-
throw new Error(`Session lane ID "${normalized}" is reserved.`);
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
return normalized;
|
|
890
|
-
}
|
|
891
|
-
|
|
892
1721
|
function extractSectionBody(content, sectionTitle) {
|
|
893
1722
|
const heading = `## ${sectionTitle}`.toLowerCase();
|
|
894
1723
|
const lines = content.split('\n');
|
|
@@ -999,12 +1828,51 @@ function renderLaneMetadata(options) {
|
|
|
999
1828
|
renderYamlArray('repos', options.repos),
|
|
1000
1829
|
renderYamlArray('claimed_paths', options.claims),
|
|
1001
1830
|
`started_at: ${formatLocalDateTime(new Date())}`,
|
|
1831
|
+
// D-282: the runtime assignment travels with the lane record (local-only
|
|
1832
|
+
// per D-278) so lane-env and close-time cleanup read it back.
|
|
1833
|
+
...(options.runtime
|
|
1834
|
+
? [
|
|
1835
|
+
'runtime:',
|
|
1836
|
+
` port: ${options.runtime.port}`,
|
|
1837
|
+
` tmp_dir: ${quoteYamlString(options.runtime.tmpDir)}`,
|
|
1838
|
+
]
|
|
1839
|
+
: []),
|
|
1840
|
+
...(options.branch ? [`branch: ${quoteYamlString(options.branch)}`] : []),
|
|
1841
|
+
...(options.worktreeContainer ? [`worktree_container: ${quoteYamlString(options.worktreeContainer)}`] : []),
|
|
1842
|
+
...renderYamlBlockMapLines('worktrees', options.worktrees),
|
|
1843
|
+
...renderYamlBlockMapLines('worktree_sources', options.worktreeSources),
|
|
1844
|
+
...renderYamlBlockMapLines('base_revisions', options.baseRevisions),
|
|
1845
|
+
...(options.laneMarker
|
|
1846
|
+
? [
|
|
1847
|
+
'lane_marker:',
|
|
1848
|
+
` path: ${quoteYamlString(options.laneMarker.path)}`,
|
|
1849
|
+
` token: ${quoteYamlString(options.laneMarker.token)}`,
|
|
1850
|
+
` created_at: ${options.laneMarker.createdAt}`,
|
|
1851
|
+
]
|
|
1852
|
+
: []),
|
|
1002
1853
|
'decision_snapshot:',
|
|
1003
1854
|
` highest_decision_id: ${options.highestDecisionId ?? 'null'}`,
|
|
1004
1855
|
'',
|
|
1005
1856
|
].join('\n');
|
|
1006
1857
|
}
|
|
1007
1858
|
|
|
1859
|
+
/**
|
|
1860
|
+
* One-level block map of id keys to quoted string values, omitted entirely
|
|
1861
|
+
* when empty. Writers validate keys with isSimpleYamlKeySafe before recording
|
|
1862
|
+
* them — an unparseable key would null-degrade the whole lane for readers.
|
|
1863
|
+
*/
|
|
1864
|
+
function renderYamlBlockMapLines(key, map) {
|
|
1865
|
+
const entries = map ? Object.entries(map).filter(([, value]) => value !== null && value !== undefined) : [];
|
|
1866
|
+
if (entries.length === 0) {
|
|
1867
|
+
return [];
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
return [
|
|
1871
|
+
`${key}:`,
|
|
1872
|
+
...entries.map(([mapKey, value]) => ` ${mapKey}: ${quoteYamlString(String(value))}`),
|
|
1873
|
+
];
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1008
1876
|
function renderActiveSessionIndex(current, lanes) {
|
|
1009
1877
|
const lines = [
|
|
1010
1878
|
`current: ${current ?? 'null'}`,
|