@vibecompass/vibecompass 0.1.0 → 0.2.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/README.md +70 -15
- package/package.json +1 -1
- package/src/cli.js +150 -5
- package/src/generators/agent-files/template.js +11 -2
- package/src/index.js +6 -1
- package/src/scaffold.js +71 -31
- package/src/session.js +424 -53
- package/src/setup.js +54 -0
- package/src/workflow.js +1 -1
package/src/session.js
CHANGED
|
@@ -7,6 +7,25 @@ import { syncAgentInstructionFiles } from './generators/agent-files/index.js';
|
|
|
7
7
|
const CURRENT_SESSION_REQUIRED_FIELDS = ['Date:', 'Working on:', 'Last thing completed:', 'Blockers:', 'Next session should:'];
|
|
8
8
|
const WIP_HEADER_PATTERN = /^# WIP — (\d{4}-\d{2}-\d{2}) \(session (\d+)\)$/m;
|
|
9
9
|
const SESSION_FILENAME_PATTERN = /^(\d{4}-\d{2}-\d{2})-(\d+)-([a-z0-9-]+)\.md$/i;
|
|
10
|
+
const LANE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/;
|
|
11
|
+
const RESERVED_LANE_IDS = new Set([
|
|
12
|
+
'active',
|
|
13
|
+
'current',
|
|
14
|
+
'default',
|
|
15
|
+
'false',
|
|
16
|
+
'handoff',
|
|
17
|
+
'index',
|
|
18
|
+
'new',
|
|
19
|
+
'no',
|
|
20
|
+
'null',
|
|
21
|
+
'off',
|
|
22
|
+
'on',
|
|
23
|
+
'sessions',
|
|
24
|
+
'state',
|
|
25
|
+
'true',
|
|
26
|
+
'wip',
|
|
27
|
+
'yes',
|
|
28
|
+
]);
|
|
10
29
|
|
|
11
30
|
export async function startProjectSession(options) {
|
|
12
31
|
const normalized = normalizeSessionPaths(options);
|
|
@@ -15,18 +34,14 @@ export async function startProjectSession(options) {
|
|
|
15
34
|
|
|
16
35
|
await ensureInitializedProjectMemory(normalized.rootDir);
|
|
17
36
|
const claude = await readClaudeFile(normalized.claudePath);
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
throw new Error(
|
|
21
|
-
`An active session scratch file already exists under ${normalized.sessionsDir}. Close or recover the existing session before starting a new one.`,
|
|
22
|
-
);
|
|
23
|
-
}
|
|
37
|
+
const sessionId = await resolveStartSessionId(normalized, options);
|
|
38
|
+
const lanePaths = getLanePaths(normalized, sessionId);
|
|
24
39
|
|
|
25
40
|
const nextSessionNumber = await getNextSessionNumber(normalized.sessionsDir, sessionDate);
|
|
26
41
|
const currentSession = parseCurrentSessionBlock(claude.content);
|
|
27
42
|
const updatedClaude = replaceCurrentSessionBlock(claude.content, {
|
|
28
|
-
date: `${sessionDate} (session ${nextSessionNumber})`,
|
|
29
|
-
workingOn
|
|
43
|
+
date: `${sessionDate} (session ${nextSessionNumber}, lane ${sessionId})`,
|
|
44
|
+
workingOn: `${workingOn} [${sessionId}]`,
|
|
30
45
|
lastThingCompleted:
|
|
31
46
|
normalizeOptionalString(options?.lastThingCompleted) ??
|
|
32
47
|
currentSession.lastThingCompleted ??
|
|
@@ -37,41 +52,75 @@ export async function startProjectSession(options) {
|
|
|
37
52
|
'Finish the active session and close it with a finalized session note.',
|
|
38
53
|
});
|
|
39
54
|
|
|
40
|
-
await mkdir(normalized.
|
|
55
|
+
await mkdir(normalized.activeSessionsDir, { recursive: true });
|
|
56
|
+
await mkdir(lanePaths.laneDir, { recursive: false }).catch((error) => {
|
|
57
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'EEXIST') {
|
|
58
|
+
throw new Error(`Active session lane "${sessionId}" already exists. Choose a different --id or close that lane first.`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
throw error;
|
|
62
|
+
});
|
|
41
63
|
await writeFile(normalized.claudePath, updatedClaude, 'utf8');
|
|
42
64
|
await writeFile(
|
|
43
|
-
|
|
65
|
+
lanePaths.sessionFilePath,
|
|
66
|
+
renderLaneMetadata({
|
|
67
|
+
sessionId,
|
|
68
|
+
sessionDate,
|
|
69
|
+
sessionNumber: nextSessionNumber,
|
|
70
|
+
workingOn,
|
|
71
|
+
features: normalizeStringArray(options?.features),
|
|
72
|
+
repos: normalizeStringArray(options?.repos),
|
|
73
|
+
claims: normalizeStringArray(options?.claims),
|
|
74
|
+
}),
|
|
75
|
+
'utf8',
|
|
76
|
+
);
|
|
77
|
+
await writeFile(
|
|
78
|
+
lanePaths.wipFilePath,
|
|
44
79
|
renderWipTemplate({
|
|
45
80
|
sessionDate,
|
|
46
81
|
sessionNumber: nextSessionNumber,
|
|
47
82
|
workingOn,
|
|
83
|
+
sessionId,
|
|
48
84
|
}),
|
|
49
85
|
'utf8',
|
|
50
86
|
);
|
|
51
87
|
await writeFile(
|
|
52
|
-
|
|
88
|
+
lanePaths.handoffFilePath,
|
|
53
89
|
renderHandoffTemplate({
|
|
54
90
|
sessionDate,
|
|
55
91
|
sessionNumber: nextSessionNumber,
|
|
56
92
|
workingOn,
|
|
93
|
+
sessionId,
|
|
57
94
|
}),
|
|
58
95
|
'utf8',
|
|
59
96
|
);
|
|
97
|
+
await upsertActiveSessionIndex(normalized, {
|
|
98
|
+
id: sessionId,
|
|
99
|
+
status: 'active',
|
|
100
|
+
workingOn,
|
|
101
|
+
}, { current: sessionId });
|
|
102
|
+
const agentFileSync = await syncAgentInstructionFilesSafely({
|
|
103
|
+
rootDir: normalized.rootDir,
|
|
104
|
+
toolingRootDir: normalized.toolingRootDir,
|
|
105
|
+
});
|
|
60
106
|
|
|
61
107
|
return {
|
|
62
108
|
rootDir: normalized.rootDir,
|
|
63
109
|
toolingRootDir: normalized.toolingRootDir,
|
|
64
110
|
claudePath: normalized.claudePath,
|
|
65
|
-
|
|
66
|
-
|
|
111
|
+
sessionId,
|
|
112
|
+
sessionFilePath: lanePaths.sessionFilePath,
|
|
113
|
+
wipFilePath: lanePaths.wipFilePath,
|
|
114
|
+
handoffFilePath: lanePaths.handoffFilePath,
|
|
67
115
|
sessionDate,
|
|
68
116
|
sessionNumber: nextSessionNumber,
|
|
117
|
+
agentFileSync,
|
|
69
118
|
};
|
|
70
119
|
}
|
|
71
120
|
|
|
72
121
|
export async function closeProjectSession(options) {
|
|
73
122
|
const normalized = normalizeSessionPaths(options);
|
|
74
|
-
const title = requireNonEmptyString(options?.title, '
|
|
123
|
+
const title = requireNonEmptyString(options?.title, 'session close requires a non-empty title.');
|
|
75
124
|
const completed = normalizeStringArray(options?.completed);
|
|
76
125
|
const models = normalizeStringArray(options?.models);
|
|
77
126
|
const nextSteps = normalizeStringArray(options?.nextSteps);
|
|
@@ -79,25 +128,27 @@ export async function closeProjectSession(options) {
|
|
|
79
128
|
const blockers = normalizeStringArray(options?.blockers);
|
|
80
129
|
|
|
81
130
|
if (completed.length === 0) {
|
|
82
|
-
throw new Error('
|
|
131
|
+
throw new Error('session close requires at least one completed item.');
|
|
83
132
|
}
|
|
84
133
|
|
|
85
134
|
if (nextSteps.length === 0) {
|
|
86
|
-
throw new Error('
|
|
135
|
+
throw new Error('session close requires at least one next-step entry.');
|
|
87
136
|
}
|
|
88
137
|
|
|
89
138
|
await ensureInitializedProjectMemory(normalized.rootDir, { allowMissingProjectFile: true });
|
|
90
139
|
const claude = await readClaudeFile(normalized.claudePath);
|
|
91
140
|
const workflowSettings = await readWorkflowSettings(normalized.projectFilePath);
|
|
141
|
+
const sessionId = await resolveExistingSessionId(normalized, options);
|
|
142
|
+
const lanePaths = sessionId ? getLanePaths(normalized, sessionId) : normalized;
|
|
92
143
|
|
|
93
|
-
if (!(await fileExists(
|
|
144
|
+
if (!(await fileExists(lanePaths.wipFilePath))) {
|
|
94
145
|
throw new Error(
|
|
95
|
-
`No active session scratchpad exists at ${
|
|
146
|
+
`No active session scratchpad exists at ${lanePaths.wipFilePath}. Start a session before trying to close it.`,
|
|
96
147
|
);
|
|
97
148
|
}
|
|
98
149
|
|
|
99
|
-
const wipContent = await readFile(
|
|
100
|
-
const activeSession = parseActiveSession(wipContent);
|
|
150
|
+
const wipContent = await readFile(lanePaths.wipFilePath, 'utf8');
|
|
151
|
+
const activeSession = parseActiveSession(wipContent, lanePaths.wipFilePath);
|
|
101
152
|
const currentSession = parseCurrentSessionBlock(claude.content);
|
|
102
153
|
const workedOn =
|
|
103
154
|
normalizeOptionalString(options?.workedOn) ??
|
|
@@ -105,12 +156,12 @@ export async function closeProjectSession(options) {
|
|
|
105
156
|
currentSession.workingOn;
|
|
106
157
|
|
|
107
158
|
if (!workedOn) {
|
|
108
|
-
throw new Error('
|
|
159
|
+
throw new Error('session close could not determine what the session worked on. Pass workedOn explicitly.');
|
|
109
160
|
}
|
|
110
161
|
|
|
111
162
|
const sessionSlug = slugifyTitle(title);
|
|
112
163
|
if (!sessionSlug) {
|
|
113
|
-
throw new Error('
|
|
164
|
+
throw new Error('session close title must contain at least one letter or number.');
|
|
114
165
|
}
|
|
115
166
|
|
|
116
167
|
const sessionRelativePath = toPosix(
|
|
@@ -138,22 +189,34 @@ export async function closeProjectSession(options) {
|
|
|
138
189
|
'utf8',
|
|
139
190
|
);
|
|
140
191
|
|
|
141
|
-
const hadHandoff = await fileExists(
|
|
142
|
-
|
|
143
|
-
if (
|
|
144
|
-
await rm(
|
|
192
|
+
const hadHandoff = await fileExists(lanePaths.handoffFilePath);
|
|
193
|
+
let activeSessionIndex = null;
|
|
194
|
+
if (sessionId) {
|
|
195
|
+
await rm(lanePaths.laneDir, { recursive: true, force: true });
|
|
196
|
+
activeSessionIndex = await removeActiveSessionFromIndex(normalized, sessionId);
|
|
197
|
+
} else {
|
|
198
|
+
await rm(lanePaths.wipFilePath, { force: true });
|
|
199
|
+
if (hadHandoff) {
|
|
200
|
+
await rm(lanePaths.handoffFilePath, { force: true });
|
|
201
|
+
}
|
|
145
202
|
}
|
|
146
203
|
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
204
|
+
const closedSummary = `Closed session ${activeSession.sessionNumber} and wrote \`${sessionRelativePath}\`.`;
|
|
205
|
+
const updatedClaude = replaceCurrentSessionBlock(
|
|
206
|
+
claude.content,
|
|
207
|
+
activeSessionIndex?.current
|
|
208
|
+
? buildCurrentSessionFieldsForLane(activeSessionIndex.lanes.find((lane) => lane.id === activeSessionIndex.current), {
|
|
209
|
+
lastThingCompleted: normalizeOptionalString(options?.lastThingCompleted) ?? closedSummary,
|
|
210
|
+
})
|
|
211
|
+
: {
|
|
212
|
+
date: `${activeSession.sessionDate} (session ${activeSession.sessionNumber})`,
|
|
213
|
+
workingOn: 'Session closed. Ready for the next builder session.',
|
|
214
|
+
lastThingCompleted: normalizeOptionalString(options?.lastThingCompleted) ?? closedSummary,
|
|
215
|
+
blockers: blockers.length > 0 ? summarizeList(blockers) : 'No blocker remains.',
|
|
216
|
+
nextSessionShould:
|
|
217
|
+
normalizeOptionalString(options?.nextSessionShould) ?? summarizeOrderedList(nextSteps),
|
|
218
|
+
},
|
|
219
|
+
);
|
|
157
220
|
|
|
158
221
|
await writeFile(normalized.claudePath, updatedClaude, 'utf8');
|
|
159
222
|
const agentFileSync = await syncAgentInstructionFilesSafely({
|
|
@@ -166,6 +229,7 @@ export async function closeProjectSession(options) {
|
|
|
166
229
|
toolingRootDir: normalized.toolingRootDir,
|
|
167
230
|
claudePath: normalized.claudePath,
|
|
168
231
|
projectFilePath: normalized.projectFilePath,
|
|
232
|
+
sessionId,
|
|
169
233
|
sessionFilePath,
|
|
170
234
|
sessionRelativePath,
|
|
171
235
|
sessionDate: activeSession.sessionDate,
|
|
@@ -173,12 +237,61 @@ export async function closeProjectSession(options) {
|
|
|
173
237
|
workflowGuidance: buildCloseSessionGuidance(workflowSettings),
|
|
174
238
|
agentFileSync,
|
|
175
239
|
removedScratchFiles: [
|
|
176
|
-
|
|
177
|
-
...(hadHandoff ? [
|
|
240
|
+
lanePaths.wipFilePath,
|
|
241
|
+
...(hadHandoff ? [lanePaths.handoffFilePath] : []),
|
|
178
242
|
],
|
|
179
243
|
};
|
|
180
244
|
}
|
|
181
245
|
|
|
246
|
+
export async function listProjectSessions(options = {}) {
|
|
247
|
+
const normalized = normalizeSessionPaths(options);
|
|
248
|
+
await ensureInitializedProjectMemory(normalized.rootDir, { allowMissingProjectFile: true });
|
|
249
|
+
const index = await readActiveSessionIndex(normalized);
|
|
250
|
+
const lanes = await listActiveSessionLanes(normalized);
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
rootDir: normalized.rootDir,
|
|
254
|
+
current: index.current ?? lanes[0]?.id ?? null,
|
|
255
|
+
lanes,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function switchProjectSession(options = {}) {
|
|
260
|
+
const normalized = normalizeSessionPaths(options);
|
|
261
|
+
const sessionId = validateLaneId(requireNonEmptyString(options?.sessionId, 'switch-session requires a session ID.'));
|
|
262
|
+
const lanes = await listActiveSessionLanes(normalized);
|
|
263
|
+
const lane = lanes.find((item) => item.id === sessionId);
|
|
264
|
+
|
|
265
|
+
if (!lane) {
|
|
266
|
+
throw new Error(`Active session lane "${sessionId}" does not exist.`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
await upsertActiveSessionIndex(normalized, null, { current: sessionId });
|
|
270
|
+
const claude = await readClaudeFile(normalized.claudePath);
|
|
271
|
+
await writeFile(
|
|
272
|
+
normalized.claudePath,
|
|
273
|
+
replaceCurrentSessionBlock(
|
|
274
|
+
claude.content,
|
|
275
|
+
buildCurrentSessionFieldsForLane(lane, {
|
|
276
|
+
lastThingCompleted: `Switched current lane to ${sessionId}.`,
|
|
277
|
+
}),
|
|
278
|
+
),
|
|
279
|
+
'utf8',
|
|
280
|
+
);
|
|
281
|
+
const agentFileSync = await syncAgentInstructionFilesSafely({
|
|
282
|
+
rootDir: normalized.rootDir,
|
|
283
|
+
toolingRootDir: normalized.toolingRootDir,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
rootDir: normalized.rootDir,
|
|
288
|
+
claudePath: normalized.claudePath,
|
|
289
|
+
current: sessionId,
|
|
290
|
+
lanes,
|
|
291
|
+
agentFileSync,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
182
295
|
async function syncAgentInstructionFilesSafely(options) {
|
|
183
296
|
try {
|
|
184
297
|
return await syncAgentInstructionFiles(options);
|
|
@@ -203,20 +316,32 @@ async function syncAgentInstructionFilesSafely(options) {
|
|
|
203
316
|
|
|
204
317
|
function normalizeSessionPaths(options) {
|
|
205
318
|
const cwd = options?.cwd ? path.resolve(options.cwd) : process.cwd();
|
|
319
|
+
const rootDir = path.resolve(cwd, options?.rootDir ?? '.compass');
|
|
320
|
+
const toolingRootDir = options?.toolingRootDir
|
|
321
|
+
? path.resolve(cwd, options.toolingRootDir)
|
|
322
|
+
: cwd;
|
|
206
323
|
|
|
207
324
|
return {
|
|
208
|
-
rootDir
|
|
209
|
-
toolingRootDir
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
),
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
325
|
+
rootDir,
|
|
326
|
+
toolingRootDir,
|
|
327
|
+
projectFilePath: path.resolve(rootDir, 'project.yaml'),
|
|
328
|
+
claudePath: path.resolve(toolingRootDir, 'CLAUDE.md'),
|
|
329
|
+
sessionsDir: path.resolve(rootDir, 'sessions'),
|
|
330
|
+
activeSessionsDir: path.resolve(rootDir, 'sessions/active'),
|
|
331
|
+
activeSessionsIndexPath: path.resolve(rootDir, 'sessions/active/index.yaml'),
|
|
332
|
+
wipFilePath: path.resolve(rootDir, 'sessions/wip.md'),
|
|
333
|
+
handoffFilePath: path.resolve(rootDir, 'sessions/handoff.md'),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function getLanePaths(normalized, sessionId) {
|
|
338
|
+
const laneDir = path.resolve(normalized.activeSessionsDir, sessionId);
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
laneDir,
|
|
342
|
+
sessionFilePath: path.resolve(laneDir, 'session.yaml'),
|
|
343
|
+
wipFilePath: path.resolve(laneDir, 'wip.md'),
|
|
344
|
+
handoffFilePath: path.resolve(laneDir, 'handoff.md'),
|
|
220
345
|
};
|
|
221
346
|
}
|
|
222
347
|
|
|
@@ -302,13 +427,195 @@ function replaceCurrentSessionBlock(content, fields) {
|
|
|
302
427
|
return `${content.slice(0, sessionFence.bodyStart)}${blockBody}${content.slice(sessionFence.bodyEnd)}`;
|
|
303
428
|
}
|
|
304
429
|
|
|
430
|
+
function buildCurrentSessionFieldsForLane(lane, overrides = {}) {
|
|
431
|
+
if (!lane) {
|
|
432
|
+
throw new Error('Cannot update the Current session block without active lane metadata.');
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const sessionDate = lane.sessionDate ?? normalizeSessionDate();
|
|
436
|
+
const sessionNumber = lane.sessionNumber || 0;
|
|
437
|
+
const workingOn = lane.workingOn ?? 'No summary recorded.';
|
|
438
|
+
|
|
439
|
+
return {
|
|
440
|
+
date: `${sessionDate} (session ${sessionNumber}, lane ${lane.id})`,
|
|
441
|
+
workingOn: `${workingOn} [${lane.id}]`,
|
|
442
|
+
lastThingCompleted:
|
|
443
|
+
overrides.lastThingCompleted ?? 'Current lane selected from `sessions/active/index.yaml`.',
|
|
444
|
+
blockers: overrides.blockers ?? 'No blocker recorded for the selected lane.',
|
|
445
|
+
nextSessionShould:
|
|
446
|
+
overrides.nextSessionShould ?? 'Continue the selected active lane from `sessions/active/index.yaml`.',
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
305
450
|
async function getNextSessionNumber(sessionsDir, sessionDate) {
|
|
306
451
|
const sessions = await listFinalizedSessions(sessionsDir);
|
|
307
|
-
const
|
|
452
|
+
const activeSessions = await listActiveSessionLanes({ sessionsDir, activeSessionsDir: path.join(sessionsDir, 'active') });
|
|
453
|
+
const todaysSessions = [
|
|
454
|
+
...sessions,
|
|
455
|
+
...activeSessions,
|
|
456
|
+
].filter((session) => session.sessionDate === sessionDate);
|
|
308
457
|
const highestNumber = todaysSessions.reduce((max, session) => Math.max(max, session.sessionNumber), 0);
|
|
309
458
|
return highestNumber + 1;
|
|
310
459
|
}
|
|
311
460
|
|
|
461
|
+
async function resolveStartSessionId(normalized, options) {
|
|
462
|
+
const explicitId = normalizeOptionalString(options?.sessionId);
|
|
463
|
+
if (explicitId) {
|
|
464
|
+
return validateLaneId(explicitId);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if ((await fileExists(normalized.wipFilePath)) || (await fileExists(normalized.handoffFilePath))) {
|
|
468
|
+
throw new Error(
|
|
469
|
+
`Legacy active session scratch files already exist under ${normalized.sessionsDir}. Close or recover that session before starting a lane.`,
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
throw new Error('start-session requires --id <lane-id> so each active lane has a meaningful name.');
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function resolveExistingSessionId(normalized, options) {
|
|
477
|
+
const explicitId = normalizeOptionalString(options?.sessionId);
|
|
478
|
+
if (explicitId) {
|
|
479
|
+
return validateLaneId(explicitId);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const lanes = await listActiveSessionLanes(normalized);
|
|
483
|
+
if (lanes.length === 1) {
|
|
484
|
+
return lanes[0].id;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (lanes.length > 1) {
|
|
488
|
+
throw new Error('Multiple active session lanes exist. Pass --session to choose which lane to close.');
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function listActiveSessionLanes(normalized) {
|
|
495
|
+
try {
|
|
496
|
+
const entries = await readdir(normalized.activeSessionsDir, { withFileTypes: true });
|
|
497
|
+
const lanes = [];
|
|
498
|
+
for (const entry of entries) {
|
|
499
|
+
if (!entry.isDirectory()) {
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const sessionId = entry.name;
|
|
504
|
+
const lanePaths = getLanePaths(normalized, sessionId);
|
|
505
|
+
const metadata = await readLaneMetadata(lanePaths.sessionFilePath);
|
|
506
|
+
const wipSession = await readLaneWipSession(lanePaths.wipFilePath);
|
|
507
|
+
lanes.push({
|
|
508
|
+
id: sessionId,
|
|
509
|
+
status: metadata.status ?? 'active',
|
|
510
|
+
workingOn: metadata.workingOn ?? null,
|
|
511
|
+
features: metadata.features,
|
|
512
|
+
repos: metadata.repos,
|
|
513
|
+
claims: metadata.claims,
|
|
514
|
+
sessionDate: metadata.sessionDate ?? wipSession?.sessionDate ?? null,
|
|
515
|
+
sessionNumber: metadata.sessionNumber ?? wipSession?.sessionNumber ?? 0,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return lanes.sort((left, right) => left.id.localeCompare(right.id));
|
|
520
|
+
} catch (error) {
|
|
521
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
|
522
|
+
return [];
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
throw error;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function readLaneWipSession(wipFilePath) {
|
|
530
|
+
try {
|
|
531
|
+
return parseActiveSession(await readFile(wipFilePath, 'utf8'), wipFilePath);
|
|
532
|
+
} catch {
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async function readLaneMetadata(sessionFilePath) {
|
|
538
|
+
try {
|
|
539
|
+
const content = await readFile(sessionFilePath, 'utf8');
|
|
540
|
+
const data = parseSimpleYaml(content, { sourceName: sessionFilePath });
|
|
541
|
+
return {
|
|
542
|
+
status: normalizeOptionalString(data.status),
|
|
543
|
+
workingOn: normalizeOptionalString(data.working_on),
|
|
544
|
+
features: normalizeStringArray(data.feature_slugs),
|
|
545
|
+
repos: normalizeStringArray(data.repos),
|
|
546
|
+
claims: normalizeStringArray(data.claimed_paths),
|
|
547
|
+
sessionDate: normalizeOptionalString(data.session_date),
|
|
548
|
+
sessionNumber: typeof data.session_number === 'number' ? data.session_number : Number(data.session_number) || null,
|
|
549
|
+
};
|
|
550
|
+
} catch {
|
|
551
|
+
return {
|
|
552
|
+
status: null,
|
|
553
|
+
workingOn: null,
|
|
554
|
+
features: [],
|
|
555
|
+
repos: [],
|
|
556
|
+
claims: [],
|
|
557
|
+
sessionDate: null,
|
|
558
|
+
sessionNumber: null,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async function readActiveSessionIndex(normalized) {
|
|
564
|
+
try {
|
|
565
|
+
const data = parseSimpleYaml(await readFile(normalized.activeSessionsIndexPath, 'utf8'), {
|
|
566
|
+
sourceName: normalized.activeSessionsIndexPath,
|
|
567
|
+
});
|
|
568
|
+
return {
|
|
569
|
+
current: normalizeOptionalString(data.current),
|
|
570
|
+
};
|
|
571
|
+
} catch {
|
|
572
|
+
return {
|
|
573
|
+
current: null,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function upsertActiveSessionIndex(normalized, lane, options = {}) {
|
|
579
|
+
const currentIndex = await readActiveSessionIndex(normalized);
|
|
580
|
+
const lanes = await listActiveSessionLanes(normalized);
|
|
581
|
+
const laneMap = new Map(lanes.map((item) => [item.id, item]));
|
|
582
|
+
if (lane) {
|
|
583
|
+
laneMap.set(lane.id, {
|
|
584
|
+
id: lane.id,
|
|
585
|
+
status: lane.status,
|
|
586
|
+
workingOn: lane.workingOn,
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const current = options.current ?? currentIndex.current ?? lane?.id ?? null;
|
|
591
|
+
await mkdir(normalized.activeSessionsDir, { recursive: true });
|
|
592
|
+
await writeFile(normalized.activeSessionsIndexPath, renderActiveSessionIndex(current, [...laneMap.values()]), 'utf8');
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Removes a closed lane from the active index and returns the surviving lane
|
|
597
|
+
* state used to refresh the Current session block. `lanes` contains the full
|
|
598
|
+
* lane metadata read from each sibling lane's `session.yaml` / `wip.md`.
|
|
599
|
+
*/
|
|
600
|
+
async function removeActiveSessionFromIndex(normalized, sessionId) {
|
|
601
|
+
const index = await readActiveSessionIndex(normalized);
|
|
602
|
+
const lanes = (await listActiveSessionLanes(normalized)).filter((lane) => lane.id !== sessionId);
|
|
603
|
+
const current = index.current === sessionId ? lanes[0]?.id ?? null : index.current;
|
|
604
|
+
if (lanes.length === 0) {
|
|
605
|
+
await rm(normalized.activeSessionsIndexPath, { force: true });
|
|
606
|
+
return {
|
|
607
|
+
current: null,
|
|
608
|
+
lanes,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
await writeFile(normalized.activeSessionsIndexPath, renderActiveSessionIndex(current, lanes), 'utf8');
|
|
613
|
+
return {
|
|
614
|
+
current,
|
|
615
|
+
lanes,
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
|
|
312
619
|
async function listFinalizedSessions(sessionsDir) {
|
|
313
620
|
try {
|
|
314
621
|
const entries = await readdir(sessionsDir, { withFileTypes: true });
|
|
@@ -337,10 +644,10 @@ async function listFinalizedSessions(sessionsDir) {
|
|
|
337
644
|
}
|
|
338
645
|
}
|
|
339
646
|
|
|
340
|
-
function parseActiveSession(wipContent) {
|
|
647
|
+
function parseActiveSession(wipContent, wipFilePath = 'sessions/active/<lane-id>/wip.md') {
|
|
341
648
|
const match = wipContent.match(WIP_HEADER_PATTERN);
|
|
342
649
|
if (!match) {
|
|
343
|
-
throw new Error(
|
|
650
|
+
throw new Error(`${wipFilePath} is missing the expected "# WIP — YYYY-MM-DD (session N)" header.`);
|
|
344
651
|
}
|
|
345
652
|
|
|
346
653
|
return {
|
|
@@ -349,6 +656,19 @@ function parseActiveSession(wipContent) {
|
|
|
349
656
|
};
|
|
350
657
|
}
|
|
351
658
|
|
|
659
|
+
function validateLaneId(value) {
|
|
660
|
+
const normalized = value.trim();
|
|
661
|
+
if (!LANE_ID_PATTERN.test(normalized)) {
|
|
662
|
+
throw new Error('Session lane ID must be a lowercase slug 3-64 characters long using letters, numbers, and hyphens.');
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (RESERVED_LANE_IDS.has(normalized)) {
|
|
666
|
+
throw new Error(`Session lane ID "${normalized}" is reserved.`);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return normalized;
|
|
670
|
+
}
|
|
671
|
+
|
|
352
672
|
function extractSectionBody(content, sectionTitle) {
|
|
353
673
|
const heading = `## ${sectionTitle}`.toLowerCase();
|
|
354
674
|
const lines = content.split('\n');
|
|
@@ -407,6 +727,8 @@ function findCurrentSessionFence(content) {
|
|
|
407
727
|
function renderWipTemplate(options) {
|
|
408
728
|
return `# WIP — ${options.sessionDate} (session ${options.sessionNumber})
|
|
409
729
|
|
|
730
|
+
Session lane: ${options.sessionId}
|
|
731
|
+
|
|
410
732
|
## Working on
|
|
411
733
|
${options.workingOn}
|
|
412
734
|
|
|
@@ -423,6 +745,8 @@ ${options.workingOn}
|
|
|
423
745
|
function renderHandoffTemplate(options) {
|
|
424
746
|
return `# Handoff — ${options.sessionDate} (session ${options.sessionNumber})
|
|
425
747
|
|
|
748
|
+
Session lane: ${options.sessionId}
|
|
749
|
+
|
|
426
750
|
## Builder → Reviewer
|
|
427
751
|
|
|
428
752
|
### What changed
|
|
@@ -444,6 +768,53 @@ function renderHandoffTemplate(options) {
|
|
|
444
768
|
`;
|
|
445
769
|
}
|
|
446
770
|
|
|
771
|
+
function renderLaneMetadata(options) {
|
|
772
|
+
return [
|
|
773
|
+
`id: ${options.sessionId}`,
|
|
774
|
+
'status: active',
|
|
775
|
+
`session_date: ${options.sessionDate}`,
|
|
776
|
+
`session_number: ${options.sessionNumber}`,
|
|
777
|
+
`working_on: ${quoteYamlString(options.workingOn)}`,
|
|
778
|
+
renderYamlArray('feature_slugs', options.features),
|
|
779
|
+
renderYamlArray('repos', options.repos),
|
|
780
|
+
renderYamlArray('claimed_paths', options.claims),
|
|
781
|
+
`started_at: ${new Date().toISOString()}`,
|
|
782
|
+
'decision_snapshot:',
|
|
783
|
+
' highest_decision_id: null',
|
|
784
|
+
'',
|
|
785
|
+
].join('\n');
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function renderActiveSessionIndex(current, lanes) {
|
|
789
|
+
const lines = [
|
|
790
|
+
`current: ${current ?? 'null'}`,
|
|
791
|
+
'lanes:',
|
|
792
|
+
];
|
|
793
|
+
|
|
794
|
+
for (const lane of lanes.sort((left, right) => left.id.localeCompare(right.id))) {
|
|
795
|
+
lines.push(` - id: ${lane.id}`);
|
|
796
|
+
lines.push(` status: ${lane.status ?? 'active'}`);
|
|
797
|
+
lines.push(` working_on: ${quoteYamlString(lane.workingOn ?? '')}`);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
return `${lines.join('\n')}\n`;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function renderYamlArray(key, values) {
|
|
804
|
+
if (!values || values.length === 0) {
|
|
805
|
+
return `${key}: []`;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
return [
|
|
809
|
+
`${key}:`,
|
|
810
|
+
...values.map((value) => ` - ${quoteYamlString(value)}`),
|
|
811
|
+
].join('\n');
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function quoteYamlString(value) {
|
|
815
|
+
return JSON.stringify(value);
|
|
816
|
+
}
|
|
817
|
+
|
|
447
818
|
function renderSessionNote(options) {
|
|
448
819
|
return `# Session — ${options.sessionDate}-${options.sessionNumber} — ${options.title}
|
|
449
820
|
|