@vibecompass/vibecompass 0.10.8 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -1
- package/README.md +32 -0
- package/package.json +1 -1
- package/src/cli.js +602 -8
- package/src/compatibility.js +34 -1
- package/src/decision-index.js +386 -0
- package/src/decisions.js +195 -0
- package/src/docs-review.js +27 -27
- package/src/docs-update.js +459 -24
- package/src/generators/agent-files/index.js +8 -0
- package/src/generators/agent-files/template.js +6 -3
- package/src/git-binding.js +676 -0
- package/src/init.js +9 -0
- package/src/lane-marker.js +339 -0
- package/src/lane-runtime.js +254 -0
- package/src/manifest.js +19 -0
- package/src/project-memory.js +27 -1
- package/src/refresh-workflow.js +8 -0
- package/src/scaffold.js +19 -11
- package/src/serialization.js +205 -0
- package/src/session.js +942 -74
- package/src/simple-yaml.js +24 -4
- package/src/status.js +43 -5
- package/src/sync.js +5 -0
- package/src/workflows/registry.js +1 -1
package/src/docs-review.js
CHANGED
|
@@ -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
|
-
|
|
1953
|
-
|
|
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');
|
package/src/docs-update.js
CHANGED
|
@@ -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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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
|
|
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(
|
|
655
|
+
return path.resolve(workspaceDir, repo.path);
|
|
227
656
|
}
|
|
228
657
|
|
|
229
|
-
return path.resolve(
|
|
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
|
|
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:
|
|
900
|
+
newDecisionIds: newDecisions.map((entry) => entry.id),
|
|
901
|
+
newDecisions,
|
|
467
902
|
};
|
|
468
903
|
}
|
|
469
904
|
|