@vibecompass/vibecompass 0.14.0 → 0.16.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/scaffold.js CHANGED
@@ -226,6 +226,7 @@ This workspace uses VibeCompass project memory rooted at \`${rootRelativePath}\`
226
226
  - \`${rootRelativePath}/sessions/active/<lane-id>/session.yaml\` — lane metadata (including the recorded \`lane_marker\` when one exists)
227
227
  - \`${rootRelativePath}/sessions/active/<lane-id>/wip.md\` — lane-local builder scratchpad
228
228
  - \`${rootRelativePath}/sessions/active/<lane-id>/handoff.md\` — lane-local builder/reviewer relay
229
+ - \`${rootRelativePath}/sessions/active/<lane-id>/brief.md\` — generated session brief (D-359, D-364): the memory this lane should read first, written by \`start-session\` and \`continue-session\` when \`brief.enabled: true\` (opt-in, D-368) or on request with \`vibecompass brief --write\`; never hand-edit
229
230
  - \`.vibecompass-lane.yaml\` — worktree-local lane marker (D-280); lives outside the memory root, written only by \`vibecompass write-lane-marker\` or worktree provisioning, never synced
230
231
  - \`${rootRelativePath}/decisions/INDEX.md\` — derived grouped decision index; refresh with \`vibecompass refresh-decision-index\` (D-283, structure-preserving) instead of hand-editing rows
231
232
 
@@ -233,6 +234,7 @@ This workspace uses VibeCompass project memory rooted at \`${rootRelativePath}\`
233
234
  - VibeCompass active builder sessions are named lanes. Use one lane per active feature or workstream.
234
235
  - \`vibecompass start-session\` requires \`--id <lane-id>\` so each active lane has a meaningful feature or workstream name.
235
236
  - The active lane scratch files live under \`${rootRelativePath}/sessions/active/<lane-id>/\`.
237
+ - When \`${rootRelativePath}/project.yaml\` sets \`brief.enabled: true\` (opt-in, D-368), \`start-session\` and \`continue-session\` write the lane's generated \`brief.md\` (D-359, D-364): the task is the lane's working-on, the files its claims, and the features its feature slugs. A brief failure is reported as a warning or an incomplete brief and never fails the lifecycle command. \`--no-brief\` skips it for one command, \`vibecompass brief --session <lane-id> --write\` writes one on request whatever the setting, and \`brief.exclude\` lists root-relative globs the brief must never read.
236
238
  - 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
239
  - \`${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
240
  - 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\`.
@@ -276,7 +278,8 @@ ${renderWorkflowDefaults(workflow)}
276
278
  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).
277
279
  4. If present, read \`${rootRelativePath}/sessions/active/<lane-id>/wip.md\`.
278
280
  5. If present, read \`${rootRelativePath}/sessions/active/<lane-id>/handoff.md\`.
279
- 6. Read the relevant docs under \`${rootRelativePath}/architecture/\` and \`${rootRelativePath}/decisions/\`.
281
+ 6. If present, read \`${rootRelativePath}/sessions/active/<lane-id>/brief.md\` before planning: the generated session brief selects the architecture docs, decisions (with their declared successors), and notes for the lane's task. Its status line says whether it is complete, partial, incomplete (read the listed required reads first), or no-match; \`vibecompass brief --check\` reports whether it is stale.
282
+ 7. Read the relevant docs under \`${rootRelativePath}/architecture/\` and \`${rootRelativePath}/decisions/\`, starting from the brief's units and follow-up reads.
280
283
 
281
284
  ## Open, resume, or choose (D-353)
282
285
  Both \`start session\` and \`continue session\` begin with the active-lane inventory. Then:
@@ -284,7 +287,7 @@ Both \`start session\` and \`continue session\` begin with the active-lane inven
284
287
  - **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
288
  - **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
289
  - **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.
290
+ - After resuming, read the lane's \`wip.md\` (latest \`## Log\` entries and \`## Reviewer input needed\`), \`handoff.md\`, \`brief.md\` if present (with \`brief.enabled: true\`, continue-session keeps a current brief and regenerates a missing or stale one), 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
291
  - 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
292
 
290
293
  ## Builder workflow
@@ -302,6 +305,8 @@ Session lane: <lane-id>
302
305
 
303
306
  ## Reviewer input needed
304
307
 
308
+ ## Context used
309
+
305
310
  ## Review log
306
311
  \`\`\`
307
312
 
@@ -330,6 +335,8 @@ Session lane: <lane-id>
330
335
  During the session:
331
336
  - append short summaries to \`wip.md\` after meaningful exchanges
332
337
  - keep \`handoff.md\` current after substantive work blocks
338
+ - optionally list under \`wip.md\` \`## Context used\` the docs and decisions the lane actually relied on (path or D-number, and what for), so the brief's usefulness can be audited
339
+ - refresh the lane brief with \`vibecompass brief --write\` after the lane's scope changes; \`vibecompass brief --check\` reports whether it is stale
333
340
  - 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
334
341
  - run \`vibecompass docs-update --session <lane-id>\` whenever you need an ad hoc targeted documentation-maintenance plan for the current session delta
335
342
  - 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)
@@ -588,6 +595,7 @@ Finalized session notes for ${projectConfig.name} live here.
588
595
  - \`active/<lane-id>/session.yaml\` — lane metadata, including the recorded \`lane_marker\` when one exists
589
596
  - \`active/<lane-id>/wip.md\` — builder scratchpad during an active lane
590
597
  - \`active/<lane-id>/handoff.md\` — reviewer/builder baton-pass during an active lane
598
+ - \`active/<lane-id>/brief.md\` — generated session brief for the lane (D-359, D-364); regenerated, never hand-edited
591
599
 
592
600
  Those scratch files are session-scoped working artifacts, not finalized history.
593
601
  `;
@@ -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
+ }
package/src/session.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { access, mkdir, readFile, readdir, realpath, rm, rmdir, stat, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
+ import { refreshLaneBriefForLifecycle } from './brief-lifecycle.js';
4
5
  import { scanProjectMemory } from './project-memory.js';
5
6
  import { parseSimpleYaml } from './simple-yaml.js';
6
7
  import { buildCloseSessionGuidance, resolveWorkflowSettings } from './workflow.js';
@@ -398,6 +399,16 @@ async function startProjectSessionLocked(normalized, options, markerContext) {
398
399
  rootDir: normalized.rootDir,
399
400
  toolingRootDir: normalized.toolingRootDir,
400
401
  });
402
+ // D-359/D-364/D-368: the opt-in lane brief is written only after every
403
+ // lifecycle write succeeded, still inside the memory-root lock; it never
404
+ // throws, so a brief failure cannot overturn the start.
405
+ const brief = await refreshLaneBriefForLifecycle({
406
+ trigger: 'start',
407
+ rootDir: normalized.rootDir,
408
+ laneId: sessionId,
409
+ disabled: options?.brief === false,
410
+ buildBrief: options?.briefBuilder,
411
+ });
401
412
 
402
413
  return {
403
414
  rootDir: normalized.rootDir,
@@ -420,7 +431,9 @@ async function startProjectSessionLocked(normalized, options, markerContext) {
420
431
  ...(gitBinding?.warnings ?? []),
421
432
  ...manifestRefresh.warnings,
422
433
  ...auditWarnings,
434
+ ...brief.warnings,
423
435
  ],
436
+ brief,
424
437
  ...(gitBinding
425
438
  ? {
426
439
  gitBinding: {
@@ -1145,6 +1158,18 @@ async function continueProjectSessionLocked(normalized, options, markerContext)
1145
1158
  rootDir: normalized.rootDir,
1146
1159
  toolingRootDir: normalized.toolingRootDir,
1147
1160
  });
1161
+ // D-359/D-364: keep a current lane brief, regenerate a missing or stale
1162
+ // one. `--source-root` (D-255 dogfood) is read only when named here; it is
1163
+ // never inferred from the existing brief's header.
1164
+ const brief = await refreshLaneBriefForLifecycle({
1165
+ trigger: 'continue',
1166
+ rootDir: normalized.rootDir,
1167
+ laneId: lane.id,
1168
+ disabled: options?.brief === false,
1169
+ sourceRootDir: options?.sourceRootDir ? path.resolve(normalized.cwd, options.sourceRootDir) : undefined,
1170
+ sourceSessionId: normalizeOptionalString(options?.sourceSessionId) ?? undefined,
1171
+ buildBrief: options?.briefBuilder,
1172
+ });
1148
1173
  const finalizedSessions = await listFinalizedSessions(normalized.sessionsDir);
1149
1174
  const latestFinalized = finalizedSessions.length > 0 ? finalizedSessions[finalizedSessions.length - 1] : null;
1150
1175
  // The returned inventory is the post-resume state (what list-sessions shows
@@ -1181,6 +1206,7 @@ async function continueProjectSessionLocked(normalized, options, markerContext)
1181
1206
  otherLanes: inventoryAfter.lanes.filter((item) => item.id !== lane.id),
1182
1207
  inventory: inventoryAfter,
1183
1208
  docsUpdatePlan: docsUpdate.plan,
1209
+ brief,
1184
1210
  warnings: [
1185
1211
  ...markerContext.warnings,
1186
1212
  ...selection.warnings,
@@ -1196,6 +1222,7 @@ async function continueProjectSessionLocked(normalized, options, markerContext)
1196
1222
  }),
1197
1223
  ...docsUpdate.warnings,
1198
1224
  ...manifestRefresh.warnings,
1225
+ ...brief.warnings,
1199
1226
  ],
1200
1227
  manifest: manifestRefresh.manifest,
1201
1228
  agentFileSync,
@@ -2279,6 +2306,9 @@ ${options.workingOn}
2279
2306
  ## Reviewer input needed
2280
2307
  - None yet.
2281
2308
 
2309
+ ## Context used
2310
+ - Optional: the docs and decisions this lane actually relied on (path or D-number, and what for) — an audit trail for the session brief; list what you used, not everything the brief showed.
2311
+
2282
2312
  ## Review log
2283
2313
  `;
2284
2314
  }
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)) {
package/src/sync.js CHANGED
@@ -4,6 +4,11 @@ import { sha256Text } from './hash.js';
4
4
  import { writeStateManifest } from './manifest.js';
5
5
  import { withMemoryRootLock } from './serialization.js';
6
6
  import { parseSimpleYaml } from './simple-yaml.js';
7
+ import {
8
+ credentialStoreOptionsFrom,
9
+ formatMissingCredentialError,
10
+ resolveSyncCredential,
11
+ } from './credential-store.js';
7
12
  import {
8
13
  buildSyncStateWithCursor,
9
14
  readSyncCursor,
@@ -465,12 +470,14 @@ async function loadSyncContext(options, environment) {
465
470
  throw new Error('Hosted sync commands require sync.api_url, sync.project_id, and sync.credential_env_var in project.yaml (or a named target under sync.targets).');
466
471
  }
467
472
 
468
- const credential = normalizeOptionalString((environment.env ?? process.env)[binding.credentialEnvVar]);
473
+ // D-355: env var override first, then the local credential store
474
+ // (keychain or per-user file). The resolved value is used only as the
475
+ // bearer credential and never written anywhere.
476
+ const resolution = await resolveSyncCredential(binding, credentialStoreOptionsFrom(environment));
477
+ const credential = resolution.credential;
469
478
  if (!credential) {
470
479
  throw new Error(
471
- `Hosted sync command requires ${binding.credentialEnvVar}. New terminals do not inherit one-off exports: `
472
- + `re-export it (export ${binding.credentialEnvVar}="<sync token>") or persist it in your shell profile (~/.zshenv or ~/.bashrc). `
473
- + 'Lost the token? Rotate it on the hosted dashboard under Setup -> Hosted sync, then export the new value.',
480
+ formatMissingCredentialError(binding, { action: 'This hosted sync command', detail: resolution.detail }),
474
481
  );
475
482
  }
476
483
 
@@ -485,6 +492,7 @@ async function loadSyncContext(options, environment) {
485
492
  manifestPath,
486
493
  fetch: fetchImpl,
487
494
  credential,
495
+ credentialSource: resolution.source,
488
496
  apiUrl: binding.apiUrl,
489
497
  projectId: binding.projectId,
490
498
  syncTarget: binding.target,