@vibecompass/vibecompass 0.13.1 → 0.14.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 +31 -1
- package/README.md +11 -1
- package/package.json +1 -1
- package/src/cli.js +149 -10
- package/src/generators/agent-files/index.js +11 -3
- package/src/generators/agent-files/template.js +1 -0
- package/src/index.js +2 -0
- package/src/scaffold.js +15 -4
- package/src/session.js +456 -5
- package/src/workflows/registry.js +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.14.0 - 2026-09-23
|
|
4
|
+
|
|
5
|
+
- Add `continue-session [<lane-id>]` (D-353): resume an already-open lane
|
|
6
|
+
through the D-277 resolver (explicit id → worktree marker → single lane).
|
|
7
|
+
The lane keeps its opening date and session number; the command records
|
|
8
|
+
`resumed_at` / `resume_count` in `session.yaml`, appends a dated `Resumed`
|
|
9
|
+
line to `wip.md ## Log`, moves the index `current` hint and the Current
|
|
10
|
+
session block to the lane (showing the resume date), optionally refreshes
|
|
11
|
+
`--working-on`, prints the wip/handoff/latest-note paths plus runtime and
|
|
12
|
+
git-binding reminders, re-emits the docs-update staleness set as
|
|
13
|
+
`Resume staleness` warnings, and supports `--json`. With two or more active
|
|
14
|
+
lanes and no selection, or with an unknown explicit id, it fails closed and
|
|
15
|
+
prints the lane inventory. All inputs are read and outputs prepared before
|
|
16
|
+
the first write, so a refusal (missing CLAUDE.md, unparseable scratch or
|
|
17
|
+
metadata) changes no bytes; `--working-on` text is written literally,
|
|
18
|
+
hand-spaced or CRLF metadata keys are replaced rather than duplicated, and
|
|
19
|
+
Current-session fields carry over only for the exact same lane id.
|
|
20
|
+
- `list-sessions` renders the shared lane inventory (opened date/session
|
|
21
|
+
number, resume count, branch/worktrees, port, last log line, current hint)
|
|
22
|
+
and accepts `--json`; the header now says `current hint`.
|
|
23
|
+
- `start-session --id <existing-lane>` fails closed before any preflight and
|
|
24
|
+
names `continue-session`; starting a lane while others are active warns with
|
|
25
|
+
the resume alternative.
|
|
26
|
+
- Generated `context.md` gains an "Open, resume, or choose" protocol and a
|
|
27
|
+
`continue session` prompt command; the managed agent block gains the
|
|
28
|
+
matching hard rule (resume instead of re-start; with 2+ lanes inventory and
|
|
29
|
+
ask, never adopt the current hint silently).
|
|
30
|
+
- Report byte-identical managed agent instruction files as `unchanged` from
|
|
31
|
+
`sync-agents`, `refresh-workflow`, and session lifecycle syncs instead of
|
|
32
|
+
reporting a no-op as `update` / `dry-run-update`; missing or incomplete
|
|
33
|
+
managed markers remain warning-only and are never overwritten.
|
|
4
34
|
|
|
5
35
|
## 0.13.1 - 2026-09-11
|
|
6
36
|
|
package/README.md
CHANGED
|
@@ -128,11 +128,21 @@ npx -y @vibecompass/vibecompass@latest status --root .compass
|
|
|
128
128
|
|
|
129
129
|
# Session workflow
|
|
130
130
|
npx -y @vibecompass/vibecompass@latest start-session --root .compass --id auth-flow --working-on "Auth flow"
|
|
131
|
-
npx -y @vibecompass/vibecompass@latest list-sessions --root .compass
|
|
131
|
+
npx -y @vibecompass/vibecompass@latest list-sessions --root .compass # --json for the machine-readable lane inventory
|
|
132
|
+
npx -y @vibecompass/vibecompass@latest continue-session --root .compass # resume the open lane (D-353); name it with 2+ lanes: continue-session auth-flow
|
|
132
133
|
npx -y @vibecompass/vibecompass@latest switch-session auth-flow --root .compass
|
|
133
134
|
npx -y @vibecompass/vibecompass@latest docs-update --root .compass --session auth-flow
|
|
134
135
|
npx -y @vibecompass/vibecompass@latest close-session --root .compass --session auth-flow --title "Auth Flow" --completed "Built auth" --architecture-docs updated --decision-log not-needed --session-maintenance updated --next-step "Review"
|
|
135
136
|
|
|
137
|
+
# Resuming (D-353): an active lane is an open session, not a crash. continue-session
|
|
138
|
+
# keeps the lane's opening date and session number, records resumed_at/resume_count
|
|
139
|
+
# in session.yaml, appends a dated "Resumed" line to wip.md, moves the index current
|
|
140
|
+
# hint and the Current session block to the lane, and prints the files to read next
|
|
141
|
+
# plus resume-staleness warnings. With two or more active lanes and no worktree
|
|
142
|
+
# marker it fails closed and prints the lane inventory instead of guessing;
|
|
143
|
+
# start-session --id <existing-lane> fails closed and points at continue-session.
|
|
144
|
+
npx -y @vibecompass/vibecompass@latest continue-session auth-flow --root .compass --working-on "Auth flow, phase 2"
|
|
145
|
+
|
|
136
146
|
# Optional git binding for a lane (D-281): create/reuse a branch in each bound
|
|
137
147
|
# repo; --worktree provisions per-repo worktrees under
|
|
138
148
|
# <workspace>/worktrees/<lane-id>/<repo-id> with the lane marker in the
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -8,7 +8,7 @@ import { planDocsUpdate, renderDocsUpdatePlan } from './docs-update.js';
|
|
|
8
8
|
import { resolveConnectHostedCliOptions, resolveInitCliOptions } from './setup.js';
|
|
9
9
|
import { readFile } from 'node:fs/promises';
|
|
10
10
|
import path from 'node:path';
|
|
11
|
-
import { closeProjectSession, listProjectSessions, readLaneEnvironment, rebuildActiveSessionIndex, startProjectSession, switchProjectSession, writeLaneMarkerForSession } from './session.js';
|
|
11
|
+
import { closeProjectSession, continueProjectSession, listProjectSessions, readLaneEnvironment, rebuildActiveSessionIndex, renderLaneInventoryLines, startProjectSession, switchProjectSession, writeLaneMarkerForSession } from './session.js';
|
|
12
12
|
import { appendDecisionEntry, formatDecisionId, readNextDecisionId } from './decisions.js';
|
|
13
13
|
import { checkGroupedDecisionIndex, refreshGroupedDecisionIndex, resolveDefaultIndexGroupLabel } from './decision-index.js';
|
|
14
14
|
import { resolveLaneMarkerContext, resolveLaneSelection } from './lane-marker.js';
|
|
@@ -298,20 +298,66 @@ export async function runCli(argv, io = createDefaultIo(), runtime = {}) {
|
|
|
298
298
|
return 0;
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
if (parsed.command === 'continue-session') {
|
|
302
|
+
await writeCompatibilityPreflightWarnings(io, parsed.options, runtime);
|
|
303
|
+
const result = await continueProjectSession({
|
|
304
|
+
...parsed.options,
|
|
305
|
+
...(runtime.cwd ? { cwd: runtime.cwd } : {}),
|
|
306
|
+
});
|
|
307
|
+
if (parsed.options.json) {
|
|
308
|
+
const { manifest: _manifest, docsUpdatePlan: _plan, agentFileSync, ...json } = result;
|
|
309
|
+
io.stdout.write(`${JSON.stringify({ ...json, agentFileSync: agentFileSync?.results ?? [] }, null, 2)}\n`);
|
|
310
|
+
writeWarnings(io, result.warnings);
|
|
311
|
+
return 0;
|
|
312
|
+
}
|
|
313
|
+
{
|
|
314
|
+
// Text mode.
|
|
315
|
+
io.stdout.write(`Continued lane ${result.sessionId} (session ${result.sessionDate}-${result.sessionNumber}, resume #${result.resumeCount}${result.laneAgeDays != null ? `, opened ${result.laneAgeDays} day${result.laneAgeDays === 1 ? '' : 's'} ago` : ''}; selected via ${result.selectionSource})\n`);
|
|
316
|
+
io.stdout.write(`Working on: ${result.workingOn}${result.workingOnUpdated ? ' (updated)' : ''}\n`);
|
|
317
|
+
io.stdout.write(`Updated ${result.claudePath}\n`);
|
|
318
|
+
io.stdout.write('Read next:\n');
|
|
319
|
+
io.stdout.write(`- ${result.wipFilePath}\n`);
|
|
320
|
+
io.stdout.write(`- ${result.handoffFilePath}\n`);
|
|
321
|
+
if (result.latestFinalizedSessionPath) {
|
|
322
|
+
io.stdout.write(`- ${result.latestFinalizedSessionPath} (latest finalized note)\n`);
|
|
323
|
+
}
|
|
324
|
+
if (result.gitBinding) {
|
|
325
|
+
const worktreeRepos = Object.keys(result.gitBinding.worktrees);
|
|
326
|
+
io.stdout.write(`Git binding: branch "${result.gitBinding.branch}"${worktreeRepos.length > 0 ? ` with worktrees for ${worktreeRepos.join(', ')} under ${result.gitBinding.worktreeContainer}` : ' (no worktrees)'}\n`);
|
|
327
|
+
}
|
|
328
|
+
if (result.runtime) {
|
|
329
|
+
io.stdout.write(`Runtime: port ${result.runtime.port}, temp dir ${result.runtime.tmpDir}\n`);
|
|
330
|
+
io.stdout.write(`Export into a shell with: eval "$(vibecompass lane-env${result.otherLanes.length > 0 ? ` --session ${result.sessionId}` : ''})" (D-282).\n`);
|
|
331
|
+
}
|
|
332
|
+
if (result.otherLanes.length > 0) {
|
|
333
|
+
io.stdout.write(`Other active lanes (${result.otherLanes.length}) — pass --session ${result.sessionId} to lane-scoped commands, or run them from this lane's worktree:\n`);
|
|
334
|
+
for (const lane of result.otherLanes) {
|
|
335
|
+
io.stdout.write(`- ${lane.id}: ${lane.workingOn ?? 'No summary recorded'}\n`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
writeWarnings(io, result.warnings);
|
|
340
|
+
writeAgentFileSyncResult(io, result.agentFileSync);
|
|
341
|
+
return 0;
|
|
342
|
+
}
|
|
343
|
+
|
|
301
344
|
if (parsed.command === 'list-sessions') {
|
|
302
345
|
await writeCompatibilityPreflightWarnings(io, parsed.options, runtime);
|
|
303
346
|
const result = await listProjectSessions({
|
|
304
347
|
...parsed.options,
|
|
305
348
|
...(runtime.cwd ? { cwd: runtime.cwd } : {}),
|
|
306
349
|
});
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
350
|
+
if (parsed.options.json) {
|
|
351
|
+
io.stdout.write(`${JSON.stringify({ root_dir: result.rootDir, ...result.inventory }, null, 2)}\n`);
|
|
352
|
+
return 0;
|
|
353
|
+
}
|
|
354
|
+
for (const line of renderLaneInventoryLines(result.inventory)) {
|
|
355
|
+
io.stdout.write(`${line}\n`);
|
|
356
|
+
}
|
|
357
|
+
if (result.inventory.lanes.length > 1) {
|
|
358
|
+
io.stdout.write('Multiple lanes are active: resume one with `vibecompass continue-session <lane-id>`; the current hint is not a selection (D-277/D-353).\n');
|
|
359
|
+
} else if (result.inventory.lanes.length === 1) {
|
|
360
|
+
io.stdout.write(`Resume with \`vibecompass continue-session\` (single active lane resolves implicitly; D-353).\n`);
|
|
315
361
|
}
|
|
316
362
|
return 0;
|
|
317
363
|
}
|
|
@@ -736,6 +782,10 @@ export function parseCliArgs(argv) {
|
|
|
736
782
|
return parseListSessionsArgs(rest);
|
|
737
783
|
}
|
|
738
784
|
|
|
785
|
+
if (command === 'continue-session') {
|
|
786
|
+
return parseContinueSessionArgs(rest);
|
|
787
|
+
}
|
|
788
|
+
|
|
739
789
|
if (command === 'switch-session') {
|
|
740
790
|
return parseSwitchSessionArgs(rest);
|
|
741
791
|
}
|
|
@@ -1393,6 +1443,11 @@ function parseListSessionsArgs(argv) {
|
|
|
1393
1443
|
throw new Error(`Unexpected argument "${token}".`);
|
|
1394
1444
|
}
|
|
1395
1445
|
|
|
1446
|
+
if (token === '--json') {
|
|
1447
|
+
parsed.json = true;
|
|
1448
|
+
continue;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1396
1451
|
const value = argv[index + 1];
|
|
1397
1452
|
if (value === undefined) {
|
|
1398
1453
|
throw new Error(`Flag "${token}" requires a value.`);
|
|
@@ -1636,6 +1691,77 @@ function parseNextDecisionIdArgs(argv) {
|
|
|
1636
1691
|
return { command: 'next-decision-id', options: parsed };
|
|
1637
1692
|
}
|
|
1638
1693
|
|
|
1694
|
+
function parseContinueSessionArgs(argv) {
|
|
1695
|
+
const parsed = {};
|
|
1696
|
+
const positional = [];
|
|
1697
|
+
|
|
1698
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
1699
|
+
const token = argv[index];
|
|
1700
|
+
|
|
1701
|
+
if (!token.startsWith('--')) {
|
|
1702
|
+
positional.push(token);
|
|
1703
|
+
continue;
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
if (token === '--json') {
|
|
1707
|
+
parsed.json = true;
|
|
1708
|
+
continue;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
const value = argv[index + 1];
|
|
1712
|
+
if (value === undefined) {
|
|
1713
|
+
throw new Error(`Flag "${token}" requires a value.`);
|
|
1714
|
+
}
|
|
1715
|
+
index += 1;
|
|
1716
|
+
|
|
1717
|
+
switch (token) {
|
|
1718
|
+
case '--root':
|
|
1719
|
+
parsed.rootDir = value;
|
|
1720
|
+
break;
|
|
1721
|
+
case '--tooling-root':
|
|
1722
|
+
parsed.toolingRootDir = value;
|
|
1723
|
+
break;
|
|
1724
|
+
case '--session':
|
|
1725
|
+
parsed.sessionId = value;
|
|
1726
|
+
break;
|
|
1727
|
+
case '--working-on':
|
|
1728
|
+
parsed.workingOn = value;
|
|
1729
|
+
break;
|
|
1730
|
+
case '--date':
|
|
1731
|
+
parsed.date = value;
|
|
1732
|
+
break;
|
|
1733
|
+
case '--last-thing-completed':
|
|
1734
|
+
parsed.lastThingCompleted = value;
|
|
1735
|
+
break;
|
|
1736
|
+
case '--blockers':
|
|
1737
|
+
parsed.blockers = value;
|
|
1738
|
+
break;
|
|
1739
|
+
case '--next-session-should':
|
|
1740
|
+
parsed.nextSessionShould = value;
|
|
1741
|
+
break;
|
|
1742
|
+
default:
|
|
1743
|
+
throw new Error(`Unknown flag "${token}".`);
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
if (positional.length > 1) {
|
|
1748
|
+
throw new Error(`Unexpected argument "${positional[1]}".`);
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
if (parsed.sessionId && positional[0] && parsed.sessionId !== positional[0]) {
|
|
1752
|
+
throw new Error(`Conflicting lane IDs: positional "${positional[0]}" and --session "${parsed.sessionId}".`);
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
if (!parsed.sessionId && positional[0]) {
|
|
1756
|
+
parsed.sessionId = positional[0];
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
return {
|
|
1760
|
+
command: 'continue-session',
|
|
1761
|
+
options: parsed,
|
|
1762
|
+
};
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1639
1765
|
function parseSwitchSessionArgs(argv) {
|
|
1640
1766
|
const parsed = {};
|
|
1641
1767
|
const positional = [];
|
|
@@ -2369,7 +2495,8 @@ function usageText() {
|
|
|
2369
2495
|
' vibecompass start-session --id <lane-id> --working-on <text> [--branch <name> [--worktree]] [options]',
|
|
2370
2496
|
' vibecompass close-session --title <text> --completed <text> --architecture-docs <status> --decision-log <status> --session-maintenance <status> [options]',
|
|
2371
2497
|
' vibecompass end-session --title <text> --completed <text> --architecture-docs <status> --decision-log <status> --session-maintenance <status> [options] # alias',
|
|
2372
|
-
' vibecompass
|
|
2498
|
+
' vibecompass continue-session [<lane-id>] [--session <lane-id>] [--working-on <text>] [--json] [options]',
|
|
2499
|
+
' vibecompass list-sessions [--json] [options]',
|
|
2373
2500
|
' vibecompass switch-session <id> [options]',
|
|
2374
2501
|
' vibecompass rebuild-active-index [--current <lane-id>] [options]',
|
|
2375
2502
|
' vibecompass write-lane-marker --session <lane-id> [--dir <path>] [options]',
|
|
@@ -2481,9 +2608,21 @@ function usageText() {
|
|
|
2481
2608
|
' --last-thing-completed <text> Optional override for the CLAUDE.md completed summary',
|
|
2482
2609
|
' --next-session-should <text> Optional override for the CLAUDE.md next-session summary',
|
|
2483
2610
|
'',
|
|
2611
|
+
'Continue-session options (D-353):',
|
|
2612
|
+
' --root <path> Project-memory root. Explicit --root wins; otherwise the nearest worktree lane marker supplies it, else .compass',
|
|
2613
|
+
' --tooling-root <path> Tooling root that contains CLAUDE.md. Defaults to cwd; follows the memory root placement when a marker supplies the root',
|
|
2614
|
+
' --session <lane-id> Lane to resume; positional ID is also accepted. Omitted: nearest worktree lane marker, else the single active lane; 2+ active lanes fail closed and print the lane inventory (D-277)',
|
|
2615
|
+
' --working-on <text> Optional refreshed lane scope; rewrites working_on in session.yaml, the index, wip.md, and CLAUDE.md',
|
|
2616
|
+
' --date <YYYY-MM-DD> Optional explicit resume date (the lane keeps its opening date and session number)',
|
|
2617
|
+
' --last-thing-completed <text> Optional override for the CLAUDE.md current-session block',
|
|
2618
|
+
' --blockers <text> Optional current blockers summary',
|
|
2619
|
+
' --next-session-should <text> Optional current-session handoff summary',
|
|
2620
|
+
' --json Print the resume result (lane, paths, runtime, other lanes, warnings) as JSON',
|
|
2621
|
+
'',
|
|
2484
2622
|
'List/switch-session options:',
|
|
2485
2623
|
' --root <path> Project-memory root. Defaults to .compass',
|
|
2486
2624
|
' --session <lane-id> Lane ID for switch-session; positional ID is also accepted',
|
|
2625
|
+
' --json list-sessions: print the lane inventory as JSON',
|
|
2487
2626
|
'',
|
|
2488
2627
|
'Rebuild-active-index options:',
|
|
2489
2628
|
' --root <path> Project-memory root. Defaults to .compass',
|
|
@@ -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 (
|
|
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
|
|
110
|
+
status,
|
|
103
111
|
warning: applied.warning,
|
|
104
|
-
changed
|
|
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
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
|
|
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.
|
|
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
|
|
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.
|
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],
|
|
@@ -7,7 +7,8 @@ export const WORKFLOW_REGISTRY = [
|
|
|
7
7
|
detailPath: 'context.md',
|
|
8
8
|
summary: 'Builder/reviewer lane lifecycle, handoffs, planning mode, and close-out.',
|
|
9
9
|
promptCommands: [
|
|
10
|
-
{ trigger: 'start session', summary: 'builder role trigger' },
|
|
10
|
+
{ trigger: 'start session', summary: 'builder role trigger; opens a new named lane — if a lane is already active, resume it with `continue session` instead of opening a duplicate' },
|
|
11
|
+
{ trigger: 'continue session', summary: 'builder resumes an already-open lane with `vibecompass continue-session [<lane-id>]` (D-353); with multiple active lanes name the lane explicitly: `continue session <lane-id>` — the agent presents the lane inventory and asks rather than guessing' },
|
|
11
12
|
{ trigger: 'join as reviewer', summary: 'reviewer role trigger' },
|
|
12
13
|
{ trigger: 'planning mode', summary: 'optional prompt-level mode for scoping work before implementation' },
|
|
13
14
|
{ trigger: 'review handoff', summary: 'reviewer reads the selected lane handoff and appends findings; with multiple active lanes name the lane explicitly: `review handoff <lane-id>`' },
|