@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.
@@ -19,6 +19,7 @@ import {
19
19
  writeDocsReviewSourceInventory,
20
20
  } from './source-inventory.js';
21
21
  import { readSyncCursor, resolveSyncBinding } from './sync-binding.js';
22
+ import { formatMissingCredentialError, resolveSyncCredential } from './credential-store.js';
22
23
  import { PACKAGE_VERSION } from './version.js';
23
24
  import { withMemoryRootLock } from './serialization.js';
24
25
  import { listDecisionFileNames, readNextDecisionId } from './decisions.js';
@@ -89,6 +90,7 @@ export async function preflightDocsReview(options = {}, environment = {}) {
89
90
  rootDir,
90
91
  statePath,
91
92
  env: environment.env ?? process.env,
93
+ credentialStore: environment.runtime?.credentialStore ?? null,
92
94
  fetch: environment.runtime?.fetch ?? globalThis.fetch,
93
95
  });
94
96
  }
@@ -137,6 +139,7 @@ export async function preflightDocsReview(options = {}, environment = {}) {
137
139
  const hosted = options.submitHosted
138
140
  ? await submitHostedDocsReview({
139
141
  env,
142
+ credentialStore: environment.runtime?.credentialStore ?? null,
140
143
  project,
141
144
  reviewConfig,
142
145
  reviewPrompt,
@@ -1588,9 +1591,20 @@ async function submitHostedDocsReview(options) {
1588
1591
  throw new Error('docs-review --submit-hosted requires sync.credential_env_var in project.yaml.');
1589
1592
  }
1590
1593
 
1591
- const credential = normalizeOptionalString(options.env?.[credentialEnvVar]);
1594
+ // D-355: env override, then the local credential store.
1595
+ const submitBinding = {
1596
+ apiUrl: options.runtime.api_url,
1597
+ projectId: options.runtime.project_id,
1598
+ credentialEnvVar,
1599
+ target: options.runtime.sync_target ?? null,
1600
+ };
1601
+ const submitResolution = await resolveSyncCredential(submitBinding, {
1602
+ env: options.env ?? process.env,
1603
+ ...(options.credentialStore ?? {}),
1604
+ });
1605
+ const credential = submitResolution.credential;
1592
1606
  if (!credential) {
1593
- throw new Error(`docs-review --submit-hosted requires ${credentialEnvVar}.`);
1607
+ throw new Error(formatMissingCredentialError(submitBinding, { action: 'docs-review --submit-hosted', detail: submitResolution.detail }));
1594
1608
  }
1595
1609
 
1596
1610
  const endpoint = new URL(
@@ -1758,9 +1772,19 @@ async function pollHostedDocsReview(options) {
1758
1772
  throw new Error('docs-review --poll-hosted requires runtime.credential_env_var in state/docs-review.json.');
1759
1773
  }
1760
1774
 
1761
- const credential = normalizeOptionalString(options.env?.[credentialEnvVar]);
1775
+ const pollBinding = {
1776
+ apiUrl: runtime.api_url,
1777
+ projectId: runtime.project_id,
1778
+ credentialEnvVar,
1779
+ target: runtime.sync_target ?? null,
1780
+ };
1781
+ const pollResolution = await resolveSyncCredential(pollBinding, {
1782
+ env: options.env ?? process.env,
1783
+ ...(options.credentialStore ?? {}),
1784
+ });
1785
+ const credential = pollResolution.credential;
1762
1786
  if (!credential) {
1763
- throw new Error(`docs-review --poll-hosted requires ${credentialEnvVar}.`);
1787
+ throw new Error(formatMissingCredentialError(pollBinding, { action: 'docs-review --poll-hosted', detail: pollResolution.detail }));
1764
1788
  }
1765
1789
 
1766
1790
  const endpoint = new URL(
@@ -86,11 +86,19 @@ async function syncAgentInstructionFilesLocked(options, cwd, rootDir) {
86
86
  const applied = applyManagedBlock(existingContent, generatedContent, {
87
87
  adoptExisting,
88
88
  });
89
+ const changed = applied.warning === null && applied.content !== existingContent;
89
90
  const conflicts = applied.status === 'adopt'
90
91
  ? scanPotentialWorkflowConflicts(existingContent)
91
92
  : [];
93
+ let status = applied.status;
92
94
 
93
- if (!dryRun && applied.warning === null && applied.content !== existingContent) {
95
+ if (applied.warning === null) {
96
+ status = changed
97
+ ? (dryRun ? `dry-run-${applied.status}` : applied.status)
98
+ : 'unchanged';
99
+ }
100
+
101
+ if (!dryRun && changed) {
94
102
  await mkdir(path.dirname(outputPath), { recursive: true });
95
103
  await writeFile(outputPath, applied.content, 'utf8');
96
104
  }
@@ -99,9 +107,9 @@ async function syncAgentInstructionFilesLocked(options, cwd, rootDir) {
99
107
  format: format.name,
100
108
  path: outputPath,
101
109
  relativePath: toPosix(path.relative(toolingRootDir, outputPath)),
102
- status: dryRun && applied.warning === null ? `dry-run-${applied.status}` : applied.status,
110
+ status,
103
111
  warning: applied.warning,
104
- changed: applied.warning === null && applied.content !== existingContent,
112
+ changed,
105
113
  conflicts,
106
114
  });
107
115
  }
@@ -47,6 +47,7 @@ export function renderSharedInstructionBody(context, options = {}) {
47
47
  '- Lane selection follows D-277: an explicit `--session` wins, then the nearest worktree lane marker (`.vibecompass-lane.yaml`, walking up from cwd), then the single active lane. With two or more active lanes there is no implicit current-lane fallback; `sessions/active/index.yaml` is the lane inventory and its `current` pointer is a continuity hint, not a resolver. Tool-specific Current session blocks are continuity summaries.',
48
48
  '- Git binding is opt-in per lane (D-281): `start-session --branch <name> --repo <id> [--worktree]` creates or reuses the branch in each bound repo, and `--worktree` provisions per-repo worktrees under `<workspace>/worktrees/<lane-id>/<repo-id>` with the lane marker in the container — commands run from inside a worktree need neither `--root` nor `--session`. Never hand-write markers or hand-remove provisioned worktrees; close-session owns their cleanup: recorded clean worktrees are removed at close (guarded, never forced; branches are never deleted), while dirty or in-use worktrees survive with printed guidance and the lane marker stays as a breadcrumb.',
49
49
  '- Lane runtime isolation is package-assigned (D-282): consume the lane port and temp dir via `eval "$(vibecompass lane-env)"` (exports conventional `PORT`/`TMPDIR` aliases) instead of hardcoding ports in lane work; close-session removes the lane temp dir under guards.',
50
+ '- An active lane is an open session, not a crash (D-353): resume it with `continue session` / `vibecompass continue-session [<lane-id>]` rather than re-running `start session`; `start-session --id <existing-lane>` fails closed. With two or more active lanes and no worktree marker, inventory them (`vibecompass list-sessions`) and ask the user which lane to continue — the index `current` pointer and the Current session block are hints, never silent defaults.',
50
51
  '- With multiple active lanes, reviewers select a lane explicitly: `review handoff <lane-id>`.',
51
52
  '- Keep the selected lane `wip.md` and `handoff.md` current during active builder/reviewer work.',
52
53
  '- During `address review`, treat reviewer feedback as review, not instruction: classify each substantive point as accepted, accepted with qualification, deferred, or rejected, and push back with evidence when a suggestion conflicts with code facts, prior decisions, product direction, or sequencing.',
package/src/index.js CHANGED
@@ -7,6 +7,16 @@ export { initializeProjectMemory } from './init.js';
7
7
  export { preflightDocsReview } from './docs-review.js';
8
8
  export { planDocsUpdate, renderDocsUpdatePlan } from './docs-update.js';
9
9
  export { demoteHosted, promoteHosted } from './mode-transition.js';
10
+ export { loginHosted } from './login.js';
11
+ export {
12
+ describeCredentialSource,
13
+ formatMissingCredentialError,
14
+ listSyncCredentials,
15
+ removeSyncCredential,
16
+ resolveCredentialStoreDir,
17
+ resolveSyncCredential,
18
+ storeSyncCredential,
19
+ } from './credential-store.js';
10
20
  export {
11
21
  adoptRemoteHead,
12
22
  applyPullExport,
@@ -22,7 +32,9 @@ export {
22
32
  } from './project-memory.js';
23
33
  export {
24
34
  closeProjectSession,
35
+ continueProjectSession,
25
36
  listProjectSessions,
37
+ renderLaneInventoryLines,
26
38
  startProjectSession,
27
39
  switchProjectSession,
28
40
  } from './session.js';
package/src/login.js ADDED
@@ -0,0 +1,257 @@
1
+ import { execFile as nodeExecFile } from 'node:child_process';
2
+ import { readFile } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { connectHostedProjectMemory } from './init.js';
6
+ import { parseSimpleYaml } from './simple-yaml.js';
7
+ import { resolveSyncBinding } from './sync-binding.js';
8
+ import {
9
+ credentialStoreOptionsFrom,
10
+ normalizeApiUrl,
11
+ storeSyncCredential,
12
+ } from './credential-store.js';
13
+ import { PACKAGE_VERSION } from './version.js';
14
+
15
+ /**
16
+ * Browser device-code sign-in for hosted sync (D-355).
17
+ *
18
+ * The CLI asks the hosted app for a device code, shows (and opens) the
19
+ * verification link plus a short user code, and polls until the signed-in
20
+ * project owner approves the code in the dashboard. The claim response is the
21
+ * only place the plaintext token ever exists: the CLI stores it in the local
22
+ * credential store and, when the root has no sync binding yet, writes the
23
+ * non-secret binding into project.yaml exactly as `connect-hosted` would.
24
+ */
25
+
26
+ export const DEFAULT_HOSTED_API_URL = 'https://vibecompass.dev';
27
+ export const DEFAULT_SYNC_CREDENTIAL_ENV_VAR = 'VIBECOMPASS_SYNC_TOKEN';
28
+ export const DEVICE_AUTH_START_ROUTE = 'api/cli/device-auth';
29
+ export const DEVICE_AUTH_TOKEN_ROUTE = 'api/cli/device-auth/token';
30
+ const DEFAULT_POLL_INTERVAL_SECONDS = 5;
31
+ const DEFAULT_POLL_TIMEOUT_SECONDS = 600;
32
+ const REQUEST_TIMEOUT_MS = 15_000;
33
+ const VALID_MODES = new Set(['local-only', 'local-primary', 'hosted-only']);
34
+
35
+ export async function loginHosted(options = {}, environment = {}) {
36
+ const cwd = environment.cwd ? path.resolve(environment.cwd) : process.cwd();
37
+ const rootDir = path.resolve(cwd, options.rootDir ?? '.compass');
38
+ const projectFilePath = path.join(rootDir, 'project.yaml');
39
+ const io = environment.io ?? {};
40
+ const write = (text) => (io.stdout ?? process.stdout).write(text);
41
+ const fetchImpl = environment.runtime?.fetch ?? globalThis.fetch;
42
+ if (typeof fetchImpl !== 'function') {
43
+ throw new Error('login requires a fetch implementation.');
44
+ }
45
+ const sleep = environment.runtime?.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
46
+ const now = environment.runtime?.now ?? (() => Date.now());
47
+
48
+ let project;
49
+ try {
50
+ project = parseSimpleYaml(await readFile(projectFilePath, 'utf8'), { sourceName: projectFilePath });
51
+ } catch (error) {
52
+ if (error && typeof error === 'object' && error.code === 'ENOENT') {
53
+ throw new Error(`No project.yaml found in ${rootDir}. Run "vibecompass init" first, then "vibecompass login".`);
54
+ }
55
+ throw error;
56
+ }
57
+ if (!VALID_MODES.has(project.mode)) {
58
+ throw new Error('login requires project.yaml mode to be local-only, local-primary, or hosted-only.');
59
+ }
60
+
61
+ const binding = resolveLoginBinding(project, options);
62
+ const apiUrl = binding?.apiUrl ?? options.apiUrl ?? DEFAULT_HOSTED_API_URL;
63
+ if (binding && options.apiUrl && normalizeApiUrl(options.apiUrl) !== normalizeApiUrl(binding.apiUrl)) {
64
+ throw new Error(
65
+ `--api-url ${options.apiUrl} does not match the bound hosted URL ${binding.apiUrl}${binding.target ? ` for target ${binding.target}` : ''}. `
66
+ + 'Pass --sync-target <name> to sign in to a different environment, or rebind with connect-hosted.',
67
+ );
68
+ }
69
+ const projectHint = binding?.projectId ?? options.projectId ?? null;
70
+ const credentialEnvVar = binding?.credentialEnvVar ?? options.credentialEnvVar ?? DEFAULT_SYNC_CREDENTIAL_ENV_VAR;
71
+
72
+ // Every network wait (start, each poll, and body reads) is bounded by the
73
+ // overall deadline so a stalled server can never outlive --poll-timeout.
74
+ const requestedTimeoutSeconds = positiveNumber(options.pollTimeoutSeconds, DEFAULT_POLL_TIMEOUT_SECONDS);
75
+ let deadline = now() + requestedTimeoutSeconds * 1000;
76
+ const timing = { now, deadline: () => deadline };
77
+
78
+ const start = await postJson(fetchImpl, apiUrl, DEVICE_AUTH_START_ROUTE, {
79
+ ...(projectHint ? { project_id: projectHint } : {}),
80
+ label: options.label ?? os.hostname(),
81
+ package_version: PACKAGE_VERSION,
82
+ }, timing);
83
+ if (!start.ok) {
84
+ throw new Error(`Hosted sign-in could not start (HTTP ${start.status}${start.body?.error ? `: ${start.body.error}` : ''}). Check the hosted URL ${apiUrl} and try again.`);
85
+ }
86
+ const deviceCode = requireString(start.body, 'device_code');
87
+ const userCode = requireString(start.body, 'user_code');
88
+ const verificationUri = requireString(start.body, 'verification_uri_complete', 'verification_uri');
89
+ const expiresIn = positiveNumber(start.body.expires_in, DEFAULT_POLL_TIMEOUT_SECONDS);
90
+ let intervalMs = positiveNumber(start.body.interval, DEFAULT_POLL_INTERVAL_SECONDS) * 1000;
91
+
92
+ write('Sign in to VibeCompass\n');
93
+ write(`Open: ${verificationUri}\n`);
94
+ write(`Code: ${userCode}\n`);
95
+ if (!options.noBrowser) {
96
+ const opened = await openBrowser(verificationUri, environment);
97
+ write(opened ? 'Opening your browser…\n' : 'Could not open a browser automatically; paste the link into any browser.\n');
98
+ }
99
+ write(`Waiting for approval in the dashboard (expires in ${Math.round(expiresIn / 60)} minutes)…\n`);
100
+
101
+ deadline = Math.min(deadline, now() + expiresIn * 1000);
102
+ let claim = null;
103
+ while (!claim) {
104
+ const remaining = deadline - now();
105
+ if (remaining <= 0) {
106
+ throw new Error('Timed out waiting for the sign-in to be approved. Run "vibecompass login" again.');
107
+ }
108
+ await sleep(Math.min(intervalMs, remaining));
109
+ const poll = await postJson(fetchImpl, apiUrl, DEVICE_AUTH_TOKEN_ROUTE, { device_code: deviceCode }, timing);
110
+ if (poll.ok && poll.body?.status === 'approved') {
111
+ claim = poll.body;
112
+ break;
113
+ }
114
+ const code = typeof poll.body?.error === 'string' ? poll.body.error : null;
115
+ if (code === 'authorization_pending') {
116
+ continue;
117
+ }
118
+ if (code === 'slow_down') {
119
+ intervalMs += 5000;
120
+ continue;
121
+ }
122
+ if (code === 'expired_token') {
123
+ throw new Error('The sign-in code expired before it was approved. Run "vibecompass login" again.');
124
+ }
125
+ if (code === 'access_denied') {
126
+ throw new Error('The sign-in request was denied in the dashboard.');
127
+ }
128
+ throw new Error(`Hosted sign-in failed (HTTP ${poll.status}${code ? `: ${code}` : ''}).`);
129
+ }
130
+
131
+ const token = requireString(claim, 'token');
132
+ const approvedProject = claim.project && typeof claim.project === 'object' ? claim.project : {};
133
+ const projectId = requireString(approvedProject, 'id');
134
+ const projectName = typeof approvedProject.name === 'string' ? approvedProject.name : projectId;
135
+
136
+ const stored = await storeSyncCredential(
137
+ {
138
+ apiUrl,
139
+ projectId,
140
+ token,
141
+ label: `VibeCompass sync token (${projectName})`,
142
+ source: 'login',
143
+ backend: options.credentialStore,
144
+ },
145
+ credentialStoreOptionsFrom(environment),
146
+ );
147
+
148
+ const warnings = [];
149
+ let bindingWritten = null;
150
+ if (!binding) {
151
+ bindingWritten = await connectHostedProjectMemory({
152
+ cwd,
153
+ rootDir: options.rootDir ?? '.compass',
154
+ ...(options.syncTarget ? { targetName: options.syncTarget } : {}),
155
+ sync: { apiUrl, projectId, credentialEnvVar },
156
+ });
157
+ } else if (binding.projectId !== projectId) {
158
+ warnings.push(
159
+ `This root is bound to project ${binding.projectId}${binding.target ? ` (target ${binding.target})` : ''}, but you approved ${projectName} (${projectId}). `
160
+ + `The token is stored for the approved project; rebind with "vibecompass connect-hosted${binding.target ? ` --target ${binding.target}` : ''} --sync-api-url ${apiUrl} --sync-project-id ${projectId} --sync-credential-env-var ${credentialEnvVar}" if that was intended.`,
161
+ );
162
+ }
163
+
164
+ return {
165
+ apiUrl,
166
+ project: { id: projectId, name: projectName },
167
+ credential: claim.credential && typeof claim.credential === 'object' ? claim.credential : null,
168
+ stored,
169
+ binding: bindingWritten,
170
+ previousBinding: binding,
171
+ mode: bindingWritten?.mode ?? project.mode,
172
+ warnings,
173
+ };
174
+ }
175
+
176
+ function resolveLoginBinding(project, options) {
177
+ try {
178
+ return resolveSyncBinding(project, options.syncTarget ?? null);
179
+ } catch (error) {
180
+ // An unknown --sync-target may be created by login only when the caller
181
+ // is explicit about which hosted environment it points at (D-236: a typo
182
+ // must never silently land in another environment).
183
+ if (options.syncTarget && options.apiUrl) {
184
+ return null;
185
+ }
186
+ throw new Error(`${error.message} To create it through login, add --api-url <hosted url>.`);
187
+ }
188
+ }
189
+
190
+ async function postJson(fetchImpl, apiUrl, routeName, body, timing) {
191
+ const endpoint = new URL(routeName, apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`);
192
+ const remaining = timing ? timing.deadline() - timing.now() : REQUEST_TIMEOUT_MS;
193
+ if (remaining <= 0) {
194
+ throw new Error('Timed out waiting for the sign-in to be approved. Run "vibecompass login" again.');
195
+ }
196
+ const controller = new AbortController();
197
+ const timer = setTimeout(() => controller.abort(), Math.min(REQUEST_TIMEOUT_MS, remaining));
198
+ try {
199
+ let response;
200
+ try {
201
+ response = await fetchImpl(endpoint.href, {
202
+ method: 'POST',
203
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
204
+ body: JSON.stringify(body),
205
+ signal: controller.signal,
206
+ });
207
+ } catch (error) {
208
+ if (controller.signal.aborted) {
209
+ throw new Error(`Hosted sign-in request to ${endpoint.origin} timed out. Check the connection and run "vibecompass login" again.`);
210
+ }
211
+ throw new Error(`Could not reach ${endpoint.origin} (${error?.message ?? 'network error'}).`);
212
+ }
213
+ let parsed = null;
214
+ try {
215
+ parsed = typeof response.json === 'function' ? await response.json() : null;
216
+ } catch {
217
+ if (controller.signal.aborted) {
218
+ throw new Error(`Hosted sign-in response from ${endpoint.origin} stalled. Check the connection and run "vibecompass login" again.`);
219
+ }
220
+ parsed = null;
221
+ }
222
+ return { ok: Boolean(response.ok), status: response.status, body: parsed && typeof parsed === 'object' ? parsed : {} };
223
+ } finally {
224
+ clearTimeout(timer);
225
+ }
226
+ }
227
+
228
+ async function openBrowser(url, environment) {
229
+ const platform = environment.runtime?.platform ?? process.platform;
230
+ const execFileImpl = environment.runtime?.execFile ?? nodeExecFile;
231
+ const [file, args] = platform === 'darwin'
232
+ ? ['open', [url]]
233
+ : platform === 'win32'
234
+ ? ['cmd', ['/c', 'start', '', url]]
235
+ : ['xdg-open', [url]];
236
+ return new Promise((resolve) => {
237
+ try {
238
+ execFileImpl(file, args, { windowsHide: true }, (error) => resolve(!error));
239
+ } catch {
240
+ resolve(false);
241
+ }
242
+ });
243
+ }
244
+
245
+ function requireString(source, ...keys) {
246
+ for (const key of keys) {
247
+ const value = source?.[key];
248
+ if (typeof value === 'string' && value.trim() !== '') {
249
+ return value.trim();
250
+ }
251
+ }
252
+ throw new Error(`Hosted sign-in response is missing ${keys[0]}.`);
253
+ }
254
+
255
+ function positiveNumber(value, fallback) {
256
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
257
+ }
@@ -2,6 +2,11 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { parseSimpleYaml } from './simple-yaml.js';
4
4
  import { resolveSyncBinding } from './sync-binding.js';
5
+ import {
6
+ credentialStoreOptionsFrom,
7
+ formatMissingCredentialError,
8
+ resolveSyncCredential,
9
+ } from './credential-store.js';
5
10
  import { withMemoryRootLock } from './serialization.js';
6
11
  import { adoptRemoteHead, pushProjectMemory } from './sync.js';
7
12
 
@@ -186,9 +191,10 @@ async function loadModeAgnosticSyncContext(rootDir, options, environment) {
186
191
  if (!binding) {
187
192
  throw new Error('Mode transitions require a sync binding in project.yaml (run connect-hosted first).');
188
193
  }
189
- const credential = ((environment.env ?? process.env)[binding.credentialEnvVar] ?? '').trim();
194
+ const resolution = await resolveSyncCredential(binding, credentialStoreOptionsFrom(environment));
195
+ const credential = resolution.credential;
190
196
  if (!credential) {
191
- throw new Error(`Mode transitions require ${binding.credentialEnvVar} to be set.`);
197
+ throw new Error(formatMissingCredentialError(binding, { action: 'This mode transition', detail: resolution.detail }));
192
198
  }
193
199
  const fetchImpl = environment.runtime?.fetch ?? globalThis.fetch;
194
200
  if (typeof fetchImpl !== 'function') {
package/src/scaffold.js CHANGED
@@ -235,6 +235,7 @@ This workspace uses VibeCompass project memory rooted at \`${rootRelativePath}\`
235
235
  - The active lane scratch files live under \`${rootRelativePath}/sessions/active/<lane-id>/\`.
236
236
  - Lane selection follows D-277: an explicit \`--session\` wins, then the nearest worktree lane marker (\`.vibecompass-lane.yaml\`, walking up from cwd), then the single active lane. With two or more active lanes there is no implicit current-lane fallback.
237
237
  - \`${rootRelativePath}/sessions/active/index.yaml\` is the lane inventory; its \`current\` pointer and the tool-specific Current session block are human-readable continuity summaries, not the lane-selection source of truth.
238
+ - An active lane directory is an open session, not an unclosed crash (D-353). Resume it with \`vibecompass continue-session [<lane-id>]\`: the lane keeps its opening date and session number; the command records \`resumed_at\`/\`resume_count\` in \`session.yaml\`, appends a dated \`Resumed\` line to \`wip.md\`, moves the index \`current\` hint and the Current session block to the lane, and re-emits runtime, git-binding, and staleness warnings. \`start-session --id <existing-lane>\` fails closed and names \`continue-session\`.
238
239
  - Optional git binding (D-281): \`start-session --branch <name> --repo <id> [--worktree]\` creates or reuses the branch in every bound repo; \`--worktree\` additionally provisions per-repo worktrees under \`<workspace>/worktrees/<lane-id>/<repo-id>\` with the lane marker written into the container, so commands run from inside a worktree need neither \`--root\` nor \`--session\`. Binding is opt-in and git is never required for lanes.
239
240
  - At close, \`close-session\` removes a bound lane's recorded clean worktrees (guarded and never forced: dirty, in-use, or unverifiable worktrees survive with guidance, the lane marker is kept while any worktree survives, and branches are never deleted). Do not hand-remove provisioned worktrees; follow the printed guidance instead.
240
241
  - Every lane gets a per-lane runtime assignment at start (D-282): a lane port and temp dir recorded in \`session.yaml\` and exported with \`eval "$(vibecompass lane-env)"\` (includes conventional \`PORT\`/\`TMPDIR\` aliases), so parallel lanes never fight over dev-server ports or temp paths. Defaults are configurable under \`project.yaml\` \`runtime:\`; \`close-session\` removes the lane temp dir under guards.
@@ -248,7 +249,8 @@ ${repos}
248
249
  ## Session roles
249
250
  | Entry trigger | Role | Owns session lifecycle? |
250
251
  |---|---|---|
251
- | "start session" | Builder | Yes — opens the session, keeps scratch files current, and writes the final session note |
252
+ | "start session" | Builder | Yes — opens a new lane, keeps scratch files current, and writes the final session note |
253
+ | "continue session" | Builder | Yes — resumes an already-open lane and owns its lifecycle from there |
252
254
  | "join as reviewer" | Reviewer | No — reviews the builder's work, appends findings, and updates handoff guidance |
253
255
 
254
256
  ## Session prompt commands
@@ -271,11 +273,20 @@ ${renderWorkflowDefaults(workflow)}
271
273
  ## Session startup
272
274
  1. Read \`${rootRelativePath}/project.yaml\`.
273
275
  2. Read the latest finalized session note in \`${rootRelativePath}/sessions/\`.
274
- 3. If present, read \`${rootRelativePath}/sessions/active/index.yaml\` for the lane inventory; select the lane from an explicit \`--session\`, the nearest worktree lane marker, or the single active lane (D-277).
276
+ 3. Inventory active lanes with \`vibecompass list-sessions\` (or read \`${rootRelativePath}/sessions/active/index.yaml\`), then follow the "Open, resume, or choose" protocol below to decide between opening a new lane and resuming one; select a lane from an explicit \`--session\`, the nearest worktree lane marker, or the single active lane (D-277).
275
277
  4. If present, read \`${rootRelativePath}/sessions/active/<lane-id>/wip.md\`.
276
278
  5. If present, read \`${rootRelativePath}/sessions/active/<lane-id>/handoff.md\`.
277
279
  6. Read the relevant docs under \`${rootRelativePath}/architecture/\` and \`${rootRelativePath}/decisions/\`.
278
280
 
281
+ ## Open, resume, or choose (D-353)
282
+ Both \`start session\` and \`continue session\` begin with the active-lane inventory. Then:
283
+ - **No active lane:** open one with \`vibecompass start-session --id <lane-id> --working-on "..."\`. \`continue session\` with no lane reports that there is nothing to resume and hands off to \`start session\`.
284
+ - **Exactly one active lane, or a cwd bound to a lane by a worktree marker:** resume it with \`vibecompass continue-session\` without asking. Open a second lane only when the user is clearly asking for separate new work; never re-open a lane that already exists.
285
+ - **Two or more active lanes, no marker, no lane named by the user:** do not guess. Present the inventory (lane id, working-on, opened date, resume count, branch/worktree, last log line) and ask which lane to continue or whether to open a new one. The index \`current\` pointer and the Current session block are continuity hints to mention, never defaults to adopt silently. Resume with \`vibecompass continue-session <lane-id>\` once the user answers (\`continue session <lane-id>\`).
286
+ - **Lane named by the user:** \`continue session <lane-id>\` resumes that lane; an unknown id fails closed with the inventory.
287
+ - After resuming, read the lane's \`wip.md\` (latest \`## Log\` entries and \`## Reviewer input needed\`), \`handoff.md\`, and the latest finalized note; treat resume-staleness warnings (new decisions past the lane snapshot, stale base revisions, newer finalized notes touching the lane scope, claim overlap) as required reading before editing.
288
+ - Run recovery (reconstruct a missing finalized note from git history) only when the Current session block date is in the past *and* no active lane exists *and* no finalized note covers that date. An active lane with a past date is simply resumable.
289
+
279
290
  ## Builder workflow
280
291
  At session start, prefer running \`vibecompass start-session --id <lane-id> --working-on "..." \`; add \`--feature\`, \`--repo\`, \`--claim\`, \`--architecture-doc\`, and \`--decision-domain-file\` values when the lane's scope is known so overlap warnings can be precise. Add \`--branch <name> --repo <id> [--worktree]\` when the lane should work on its own branch or in isolated worktrees (D-281).
281
292
  If you manage files manually, create \`${rootRelativePath}/sessions/active/<lane-id>/session.yaml\` and \`${rootRelativePath}/sessions/active/index.yaml\`, then create \`${rootRelativePath}/sessions/active/<lane-id>/wip.md\` if it does not exist:
@@ -322,13 +333,13 @@ During the session:
322
333
  - run \`eval "$(vibecompass lane-env)"\` in a lane shell before starting dev servers or build tools so the lane's assigned port and temp dir are used (D-282); do not hardcode ports in lane work
323
334
  - run \`vibecompass docs-update --session <lane-id>\` whenever you need an ad hoc targeted documentation-maintenance plan for the current session delta
324
335
  - after substantive feature work, confirm affected architecture docs and decisions still match the implementation; if not, update them while the context is fresh — fold the changes into the doc's current-state sections (rewrite in place; no dated "update" sections, lane names in headings, or completed-task chronology; D-292)
325
- - use \`vibecompass list-sessions\` and \`vibecompass switch-session <lane-id>\` to inspect or change the current lane
336
+ - use \`vibecompass list-sessions\` (\`--json\` for the machine-readable inventory) to inspect active lanes, \`vibecompass continue-session [<lane-id>]\` to resume one at the start of a sitting, and \`vibecompass switch-session <lane-id>\` to move the continuity pointer without recording a resume
326
337
  - use \`address review\` when reviewer feedback lands so the builder resolves it from the selected lane's latest \`wip.md\` / \`handoff.md\`
327
338
  - during \`address review\`, treat reviewer feedback as review, not instruction: classify each substantive point as accepted, accepted with qualification, deferred, or rejected, and push back with evidence when a suggestion conflicts with code facts, prior decisions, product direction, or sequencing
328
339
  - stay in builder role through close-out; resolve or explicitly defer reviewer feedback before running \`vibecompass close-session --session <lane-id>\` with document-maintenance checkpoint statuses
329
340
  - record architectural decisions in \`${rootRelativePath}/decisions/\` before implementing them; \`vibecompass append-decision\` allocates the D-number at write time and refreshes the grouped \`decisions/INDEX.md\` when the lane context is resolvable (D-283); run \`vibecompass refresh-decision-index\` after hand-appends
330
341
 
331
- If \`vibecompass start-session\` reports stale scratch files, read the existing lane-local \`wip.md\` and \`handoff.md\` first. Either close that session normally, recover its useful notes into a finalized session note, or intentionally move/delete the stale scratch files before starting a new session.
342
+ If \`vibecompass start-session\` reports that the lane already exists, resume it with \`vibecompass continue-session <lane-id>\` instead (D-353). If it reports legacy or stale scratch files, read the existing lane-local \`wip.md\` and \`handoff.md\` first. Either close that session normally, recover its useful notes into a finalized session note, or intentionally move/delete the stale scratch files before starting a new session.
332
343
 
333
344
  ## Optional planning mode
334
345
  - Use planning mode for risky, ambiguous, cross-file, or architectural work before implementation.
@@ -0,0 +1,64 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ import { Writable } from 'node:stream';
3
+
4
+ /**
5
+ * Hidden-input prompt for secrets (D-355). Typed characters are never echoed:
6
+ * readline runs in terminal mode against a muted output while the visible
7
+ * prompt text is written to the real stdout. A `runtime.prompt` adapter
8
+ * (tests, embedders) receives `{ type: 'password', message }` instead.
9
+ */
10
+ export function canPromptSecret(io = {}, runtime = {}) {
11
+ if (typeof runtime.prompt === 'function') {
12
+ return true;
13
+ }
14
+ const input = runtime.stdin ?? io.stdin ?? process.stdin;
15
+ return Boolean(input?.isTTY);
16
+ }
17
+
18
+ export async function promptSecret(message, io = {}, runtime = {}) {
19
+ if (typeof runtime.prompt === 'function') {
20
+ return normalizeSecret(await runtime.prompt({ type: 'password', message }));
21
+ }
22
+
23
+ const input = runtime.stdin ?? io.stdin ?? process.stdin;
24
+ const output = io.stdout ?? process.stdout;
25
+ if (!input?.isTTY) {
26
+ throw new Error('Secret prompts require an interactive terminal. Pipe the value with --token-stdin instead.');
27
+ }
28
+
29
+ output.write(`${message}: `);
30
+ const muted = new Writable({
31
+ write(_chunk, _encoding, callback) {
32
+ callback();
33
+ },
34
+ });
35
+ const rl = createInterface({ input, output: muted, terminal: true });
36
+ try {
37
+ return normalizeSecret(await rl.question(''));
38
+ } finally {
39
+ rl.close();
40
+ output.write('\n');
41
+ }
42
+ }
43
+
44
+ /** Reads one secret from stdin: the first non-empty line, trimmed. */
45
+ export async function readSecretFromStdin(io = {}, runtime = {}) {
46
+ const input = runtime.stdin ?? io.stdin ?? process.stdin;
47
+ if (typeof input?.setEncoding === 'function') {
48
+ input.setEncoding('utf8');
49
+ }
50
+ let data = '';
51
+ for await (const chunk of input) {
52
+ data += chunk;
53
+ }
54
+ const firstLine = data
55
+ .split(/\r?\n/)
56
+ .map((line) => line.trim())
57
+ .find((line) => line !== '');
58
+ return firstLine ?? null;
59
+ }
60
+
61
+ function normalizeSecret(value) {
62
+ const trimmed = String(value ?? '').trim();
63
+ return trimmed === '' ? null : trimmed;
64
+ }