@vibecompass/vibecompass 0.10.8 → 0.11.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/cli.js CHANGED
@@ -6,7 +6,12 @@ import { connectHostedProjectMemory, initializeProjectMemory, setDefaultSyncTarg
6
6
  import { preflightDocsReview } from './docs-review.js';
7
7
  import { planDocsUpdate, renderDocsUpdatePlan } from './docs-update.js';
8
8
  import { resolveConnectHostedCliOptions, resolveInitCliOptions } from './setup.js';
9
- import { closeProjectSession, listProjectSessions, startProjectSession, switchProjectSession } from './session.js';
9
+ import { readFile } from 'node:fs/promises';
10
+ import path from 'node:path';
11
+ import { closeProjectSession, listProjectSessions, readLaneEnvironment, rebuildActiveSessionIndex, startProjectSession, switchProjectSession, writeLaneMarkerForSession } from './session.js';
12
+ import { appendDecisionEntry, formatDecisionId, readNextDecisionId } from './decisions.js';
13
+ import { checkGroupedDecisionIndex, refreshGroupedDecisionIndex, resolveDefaultIndexGroupLabel } from './decision-index.js';
14
+ import { resolveLaneMarkerContext, resolveLaneSelection } from './lane-marker.js';
10
15
  import { syncAgentInstructionFiles } from './generators/agent-files/index.js';
11
16
  import { getProjectStatus, renderStatusText, toStatusJson } from './status.js';
12
17
  import { refreshWorkflow } from './refresh-workflow.js';
@@ -130,11 +135,52 @@ export async function runCli(argv, io = createDefaultIo(), runtime = {}) {
130
135
  io.stdout.write(`Updated ${result.claudePath}\n`);
131
136
  io.stdout.write(`Created ${result.wipFilePath}\n`);
132
137
  io.stdout.write(`Created ${result.handoffFilePath}\n`);
138
+ if (result.gitBinding) {
139
+ io.stdout.write(`Git binding: branch "${result.gitBinding.branch}"\n`);
140
+ for (const repo of result.gitBinding.repos) {
141
+ const location = repo.worktreePath ? ` at ${repo.worktreePath}` : '';
142
+ io.stdout.write(`- ${repo.repoId}: ${repo.mode === 'reuse' ? 'reused existing branch' : 'created branch'}${location} (base ${repo.baseRevision.slice(0, 12)})\n`);
143
+ }
144
+ if (result.gitBinding.markerPath) {
145
+ io.stdout.write(`Lane marker: ${result.gitBinding.markerPath}\n`);
146
+ const firstWorktree = result.gitBinding.repos.find((repo) => repo.worktreePath);
147
+ if (firstWorktree) {
148
+ io.stdout.write(`Next: cd ${firstWorktree.worktreePath} — commands run from inside a worktree need no --root or --session (lane marker).\n`);
149
+ }
150
+ }
151
+ }
152
+ if (result.runtime) {
153
+ io.stdout.write(`Runtime: port ${result.runtime.port}, temp dir ${result.runtime.tmpDir}\n`);
154
+ io.stdout.write('Export into a shell with: eval "$(vibecompass lane-env)" (D-282; pass --session <lane-id> when multiple lanes are active).\n');
155
+ }
133
156
  writeWarnings(io, result.warnings);
134
157
  writeAgentFileSyncResult(io, result.agentFileSync);
135
158
  return 0;
136
159
  }
137
160
 
161
+ if (parsed.command === 'lane-env') {
162
+ await writeCompatibilityPreflightWarnings(io, parsed.options, runtime);
163
+ const result = await readLaneEnvironment({
164
+ ...parsed.options,
165
+ ...(runtime.cwd ? { cwd: runtime.cwd } : {}),
166
+ });
167
+ if (parsed.options.json) {
168
+ io.stdout.write(`${JSON.stringify({
169
+ root_dir: result.rootDir,
170
+ lane_id: result.sessionId,
171
+ port: result.port,
172
+ tmp_dir: result.tmpDir,
173
+ env: result.env,
174
+ }, null, 2)}\n`);
175
+ } else {
176
+ for (const [name, value] of Object.entries(result.env)) {
177
+ io.stdout.write(`export ${name}=${shellQuoteSingle(value)}\n`);
178
+ }
179
+ }
180
+ writeWarnings(io, result.warnings);
181
+ return 0;
182
+ }
183
+
138
184
  if (parsed.command === 'connect-hosted') {
139
185
  await writeCompatibilityPreflightWarnings(io, parsed.options, runtime);
140
186
  const connectPlan = await resolveConnectHostedCliOptions(parsed.options, {
@@ -232,6 +278,8 @@ export async function runCli(argv, io = createDefaultIo(), runtime = {}) {
232
278
  io.stdout.write(`Closed session ${result.sessionDate}-${result.sessionNumber}\n`);
233
279
  io.stdout.write(`Wrote ${result.sessionFilePath}\n`);
234
280
  io.stdout.write(`Updated ${result.claudePath}\n`);
281
+ writeWorktreeCleanupResult(io, result.worktreeCleanup);
282
+ writeRuntimeCleanupResult(io, result.runtimeCleanup);
235
283
  if (result.docsUpdatePlan) {
236
284
  io.stdout.write(renderDocsUpdatePlan(result.docsUpdatePlan));
237
285
  }
@@ -446,6 +494,133 @@ export async function runCli(argv, io = createDefaultIo(), runtime = {}) {
446
494
  return 0;
447
495
  }
448
496
 
497
+ if (parsed.command === 'rebuild-active-index') {
498
+ const result = await rebuildActiveSessionIndex({
499
+ ...parsed.options,
500
+ ...(runtime.cwd ? { cwd: runtime.cwd } : {}),
501
+ });
502
+ if (result.removed) {
503
+ io.stdout.write('No active lanes; removed sessions/active/index.yaml.\n');
504
+ } else {
505
+ io.stdout.write(`Rebuilt ${result.indexPath}\n`);
506
+ io.stdout.write(`Current lane: ${result.current}\n`);
507
+ for (const lane of result.lanes) {
508
+ io.stdout.write(`- ${lane.id}${lane.workingOn ? `: ${lane.workingOn}` : ''}\n`);
509
+ }
510
+ }
511
+ return 0;
512
+ }
513
+
514
+ if (parsed.command === 'write-lane-marker') {
515
+ await writeCompatibilityPreflightWarnings(io, parsed.options, runtime);
516
+ const result = await writeLaneMarkerForSession({
517
+ ...parsed.options,
518
+ ...(runtime.cwd ? { cwd: runtime.cwd } : {}),
519
+ });
520
+ io.stdout.write(`Wrote lane marker for "${result.sessionId}" at ${result.markerPath}\n`);
521
+ io.stdout.write(`Recorded the marker in the lane's session.yaml (token-matched removal at close-session).\n`);
522
+ writeWarnings(io, result.warnings);
523
+ return 0;
524
+ }
525
+
526
+ if (parsed.command === 'append-decision') {
527
+ const cwd = runtime.cwd ? path.resolve(runtime.cwd) : process.cwd();
528
+ if (!parsed.options.entryPath) {
529
+ throw new Error('append-decision requires --entry <staged-entry-file>.');
530
+ }
531
+ // D-280 marker-aware root: an explicit --root wins, else the nearest
532
+ // worktree lane marker supplies the root, so a builder appending a
533
+ // decision mid-lane from inside a worktree needs neither --root nor
534
+ // --session (matching lane-env / close-session).
535
+ const markerContext = await resolveDecisionCommandContext(cwd, parsed.options);
536
+ const rootDir = markerContext.rootDir;
537
+ const entryContent = await readFile(path.resolve(cwd, parsed.options.entryPath), 'utf8');
538
+ // D-283: refresh the grouped index when a group label is determinable —
539
+ // explicit --group wins, else the resolvable lane context labels the
540
+ // group. With neither, the append keeps the hand-refresh reminder.
541
+ const labelResult = parsed.options.groupLabel
542
+ ? { label: parsed.options.groupLabel, warnings: [] }
543
+ : await resolveIndexGroupLabelSafely({ rootDir, cwd, sessionId: parsed.options.sessionId ?? null, marker: markerContext.marker });
544
+ const groupLabel = labelResult.label;
545
+ const result = await appendDecisionEntry({
546
+ rootDir,
547
+ target: parsed.options.target,
548
+ entryContent,
549
+ ...(groupLabel
550
+ ? { indexRefresher: () => refreshGroupedDecisionIndex({ rootDir, groupLabel }) }
551
+ : {}),
552
+ });
553
+ io.stdout.write(`Appended ${formatDecisionId(result.decisionId)} to ${result.targetPath}\n`);
554
+ for (const warning of uniqueStrings([...markerContext.warnings, ...labelResult.warnings, ...result.warnings])) {
555
+ io.stdout.write(`Warning: ${warning}\n`);
556
+ }
557
+ io.stdout.write(`${result.indexReminder}\n`);
558
+ return 0;
559
+ }
560
+
561
+ if (parsed.command === 'refresh-decision-index') {
562
+ const cwd = runtime.cwd ? path.resolve(runtime.cwd) : process.cwd();
563
+ // D-280 marker-aware root, matching lane-env / close-session, so the
564
+ // command works from inside a provisioned worktree with no flags.
565
+ const markerContext = await resolveDecisionCommandContext(cwd, parsed.options);
566
+ const rootDir = markerContext.rootDir;
567
+
568
+ if (parsed.options.check) {
569
+ const result = await checkGroupedDecisionIndex({ rootDir });
570
+ writeWarnings(io, markerContext.warnings);
571
+ if (result.ok) {
572
+ io.stdout.write(
573
+ `Grouped decision index check: clean (${result.stats.rowCount} rows in ${result.stats.groupCount} groups; ${result.stats.decisionCount} canonical decisions)\n`,
574
+ );
575
+ return 0;
576
+ }
577
+ io.stderr.write(`Grouped decision index check failed for ${result.indexPath}:\n`);
578
+ for (const problem of result.problems) {
579
+ io.stderr.write(`- ${problem}\n`);
580
+ }
581
+ return 1;
582
+ }
583
+
584
+ // Resolve the group label tolerantly: a no-op refresh (nothing missing)
585
+ // must not fail just because 2+ lanes are active with no marker/--session.
586
+ // The label is only load-bearing when rows are actually missing, and
587
+ // refreshGroupedDecisionIndex raises the actionable "pass --group" problem
588
+ // in exactly that case.
589
+ const labelResult = parsed.options.groupLabel
590
+ ? { label: parsed.options.groupLabel, warnings: [] }
591
+ : await resolveIndexGroupLabelSafely({ rootDir, cwd, sessionId: parsed.options.sessionId ?? null, marker: markerContext.marker });
592
+ const result = await refreshGroupedDecisionIndex({ rootDir, groupLabel: labelResult.label });
593
+ writeWarnings(io, uniqueStrings([...markerContext.warnings, ...labelResult.warnings, ...result.warnings]));
594
+ if (result.problems.length > 0) {
595
+ io.stderr.write(`Grouped decision index refresh refused for ${result.indexPath} (D-283 fail-closed):\n`);
596
+ for (const problem of result.problems) {
597
+ io.stderr.write(`- ${problem}\n`);
598
+ }
599
+ return 1;
600
+ }
601
+ if (result.upToDate) {
602
+ io.stdout.write(`decisions/INDEX.md is up to date (nothing missing).\n`);
603
+ } else {
604
+ io.stdout.write(`Refreshed ${result.indexPath} (structure-preserving, D-283)\n`);
605
+ io.stdout.write(`Added under "## ${result.groupLabel}":\n`);
606
+ for (const decision of result.added) {
607
+ io.stdout.write(`- ${formatDecisionId(decision.id)} | ${decision.title} | ${decision.domain}.md\n`);
608
+ }
609
+ }
610
+ return 0;
611
+ }
612
+
613
+ if (parsed.command === 'next-decision-id') {
614
+ const cwd = runtime.cwd ? path.resolve(runtime.cwd) : process.cwd();
615
+ const rootDir = path.resolve(cwd, parsed.options.rootDir ?? '.compass');
616
+ const nextId = await readNextDecisionId(rootDir);
617
+ io.stdout.write(`Advisory next decision ID: ${formatDecisionId(nextId)}\n`);
618
+ io.stdout.write(
619
+ 'Allocation happens at write time under the memory-root lock (D-276). Use `vibecompass append-decision`, or re-read the log immediately before appending by hand.\n',
620
+ );
621
+ return 0;
622
+ }
623
+
449
624
  throw new Error(`Unknown command "${parsed.command}".`);
450
625
  }
451
626
 
@@ -497,6 +672,30 @@ export function parseCliArgs(argv) {
497
672
  return parseSwitchSessionArgs(rest);
498
673
  }
499
674
 
675
+ if (command === 'rebuild-active-index') {
676
+ return parseRebuildActiveIndexArgs(rest);
677
+ }
678
+
679
+ if (command === 'write-lane-marker') {
680
+ return parseWriteLaneMarkerArgs(rest);
681
+ }
682
+
683
+ if (command === 'lane-env') {
684
+ return parseLaneEnvArgs(rest);
685
+ }
686
+
687
+ if (command === 'append-decision') {
688
+ return parseAppendDecisionArgs(rest);
689
+ }
690
+
691
+ if (command === 'refresh-decision-index') {
692
+ return parseRefreshDecisionIndexArgs(rest);
693
+ }
694
+
695
+ if (command === 'next-decision-id') {
696
+ return parseNextDecisionIdArgs(rest);
697
+ }
698
+
500
699
  if (command === 'sync-agents') {
501
700
  return parseSyncAgentsArgs(rest);
502
701
  }
@@ -712,6 +911,16 @@ function parseStartSessionArgs(argv) {
712
911
  throw new Error(`Unexpected argument "${token}".`);
713
912
  }
714
913
 
914
+ // Boolean flags first (D-281): --worktree takes no value — the container
915
+ // location is fixed to <workspace>/worktrees/<lane-id> per D-279.
916
+ if (token === '--worktree') {
917
+ parsed.worktree = true;
918
+ continue;
919
+ }
920
+ if (token.startsWith('--worktree=')) {
921
+ throw new Error('Flag "--worktree" takes no value; the worktree container is fixed to <workspace>/worktrees/<lane-id> (D-279).');
922
+ }
923
+
715
924
  const value = argv[index + 1];
716
925
  if (value === undefined) {
717
926
  throw new Error(`Flag "${token}" requires a value.`);
@@ -740,6 +949,9 @@ function parseStartSessionArgs(argv) {
740
949
  case '--claim':
741
950
  parsed.claims = [...(parsed.claims ?? []), value];
742
951
  break;
952
+ case '--branch':
953
+ parsed.branch = value;
954
+ break;
743
955
  case '--date':
744
956
  parsed.date = value;
745
957
  break;
@@ -763,6 +975,50 @@ function parseStartSessionArgs(argv) {
763
975
  };
764
976
  }
765
977
 
978
+ function parseLaneEnvArgs(argv) {
979
+ const parsed = {};
980
+
981
+ for (let index = 0; index < argv.length; index += 1) {
982
+ const token = argv[index];
983
+
984
+ if (!token.startsWith('--')) {
985
+ throw new Error(`Unexpected argument "${token}".`);
986
+ }
987
+
988
+ if (token === '--json') {
989
+ parsed.json = true;
990
+ continue;
991
+ }
992
+
993
+ if (token === '--no-conventional') {
994
+ parsed.conventionalAliases = false;
995
+ continue;
996
+ }
997
+
998
+ const value = argv[index + 1];
999
+ if (value === undefined) {
1000
+ throw new Error(`Flag "${token}" requires a value.`);
1001
+ }
1002
+ index += 1;
1003
+
1004
+ switch (token) {
1005
+ case '--root':
1006
+ parsed.rootDir = value;
1007
+ break;
1008
+ case '--session':
1009
+ parsed.sessionId = value;
1010
+ break;
1011
+ default:
1012
+ throw new Error(`Unknown flag "${token}".`);
1013
+ }
1014
+ }
1015
+
1016
+ return {
1017
+ command: 'lane-env',
1018
+ options: parsed,
1019
+ };
1020
+ }
1021
+
766
1022
  function parseConnectHostedArgs(argv) {
767
1023
  const parsed = {};
768
1024
 
@@ -1071,6 +1327,228 @@ function parseListSessionsArgs(argv) {
1071
1327
  };
1072
1328
  }
1073
1329
 
1330
+ function parseRebuildActiveIndexArgs(argv) {
1331
+ const parsed = {};
1332
+
1333
+ for (let index = 0; index < argv.length; index += 1) {
1334
+ const token = argv[index];
1335
+ const value = argv[index + 1];
1336
+ if (value === undefined) {
1337
+ throw new Error(`Flag "${token}" requires a value.`);
1338
+ }
1339
+ index += 1;
1340
+
1341
+ switch (token) {
1342
+ case '--root':
1343
+ parsed.rootDir = value;
1344
+ break;
1345
+ case '--current':
1346
+ parsed.current = value;
1347
+ break;
1348
+ default:
1349
+ throw new Error(`Unknown flag "${token}".`);
1350
+ }
1351
+ }
1352
+
1353
+ return { command: 'rebuild-active-index', options: parsed };
1354
+ }
1355
+
1356
+ function parseWriteLaneMarkerArgs(argv) {
1357
+ const parsed = {};
1358
+
1359
+ for (let index = 0; index < argv.length; index += 1) {
1360
+ const token = argv[index];
1361
+
1362
+ if (!token.startsWith('--')) {
1363
+ throw new Error(`Unexpected argument "${token}".`);
1364
+ }
1365
+
1366
+ const value = argv[index + 1];
1367
+ if (value === undefined) {
1368
+ throw new Error(`Flag "${token}" requires a value.`);
1369
+ }
1370
+ index += 1;
1371
+
1372
+ switch (token) {
1373
+ case '--root':
1374
+ parsed.rootDir = value;
1375
+ break;
1376
+ case '--session':
1377
+ parsed.sessionId = value;
1378
+ break;
1379
+ case '--dir':
1380
+ parsed.dir = value;
1381
+ break;
1382
+ default:
1383
+ throw new Error(`Unknown flag "${token}".`);
1384
+ }
1385
+ }
1386
+
1387
+ return { command: 'write-lane-marker', options: parsed };
1388
+ }
1389
+
1390
+ function parseAppendDecisionArgs(argv) {
1391
+ const parsed = {};
1392
+
1393
+ for (let index = 0; index < argv.length; index += 1) {
1394
+ const token = argv[index];
1395
+ const value = argv[index + 1];
1396
+ if (value === undefined) {
1397
+ throw new Error(`Flag "${token}" requires a value.`);
1398
+ }
1399
+ index += 1;
1400
+
1401
+ switch (token) {
1402
+ case '--root':
1403
+ parsed.rootDir = value;
1404
+ break;
1405
+ case '--target':
1406
+ parsed.target = value;
1407
+ break;
1408
+ case '--entry':
1409
+ parsed.entryPath = value;
1410
+ break;
1411
+ case '--group':
1412
+ parsed.groupLabel = value;
1413
+ break;
1414
+ case '--session':
1415
+ parsed.sessionId = value;
1416
+ break;
1417
+ default:
1418
+ throw new Error(`Unknown flag "${token}".`);
1419
+ }
1420
+ }
1421
+
1422
+ return { command: 'append-decision', options: parsed };
1423
+ }
1424
+
1425
+ function parseRefreshDecisionIndexArgs(argv) {
1426
+ const parsed = {};
1427
+
1428
+ for (let index = 0; index < argv.length; index += 1) {
1429
+ const token = argv[index];
1430
+
1431
+ if (!token.startsWith('--')) {
1432
+ throw new Error(`Unexpected argument "${token}".`);
1433
+ }
1434
+
1435
+ if (token === '--check') {
1436
+ parsed.check = true;
1437
+ continue;
1438
+ }
1439
+
1440
+ const value = argv[index + 1];
1441
+ if (value === undefined) {
1442
+ throw new Error(`Flag "${token}" requires a value.`);
1443
+ }
1444
+ index += 1;
1445
+
1446
+ switch (token) {
1447
+ case '--root':
1448
+ parsed.rootDir = value;
1449
+ break;
1450
+ case '--session':
1451
+ parsed.sessionId = value;
1452
+ break;
1453
+ case '--group':
1454
+ parsed.groupLabel = value;
1455
+ break;
1456
+ default:
1457
+ throw new Error(`Unknown flag "${token}".`);
1458
+ }
1459
+ }
1460
+
1461
+ return { command: 'refresh-decision-index', options: parsed };
1462
+ }
1463
+
1464
+ /**
1465
+ * D-280 marker-aware root resolution for the decision-log commands
1466
+ * (append-decision, refresh-decision-index): an explicit --root wins, else the
1467
+ * nearest worktree lane marker supplies the memory root, else cwd/.compass.
1468
+ * Returns the resolved root plus any marker warnings.
1469
+ */
1470
+ async function resolveDecisionCommandContext(cwd, options) {
1471
+ const markerContext = await resolveLaneMarkerContext({
1472
+ cwd,
1473
+ explicitRootDir: options.rootDir ?? null,
1474
+ explicitSessionId: options.sessionId ?? null,
1475
+ });
1476
+ const rootDir = markerContext.rootDir;
1477
+ const warnings = [...markerContext.warnings];
1478
+
1479
+ // D-277/D-280: validate an explicit selection signal up front, before the
1480
+ // command decides whether it needs a group label. A `--session` naming a
1481
+ // non-active lane, or a resolved worktree marker that is stale (names a
1482
+ // non-active lane), must fail the command closed even when an explicit
1483
+ // `--group` skips label resolution and even for read-only `--check`. The
1484
+ // 2+-lane no-selection ambiguity carries no explicit signal and is left for
1485
+ // tolerant label handling (a no-op refresh must still succeed there).
1486
+ if (options.sessionId || markerContext.marker) {
1487
+ const sessions = await listProjectSessions({ rootDir, cwd });
1488
+ const selection = resolveLaneSelection({
1489
+ explicitSessionId: options.sessionId ?? null,
1490
+ marker: markerContext.marker,
1491
+ laneIds: sessions.lanes.map((lane) => lane.id),
1492
+ rootDir,
1493
+ purpose: 'run this decision-log command in',
1494
+ });
1495
+ warnings.push(...selection.warnings);
1496
+ }
1497
+
1498
+ return { rootDir, marker: markerContext.marker, warnings };
1499
+ }
1500
+
1501
+ /**
1502
+ * D-283 auto-labeling for the decision-log commands. Tolerance is narrow: only
1503
+ * the "2+ active lanes with no explicit selection" ambiguity degrades to "no
1504
+ * label" (so a no-op refresh still succeeds and an append still lands with the
1505
+ * hand-refresh reminder). An *explicit* selection signal — a `--session` value
1506
+ * or a resolved worktree marker — must still fail closed on a bad selection
1507
+ * (a `--session` typo naming a non-active lane, or a stale marker) per
1508
+ * D-277/D-280; swallowing those would let `append-decision --session <typo>`
1509
+ * silently append a canonical decision against the wrong/no lane. Selection
1510
+ * warnings are returned so they are no longer discarded.
1511
+ */
1512
+ async function resolveIndexGroupLabelSafely(options) {
1513
+ const hasExplicitSelection = Boolean(options.sessionId) || Boolean(options.marker);
1514
+ if (hasExplicitSelection) {
1515
+ // Let a bad explicit selection propagate and fail the command closed.
1516
+ const resolved = await resolveDefaultIndexGroupLabel(options);
1517
+ return { label: resolved?.label ?? null, warnings: resolved?.warnings ?? [] };
1518
+ }
1519
+
1520
+ try {
1521
+ const resolved = await resolveDefaultIndexGroupLabel(options);
1522
+ return { label: resolved?.label ?? null, warnings: resolved?.warnings ?? [] };
1523
+ } catch {
1524
+ // Only the 2+-lane no-selection ambiguity reaches here.
1525
+ return { label: null, warnings: [] };
1526
+ }
1527
+ }
1528
+
1529
+ function parseNextDecisionIdArgs(argv) {
1530
+ const parsed = {};
1531
+
1532
+ for (let index = 0; index < argv.length; index += 1) {
1533
+ const token = argv[index];
1534
+ const value = argv[index + 1];
1535
+ if (value === undefined) {
1536
+ throw new Error(`Flag "${token}" requires a value.`);
1537
+ }
1538
+ index += 1;
1539
+
1540
+ switch (token) {
1541
+ case '--root':
1542
+ parsed.rootDir = value;
1543
+ break;
1544
+ default:
1545
+ throw new Error(`Unknown flag "${token}".`);
1546
+ }
1547
+ }
1548
+
1549
+ return { command: 'next-decision-id', options: parsed };
1550
+ }
1551
+
1074
1552
  function parseSwitchSessionArgs(argv) {
1075
1553
  const parsed = {};
1076
1554
  const positional = [];
@@ -1545,6 +2023,85 @@ function writeRefreshWorkflowResult(io, result) {
1545
2023
  writeAgentFileSyncResult(io, result.agentFileSync);
1546
2024
  }
1547
2025
 
2026
+ // D-281 close-side worktree cleanup summary. Status lines stay on stdout;
2027
+ // the actionable per-worktree guidance travels on result.warnings (stderr).
2028
+ const SURVIVING_WORKTREE_REASON_LABELS = {
2029
+ 'container-unverified': 'container marker unverified',
2030
+ 'outside-container': 'outside the lane container',
2031
+ 'cwd-inside': 'current working directory is inside it',
2032
+ 'not-absolute': 'recorded path not absolute',
2033
+ dirty: 'uncommitted changes',
2034
+ 'status-unknown': 'cleanliness unknown',
2035
+ 'no-source-recorded': 'no recorded source repo',
2036
+ 'git-refused': 'git refused the removal',
2037
+ };
2038
+
2039
+ function writeWorktreeCleanupResult(io, worktreeCleanup) {
2040
+ if (!worktreeCleanup) {
2041
+ return;
2042
+ }
2043
+
2044
+ io.stdout.write('Worktree cleanup:\n');
2045
+ for (const removed of worktreeCleanup.removed) {
2046
+ io.stdout.write(`- ${removed.repoId}: removed ${removed.worktreePath}\n`);
2047
+ }
2048
+ for (const surviving of worktreeCleanup.surviving) {
2049
+ const label = SURVIVING_WORKTREE_REASON_LABELS[surviving.reason] ?? surviving.reason;
2050
+ io.stdout.write(`- ${surviving.repoId}: kept ${surviving.worktreePath} (${label})\n`);
2051
+ }
2052
+ if (worktreeCleanup.markerRemoved) {
2053
+ io.stdout.write('- lane marker removed (token-matched)\n');
2054
+ } else if (worktreeCleanup.markerKept) {
2055
+ io.stdout.write('- lane marker kept while worktrees survive (D-281)\n');
2056
+ }
2057
+ if (worktreeCleanup.containerRemoved) {
2058
+ io.stdout.write('- container removed (empty)\n');
2059
+ }
2060
+ if (worktreeCleanup.branch) {
2061
+ io.stdout.write(`- branch "${worktreeCleanup.branch}" left in place (close-session never deletes branches; D-281)\n`);
2062
+ }
2063
+ }
2064
+
2065
+ // D-282 close-side lane temp-dir cleanup summary. A missing recorded dir is
2066
+ // benign crash residue and prints nothing, matching recorded-but-missing
2067
+ // worktrees; guard refusals print the kept path with a short label while the
2068
+ // actionable guidance travels on result.warnings (stderr).
2069
+ const KEPT_LANE_TMP_REASON_LABELS = {
2070
+ 'not-absolute': 'recorded path not absolute',
2071
+ 'outside-namespace': 'outside the lane temp namespace',
2072
+ 'lane-id-mismatch': "does not end in the lane's id",
2073
+ 'cwd-inside': 'current working directory is inside it',
2074
+ 'remove-failed': 'removal failed',
2075
+ };
2076
+
2077
+ function writeRuntimeCleanupResult(io, runtimeCleanup) {
2078
+ if (!runtimeCleanup) {
2079
+ return;
2080
+ }
2081
+
2082
+ if (runtimeCleanup.removed) {
2083
+ io.stdout.write(`Lane temp dir: removed ${runtimeCleanup.tmpDir}\n`);
2084
+ return;
2085
+ }
2086
+
2087
+ if (runtimeCleanup.reason === 'missing') {
2088
+ return;
2089
+ }
2090
+
2091
+ const label = KEPT_LANE_TMP_REASON_LABELS[runtimeCleanup.reason] ?? runtimeCleanup.reason;
2092
+ io.stdout.write(`Lane temp dir: kept ${runtimeCleanup.tmpDir} (${label})\n`);
2093
+ }
2094
+
2095
+ function shellQuoteSingle(value) {
2096
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
2097
+ }
2098
+
2099
+ /** Preserves order, drops duplicate warning strings (D-283 eager validation
2100
+ * can surface the same selection warning twice). */
2101
+ function uniqueStrings(items) {
2102
+ return [...new Set(items)];
2103
+ }
2104
+
1548
2105
  function writeDocumentMaintenanceCheckpoint(io, documentMaintenance) {
1549
2106
  if (!documentMaintenance) {
1550
2107
  return;
@@ -1606,11 +2163,17 @@ function usageText() {
1606
2163
  ' vibecompass status [options]',
1607
2164
  ' vibecompass refresh-workflow [--dry-run|--apply] [options]',
1608
2165
  ' vibecompass docs-update [--session <lane-id>] [options]',
1609
- ' vibecompass start-session --id <lane-id> --working-on <text> [options]',
2166
+ ' vibecompass start-session --id <lane-id> --working-on <text> [--branch <name> [--worktree]] [options]',
1610
2167
  ' vibecompass close-session --title <text> --completed <text> --architecture-docs <status> --decision-log <status> --session-maintenance <status> [options]',
1611
2168
  ' vibecompass end-session --title <text> --completed <text> --architecture-docs <status> --decision-log <status> --session-maintenance <status> [options] # alias',
1612
2169
  ' vibecompass list-sessions [options]',
1613
2170
  ' vibecompass switch-session <id> [options]',
2171
+ ' vibecompass rebuild-active-index [--current <lane-id>] [options]',
2172
+ ' vibecompass write-lane-marker --session <lane-id> [--dir <path>] [options]',
2173
+ ' vibecompass lane-env [--session <lane-id>] [--json] [--no-conventional] [options]',
2174
+ ' vibecompass append-decision --target <domain.md> --entry <staged-entry.md> [--group <label>] [options]',
2175
+ ' vibecompass refresh-decision-index [--check] [--session <lane-id>] [--group <label>] [options]',
2176
+ ' vibecompass next-decision-id [options]',
1614
2177
  ' vibecompass sync-agents [options]',
1615
2178
  ' vibecompass push [options]',
1616
2179
  ' vibecompass pull-preview [options]',
@@ -1672,30 +2235,32 @@ function usageText() {
1672
2235
  ' --allow-downgrade-templates Allow applying templates with a CLI older than the root stamp',
1673
2236
  '',
1674
2237
  'Docs-update options:',
1675
- ' --root <path> Project-memory root. Defaults to .compass',
1676
- ' --session <lane-id> Active session lane to inspect; defaults to current lane',
2238
+ ' --root <path> Project-memory root. Explicit --root wins; otherwise the nearest worktree lane marker supplies it, else .compass',
2239
+ ' --session <lane-id> Active session lane to inspect. Omitted: nearest worktree lane marker, else the single active lane; 2+ active lanes require --session or a marker (D-277)',
1677
2240
  ' --changed <repo:path|path> Repeatable explicit changed path; defaults to git status when omitted',
1678
2241
  ' --json Print the typed docs-update plan as JSON',
1679
2242
  '',
1680
2243
  'Start-session options:',
1681
2244
  ' --root <path> Project-memory root. Defaults to .compass',
1682
- ' --tooling-root <path> Tooling root that contains CLAUDE.md. Defaults to cwd',
2245
+ ' --tooling-root <path> Tooling root that contains CLAUDE.md. Defaults to cwd; follows the memory root placement when a marker supplies the root or cwd has no CLAUDE.md under an explicit --root',
1683
2246
  ' --working-on <text> Required active-session summary',
1684
2247
  ' --id <lane-id> Required active session lane ID',
1685
2248
  ' --feature <slug> Repeatable feature slug for the lane',
1686
2249
  ' --repo <id> Repeatable repo ID for the lane',
1687
2250
  ' --claim <path> Repeatable path claim for overlap warnings',
2251
+ ' --branch <name> Opt-in git binding (D-281): create or reuse this branch in every bound --repo; requires at least one --repo',
2252
+ ' --worktree With --branch: provision per-repo worktrees under <workspace>/worktrees/<lane-id>/<repo-id> (D-279) and write the lane marker into the container',
1688
2253
  ' --date <YYYY-MM-DD> Optional explicit session date',
1689
2254
  ' --last-thing-completed <text> Optional override for the CLAUDE.md current-session block',
1690
2255
  ' --blockers <text> Optional current blockers summary',
1691
2256
  ' --next-session-should <text> Optional current-session handoff summary',
1692
2257
  '',
1693
2258
  'Close-session options (also accepted by end-session):',
1694
- ' --root <path> Project-memory root. Defaults to .compass',
1695
- ' --tooling-root <path> Tooling root that contains CLAUDE.md. Defaults to cwd',
2259
+ ' --root <path> Project-memory root. Explicit --root wins; otherwise the nearest worktree lane marker supplies it, else .compass',
2260
+ ' --tooling-root <path> Tooling root that contains CLAUDE.md. Defaults to cwd; follows the memory root placement when a marker supplies the root or cwd has no CLAUDE.md under an explicit --root',
1696
2261
  ' --title <text> Required display title for the finalized session note',
1697
2262
  ' --worked-on <text> Optional override for "What we worked on"',
1698
- ' --session <lane-id> Active session lane to close',
2263
+ ' --session <lane-id> Active session lane to close. Omitted: nearest worktree lane marker, else the single active lane; 2+ active lanes require --session or a marker (D-277)',
1699
2264
  ' --completed <text> Repeatable completed item',
1700
2265
  ' --decision <text> Repeatable decision reference or summary',
1701
2266
  ' --model <text> Optional repeatable model contribution entry',
@@ -1711,6 +2276,35 @@ function usageText() {
1711
2276
  ' --root <path> Project-memory root. Defaults to .compass',
1712
2277
  ' --session <lane-id> Lane ID for switch-session; positional ID is also accepted',
1713
2278
  '',
2279
+ 'Rebuild-active-index options:',
2280
+ ' --root <path> Project-memory root. Defaults to .compass',
2281
+ ' --current <lane-id> Explicit current-lane selection; required when multiple lanes are active and the existing pointer is invalid',
2282
+ '',
2283
+ 'Write-lane-marker options (D-280):',
2284
+ ' --session <lane-id> Required active lane the marker binds to',
2285
+ ' --dir <path> Marker target directory; defaults to cwd. Must be path-disjoint from the memory root',
2286
+ ' --root <path> Project-memory root. Explicit --root wins; otherwise the nearest worktree lane marker supplies it, else .compass',
2287
+ '',
2288
+ 'Lane-env options (D-282):',
2289
+ ' --root <path> Project-memory root. Explicit --root wins; otherwise the nearest worktree lane marker supplies it, else .compass',
2290
+ ' --session <lane-id> Lane to export. Omitted: nearest worktree lane marker, else the single active lane; 2+ active lanes require --session or a marker (D-277)',
2291
+ ' --json Print the runtime assignment as JSON instead of shell export lines',
2292
+ ' --no-conventional Omit the conventional PORT/TMPDIR alias exports',
2293
+ ' Consume with: eval "$(vibecompass lane-env)"; defaults come from project.yaml runtime.{port_base,port_step,tmp_base}',
2294
+ '',
2295
+ 'Decision-log options (append-decision / next-decision-id):',
2296
+ ' --root <path> Project-memory root. Defaults to .compass',
2297
+ ' --target <domain.md> Decision domain file under decisions/ (e.g. cross-cutting.md); never INDEX.md',
2298
+ ' --entry <path> Staged entry file starting with "### D-NEXT — <title>"; the D-number is allocated atomically at write time (D-276)',
2299
+ ' --group <label> Grouped index group label for the D-283 refresh (e.g. "2026-07-03 — Session 3 (lane-x lane)"); defaults to the resolvable lane context',
2300
+ '',
2301
+ 'Refresh-decision-index options (D-283):',
2302
+ ' --root <path> Project-memory root. Defaults to .compass',
2303
+ ' --check Validate the grouped index against canonical decision entries without writing',
2304
+ ' --session <lane-id> Lane whose date/session/id labels a new group; defaults to the D-277 lane resolution',
2305
+ ' --group <label> Explicit group label for appended rows; wins over the lane-derived label',
2306
+ ' Structure-preserving: existing groups/rows are kept verbatim; unparseable structure fails closed',
2307
+ '',
1714
2308
  'Sync-agents options:',
1715
2309
  ' --root <path> Project-memory root. Defaults to .compass',
1716
2310
  ' --tooling-root <path> Directory where agent files are written. Defaults to cwd',