@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/CHANGELOG.md +96 -1
- package/README.md +51 -9
- package/package.json +1 -1
- package/src/cli.js +611 -14
- package/src/credential-store.js +856 -0
- package/src/docs-review.js +28 -4
- package/src/generators/agent-files/index.js +11 -3
- package/src/generators/agent-files/template.js +1 -0
- package/src/index.js +12 -0
- package/src/login.js +257 -0
- package/src/mode-transition.js +8 -2
- package/src/scaffold.js +15 -4
- package/src/secret-prompt.js +64 -0
- package/src/session.js +456 -5
- package/src/status.js +54 -9
- package/src/sync.js +12 -4
- package/src/workflows/registry.js +2 -1
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.
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
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
|
|
211
|
-
|
|
212
|
-
|
|
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 {
|
|
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')
|
|
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
|
|
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)) {
|