@vibecompass/vibecompass 0.10.8 → 0.11.1

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.
@@ -16,6 +16,9 @@ import {
16
16
  } from './source-inventory.js';
17
17
  import { readSyncCursor, resolveSyncBinding } from './sync-binding.js';
18
18
  import { PACKAGE_VERSION } from './version.js';
19
+ import { withMemoryRootLock } from './serialization.js';
20
+ import { listDecisionFileNames, readNextDecisionId } from './decisions.js';
21
+ import { looksLikeGroupedDecisionIndex } from './decision-index.js';
19
22
 
20
23
  const DOCS_REVIEW_PROMPT_VERSION = 'VibeCompass Docs Review Prompt v7';
21
24
  const DOCS_REVIEW_PARSER_VERSION = 'docs-review-parser-v1';
@@ -229,6 +232,10 @@ async function completeDocsReview(options) {
229
232
  }
230
233
 
231
234
  async function applyDocsReviewOutput(options) {
235
+ return withMemoryRootLock(options.rootDir, 'docs-review-apply-output', () => applyDocsReviewOutputLocked(options));
236
+ }
237
+
238
+ async function applyDocsReviewOutputLocked(options) {
232
239
  const current = await readDocsReviewMarker(options.statePath);
233
240
  const outputPath = options.outputPath
234
241
  ? path.resolve(options.rootDir, options.outputPath)
@@ -391,6 +398,10 @@ async function applyDocsReviewOutput(options) {
391
398
  }
392
399
 
393
400
  async function applyDecisionArtifact(options) {
401
+ return withMemoryRootLock(options.rootDir, 'docs-review-apply-decision-artifact', () => applyDecisionArtifactLocked(options));
402
+ }
403
+
404
+ async function applyDecisionArtifactLocked(options) {
394
405
  const current = await readDocsReviewMarker(options.statePath);
395
406
  const artifactId = normalizeOptionalString(options.artifactId);
396
407
  if (!artifactId) {
@@ -1949,33 +1960,8 @@ async function applyDecisionRecommendations(options) {
1949
1960
  };
1950
1961
  }
1951
1962
 
1952
- async function readNextDecisionId(rootDir) {
1953
- const decisionsDir = path.join(rootDir, 'decisions');
1954
- let max = 0;
1955
-
1956
- for (const fileName of await listDecisionFileNames(decisionsDir)) {
1957
- const content = await readFile(path.join(decisionsDir, fileName), 'utf8');
1958
- for (const match of content.matchAll(/^###\s+D-(\d+)\b/gm)) {
1959
- max = Math.max(max, Number(match[1]));
1960
- }
1961
- }
1962
-
1963
- return max + 1;
1964
- }
1965
-
1966
- async function listDecisionFileNames(decisionsDir) {
1967
- try {
1968
- return (await readdir(decisionsDir))
1969
- .filter((fileName) =>
1970
- fileName.endsWith('.md') &&
1971
- !['INDEX.md', 'README.md', 'EXAMPLE.md'].includes(fileName),
1972
- )
1973
- .sort((left, right) => left.localeCompare(right));
1974
- } catch (error) {
1975
- if (error?.code === 'ENOENT') return [];
1976
- throw error;
1977
- }
1978
- }
1963
+ // readNextDecisionId and listDecisionFileNames moved to decisions.js so the
1964
+ // atomic append path (D-276) and these apply paths share one allocation scan.
1979
1965
 
1980
1966
  function renderDecisionEntry(options) {
1981
1967
  const decisionNumber = `D-${String(options.decisionId).padStart(3, '0')}`;
@@ -2023,6 +2009,20 @@ async function refreshDecisionIndex(rootDir) {
2023
2009
  const warnings = [];
2024
2010
  const rows = [];
2025
2011
 
2012
+ // D-283/D-255 quarantine: the flat generator must never be pointed at a
2013
+ // grouped index — a wholesale rewrite would destroy the hand-authored
2014
+ // session group headings. Any "## " heading counts as grouped-shaped, so a
2015
+ // malformed grouped index survives too.
2016
+ const existingIndex = await readFile(indexPath, 'utf8').catch(() => null);
2017
+ if (existingIndex !== null && looksLikeGroupedDecisionIndex(existingIndex)) {
2018
+ return {
2019
+ refreshed: false,
2020
+ warnings: [
2021
+ 'decisions/INDEX.md uses the grouped session shape; --refresh-index (the flat D-209 generator) refuses to overwrite it. Use `vibecompass refresh-decision-index` (D-283) instead.',
2022
+ ],
2023
+ };
2024
+ }
2025
+
2026
2026
  for (const fileName of await listDecisionFileNames(decisionsDir)) {
2027
2027
  const relativePath = `decisions/${fileName}`;
2028
2028
  const content = await readFile(path.join(decisionsDir, fileName), 'utf8');
@@ -1,8 +1,10 @@
1
1
  import { execFile } from 'node:child_process';
2
- import { readFile } from 'node:fs/promises';
2
+ import { readFile, readdir, stat } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { promisify } from 'node:util';
5
5
  import { parseFrontmatter } from './frontmatter.js';
6
+ import { readCurrentRepoHead, resolveRepoSourceDir } from './git-binding.js';
7
+ import { resolveLaneMarkerContext, resolveLaneSelection } from './lane-marker.js';
6
8
  import { scanProjectMemory } from './project-memory.js';
7
9
  import { parseSimpleYaml } from './simple-yaml.js';
8
10
 
@@ -19,13 +21,39 @@ const PACKAGE_OWNED_PATH_PATTERNS = [
19
21
 
20
22
  export async function planDocsUpdate(options = {}) {
21
23
  const normalized = normalizeDocsUpdateOptions(options);
24
+ // D-280: docs-update shares the same context resolution as session
25
+ // commands — explicit --root wins, else the nearest valid marker's
26
+ // memory_root, else cwd/.compass — and the same D-277 lane selection.
27
+ const markerContext = await resolveLaneMarkerContext({
28
+ cwd: normalized.cwd,
29
+ explicitRootDir: options.rootDir ?? null,
30
+ explicitSessionId: normalized.sessionId,
31
+ });
32
+ normalized.rootDir = markerContext.rootDir;
33
+ // suppressLaneWarnings marks the embedded close-session plan, which already
34
+ // carries the same marker-context warnings on the close result itself.
35
+ const warnings = options.suppressLaneWarnings === true ? [] : [...markerContext.warnings];
22
36
  const scan = await scanProjectMemory(normalized.rootDir);
23
37
  const project = scan.project?.extracted ?? {};
24
- const activeSession = await readActiveSession(normalized.rootDir, normalized.sessionId);
38
+ const activeSession = await readActiveSession(normalized.rootDir, normalized.sessionId, {
39
+ markerContext,
40
+ warnings,
41
+ suppressLaneWarnings: options.suppressLaneWarnings === true,
42
+ });
25
43
  const changedFiles = await resolveChangedFiles({
26
44
  cwd: normalized.cwd,
45
+ // Repo working copies live as siblings of the memory root (the
46
+ // workspace), not relative to wherever the command runs — a worktree or
47
+ // nested cwd previously made every repo dir resolve to a nonexistent
48
+ // path whose git-status errors were silently swallowed.
49
+ workspaceDir: path.dirname(normalized.rootDir),
50
+ // D-281: a git-bound lane's diff lives in its recorded worktrees, not the
51
+ // source checkouts — scan those for bound repos so the delta carries
52
+ // correct `repo-id:` prefixes.
53
+ laneWorktrees: activeSession?.worktrees ?? {},
27
54
  explicitChangedFiles: normalized.changedFiles,
28
55
  repos: project.repos ?? [],
56
+ warnings,
29
57
  });
30
58
  const architectureDocs = scan.documents
31
59
  .filter((document) => document.kind === 'architecture')
@@ -41,6 +69,19 @@ export async function planDocsUpdate(options = {}) {
41
69
  .filter((file) => isPackageOwnedPath(file.normalizedPath))
42
70
  .map((file) => file.raw);
43
71
  const decisionStatus = summarizeDecisionStatus(scan, activeSession);
72
+ // A:192 (D-281): the pre-close staleness set is computed on every
73
+ // docs-update run for the selected lane and re-emitted by close-session, so
74
+ // mid-session and close-out see the same target set.
75
+ const staleness = activeSession
76
+ ? await buildPreCloseStaleness({
77
+ rootDir: normalized.rootDir,
78
+ workspaceDir: path.dirname(normalized.rootDir),
79
+ activeSession,
80
+ otherLanes: await listOtherActiveLanes(path.join(normalized.rootDir, 'sessions', 'active'), activeSession.id),
81
+ decisionStatus,
82
+ projectRepos: project.repos ?? [],
83
+ })
84
+ : null;
44
85
  const recommendations = buildRecommendations({
45
86
  changedFiles,
46
87
  affectedArchitectureDocs,
@@ -70,8 +111,10 @@ export async function planDocsUpdate(options = {}) {
70
111
  needsNewDoc: changedFiles.some((file) => isImplementationLikePath(file.normalizedPath)) && affectedArchitectureDocs.length === 0,
71
112
  },
72
113
  decisions: decisionStatus,
114
+ staleness,
73
115
  packageOwnedChanges,
74
116
  recommendations,
117
+ warnings,
75
118
  };
76
119
  }
77
120
 
@@ -109,6 +152,20 @@ export function renderDocsUpdatePlan(plan) {
109
152
  lines.push('- No new decisions detected since lane start.');
110
153
  }
111
154
 
155
+ if (plan.staleness) {
156
+ lines.push('Pre-close staleness set:');
157
+ if (plan.staleness.entries.length === 0) {
158
+ lines.push('- none detected');
159
+ } else {
160
+ for (const entry of plan.staleness.entries) {
161
+ lines.push(`- ${entry}`);
162
+ }
163
+ }
164
+ for (const skipped of plan.staleness.notEvaluated) {
165
+ lines.push(`- (not evaluated) ${skipped}`);
166
+ }
167
+ }
168
+
112
169
  if (plan.packageOwnedChanges.length > 0) {
113
170
  lines.push('Package-owned generated/state surfaces:');
114
171
  for (const filePath of plan.packageOwnedChanges) {
@@ -121,6 +178,10 @@ export function renderDocsUpdatePlan(plan) {
121
178
  lines.push(`- ${recommendation}`);
122
179
  }
123
180
 
181
+ for (const warning of plan.warnings ?? []) {
182
+ lines.push(`Warning: ${warning}`);
183
+ }
184
+
124
185
  return `${lines.join('\n')}\n`;
125
186
  }
126
187
 
@@ -134,20 +195,25 @@ function normalizeDocsUpdateOptions(options) {
134
195
  };
135
196
  }
136
197
 
137
- async function readActiveSession(rootDir, requestedSessionId) {
198
+ async function readActiveSession(rootDir, requestedSessionId, laneContext = {}) {
138
199
  const activeRoot = path.join(rootDir, 'sessions', 'active');
139
- const indexPath = path.join(activeRoot, 'index.yaml');
140
- let sessionId = normalizeOptionalString(requestedSessionId);
141
-
142
- if (!sessionId) {
143
- try {
144
- const indexData = parseSimpleYaml(await readFile(indexPath, 'utf8'), { sourceName: indexPath });
145
- sessionId = normalizeOptionalString(indexData.current);
146
- } catch {
147
- sessionId = null;
148
- }
200
+ // D-277 lane selection via the shared resolver: explicit --session wins,
201
+ // then the worktree marker (stale markers fail closed), then the single
202
+ // active lane; with 2+ lanes and no selection this throws instead of
203
+ // silently trusting the root-global index pointer.
204
+ const selection = resolveLaneSelection({
205
+ explicitSessionId: normalizeOptionalString(requestedSessionId),
206
+ marker: laneContext.markerContext?.marker ?? null,
207
+ laneIds: await listActiveLaneDirIds(activeRoot),
208
+ rootDir,
209
+ purpose: 'plan docs updates for',
210
+ suppressLaneWarnings: laneContext.suppressLaneWarnings === true,
211
+ });
212
+ if (Array.isArray(laneContext.warnings)) {
213
+ laneContext.warnings.push(...selection.warnings);
149
214
  }
150
215
 
216
+ const sessionId = selection.sessionId;
151
217
  if (!sessionId) {
152
218
  return null;
153
219
  }
@@ -161,6 +227,10 @@ async function readActiveSession(rootDir, requestedSessionId) {
161
227
  repos: normalizeStringArray(data.repos),
162
228
  claimedPaths: normalizeStringArray(data.claimed_paths),
163
229
  featureSlugs: normalizeStringArray(data.feature_slugs),
230
+ startedAt: normalizeOptionalString(data.started_at),
231
+ worktrees: normalizeWorktreeMap(data.worktrees),
232
+ worktreeSources: normalizeWorktreeMap(data.worktree_sources),
233
+ baseRevisions: normalizeStringValueMap(data.base_revisions),
164
234
  decisionSnapshotHighestId: Number.isInteger(data.decision_snapshot?.highest_decision_id)
165
235
  ? data.decision_snapshot.highest_decision_id
166
236
  : null,
@@ -172,25 +242,370 @@ async function readActiveSession(rootDir, requestedSessionId) {
172
242
  repos: [],
173
243
  claimedPaths: [],
174
244
  featureSlugs: [],
245
+ startedAt: null,
246
+ worktrees: {},
247
+ worktreeSources: {},
248
+ baseRevisions: {},
175
249
  decisionSnapshotHighestId: null,
176
250
  };
177
251
  }
178
252
  }
179
253
 
254
+ /** Other active lanes' scope fields, for the pre-close claim-overlap check. */
255
+ async function listOtherActiveLanes(activeRoot, selectedId) {
256
+ const lanes = [];
257
+ for (const laneId of await listActiveLaneDirIds(activeRoot)) {
258
+ if (laneId === selectedId) {
259
+ continue;
260
+ }
261
+
262
+ try {
263
+ const sessionPath = path.join(activeRoot, laneId, 'session.yaml');
264
+ const data = parseSimpleYaml(await readFile(sessionPath, 'utf8'), { sourceName: sessionPath });
265
+ lanes.push({
266
+ id: laneId,
267
+ repos: normalizeStringArray(data.repos),
268
+ claimedPaths: normalizeStringArray(data.claimed_paths),
269
+ });
270
+ } catch {
271
+ // An unreadable sibling lane cannot contribute overlap detail; the lane
272
+ // lifecycle commands own reporting corrupt lane metadata.
273
+ }
274
+ }
275
+
276
+ return lanes;
277
+ }
278
+
279
+ function normalizeWorktreeMap(value) {
280
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
281
+ return {};
282
+ }
283
+
284
+ const result = {};
285
+ for (const [key, entry] of Object.entries(value)) {
286
+ const normalized = normalizeOptionalString(entry);
287
+ if (normalized) {
288
+ result[key] = path.resolve(normalized);
289
+ }
290
+ }
291
+
292
+ return result;
293
+ }
294
+
295
+ /** Plain string map (base_revisions) — values are revisions, not paths. */
296
+ function normalizeStringValueMap(value) {
297
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
298
+ return {};
299
+ }
300
+
301
+ const result = {};
302
+ for (const [key, entry] of Object.entries(value)) {
303
+ const normalized = entry === null || entry === undefined ? null : String(entry).trim();
304
+ if (normalized) {
305
+ result[key] = normalized;
306
+ }
307
+ }
308
+
309
+ return result;
310
+ }
311
+
312
+ const SESSION_NOTE_FILENAME_PATTERN = /^\d{4}-\d{2}-\d{2}-\d+-[a-z0-9-]+\.md$/i;
313
+
314
+ /**
315
+ * A:192 (D-281): the pre-close staleness set. Everything here is a warning
316
+ * surface — actionable and non-destructive; the closer chooses whether to
317
+ * reconcile, close anyway, or keep the lane active. Pieces that cannot be
318
+ * evaluated (no started_at, no decision snapshot) are named instead of being
319
+ * silently skipped.
320
+ */
321
+ async function buildPreCloseStaleness(options) {
322
+ const activeSession = options.activeSession;
323
+ const entries = [];
324
+ const notEvaluated = [];
325
+
326
+ // 1. New canonical decisions appended after the lane's frozen start-of-lane
327
+ // snapshot (D-266). Elevated from the informational decision-log summary:
328
+ // the closer confirms each one is either this lane's own append or does
329
+ // not change this lane's contracts.
330
+ const newDecisions = options.decisionStatus.newDecisions ?? [];
331
+ if (options.decisionStatus.decisionSnapshotHighestId === null) {
332
+ notEvaluated.push('decision staleness: the lane has no decision snapshot.');
333
+ } else {
334
+ for (const decision of newDecisions) {
335
+ entries.push(
336
+ `New decision D-${decision.id} since lane start (${decision.path}) — confirm it is this lane's own append or does not change this lane's contracts.`,
337
+ );
338
+ }
339
+ }
340
+
341
+ // 2. Base revisions stale relative to the current source repo heads. Bound
342
+ // repos compare against the recorded source checkout, not the lane
343
+ // worktree — the lane's own commits are not staleness. A repo that
344
+ // RECORDED a base but whose current head is unreadable is named in
345
+ // notEvaluated (something changed since capture); repos that never
346
+ // captured a base (non-git sources) stay silent by design — git is
347
+ // never required for lanes (D-265/D-279).
348
+ const staleBaseRevisions = [];
349
+ for (const [repoId, baseRevision] of Object.entries(activeSession.baseRevisions ?? {})) {
350
+ const sourceDir = activeSession.worktreeSources?.[repoId]
351
+ ?? resolveRepoSourceDir(options.workspaceDir, repoId, options.projectRepos);
352
+ const head = await readCurrentRepoHead(sourceDir);
353
+ if (head === null) {
354
+ notEvaluated.push(
355
+ `base-revision staleness for repo "${repoId}": the current head of ${sourceDir} could not be read (missing, non-git, nested, or unborn) even though the lane recorded base ${baseRevision.slice(0, 12)} there at start.`,
356
+ );
357
+ continue;
358
+ }
359
+ if (head !== baseRevision) {
360
+ staleBaseRevisions.push({ repoId, baseRevision, headRevision: head });
361
+ entries.push(
362
+ `Base revision for repo "${repoId}" is stale: lane base ${baseRevision.slice(0, 12)}, current source head ${head.slice(0, 12)} — review what landed since lane start.`,
363
+ );
364
+ }
365
+ }
366
+
367
+ // 3. Finalized session notes materialized after this lane started that
368
+ // mention the lane's declared scope. mtime ordering is intentional: a
369
+ // note that arrives in this root after lane start (local close, pull,
370
+ // apply-export) is new to this lane regardless of its filename date.
371
+ const newSessionNotes = [];
372
+ const startedAtMs = activeSession.startedAt ? Date.parse(activeSession.startedAt) : Number.NaN;
373
+ if (Number.isNaN(startedAtMs)) {
374
+ notEvaluated.push('session-note staleness: the lane has no parseable started_at.');
375
+ } else {
376
+ for (const note of await listFinalizedSessionNotesSince(options.rootDir, startedAtMs)) {
377
+ const reasons = describeNoteScopeOverlap(note.content, activeSession);
378
+ if (reasons.length > 0) {
379
+ newSessionNotes.push({ path: note.relativePath, reasons });
380
+ entries.push(
381
+ `Finalized session note ${note.relativePath} was written after this lane started (${reasons.join('; ')}) — check it for overlap with this lane's scope.`,
382
+ );
383
+ }
384
+ }
385
+ }
386
+
387
+ // 4. Claimed-path overlap with other active lanes (recently closed lanes
388
+ // surface through the finalized-note check above). Claims only overlap
389
+ // when their repo scopes can intersect: an explicit `repo:` prefix pins
390
+ // a claim to that repo, an unprefixed claim can live in any repo its
391
+ // lane declares — `app:src/x` vs `lib:src/x` never cross-flag.
392
+ const laneOverlaps = [];
393
+ for (const lane of options.otherLanes ?? []) {
394
+ const sharedRepos = lane.repos.filter((repoId) => (activeSession.repos ?? []).includes(repoId));
395
+ if (sharedRepos.length === 0) {
396
+ continue;
397
+ }
398
+
399
+ const overlappingPaths = [];
400
+ for (const claim of activeSession.claimedPaths ?? []) {
401
+ const claimScope = claimRepoScope(claim, activeSession.repos ?? []);
402
+ for (const otherClaim of lane.claimedPaths) {
403
+ if (!repoScopesIntersect(claimScope, claimRepoScope(otherClaim, lane.repos))) {
404
+ continue;
405
+ }
406
+ if (pathsOverlap(normalizeClaimPath(claim), normalizeClaimPath(otherClaim))) {
407
+ overlappingPaths.push(normalizeClaimPath(claim));
408
+ }
409
+ }
410
+ }
411
+ if (overlappingPaths.length > 0) {
412
+ const uniquePaths = Array.from(new Set(overlappingPaths));
413
+ laneOverlaps.push({ laneId: lane.id, sharedRepos, overlappingPaths: uniquePaths });
414
+ entries.push(
415
+ `Active lane "${lane.id}" overlaps this lane's claimed path(s) ${uniquePaths.join(', ')} in shared repo(s) ${sharedRepos.join(', ')} — coordinate before closing.`,
416
+ );
417
+ }
418
+ }
419
+
420
+ return {
421
+ newDecisions,
422
+ staleBaseRevisions,
423
+ newSessionNotes,
424
+ laneOverlaps,
425
+ notEvaluated,
426
+ entries,
427
+ };
428
+ }
429
+
430
+ /**
431
+ * Notes newer than the lane start, stat-first: mature roots accumulate
432
+ * finalized notes forever, so content is read only for the (normally tiny)
433
+ * set that passes the mtime filter.
434
+ */
435
+ async function listFinalizedSessionNotesSince(rootDir, sinceMs) {
436
+ const sessionsDir = path.join(rootDir, 'sessions');
437
+ let names;
438
+ try {
439
+ names = await readdir(sessionsDir);
440
+ } catch {
441
+ return [];
442
+ }
443
+
444
+ const candidates = await Promise.all(
445
+ names
446
+ .filter((name) => SESSION_NOTE_FILENAME_PATTERN.test(name))
447
+ .map(async (name) => {
448
+ const filePath = path.join(sessionsDir, name);
449
+ try {
450
+ const fileStat = await stat(filePath);
451
+ return fileStat.mtimeMs > sinceMs ? { name, filePath, mtimeMs: fileStat.mtimeMs } : null;
452
+ } catch {
453
+ return null;
454
+ }
455
+ }),
456
+ );
457
+
458
+ const notes = [];
459
+ for (const candidate of candidates.filter(Boolean).sort((left, right) => left.name.localeCompare(right.name))) {
460
+ try {
461
+ notes.push({
462
+ relativePath: `sessions/${candidate.name}`,
463
+ mtimeMs: candidate.mtimeMs,
464
+ content: await readFile(candidate.filePath, 'utf8'),
465
+ });
466
+ } catch {
467
+ // A note that disappears mid-scan is not staleness.
468
+ }
469
+ }
470
+
471
+ return notes;
472
+ }
473
+
474
+ /**
475
+ * Scope-overlap heuristics between a finalized note and the lane. Claimed
476
+ * paths match only against backticked path-like tokens in the note (session
477
+ * notes reference files in backticks by convention) with repo scoping — an
478
+ * unanchored substring scan would make directory claims like `src/` match
479
+ * virtually every note. Feature slugs match by mention; repos only via
480
+ * backticked `repo:path` references (a bare repo id matches too much prose
481
+ * to be a signal).
482
+ */
483
+ function describeNoteScopeOverlap(content, activeSession) {
484
+ const reasons = [];
485
+ const lowerContent = content.toLowerCase();
486
+ const noteTokens = Array.from(content.matchAll(/`([^`\n]+)`/g))
487
+ .map((match) => match[1].trim())
488
+ .filter((token) => token.includes('/') || token.includes(':'));
489
+
490
+ for (const claim of activeSession.claimedPaths ?? []) {
491
+ const claimPath = normalizeClaimPath(claim);
492
+ if (!claimPath) {
493
+ continue;
494
+ }
495
+ const claimScope = claimRepoScope(claim, activeSession.repos ?? []);
496
+ const matched = noteTokens.some((token) => {
497
+ const tokenRepoMatch = token.match(/^([^:/\s]+):(.+)$/);
498
+ if (tokenRepoMatch && claimScope && !claimScope.includes(tokenRepoMatch[1])) {
499
+ return false;
500
+ }
501
+ const tokenPath = normalizePath(tokenRepoMatch ? tokenRepoMatch[2] : token).replace(/\/+$/, '');
502
+ return tokenPath !== '' && pathsOverlap(claimPath, tokenPath);
503
+ });
504
+ if (matched) {
505
+ reasons.push(`mentions claimed path ${claimPath}`);
506
+ }
507
+ }
508
+
509
+ for (const feature of activeSession.featureSlugs ?? []) {
510
+ if (feature && lowerContent.includes(feature.toLowerCase())) {
511
+ reasons.push(`mentions lane feature "${feature}"`);
512
+ }
513
+ }
514
+
515
+ for (const repoId of activeSession.repos ?? []) {
516
+ const referencePattern = new RegExp(`\`${escapeRegExp(repoId)}:[^\`\\n]+\``);
517
+ if (referencePattern.test(content)) {
518
+ reasons.push(`references repo "${repoId}" files`);
519
+ }
520
+ }
521
+
522
+ return reasons;
523
+ }
524
+
525
+ /**
526
+ * A claim's possible repo scope: an explicit `repo:` prefix pins it, an
527
+ * unprefixed claim can live in any repo its lane declares. null means the
528
+ * scope is unknowable and intersects everything (conservative for a warning
529
+ * surface).
530
+ */
531
+ function claimRepoScope(claim, laneRepos) {
532
+ const match = String(claim).match(/^([^:/\s]+):/);
533
+ if (match) {
534
+ return [match[1]];
535
+ }
536
+
537
+ return Array.isArray(laneRepos) && laneRepos.length > 0 ? laneRepos : null;
538
+ }
539
+
540
+ function repoScopesIntersect(left, right) {
541
+ if (!left || !right) {
542
+ return true;
543
+ }
544
+
545
+ return left.some((repoId) => right.includes(repoId));
546
+ }
547
+
548
+ function escapeRegExp(value) {
549
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
550
+ }
551
+
552
+ /** Claims may carry a repo prefix and a trailing slash; neither survives into prefix matching. */
553
+ function normalizeClaimPath(claim) {
554
+ return stripRepoPrefix(claim).replace(/\/+$/, '');
555
+ }
556
+
557
+ async function listActiveLaneDirIds(activeRoot) {
558
+ try {
559
+ const entries = await readdir(activeRoot, { withFileTypes: true });
560
+ return entries
561
+ .filter((entry) => entry.isDirectory())
562
+ .map((entry) => entry.name)
563
+ .sort();
564
+ } catch {
565
+ return [];
566
+ }
567
+ }
568
+
180
569
  async function resolveChangedFiles(options) {
181
570
  if (options.explicitChangedFiles.length > 0) {
182
571
  return normalizeChangedFiles(options.explicitChangedFiles, options.repos);
183
572
  }
184
573
 
185
574
  const changedPaths = new Set();
186
- for (const filePath of await readGitStatusPaths(options.cwd)) {
187
- changedPaths.add(filePath);
575
+ const laneWorktrees = options.laneWorktrees ?? {};
576
+ // A cwd equal to or inside a recorded worktree is fully covered by that
577
+ // worktree's prefixed scan below; running the unprefixed cwd scan too would
578
+ // report the same files twice, once without a repo prefix.
579
+ const cwdInsideRecordedWorktree = Object.values(laneWorktrees).some((worktreePath) =>
580
+ isPathEqualOrInside(options.cwd, worktreePath));
581
+ if (!cwdInsideRecordedWorktree) {
582
+ for (const filePath of await readGitStatusPaths(options.cwd)) {
583
+ changedPaths.add(filePath);
584
+ }
188
585
  }
189
586
 
190
587
  const repoStatusResults = await Promise.all(
191
588
  options.repos.map(async (repo) => {
192
- const repoDir = resolveRepoWorkingDirectory(options.cwd, repo);
193
- if (!repo?.id || !repoDir || repoDir === options.cwd) {
589
+ if (!repo?.id) {
590
+ return [];
591
+ }
592
+
593
+ let laneWorktree = laneWorktrees[repo.id] ?? null;
594
+ if (laneWorktree && !(await directoryExists(laneWorktree))) {
595
+ // Recorded-but-missing is benign crash residue (D-281); fall back to
596
+ // the source checkout rather than silently zeroing this repo's delta.
597
+ options.warnings?.push(
598
+ `Recorded worktree ${laneWorktree} for repo "${repo.id}" does not exist; scanning the source checkout instead.`,
599
+ );
600
+ laneWorktree = null;
601
+ }
602
+ const repoDir = laneWorktree ?? resolveRepoWorkingDirectory(options.workspaceDir, repo);
603
+ // The cwd containment skip applies only to source dirs: a cwd at or
604
+ // inside an unbound repo's checkout is already covered by the
605
+ // unprefixed scan (git status paths are toplevel-relative, so a
606
+ // prefixed scan would double-report every file). A recorded worktree
607
+ // is scanned prefixed even when it is the cwd.
608
+ if (!repoDir || (!laneWorktree && isPathEqualOrInside(options.cwd, repoDir))) {
194
609
  return [];
195
610
  }
196
611
 
@@ -217,16 +632,30 @@ async function readGitStatusPaths(cwd) {
217
632
  }
218
633
  }
219
634
 
220
- function resolveRepoWorkingDirectory(cwd, repo) {
635
+ function isPathEqualOrInside(candidate, parentDir) {
636
+ const relative = path.relative(path.resolve(parentDir), path.resolve(candidate));
637
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
638
+ }
639
+
640
+ async function directoryExists(target) {
641
+ try {
642
+ await readdir(target);
643
+ return true;
644
+ } catch {
645
+ return false;
646
+ }
647
+ }
648
+
649
+ function resolveRepoWorkingDirectory(workspaceDir, repo) {
221
650
  if (!repo?.id) {
222
651
  return null;
223
652
  }
224
653
 
225
654
  if (repo.path) {
226
- return path.resolve(cwd, repo.path);
655
+ return path.resolve(workspaceDir, repo.path);
227
656
  }
228
657
 
229
- return path.resolve(cwd, repo.id);
658
+ return path.resolve(workspaceDir, repo.id);
230
659
  }
231
660
 
232
661
  function parseGitStatusPaths(stdout) {
@@ -453,17 +882,23 @@ function pathsOverlap(left, right) {
453
882
  }
454
883
 
455
884
  function summarizeDecisionStatus(scan, activeSession) {
456
- const decisionIds = scan.documents
885
+ const decisionEntries = scan.documents
457
886
  .filter((document) => document.kind === 'decision')
458
- .flatMap((document) => document.extracted?.decision_ids ?? [])
459
- .sort((left, right) => left - right);
887
+ .flatMap((document) => (document.extracted?.decision_ids ?? []).map((id) => ({ id, path: document.path })))
888
+ .sort((left, right) => left.id - right.id);
889
+ const decisionIds = decisionEntries.map((entry) => entry.id);
460
890
  const highestDecisionId = decisionIds.length > 0 ? decisionIds[decisionIds.length - 1] : null;
461
891
  const snapshot = activeSession?.decisionSnapshotHighestId ?? null;
892
+ // A:192 staleness elevation: keep the id-with-source-file detail so the
893
+ // pre-close staleness set can name the domain file each new decision
894
+ // landed in.
895
+ const newDecisions = snapshot === null ? [] : decisionEntries.filter((entry) => entry.id > snapshot);
462
896
 
463
897
  return {
464
898
  highestDecisionId,
465
899
  decisionSnapshotHighestId: snapshot,
466
- newDecisionIds: snapshot === null ? [] : decisionIds.filter((id) => id > snapshot),
900
+ newDecisionIds: newDecisions.map((entry) => entry.id),
901
+ newDecisions,
467
902
  };
468
903
  }
469
904