@vibecompass/vibecompass 0.13.1 → 0.15.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/src/session.js CHANGED
@@ -157,6 +157,18 @@ async function startProjectSessionLocked(normalized, options, markerContext) {
157
157
  const decisionDomainFiles = normalizeStringArray(options?.decisionDomainFiles ?? options?.decisionDomains)
158
158
  .map(normalizeDecisionDomainFile);
159
159
  const existingLanes = await listActiveSessionLanes(normalized);
160
+ // D-353: an already-open lane is resumed, never re-opened; fail closed
161
+ // before any preflight so the root stays untouched.
162
+ if (existingLanes.some((lane) => lane.id === sessionId)) {
163
+ throw new Error(
164
+ `Active session lane "${sessionId}" already exists. Resume it with \`vibecompass continue-session ${sessionId}\`, or choose a different --id for new work (D-353).`,
165
+ );
166
+ }
167
+ const priorIndex = await readActiveSessionIndex(normalized);
168
+ const resumeAlternativeWarnings = buildResumeAlternativeWarnings(
169
+ existingLanes,
170
+ resolveValidatedCurrentLane(priorIndex.current, existingLanes),
171
+ );
160
172
  const overlapWarnings = buildOverlapWarnings({
161
173
  sessionId,
162
174
  features,
@@ -267,7 +279,7 @@ async function startProjectSessionLocked(normalized, options, markerContext) {
267
279
  await mkdir(normalized.activeSessionsDir, { recursive: true });
268
280
  await mkdir(lanePaths.laneDir, { recursive: false }).catch((error) => {
269
281
  if (error && typeof error === 'object' && 'code' in error && error.code === 'EEXIST') {
270
- throw new Error(`Active session lane "${sessionId}" already exists. Choose a different --id or close that lane first.`);
282
+ throw new Error(`Active session lane "${sessionId}" already exists. Resume it with \`vibecompass continue-session ${sessionId}\`, or choose a different --id for new work (D-353).`);
271
283
  }
272
284
 
273
285
  throw error;
@@ -401,6 +413,7 @@ async function startProjectSessionLocked(normalized, options, markerContext) {
401
413
  warnings: [
402
414
  ...markerContext.warnings,
403
415
  ...metadataWarnings,
416
+ ...resumeAlternativeWarnings,
404
417
  ...overlapWarnings,
405
418
  ...baseRevisionCapture.warnings,
406
419
  ...runtimeSettings.warnings,
@@ -767,13 +780,13 @@ async function canonicalizePathBestEffort(value) {
767
780
  }
768
781
 
769
782
  /** Flattens the docs-update pre-close staleness set into close warnings (A:192 re-emission). */
770
- function collectStalenessWarnings(plan) {
783
+ function collectStalenessWarnings(plan, label = 'Pre-close staleness') {
771
784
  const staleness = plan?.staleness;
772
785
  if (!staleness) {
773
786
  return [];
774
787
  }
775
788
 
776
- return (staleness.entries ?? []).map((entry) => `Pre-close staleness: ${entry}`);
789
+ return (staleness.entries ?? []).map((entry) => `${label}: ${entry}`);
777
790
  }
778
791
 
779
792
  function normalizeDocumentMaintenanceCheckpoint(value) {
@@ -893,10 +906,12 @@ export async function listProjectSessions(options = {}) {
893
906
  const index = await readActiveSessionIndex(normalized);
894
907
  const lanes = await listActiveSessionLanes(normalized);
895
908
 
909
+ const current = resolveValidatedCurrentLane(index.current, lanes);
896
910
  return {
897
911
  rootDir: normalized.rootDir,
898
- current: resolveValidatedCurrentLane(index.current, lanes),
912
+ current,
899
913
  lanes,
914
+ inventory: buildLaneInventory(lanes, current),
900
915
  };
901
916
  }
902
917
 
@@ -970,6 +985,429 @@ async function switchProjectSessionLocked(normalized, options, markerContext) {
970
985
  };
971
986
  }
972
987
 
988
+ /**
989
+ * D-353: resumes an already-open lane. Lane selection is the D-277 resolver
990
+ * (explicit id → worktree marker → single active lane); with two or more
991
+ * active lanes and no selection the command fails closed, but the error
992
+ * carries the rendered lane inventory so the caller can choose without a
993
+ * second command. Resuming never renumbers or re-dates the lane — the wip
994
+ * header, session number, and eventual finalized-note name stay anchored to
995
+ * the opening date; only the continuity surfaces (index pointer, Current
996
+ * session block, wip log, session.yaml resume bookkeeping) move.
997
+ */
998
+ export async function continueProjectSession(options = {}) {
999
+ const { normalized, markerContext } = await resolveSessionCommandContext(options, { needMarkerForLane: true });
1000
+ return withMemoryRootLock(normalized.rootDir, 'continue-session', () =>
1001
+ continueProjectSessionLocked(normalized, options, markerContext));
1002
+ }
1003
+
1004
+ async function continueProjectSessionLocked(normalized, options, markerContext) {
1005
+ await assertLaneMarkerSnapshotCurrent(markerContext);
1006
+ await ensureInitializedProjectMemory(normalized.rootDir, { allowMissingProjectFile: true });
1007
+ const lanes = await listActiveSessionLanes(normalized);
1008
+ const index = await readActiveSessionIndex(normalized);
1009
+ const inventory = buildLaneInventory(lanes, resolveValidatedCurrentLane(index.current, lanes));
1010
+
1011
+ if (lanes.length === 0) {
1012
+ throw new Error(
1013
+ 'No active session lane exists to continue. Open one with `vibecompass start-session --id <lane-id> --working-on "..."` (D-353).',
1014
+ );
1015
+ }
1016
+
1017
+ const explicitId = normalizeOptionalString(options?.sessionId);
1018
+ if (!explicitId && !markerContext.marker && lanes.length > 1) {
1019
+ throw new Error(
1020
+ [
1021
+ `Multiple active session lanes exist; continue-session will not guess which one to resume (D-277/D-353).`,
1022
+ ...renderLaneInventoryLines(inventory),
1023
+ 'Choose explicitly: `vibecompass continue-session <lane-id>`, or run the command from a directory bound to the intended lane (worktree lane marker).',
1024
+ 'The `current` pointer above is a continuity hint, not a selection.',
1025
+ ].join('\n'),
1026
+ );
1027
+ }
1028
+ // An unknown explicit id fails closed with the same inventory the multi-lane
1029
+ // refusal prints, so the caller can correct the id without a second command.
1030
+ if (explicitId && !lanes.some((item) => item.id === validateLaneId(explicitId))) {
1031
+ throw new Error(
1032
+ [
1033
+ `Session lane "${explicitId}" is not an active lane in ${normalized.rootDir}.`,
1034
+ ...renderLaneInventoryLines(inventory),
1035
+ 'Resume one of the lanes above with `vibecompass continue-session <lane-id>`.',
1036
+ ].join('\n'),
1037
+ );
1038
+ }
1039
+
1040
+ const selection = resolveLaneSelection({
1041
+ explicitSessionId: explicitId ? validateLaneId(explicitId) : null,
1042
+ marker: markerContext.marker,
1043
+ laneIds: lanes.map((lane) => lane.id),
1044
+ rootDir: normalized.rootDir,
1045
+ purpose: 'continue',
1046
+ });
1047
+ const lane = lanes.find((item) => item.id === selection.sessionId);
1048
+ if (lane.warnings.length > 0) {
1049
+ throw new Error(
1050
+ `Lane "${lane.id}"'s session.yaml could not be parsed (${lane.warnings[0]}); repair the lane metadata before continuing it, or close the lane.`,
1051
+ );
1052
+ }
1053
+
1054
+ const lanePaths = getLanePaths(normalized, lane.id);
1055
+ const missingScratch = [];
1056
+ if (!(await fileExists(lanePaths.wipFilePath))) {
1057
+ missingScratch.push(lanePaths.wipFilePath);
1058
+ }
1059
+ if (!(await fileExists(lanePaths.handoffFilePath))) {
1060
+ missingScratch.push(lanePaths.handoffFilePath);
1061
+ }
1062
+ if (missingScratch.length > 0) {
1063
+ throw new Error(
1064
+ `Lane "${lane.id}" is missing ${missingScratch.join(' and ')}. Recreate the scratch file(s) from the templates in context.md (or close the lane) before continuing it.`,
1065
+ );
1066
+ }
1067
+
1068
+ // Every required input is read and every output is prepared before the
1069
+ // first write, so a deterministic refusal (missing CLAUDE.md, unparseable
1070
+ // scratch, metadata that would not round-trip) leaves all lane, index, and
1071
+ // tooling bytes untouched.
1072
+ const wipContent = await readFile(lanePaths.wipFilePath, 'utf8');
1073
+ const activeSession = parseActiveSession(wipContent, lanePaths.wipFilePath);
1074
+ const sessionContent = await readFile(lanePaths.sessionFilePath, 'utf8');
1075
+ const claude = await readClaudeFile(normalized.claudePath);
1076
+ const currentSession = parseCurrentSessionBlock(claude.content, { optional: true });
1077
+
1078
+ const sessionDate = lane.sessionDate ?? activeSession.sessionDate;
1079
+ const sessionNumber = lane.sessionNumber || activeSession.sessionNumber;
1080
+ const resumeDate = normalizeSessionDate(options?.date);
1081
+ const resumedAt = formatLocalDateTime(new Date());
1082
+ const resumeCount = (lane.resumeCount ?? 0) + 1;
1083
+ const workingOnUpdate = normalizeOptionalString(options?.workingOn);
1084
+ const workingOn = workingOnUpdate ?? lane.workingOn ?? 'No summary recorded';
1085
+ const otherLanes = lanes.filter((item) => item.id !== lane.id);
1086
+
1087
+ const updatedSessionContent = upsertTopLevelYamlScalars(
1088
+ sessionContent,
1089
+ {
1090
+ ...(workingOnUpdate ? { working_on: quoteYamlString(workingOnUpdate) } : {}),
1091
+ resumed_at: resumedAt,
1092
+ resume_count: String(resumeCount),
1093
+ },
1094
+ lanePaths.sessionFilePath,
1095
+ );
1096
+ const otherLaneNote = otherLanes.length > 0
1097
+ ? `; ${otherLanes.length} other active lane${otherLanes.length === 1 ? '' : 's'}: ${otherLanes.map((item) => item.id).join(', ')}`
1098
+ : '';
1099
+ const logLine = `- Resumed lane on ${resumeDate} (continue-session #${resumeCount}${otherLaneNote})${workingOnUpdate ? `. Working on updated: ${workingOnUpdate}` : ''}`;
1100
+ const updatedWipContent = appendWipLogLine(
1101
+ workingOnUpdate ? replaceWipSectionBody(wipContent, 'Working on', workingOnUpdate) : wipContent,
1102
+ logLine,
1103
+ );
1104
+ // Continuity fields carry over only when the block already described this
1105
+ // exact lane (a prefix match such as `billing` vs `billing-tax` must not).
1106
+ const blockNamesThisLane = parseCurrentSessionLaneId(currentSession?.date) === lane.id;
1107
+ const updatedClaudeContent = replaceCurrentSessionBlock(
1108
+ claude.content,
1109
+ {
1110
+ date: `${sessionDate} (session ${sessionNumber}, lane ${lane.id}; resumed ${resumeDate})`,
1111
+ workingOn: `${workingOn} [${lane.id}]`,
1112
+ lastThingCompleted:
1113
+ normalizeOptionalString(options?.lastThingCompleted) ??
1114
+ (blockNamesThisLane ? currentSession.lastThingCompleted : null) ??
1115
+ `Resumed lane ${lane.id} (continue-session #${resumeCount}).`,
1116
+ blockers:
1117
+ normalizeOptionalString(options?.blockers) ??
1118
+ (blockNamesThisLane ? currentSession.blockers : null) ??
1119
+ 'No blocker recorded for the selected lane.',
1120
+ nextSessionShould:
1121
+ normalizeOptionalString(options?.nextSessionShould) ??
1122
+ (blockNamesThisLane ? currentSession.nextSessionShould : null) ??
1123
+ 'Continue this lane from its wip.md/handoff.md and close it with a finalized session note.',
1124
+ },
1125
+ {
1126
+ lanes: lanes.map((item) => ({ id: item.id, workingOn: item.id === lane.id ? workingOn : item.workingOn })),
1127
+ selectedId: lane.id,
1128
+ },
1129
+ );
1130
+
1131
+ await writeFile(lanePaths.sessionFilePath, updatedSessionContent, 'utf8');
1132
+ await writeFile(lanePaths.wipFilePath, updatedWipContent, 'utf8');
1133
+ await upsertActiveSessionIndex(normalized, { id: lane.id, status: 'active', workingOn }, { current: lane.id });
1134
+ await writeFile(normalized.claudePath, updatedClaudeContent, 'utf8');
1135
+
1136
+ const docsUpdate = await planDocsUpdateSafely({
1137
+ rootDir: normalized.rootDir,
1138
+ cwd: normalized.cwd,
1139
+ sessionId: lane.id,
1140
+ suppressLaneWarnings: true,
1141
+ planner: options?.docsUpdatePlanner,
1142
+ });
1143
+ const manifestRefresh = await refreshStateManifestSafely(normalized.rootDir);
1144
+ const agentFileSync = await syncAgentInstructionFilesSafely({
1145
+ rootDir: normalized.rootDir,
1146
+ toolingRootDir: normalized.toolingRootDir,
1147
+ });
1148
+ const finalizedSessions = await listFinalizedSessions(normalized.sessionsDir);
1149
+ const latestFinalized = finalizedSessions.length > 0 ? finalizedSessions[finalizedSessions.length - 1] : null;
1150
+ // The returned inventory is the post-resume state (what list-sessions shows
1151
+ // now), not the pre-mutation snapshot used for the refusal messages.
1152
+ const inventoryAfter = buildLaneInventory(await listActiveSessionLanes(normalized), lane.id);
1153
+
1154
+ return {
1155
+ rootDir: normalized.rootDir,
1156
+ toolingRootDir: normalized.toolingRootDir,
1157
+ claudePath: normalized.claudePath,
1158
+ sessionId: lane.id,
1159
+ selectionSource: selection.source,
1160
+ sessionFilePath: lanePaths.sessionFilePath,
1161
+ wipFilePath: lanePaths.wipFilePath,
1162
+ handoffFilePath: lanePaths.handoffFilePath,
1163
+ latestFinalizedSessionPath: latestFinalized ? path.join(normalized.sessionsDir, latestFinalized.fileName) : null,
1164
+ sessionDate,
1165
+ sessionNumber,
1166
+ resumeDate,
1167
+ resumedAt,
1168
+ resumeCount,
1169
+ workingOn,
1170
+ workingOnUpdated: Boolean(workingOnUpdate),
1171
+ startedAt: lane.startedAt ?? null,
1172
+ laneAgeDays: computeLaneAgeDays(sessionDate, resumeDate),
1173
+ runtime: lane.runtime ?? null,
1174
+ gitBinding: lane.branch
1175
+ ? {
1176
+ branch: lane.branch,
1177
+ worktreeContainer: lane.worktreeContainer ?? null,
1178
+ worktrees: lane.worktrees ?? {},
1179
+ }
1180
+ : null,
1181
+ otherLanes: inventoryAfter.lanes.filter((item) => item.id !== lane.id),
1182
+ inventory: inventoryAfter,
1183
+ docsUpdatePlan: docsUpdate.plan,
1184
+ warnings: [
1185
+ ...markerContext.warnings,
1186
+ ...selection.warnings,
1187
+ ...collectLaneMetadataWarnings(otherLanes),
1188
+ ...collectStalenessWarnings(docsUpdate.plan, 'Resume staleness'),
1189
+ ...buildSharedCheckoutWarnings({
1190
+ sessionId: lane.id,
1191
+ repos: lane.repos ?? [],
1192
+ claims: lane.claims ?? [],
1193
+ existingLanes: otherLanes,
1194
+ hasWorktree: Boolean(lane.worktreeContainer),
1195
+ context: 'continue-session',
1196
+ }),
1197
+ ...docsUpdate.warnings,
1198
+ ...manifestRefresh.warnings,
1199
+ ],
1200
+ manifest: manifestRefresh.manifest,
1201
+ agentFileSync,
1202
+ };
1203
+ }
1204
+
1205
+ /**
1206
+ * Lane inventory (D-353): the one summary shape shared by `list-sessions`,
1207
+ * the continue-session multi-lane failure, and the start-session resume
1208
+ * hint, so every surface an agent might read describes lanes the same way.
1209
+ */
1210
+ function buildLaneInventory(lanes, current) {
1211
+ return {
1212
+ current: current ?? null,
1213
+ lanes: [...lanes]
1214
+ .sort((left, right) => left.id.localeCompare(right.id))
1215
+ .map((lane) => ({
1216
+ id: lane.id,
1217
+ workingOn: lane.workingOn ?? null,
1218
+ sessionDate: lane.sessionDate ?? null,
1219
+ sessionNumber: lane.sessionNumber || null,
1220
+ startedAt: lane.startedAt ?? null,
1221
+ resumedAt: lane.resumedAt ?? null,
1222
+ resumeCount: lane.resumeCount ?? 0,
1223
+ branch: lane.branch ?? null,
1224
+ worktreeContainer: lane.worktreeContainer ?? null,
1225
+ worktreeRepos: Object.keys(lane.worktrees ?? {}),
1226
+ runtimePort: lane.runtime?.port ?? null,
1227
+ lastLogEntry: lane.lastLogEntry ?? null,
1228
+ isCurrentHint: current != null && lane.id === current,
1229
+ warnings: lane.warnings ?? [],
1230
+ })),
1231
+ };
1232
+ }
1233
+
1234
+ export function renderLaneInventoryLines(inventory) {
1235
+ const lines = [`Active session lanes${inventory.current ? ` (current hint: ${inventory.current})` : ''}:`];
1236
+ if (inventory.lanes.length === 0) {
1237
+ lines.push('- None');
1238
+ return lines;
1239
+ }
1240
+
1241
+ for (const lane of inventory.lanes) {
1242
+ const marker = lane.isCurrentHint ? '*' : '-';
1243
+ lines.push(`${marker} ${lane.id}: ${lane.workingOn ?? 'No summary recorded'}${lane.isCurrentHint ? ' [current hint]' : ''}`);
1244
+ const facts = [];
1245
+ if (lane.sessionDate) {
1246
+ facts.push(`opened ${lane.sessionDate}${lane.sessionNumber ? ` (session ${lane.sessionNumber})` : ''}`);
1247
+ }
1248
+ if (lane.resumeCount > 0) {
1249
+ facts.push(`resumed ${lane.resumeCount}×${lane.resumedAt ? ` (last ${lane.resumedAt})` : ''}`);
1250
+ }
1251
+ if (lane.branch) {
1252
+ facts.push(`branch ${lane.branch}${lane.worktreeRepos.length > 0 ? ` (worktrees: ${lane.worktreeRepos.join(', ')})` : ''}`);
1253
+ }
1254
+ if (lane.runtimePort != null) {
1255
+ facts.push(`port ${lane.runtimePort}`);
1256
+ }
1257
+ if (facts.length > 0) {
1258
+ lines.push(` ${facts.join('; ')}`);
1259
+ }
1260
+ if (lane.lastLogEntry) {
1261
+ lines.push(` last log: ${lane.lastLogEntry}`);
1262
+ }
1263
+ for (const warning of lane.warnings) {
1264
+ lines.push(` warning: ${warning}`);
1265
+ }
1266
+ }
1267
+
1268
+ return lines;
1269
+ }
1270
+
1271
+ function buildResumeAlternativeWarnings(existingLanes, currentHint = null) {
1272
+ if (existingLanes.length === 0) {
1273
+ return [];
1274
+ }
1275
+
1276
+ const ids = existingLanes.map((lane) => lane.id).sort((left, right) => left.localeCompare(right));
1277
+ const inventoryLines = renderLaneInventoryLines(buildLaneInventory(existingLanes, currentHint)).map((line) => ` ${line}`);
1278
+ return [
1279
+ [
1280
+ `${ids.length} lane${ids.length === 1 ? ' is' : 's are'} already active (${ids.join(', ')}). Opening a second lane is fine for new work; to resume existing work instead, run \`vibecompass continue-session <lane-id>\` (D-353).`,
1281
+ ...inventoryLines,
1282
+ ].join('\n'),
1283
+ ];
1284
+ }
1285
+
1286
+ /**
1287
+ * Replaces or appends top-level scalar lines in a lane session.yaml without
1288
+ * re-rendering the file, so keys this writer does not know about survive.
1289
+ * Values are written verbatim through a function replacer (callers quote
1290
+ * where needed; `$&`-style replacement tokens in user text are never
1291
+ * interpreted). Key matching accepts the horizontal spacing parseSimpleYaml
1292
+ * accepts (`key :`) and is CRLF-tolerant, so an existing spaced key is
1293
+ * replaced rather than duplicated. The result is parsed before it is returned
1294
+ * and an unparseable result is refused, so the caller never writes metadata
1295
+ * that the next reader would null-degrade.
1296
+ */
1297
+ function upsertTopLevelYamlScalars(content, scalars, sourceName = 'session.yaml') {
1298
+ let next = content;
1299
+ for (const [key, value] of Object.entries(scalars)) {
1300
+ const pattern = new RegExp(`^${key}[ \\t]*:[^\\r\\n]*(\\r?\\n|$)`, 'm');
1301
+ const line = `${key}: ${value}\n`;
1302
+ if (pattern.test(next)) {
1303
+ next = next.replace(pattern, () => line);
1304
+ } else {
1305
+ next = `${next.endsWith('\n') || next.length === 0 ? next : `${next}\n`}${line}`;
1306
+ }
1307
+ }
1308
+
1309
+ try {
1310
+ parseSimpleYaml(next, { sourceName });
1311
+ } catch (error) {
1312
+ throw new Error(
1313
+ `continue-session refused to write ${sourceName}: the updated lane metadata would not parse (${error instanceof Error ? error.message : String(error)}). Nothing was written.`,
1314
+ );
1315
+ }
1316
+
1317
+ return next;
1318
+ }
1319
+
1320
+ /**
1321
+ * Extracts the lane id recorded in a Current session block Date field
1322
+ * (`YYYY-MM-DD (session N, lane <id>[; resumed YYYY-MM-DD])`). Returns null
1323
+ * when the field has no lane clause, so callers compare full ids for
1324
+ * equality instead of substring-matching.
1325
+ */
1326
+ function parseCurrentSessionLaneId(dateField) {
1327
+ if (typeof dateField !== 'string') {
1328
+ return null;
1329
+ }
1330
+
1331
+ const match = dateField.match(/\blane ([a-z0-9][a-z0-9-]*)(?=[;)\s]|$)/);
1332
+ return match ? match[1] : null;
1333
+ }
1334
+
1335
+ function findWipSection(content, sectionTitle) {
1336
+ const lines = content.split('\n');
1337
+ const heading = `## ${sectionTitle}`.toLowerCase();
1338
+ const start = lines.findIndex((line) => line.trim().toLowerCase() === heading);
1339
+ if (start < 0) {
1340
+ return null;
1341
+ }
1342
+
1343
+ let end = lines.length;
1344
+ for (let index = start + 1; index < lines.length; index += 1) {
1345
+ if (/^##\s+/.test(lines[index])) {
1346
+ end = index;
1347
+ break;
1348
+ }
1349
+ }
1350
+
1351
+ return { lines, start, end };
1352
+ }
1353
+
1354
+ function appendWipLogLine(content, logLine) {
1355
+ const section = findWipSection(content, 'Log');
1356
+ if (!section) {
1357
+ const base = content.endsWith('\n') ? content : `${content}\n`;
1358
+ return `${base}\n## Log\n${logLine}\n`;
1359
+ }
1360
+
1361
+ const { lines, start, end } = section;
1362
+ let bodyEnd = end;
1363
+ while (bodyEnd > start + 1 && lines[bodyEnd - 1].trim() === '') {
1364
+ bodyEnd -= 1;
1365
+ }
1366
+ const body = lines.slice(start + 1, bodyEnd);
1367
+ // A template placeholder ("- None yet.") is replaced by the first real entry.
1368
+ const kept = body.length === 1 && /^-\s*none yet\.?$/i.test(body[0].trim()) ? [] : body;
1369
+ const trailer = end < lines.length ? [''] : [];
1370
+ return [...lines.slice(0, start + 1), ...kept, logLine, ...trailer, ...lines.slice(end)].join('\n');
1371
+ }
1372
+
1373
+ function replaceWipSectionBody(content, sectionTitle, bodyText) {
1374
+ const section = findWipSection(content, sectionTitle);
1375
+ if (!section) {
1376
+ return content;
1377
+ }
1378
+
1379
+ const { lines, start, end } = section;
1380
+ const trailer = end < lines.length ? [''] : [];
1381
+ return [...lines.slice(0, start + 1), bodyText, ...trailer, ...lines.slice(end)].join('\n');
1382
+ }
1383
+
1384
+ function extractLastLogEntry(content) {
1385
+ const section = findWipSection(content, 'Log');
1386
+ if (!section) {
1387
+ return null;
1388
+ }
1389
+
1390
+ const { lines, start, end } = section;
1391
+ for (let index = end - 1; index > start; index -= 1) {
1392
+ const trimmed = lines[index].trim();
1393
+ if (trimmed.length > 0) {
1394
+ return trimmed.length > 200 ? `${trimmed.slice(0, 197)}...` : trimmed;
1395
+ }
1396
+ }
1397
+
1398
+ return null;
1399
+ }
1400
+
1401
+ function computeLaneAgeDays(openedDate, resumeDate) {
1402
+ const opened = Date.parse(`${openedDate}T00:00:00Z`);
1403
+ const resumed = Date.parse(`${resumeDate}T00:00:00Z`);
1404
+ if (!Number.isFinite(opened) || !Number.isFinite(resumed)) {
1405
+ return null;
1406
+ }
1407
+
1408
+ return Math.max(0, Math.round((resumed - opened) / 86_400_000));
1409
+ }
1410
+
973
1411
  /**
974
1412
  * Rebuilds `sessions/active/index.yaml` from the active lane directories plus
975
1413
  * an explicit current-lane selection (D-266/D-277 recovery path). The current
@@ -1532,6 +1970,9 @@ async function listActiveSessionLanes(normalized) {
1532
1970
  architectureDocs: metadata.architectureDocs,
1533
1971
  decisionDomainFiles: metadata.decisionDomainFiles,
1534
1972
  startedAt: metadata.startedAt,
1973
+ resumedAt: metadata.resumedAt ?? null,
1974
+ resumeCount: metadata.resumeCount ?? 0,
1975
+ lastLogEntry: wipSession?.lastLogEntry ?? null,
1535
1976
  decisionSnapshot: metadata.decisionSnapshot,
1536
1977
  branch: metadata.branch ?? null,
1537
1978
  worktreeContainer: metadata.worktreeContainer ?? null,
@@ -1557,7 +1998,11 @@ async function listActiveSessionLanes(normalized) {
1557
1998
 
1558
1999
  async function readLaneWipSession(wipFilePath) {
1559
2000
  try {
1560
- return parseActiveSession(await readFile(wipFilePath, 'utf8'), wipFilePath);
2001
+ const content = await readFile(wipFilePath, 'utf8');
2002
+ return {
2003
+ ...parseActiveSession(content, wipFilePath),
2004
+ lastLogEntry: extractLastLogEntry(content),
2005
+ };
1561
2006
  } catch {
1562
2007
  return null;
1563
2008
  }
@@ -1576,6 +2021,9 @@ async function readLaneMetadata(sessionFilePath) {
1576
2021
  architectureDocs: normalizeStringArray(data.architecture_docs).map(normalizeArchitectureDocPath),
1577
2022
  decisionDomainFiles: normalizeStringArray(data.decision_domain_files).map(normalizeDecisionDomainFile),
1578
2023
  startedAt: normalizeOptionalString(data.started_at),
2024
+ // D-353 resume bookkeeping; absent on lanes opened before continue-session existed.
2025
+ resumedAt: normalizeOptionalString(data.resumed_at),
2026
+ resumeCount: Math.max(0, Math.trunc(parseNullableNumber(data.resume_count) ?? 0)),
1579
2027
  decisionSnapshot: {
1580
2028
  highestDecisionId: parseNullableNumber(data.decision_snapshot?.highest_decision_id),
1581
2029
  },
@@ -1611,6 +2059,8 @@ async function readLaneMetadata(sessionFilePath) {
1611
2059
  architectureDocs: [],
1612
2060
  decisionDomainFiles: [],
1613
2061
  startedAt: null,
2062
+ resumedAt: null,
2063
+ resumeCount: 0,
1614
2064
  decisionSnapshot: {
1615
2065
  highestDecisionId: null,
1616
2066
  },
@@ -1727,6 +2177,7 @@ async function listFinalizedSessions(sessionsDir) {
1727
2177
  .map((entry) => entry.name.match(SESSION_FILENAME_PATTERN))
1728
2178
  .filter(Boolean)
1729
2179
  .map((match) => ({
2180
+ fileName: match[0],
1730
2181
  sessionDate: match[1],
1731
2182
  sessionNumber: Number(match[2]),
1732
2183
  slug: match[3],
package/src/status.js CHANGED
@@ -5,6 +5,7 @@ import { sha256Text } from './hash.js';
5
5
  import { resolveLaneMarkerContext } from './lane-marker.js';
6
6
  import { parseSimpleYaml } from './simple-yaml.js';
7
7
  import { resolveSyncBinding } from './sync-binding.js';
8
+ import { describeCredentialSource, resolveSyncCredential } from './credential-store.js';
8
9
  import { inferToolingRootForMemoryRoot, listProjectSessions } from './session.js';
9
10
  import { syncAgentInstructionFiles } from './generators/agent-files/index.js';
10
11
  import { scanProjectMemory } from './project-memory.js';
@@ -68,6 +69,7 @@ export async function getProjectStatus(options = {}) {
68
69
  docsReview,
69
70
  agentFiles,
70
71
  projectMemory,
72
+ hostedSync,
71
73
  }),
72
74
  };
73
75
  }
@@ -125,6 +127,7 @@ export function renderStatusText(status) {
125
127
  : status.hostedSync?.status === 'unreachable'
126
128
  ? [`Hosted mode check: unreachable (${status.hostedSync.detail}).`]
127
129
  : []),
130
+ ...formatHostedSyncLines(status.hostedSync),
128
131
  `Root: ${status.rootDir}`,
129
132
  `Repos: ${status.project.repos.length > 0 ? status.project.repos.map((repo) => repo.id).join(', ') : 'None recorded'}`,
130
133
  `Active lanes: ${formatActiveLanes(status.sessions)}`,
@@ -207,16 +210,36 @@ async function readHostedModeCheck(projectFilePath, localMode, options = {}) {
207
210
  }
208
211
  if (!binding) return { status: 'not-configured' };
209
212
 
210
- const env = options.env ?? process.env;
211
- const credential = typeof env[binding.credentialEnvVar] === 'string'
212
- ? env[binding.credentialEnvVar].trim()
213
- : '';
213
+ const bindingSummary = {
214
+ apiUrl: binding.apiUrl,
215
+ projectId: binding.projectId,
216
+ target: binding.target ?? null,
217
+ credentialEnvVar: binding.credentialEnvVar,
218
+ };
219
+ // D-355: report only the source of the credential (env / keychain / file /
220
+ // missing), never its value.
221
+ const resolution = await resolveSyncCredential(binding, {
222
+ env: options.env ?? process.env,
223
+ ...(options.credentialStore ?? {}),
224
+ });
225
+ const credential = resolution.credential;
226
+ const credentialSource = credential ? resolution.source : 'missing';
227
+ const credentialSourceLabel = credential ? describeCredentialSource(resolution) : 'missing';
214
228
  if (!credential) {
215
- return { status: 'no-credential', credentialEnvVar: binding.credentialEnvVar };
229
+ return {
230
+ status: 'no-credential',
231
+ credentialEnvVar: binding.credentialEnvVar,
232
+ credentialSource,
233
+ credentialSourceLabel,
234
+ ...(resolution.detail ? { detail: resolution.detail } : {}),
235
+ binding: bindingSummary,
236
+ };
216
237
  }
217
238
 
218
239
  const fetchImpl = options.fetch ?? globalThis.fetch;
219
- if (typeof fetchImpl !== 'function') return { status: 'not-configured' };
240
+ if (typeof fetchImpl !== 'function') {
241
+ return { status: 'not-configured', credentialSource, credentialSourceLabel, binding: bindingSummary };
242
+ }
220
243
 
221
244
  const baseUrl = binding.apiUrl.endsWith('/') ? binding.apiUrl : `${binding.apiUrl}/`;
222
245
  const endpoint = new URL(
@@ -234,7 +257,7 @@ async function readHostedModeCheck(projectFilePath, localMode, options = {}) {
234
257
  signal: controller.signal,
235
258
  });
236
259
  if (!response.ok) {
237
- return { status: 'unreachable', detail: `HTTP ${response.status}` };
260
+ return { status: 'unreachable', detail: `HTTP ${response.status}`, credentialSource, credentialSourceLabel, binding: bindingSummary };
238
261
  }
239
262
  const data = typeof response.json === 'function' ? await response.json() : {};
240
263
  const hostedMode = typeof data.mode === 'string' ? data.mode : null;
@@ -243,9 +266,12 @@ async function readHostedModeCheck(projectFilePath, localMode, options = {}) {
243
266
  hostedMode,
244
267
  localMode,
245
268
  mismatch: Boolean(hostedMode && localMode && hostedMode !== localMode),
269
+ credentialSource,
270
+ credentialSourceLabel,
271
+ binding: bindingSummary,
246
272
  };
247
273
  } catch (error) {
248
- return { status: 'unreachable', detail: error?.message ?? 'network error' };
274
+ return { status: 'unreachable', detail: error?.message ?? 'network error', credentialSource, credentialSourceLabel, binding: bindingSummary };
249
275
  } finally {
250
276
  clearTimeout(timer);
251
277
  }
@@ -460,13 +486,32 @@ async function readProjectMemoryStatus(rootDir) {
460
486
  }
461
487
  }
462
488
 
463
- function buildRecommendations({ project, compatibility, sessions, docsReview, agentFiles, projectMemory }) {
489
+ function formatHostedSyncLines(hostedSync) {
490
+ if (!hostedSync?.binding) {
491
+ return [];
492
+ }
493
+ const { binding } = hostedSync;
494
+ const where = `${binding.apiUrl} (project ${binding.projectId}${binding.target ? `, target ${binding.target}` : ''})`;
495
+ const credential = hostedSync.credentialSource === 'missing'
496
+ ? `missing${hostedSync.detail ? ` (${hostedSync.detail})` : ''} — run \`vibecompass login\` to sign in from this terminal`
497
+ : hostedSync.credentialSourceLabel ?? hostedSync.credentialSource;
498
+ return [
499
+ `Hosted sync: ${where}`,
500
+ `Sync credential: ${credential}`,
501
+ ];
502
+ }
503
+
504
+ function buildRecommendations({ project, compatibility, sessions, docsReview, agentFiles, projectMemory, hostedSync }) {
464
505
  const recommendations = [];
465
506
  if (project.status === 'unreadable') {
466
507
  recommendations.push('vibecompass init --guided');
467
508
  return recommendations;
468
509
  }
469
510
 
511
+ if (hostedSync?.status === 'no-credential') {
512
+ recommendations.push('vibecompass login');
513
+ }
514
+
470
515
  const packageStatus = compatibility.package.status;
471
516
  const stateStatus = compatibility.state.status;
472
517
  if (['legacy', 'behind', 'ahead', 'invalid'].includes(packageStatus) || ['missing', 'unreadable', 'unsupported'].includes(stateStatus)) {