@vibecompass/vibecompass 0.11.0 → 0.11.2
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 +22 -0
- package/README.md +9 -1
- package/package.json +1 -1
- package/src/cli.js +11 -2
- package/src/lane-marker.js +39 -1
- package/src/scaffold.js +1 -1
- package/src/serialization.js +42 -3
- package/src/session.js +219 -15
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.11.2 - 2026-07-06
|
|
6
|
+
|
|
7
|
+
- Complete the remaining Phase 8 start-session overlap warnings: lanes can now
|
|
8
|
+
declare expected architecture doc paths with `--architecture-doc` and
|
|
9
|
+
decision domain files with `--decision-domain-file` / `--decision-domain`;
|
|
10
|
+
active-lane overlap warnings compare normalized path spellings for those
|
|
11
|
+
fields, and same-repo ownership ambiguity now covers one-sided or
|
|
12
|
+
unparseable path claims while treating single-repo unprefixed claims and
|
|
13
|
+
disjoint feature slugs as real non-overlapping ownership.
|
|
14
|
+
- Harden S2 lane-marker follow-ups: mutating session commands revalidate a
|
|
15
|
+
pre-lock marker snapshot after acquiring the memory-root lock, close-session
|
|
16
|
+
reports unrecorded cwd markers that name the closing lane, and marker parsing
|
|
17
|
+
round-trips Windows absolute `memory_root` values with backslashes.
|
|
18
|
+
|
|
19
|
+
## 0.11.1 - 2026-07-06
|
|
20
|
+
|
|
21
|
+
- Harden the per-root serialization lock for symlink-aliased memory roots: root keys now canonicalize through existing parent directories when the memory root itself does not yet exist, and nested lock holders compare held/requested roots by `realpath` before reacquiring the disk lock. This preserves reentrancy when an outer lock creates the root and an inner call reaches it through another spelling.
|
|
22
|
+
- Make shared-checkout lane warnings honest about runnable isolation: unbound same-repo lanes now recommend `--branch <name> --worktree` for independent files or dev servers, including at `lane-env` where `PORT` is exported, while keeping `--claim` as the ownership-review fallback.
|
|
23
|
+
- Suppress shared-checkout warnings when either the selected lane or the sibling lane is already worktree-bound, and add regression coverage for the symlink-root reentrancy and worktree-bound warning paths.
|
|
24
|
+
|
|
3
25
|
## 0.11.0 - 2026-07-03
|
|
4
26
|
|
|
5
27
|
Phase 8 concurrent multi-session lanes (stages S1–S4). Highlights: a per-root
|
package/README.md
CHANGED
|
@@ -145,7 +145,15 @@ npx -y @vibecompass/vibecompass@latest close-session --root .compass --session a
|
|
|
145
145
|
# close-session and docs-update also print a pre-close staleness set: new
|
|
146
146
|
# decisions since lane start, stale base revisions, newer finalized notes
|
|
147
147
|
# that mention the lane's scope, and claim overlap with other active lanes.
|
|
148
|
-
npx -y @vibecompass/vibecompass@latest start-session
|
|
148
|
+
npx -y @vibecompass/vibecompass@latest start-session \
|
|
149
|
+
--id auth-flow \
|
|
150
|
+
--working-on "Auth flow" \
|
|
151
|
+
--repo app \
|
|
152
|
+
--claim src/app/auth \
|
|
153
|
+
--architecture-doc architecture/product/auth/login.md \
|
|
154
|
+
--decision-domain-file cross-cutting.md \
|
|
155
|
+
--branch feature/auth \
|
|
156
|
+
--worktree
|
|
149
157
|
|
|
150
158
|
# Per-lane runtime isolation (D-282): every lane gets its own port and temp
|
|
151
159
|
# dir at start (recorded in the lane's session.yaml; defaults configurable
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -151,7 +151,7 @@ export async function runCli(argv, io = createDefaultIo(), runtime = {}) {
|
|
|
151
151
|
}
|
|
152
152
|
if (result.runtime) {
|
|
153
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');
|
|
154
|
+
io.stdout.write('Export into a shell with: eval "$(vibecompass lane-env)" (D-282; pass --session <lane-id> when multiple lanes are active; use --worktree for independently runnable same-repo lanes).\n');
|
|
155
155
|
}
|
|
156
156
|
writeWarnings(io, result.warnings);
|
|
157
157
|
writeAgentFileSyncResult(io, result.agentFileSync);
|
|
@@ -949,6 +949,13 @@ function parseStartSessionArgs(argv) {
|
|
|
949
949
|
case '--claim':
|
|
950
950
|
parsed.claims = [...(parsed.claims ?? []), value];
|
|
951
951
|
break;
|
|
952
|
+
case '--architecture-doc':
|
|
953
|
+
parsed.architectureDocs = [...(parsed.architectureDocs ?? []), value];
|
|
954
|
+
break;
|
|
955
|
+
case '--decision-domain':
|
|
956
|
+
case '--decision-domain-file':
|
|
957
|
+
parsed.decisionDomainFiles = [...(parsed.decisionDomainFiles ?? []), value];
|
|
958
|
+
break;
|
|
952
959
|
case '--branch':
|
|
953
960
|
parsed.branch = value;
|
|
954
961
|
break;
|
|
@@ -2248,6 +2255,8 @@ function usageText() {
|
|
|
2248
2255
|
' --feature <slug> Repeatable feature slug for the lane',
|
|
2249
2256
|
' --repo <id> Repeatable repo ID for the lane',
|
|
2250
2257
|
' --claim <path> Repeatable path claim for overlap warnings',
|
|
2258
|
+
' --architecture-doc <path> Repeatable architecture doc path the lane expects to edit',
|
|
2259
|
+
' --decision-domain-file <domain.md> Repeatable decision domain file the lane expects to edit (alias: --decision-domain)',
|
|
2251
2260
|
' --branch <name> Opt-in git binding (D-281): create or reuse this branch in every bound --repo; requires at least one --repo',
|
|
2252
2261
|
' --worktree With --branch: provision per-repo worktrees under <workspace>/worktrees/<lane-id>/<repo-id> (D-279) and write the lane marker into the container',
|
|
2253
2262
|
' --date <YYYY-MM-DD> Optional explicit session date',
|
|
@@ -2290,7 +2299,7 @@ function usageText() {
|
|
|
2290
2299
|
' --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
2300
|
' --json Print the runtime assignment as JSON instead of shell export lines',
|
|
2292
2301
|
' --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}',
|
|
2302
|
+
' Consume with: eval "$(vibecompass lane-env)"; defaults come from project.yaml runtime.{port_base,port_step,tmp_base}; use --worktree for independent same-repo dev servers',
|
|
2294
2303
|
'',
|
|
2295
2304
|
'Decision-log options (append-decision / next-decision-id):',
|
|
2296
2305
|
' --root <path> Project-memory root. Defaults to .compass',
|
package/src/lane-marker.js
CHANGED
|
@@ -108,7 +108,7 @@ export async function readLaneMarker(markerPath) {
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
const memoryRoot = typeof data.memory_root === 'string' ? data.memory_root.trim() : '';
|
|
111
|
-
if (!memoryRoot || !
|
|
111
|
+
if (!memoryRoot || !isAbsolutePathForAnySupportedPlatform(memoryRoot)) {
|
|
112
112
|
throw new Error(
|
|
113
113
|
`Lane marker at ${markerPath} must record memory_root as an absolute path (found ${JSON.stringify(data.memory_root ?? null)}). ` +
|
|
114
114
|
'Recreate the marker with `vibecompass write-lane-marker`, or pass both --root and --session to bypass it.',
|
|
@@ -188,6 +188,40 @@ export async function resolveLaneMarkerContext(options) {
|
|
|
188
188
|
};
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
/**
|
|
192
|
+
* In-lock revalidation for a marker snapshot captured before waiting on the
|
|
193
|
+
* memory-root lock. If the marker changed while the command waited, fail
|
|
194
|
+
* closed instead of using stale lane/root context for a mutating operation.
|
|
195
|
+
*/
|
|
196
|
+
export async function assertLaneMarkerSnapshotCurrent(markerContext) {
|
|
197
|
+
const snapshot = markerContext?.marker;
|
|
198
|
+
if (!snapshot) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let current;
|
|
203
|
+
try {
|
|
204
|
+
current = await readLaneMarker(snapshot.markerPath);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`Lane marker at ${snapshot.markerPath} changed while waiting for the project-memory lock. ` +
|
|
208
|
+
`Could not re-read the original marker (${error instanceof Error ? error.message : String(error)}). ` +
|
|
209
|
+
'Rerun the command so lane selection and root inference are based on the current marker.',
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (
|
|
214
|
+
current.laneId !== snapshot.laneId ||
|
|
215
|
+
current.memoryRoot !== snapshot.memoryRoot ||
|
|
216
|
+
current.token !== snapshot.token
|
|
217
|
+
) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
`Lane marker at ${snapshot.markerPath} changed while waiting for the project-memory lock. ` +
|
|
220
|
+
'Rerun the command so lane selection and root inference are based on the current marker.',
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
191
225
|
/**
|
|
192
226
|
* The D-277 lane selection order, shared by every lane-scoped resolver:
|
|
193
227
|
* explicit --session wins (warning when it contradicts a resolvable marker,
|
|
@@ -329,6 +363,10 @@ async function canonicalizePath(value) {
|
|
|
329
363
|
}
|
|
330
364
|
}
|
|
331
365
|
|
|
366
|
+
function isAbsolutePathForAnySupportedPlatform(value) {
|
|
367
|
+
return path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value);
|
|
368
|
+
}
|
|
369
|
+
|
|
332
370
|
async function fileExists(filePath) {
|
|
333
371
|
try {
|
|
334
372
|
await access(filePath);
|
package/src/scaffold.js
CHANGED
|
@@ -276,7 +276,7 @@ ${renderWorkflowDefaults(workflow)}
|
|
|
276
276
|
6. Read the relevant docs under \`${rootRelativePath}/architecture/\` and \`${rootRelativePath}/decisions/\`.
|
|
277
277
|
|
|
278
278
|
## Builder workflow
|
|
279
|
-
At session start, prefer running \`vibecompass start-session --id <lane-id> --working-on "..." \`; add \`--branch <name> --repo <id> [--worktree]\` when the lane should work on its own branch or in isolated worktrees (D-281).
|
|
279
|
+
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).
|
|
280
280
|
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:
|
|
281
281
|
|
|
282
282
|
\`\`\`md
|
package/src/serialization.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { mkdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -47,9 +47,9 @@ export function memoryRootLockPath(rootDir) {
|
|
|
47
47
|
* a nested call from inside the lock holder runs immediately.
|
|
48
48
|
*/
|
|
49
49
|
export async function withMemoryRootLock(rootDir, label, fn, options = {}) {
|
|
50
|
-
const key =
|
|
50
|
+
const key = await canonicalizeMemoryRootKey(rootDir);
|
|
51
51
|
const held = heldRoots.getStore();
|
|
52
|
-
if (held
|
|
52
|
+
if (await findHeldMemoryRootKey(held, key)) {
|
|
53
53
|
return fn();
|
|
54
54
|
}
|
|
55
55
|
|
|
@@ -78,6 +78,45 @@ export async function withMemoryRootLock(rootDir, label, fn, options = {}) {
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
async function canonicalizeMemoryRootKey(rootDir) {
|
|
82
|
+
const resolved = path.resolve(rootDir);
|
|
83
|
+
try {
|
|
84
|
+
return await realpath(resolved);
|
|
85
|
+
} catch {
|
|
86
|
+
try {
|
|
87
|
+
return path.join(await realpath(path.dirname(resolved)), path.basename(resolved));
|
|
88
|
+
} catch {
|
|
89
|
+
return resolved;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function findHeldMemoryRootKey(held, key) {
|
|
95
|
+
if (!held) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (held.has(key)) {
|
|
100
|
+
return key;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for (const heldKey of held) {
|
|
104
|
+
if (await memoryRootKeysReferToSamePath(heldKey, key)) {
|
|
105
|
+
return heldKey;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function memoryRootKeysReferToSamePath(left, right) {
|
|
113
|
+
try {
|
|
114
|
+
return (await realpath(left)) === (await realpath(right));
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
81
120
|
async function acquireDiskLock(key, label, options) {
|
|
82
121
|
const lockDir = memoryRootLockPath(key);
|
|
83
122
|
const attempts = options.attempts ?? DEFAULT_ATTEMPTS;
|
package/src/session.js
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
} from './lane-runtime.js';
|
|
27
27
|
import {
|
|
28
28
|
LANE_MARKER_FILENAME,
|
|
29
|
+
assertLaneMarkerSnapshotCurrent,
|
|
29
30
|
assertMarkerTargetDisjoint,
|
|
30
31
|
findEnclosingGitDir,
|
|
31
32
|
readLaneMarker,
|
|
@@ -141,6 +142,7 @@ export async function inferToolingRootForMemoryRoot(rootDir) {
|
|
|
141
142
|
}
|
|
142
143
|
|
|
143
144
|
async function startProjectSessionLocked(normalized, options, markerContext) {
|
|
145
|
+
await assertLaneMarkerSnapshotCurrent(markerContext);
|
|
144
146
|
const workingOn = requireNonEmptyString(options?.workingOn, 'start-session requires a non-empty workingOn value.');
|
|
145
147
|
const sessionDate = normalizeSessionDate(options?.date);
|
|
146
148
|
|
|
@@ -151,13 +153,19 @@ async function startProjectSessionLocked(normalized, options, markerContext) {
|
|
|
151
153
|
const features = normalizeStringArray(options?.features);
|
|
152
154
|
const repos = normalizeStringArray(options?.repos);
|
|
153
155
|
const claims = normalizeStringArray(options?.claims);
|
|
156
|
+
const architectureDocs = normalizeStringArray(options?.architectureDocs).map(normalizeArchitectureDocPath);
|
|
157
|
+
const decisionDomainFiles = normalizeStringArray(options?.decisionDomainFiles ?? options?.decisionDomains)
|
|
158
|
+
.map(normalizeDecisionDomainFile);
|
|
154
159
|
const existingLanes = await listActiveSessionLanes(normalized);
|
|
155
160
|
const overlapWarnings = buildOverlapWarnings({
|
|
156
161
|
sessionId,
|
|
157
162
|
features,
|
|
158
163
|
repos,
|
|
159
164
|
claims,
|
|
165
|
+
architectureDocs,
|
|
166
|
+
decisionDomainFiles,
|
|
160
167
|
existingLanes,
|
|
168
|
+
hasWorktree: options?.worktree === true,
|
|
161
169
|
});
|
|
162
170
|
const highestDecisionId = await readHighestDecisionId(normalized.rootDir);
|
|
163
171
|
const projectConfig = await readProjectConfig(normalized.projectFilePath);
|
|
@@ -275,6 +283,8 @@ async function startProjectSessionLocked(normalized, options, markerContext) {
|
|
|
275
283
|
features,
|
|
276
284
|
repos,
|
|
277
285
|
claims,
|
|
286
|
+
architectureDocs,
|
|
287
|
+
decisionDomainFiles,
|
|
278
288
|
baseRevisions: baseRevisionCapture.baseRevisions,
|
|
279
289
|
runtime: laneRuntime,
|
|
280
290
|
// Record-before-create (D-281): the full binding plan — including the
|
|
@@ -425,6 +435,7 @@ export async function closeProjectSession(options) {
|
|
|
425
435
|
}
|
|
426
436
|
|
|
427
437
|
async function closeProjectSessionLocked(normalized, options, markerContext) {
|
|
438
|
+
await assertLaneMarkerSnapshotCurrent(markerContext);
|
|
428
439
|
const title = requireNonEmptyString(options?.title, 'session close requires a non-empty title.');
|
|
429
440
|
const completed = normalizeStringArray(options?.completed);
|
|
430
441
|
const models = normalizeStringArray(options?.models);
|
|
@@ -583,6 +594,17 @@ async function closeProjectSessionLocked(normalized, options, markerContext) {
|
|
|
583
594
|
markerCleanupWarnings.push(...containerCleanup.warnings);
|
|
584
595
|
}
|
|
585
596
|
}
|
|
597
|
+
if (markerContext.marker?.laneId === sessionId) {
|
|
598
|
+
const contextMarkerPath = path.resolve(markerContext.marker.markerPath);
|
|
599
|
+
const recordedMarkerPath = closingLaneMetadata?.laneMarker?.path
|
|
600
|
+
? path.resolve(closingLaneMetadata.laneMarker.path)
|
|
601
|
+
: null;
|
|
602
|
+
if (contextMarkerPath !== recordedMarkerPath && (await fileExists(contextMarkerPath))) {
|
|
603
|
+
markerCleanupWarnings.push(
|
|
604
|
+
`Lane marker at ${contextMarkerPath} names the closed lane "${sessionId}" but was not recorded in session.yaml; close-session left it in place. Remove it manually or recreate it with \`vibecompass write-lane-marker\` before reusing that directory.`,
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
586
608
|
if (removal.attempted) {
|
|
587
609
|
worktreeCleanup = {
|
|
588
610
|
branch: closingLaneMetadata?.branch ?? null,
|
|
@@ -850,7 +872,18 @@ export async function readLaneEnvironment(options = {}) {
|
|
|
850
872
|
port: lane.runtime.port,
|
|
851
873
|
tmpDir: lane.runtime.tmpDir,
|
|
852
874
|
env,
|
|
853
|
-
warnings: [
|
|
875
|
+
warnings: [
|
|
876
|
+
...markerContext.warnings,
|
|
877
|
+
...selection.warnings,
|
|
878
|
+
...buildSharedCheckoutWarnings({
|
|
879
|
+
sessionId: selection.sessionId,
|
|
880
|
+
repos: lane.repos ?? [],
|
|
881
|
+
claims: lane.claims ?? [],
|
|
882
|
+
existingLanes: lanes.filter((item) => item.id !== selection.sessionId),
|
|
883
|
+
hasWorktree: Boolean(lane.worktreeContainer),
|
|
884
|
+
context: 'lane-env',
|
|
885
|
+
}),
|
|
886
|
+
],
|
|
854
887
|
};
|
|
855
888
|
}
|
|
856
889
|
|
|
@@ -888,6 +921,7 @@ export async function switchProjectSession(options = {}) {
|
|
|
888
921
|
}
|
|
889
922
|
|
|
890
923
|
async function switchProjectSessionLocked(normalized, options, markerContext) {
|
|
924
|
+
await assertLaneMarkerSnapshotCurrent(markerContext);
|
|
891
925
|
const sessionId = validateLaneId(requireNonEmptyString(options?.sessionId, 'switch-session requires a session ID.'));
|
|
892
926
|
const lanes = await listActiveSessionLanes(normalized);
|
|
893
927
|
const lane = lanes.find((item) => item.id === sessionId);
|
|
@@ -1008,6 +1042,7 @@ export async function writeLaneMarkerForSession(options) {
|
|
|
1008
1042
|
}
|
|
1009
1043
|
|
|
1010
1044
|
async function writeLaneMarkerForSessionLocked(normalized, options, markerContext) {
|
|
1045
|
+
await assertLaneMarkerSnapshotCurrent(markerContext);
|
|
1011
1046
|
const sessionId = validateLaneId(
|
|
1012
1047
|
requireNonEmptyString(options?.sessionId, 'write-lane-marker requires --session <lane-id> so the marker binds to an explicit lane.'),
|
|
1013
1048
|
);
|
|
@@ -1494,6 +1529,8 @@ async function listActiveSessionLanes(normalized) {
|
|
|
1494
1529
|
features: metadata.features,
|
|
1495
1530
|
repos: metadata.repos,
|
|
1496
1531
|
claims: metadata.claims,
|
|
1532
|
+
architectureDocs: metadata.architectureDocs,
|
|
1533
|
+
decisionDomainFiles: metadata.decisionDomainFiles,
|
|
1497
1534
|
startedAt: metadata.startedAt,
|
|
1498
1535
|
decisionSnapshot: metadata.decisionSnapshot,
|
|
1499
1536
|
branch: metadata.branch ?? null,
|
|
@@ -1536,6 +1573,8 @@ async function readLaneMetadata(sessionFilePath) {
|
|
|
1536
1573
|
features: normalizeStringArray(data.feature_slugs),
|
|
1537
1574
|
repos: normalizeStringArray(data.repos),
|
|
1538
1575
|
claims: normalizeStringArray(data.claimed_paths),
|
|
1576
|
+
architectureDocs: normalizeStringArray(data.architecture_docs).map(normalizeArchitectureDocPath),
|
|
1577
|
+
decisionDomainFiles: normalizeStringArray(data.decision_domain_files).map(normalizeDecisionDomainFile),
|
|
1539
1578
|
startedAt: normalizeOptionalString(data.started_at),
|
|
1540
1579
|
decisionSnapshot: {
|
|
1541
1580
|
highestDecisionId: parseNullableNumber(data.decision_snapshot?.highest_decision_id),
|
|
@@ -1569,6 +1608,8 @@ async function readLaneMetadata(sessionFilePath) {
|
|
|
1569
1608
|
features: [],
|
|
1570
1609
|
repos: [],
|
|
1571
1610
|
claims: [],
|
|
1611
|
+
architectureDocs: [],
|
|
1612
|
+
decisionDomainFiles: [],
|
|
1572
1613
|
startedAt: null,
|
|
1573
1614
|
decisionSnapshot: {
|
|
1574
1615
|
highestDecisionId: null,
|
|
@@ -1827,6 +1868,8 @@ function renderLaneMetadata(options) {
|
|
|
1827
1868
|
renderYamlArray('feature_slugs', options.features),
|
|
1828
1869
|
renderYamlArray('repos', options.repos),
|
|
1829
1870
|
renderYamlArray('claimed_paths', options.claims),
|
|
1871
|
+
renderYamlArray('architecture_docs', options.architectureDocs),
|
|
1872
|
+
renderYamlArray('decision_domain_files', options.decisionDomainFiles),
|
|
1830
1873
|
`started_at: ${formatLocalDateTime(new Date())}`,
|
|
1831
1874
|
// D-282: the runtime assignment travels with the lane record (local-only
|
|
1832
1875
|
// per D-278) so lane-env and close-time cleanup read it back.
|
|
@@ -2009,24 +2052,111 @@ function buildOverlapWarnings(options) {
|
|
|
2009
2052
|
);
|
|
2010
2053
|
}
|
|
2011
2054
|
|
|
2012
|
-
const claimOverlaps = findClaimOverlaps(
|
|
2055
|
+
const claimOverlaps = findClaimOverlaps(
|
|
2056
|
+
options.claims,
|
|
2057
|
+
options.repos,
|
|
2058
|
+
lane.claims ?? [],
|
|
2059
|
+
lane.repos ?? [],
|
|
2060
|
+
);
|
|
2013
2061
|
for (const overlap of claimOverlaps) {
|
|
2014
2062
|
warnings.push(
|
|
2015
2063
|
`Active session lane "${options.sessionId}" overlaps "${lane.id}" on claimed path ${overlap.repo}:${overlap.path}.`,
|
|
2016
2064
|
);
|
|
2017
2065
|
}
|
|
2018
2066
|
|
|
2067
|
+
const sharedArchitectureDocs = intersect(options.architectureDocs ?? [], lane.architectureDocs ?? []);
|
|
2068
|
+
if (sharedArchitectureDocs.length > 0) {
|
|
2069
|
+
warnings.push(
|
|
2070
|
+
`Active session lane "${options.sessionId}" overlaps "${lane.id}" on architecture doc(s): ${sharedArchitectureDocs.join(', ')}.`,
|
|
2071
|
+
);
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
const sharedDecisionDomainFiles = intersect(options.decisionDomainFiles ?? [], lane.decisionDomainFiles ?? []);
|
|
2075
|
+
if (sharedDecisionDomainFiles.length > 0) {
|
|
2076
|
+
warnings.push(
|
|
2077
|
+
`Active session lane "${options.sessionId}" overlaps "${lane.id}" on decision domain file(s): ${sharedDecisionDomainFiles.join(', ')}.`,
|
|
2078
|
+
);
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2019
2081
|
const sharedRepos = intersect(options.repos, lane.repos ?? []);
|
|
2020
|
-
|
|
2082
|
+
warnings.push(...buildSharedCheckoutWarnings({
|
|
2083
|
+
sessionId: options.sessionId,
|
|
2084
|
+
repos: options.repos,
|
|
2085
|
+
claims: options.claims,
|
|
2086
|
+
existingLanes: [lane],
|
|
2087
|
+
hasWorktree: options.hasWorktree,
|
|
2088
|
+
context: 'start-session',
|
|
2089
|
+
}));
|
|
2090
|
+
|
|
2091
|
+
const bothLackClaims = options.claims.length === 0 && (lane.claims ?? []).length === 0;
|
|
2092
|
+
if (
|
|
2093
|
+
sharedRepos.length > 0 &&
|
|
2094
|
+
(options.hasWorktree || lane.worktreeContainer) &&
|
|
2095
|
+
bothLackClaims
|
|
2096
|
+
) {
|
|
2021
2097
|
warnings.push(
|
|
2022
2098
|
`Active session lane "${options.sessionId}" shares repo(s) ${sharedRepos.join(', ')} with "${lane.id}" without path claims; add --claim values to clarify ownership.`,
|
|
2023
2099
|
);
|
|
2100
|
+
continue;
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
const disambiguatedByFeatures =
|
|
2104
|
+
options.features.length > 0 &&
|
|
2105
|
+
(lane.features ?? []).length > 0 &&
|
|
2106
|
+
sharedFeatures.length === 0;
|
|
2107
|
+
const disambiguatedByClaims = hasNarrowNonOverlappingClaims({
|
|
2108
|
+
sharedRepos,
|
|
2109
|
+
leftClaims: options.claims,
|
|
2110
|
+
leftRepos: options.repos,
|
|
2111
|
+
rightClaims: lane.claims ?? [],
|
|
2112
|
+
rightRepos: lane.repos ?? [],
|
|
2113
|
+
});
|
|
2114
|
+
if (
|
|
2115
|
+
sharedRepos.length > 0 &&
|
|
2116
|
+
claimOverlaps.length === 0 &&
|
|
2117
|
+
!disambiguatedByFeatures &&
|
|
2118
|
+
!disambiguatedByClaims
|
|
2119
|
+
) {
|
|
2120
|
+
warnings.push(
|
|
2121
|
+
`Active session lane "${options.sessionId}" shares repo(s) ${sharedRepos.join(', ')} with "${lane.id}" without narrower non-overlapping ownership; ownership is ambiguous. Add --claim paths or --feature slugs to both lanes to clarify ownership.`,
|
|
2122
|
+
);
|
|
2024
2123
|
}
|
|
2025
2124
|
}
|
|
2026
2125
|
|
|
2027
2126
|
return warnings;
|
|
2028
2127
|
}
|
|
2029
2128
|
|
|
2129
|
+
function buildSharedCheckoutWarnings(options) {
|
|
2130
|
+
if (options.hasWorktree) {
|
|
2131
|
+
return [];
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
const warnings = [];
|
|
2135
|
+
for (const lane of options.existingLanes) {
|
|
2136
|
+
if (lane.worktreeContainer) {
|
|
2137
|
+
continue;
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
const sharedRepos = intersect(options.repos, lane.repos ?? []);
|
|
2141
|
+
if (sharedRepos.length === 0) {
|
|
2142
|
+
continue;
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
const ownershipHint = options.claims.length === 0 && (lane.claims ?? []).length === 0
|
|
2146
|
+
? ' Add --claim values when shared-checkout work is intentional so ownership is reviewable.'
|
|
2147
|
+
: '';
|
|
2148
|
+
const runtimeHint = options.context === 'lane-env'
|
|
2149
|
+
? ' The exported PORT is a runtime assignment, not an independently runnable dev server for a shared checkout.'
|
|
2150
|
+
: '';
|
|
2151
|
+
warnings.push(
|
|
2152
|
+
`Active session lane "${options.sessionId}" shares repo(s) ${sharedRepos.join(', ')} with unbound lane "${lane.id}" in the same checkout. ` +
|
|
2153
|
+
`Use --branch <name> --worktree when lanes need independent files or dev servers; worktrees are the isolation boundary.${runtimeHint}${ownershipHint}`,
|
|
2154
|
+
);
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
return warnings;
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2030
2160
|
function collectLaneMetadataWarnings(lanes) {
|
|
2031
2161
|
return lanes.flatMap((lane) => lane.warnings ?? []);
|
|
2032
2162
|
}
|
|
@@ -2036,10 +2166,10 @@ function intersect(left, right) {
|
|
|
2036
2166
|
return left.filter((item) => rightSet.has(item));
|
|
2037
2167
|
}
|
|
2038
2168
|
|
|
2039
|
-
function findClaimOverlaps(leftClaims, rightClaims) {
|
|
2169
|
+
function findClaimOverlaps(leftClaims, leftRepos, rightClaims, rightRepos) {
|
|
2040
2170
|
const overlaps = [];
|
|
2041
|
-
for (const left of leftClaims
|
|
2042
|
-
for (const right of rightClaims
|
|
2171
|
+
for (const left of expandClaims(leftClaims, leftRepos)) {
|
|
2172
|
+
for (const right of expandClaims(rightClaims, rightRepos)) {
|
|
2043
2173
|
if (left.repo !== right.repo) {
|
|
2044
2174
|
continue;
|
|
2045
2175
|
}
|
|
@@ -2056,20 +2186,94 @@ function findClaimOverlaps(leftClaims, rightClaims) {
|
|
|
2056
2186
|
return overlaps;
|
|
2057
2187
|
}
|
|
2058
2188
|
|
|
2059
|
-
function
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
return null;
|
|
2189
|
+
function hasNarrowNonOverlappingClaims(options) {
|
|
2190
|
+
if (options.sharedRepos.length === 0) {
|
|
2191
|
+
return false;
|
|
2063
2192
|
}
|
|
2064
2193
|
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2194
|
+
const leftClaims = expandClaims(options.leftClaims, options.leftRepos);
|
|
2195
|
+
const rightClaims = expandClaims(options.rightClaims, options.rightRepos);
|
|
2196
|
+
return options.sharedRepos.every((repo) => {
|
|
2197
|
+
const leftForRepo = leftClaims.filter((claim) => claim.repo === repo);
|
|
2198
|
+
const rightForRepo = rightClaims.filter((claim) => claim.repo === repo);
|
|
2199
|
+
if (leftForRepo.length === 0 || rightForRepo.length === 0) {
|
|
2200
|
+
return false;
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
return leftForRepo.every((left) =>
|
|
2204
|
+
rightForRepo.every((right) => !pathPrefixesOverlap(left.path, right.path)));
|
|
2205
|
+
});
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
function expandClaims(claims, repos) {
|
|
2209
|
+
const normalizedRepos = normalizeStringArray(repos);
|
|
2210
|
+
return normalizeStringArray(claims)
|
|
2211
|
+
.flatMap((claim) => parseClaim(claim, normalizedRepos))
|
|
2212
|
+
.filter(Boolean);
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
function parseClaim(value, repos = []) {
|
|
2216
|
+
const source = String(value ?? '').trim();
|
|
2217
|
+
if (!source) {
|
|
2218
|
+
return [];
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
const normalizedRepos = normalizeStringArray(repos);
|
|
2222
|
+
const separatorIndex = source.indexOf(':');
|
|
2223
|
+
const repoId = separatorIndex > 0 ? source.slice(0, separatorIndex) : '';
|
|
2224
|
+
const rawClaimPath = separatorIndex > 0 ? source.slice(separatorIndex + 1) : '';
|
|
2225
|
+
if (separatorIndex > 0 && !rawClaimPath) {
|
|
2226
|
+
return [];
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
if (
|
|
2230
|
+
separatorIndex > 0 &&
|
|
2231
|
+
rawClaimPath &&
|
|
2232
|
+
(!isWindowsDrivePath(source) || (normalizedRepos.includes(repoId) && rawClaimPath.startsWith('/')))
|
|
2233
|
+
) {
|
|
2234
|
+
const normalizedPath = normalizeClaimPath(rawClaimPath);
|
|
2235
|
+
if (!normalizedPath) {
|
|
2236
|
+
return [];
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
return [{
|
|
2240
|
+
repo: repoId,
|
|
2241
|
+
path: normalizedPath,
|
|
2242
|
+
}];
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
const normalized = normalizeClaimPath(source);
|
|
2246
|
+
if (!normalized) {
|
|
2247
|
+
return [];
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
return normalizedRepos.map((repo) => ({
|
|
2251
|
+
repo,
|
|
2252
|
+
path: normalized,
|
|
2253
|
+
}));
|
|
2069
2254
|
}
|
|
2070
2255
|
|
|
2071
2256
|
function normalizeClaimPath(value) {
|
|
2072
|
-
return value
|
|
2257
|
+
return String(value ?? '')
|
|
2258
|
+
.trim()
|
|
2259
|
+
.replace(/\\/g, '/')
|
|
2260
|
+
.replace(/\/+/g, '/')
|
|
2261
|
+
.split('/')
|
|
2262
|
+
.filter((segment) => segment !== '' && segment !== '.')
|
|
2263
|
+
.join('/');
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
function normalizeArchitectureDocPath(value) {
|
|
2267
|
+
return normalizeClaimPath(value);
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
function normalizeDecisionDomainFile(value) {
|
|
2271
|
+
const normalized = normalizeClaimPath(value);
|
|
2272
|
+
return normalized.startsWith('decisions/') ? normalized : `decisions/${normalized}`;
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
function isWindowsDrivePath(value) {
|
|
2276
|
+
return /^[A-Za-z]:[\\/]/.test(String(value ?? ''));
|
|
2073
2277
|
}
|
|
2074
2278
|
|
|
2075
2279
|
function pathPrefixesOverlap(left, right) {
|