@vibecompass/vibecompass 0.10.4 → 0.10.6
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 +16 -0
- package/README.md +7 -2
- package/package.json +1 -1
- package/src/cli.js +101 -2
- package/src/docs-review.js +104 -6
- package/src/docs-update.js +538 -0
- package/src/index.js +1 -0
- package/src/project-memory.js +27 -0
- package/src/scaffold.js +29 -6
- package/src/session.js +134 -0
- package/src/status.js +91 -2
- package/src/workflow.js +1 -0
- package/src/workflows/registry.js +2 -1
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { parseFrontmatter } from './frontmatter.js';
|
|
6
|
+
import { scanProjectMemory } from './project-memory.js';
|
|
7
|
+
import { parseSimpleYaml } from './simple-yaml.js';
|
|
8
|
+
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
const PACKAGE_OWNED_PATH_PATTERNS = [
|
|
11
|
+
/^state\//,
|
|
12
|
+
/^context\.md$/,
|
|
13
|
+
/^workflows\//,
|
|
14
|
+
/^CLAUDE\.md$/,
|
|
15
|
+
/^AGENTS\.md$/,
|
|
16
|
+
/^\.cursorrules$/,
|
|
17
|
+
/^\.github\/copilot-instructions\.md$/,
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export async function planDocsUpdate(options = {}) {
|
|
21
|
+
const normalized = normalizeDocsUpdateOptions(options);
|
|
22
|
+
const scan = await scanProjectMemory(normalized.rootDir);
|
|
23
|
+
const project = scan.project?.extracted ?? {};
|
|
24
|
+
const activeSession = await readActiveSession(normalized.rootDir, normalized.sessionId);
|
|
25
|
+
const changedFiles = await resolveChangedFiles({
|
|
26
|
+
cwd: normalized.cwd,
|
|
27
|
+
explicitChangedFiles: normalized.changedFiles,
|
|
28
|
+
repos: project.repos ?? [],
|
|
29
|
+
});
|
|
30
|
+
const architectureDocs = scan.documents
|
|
31
|
+
.filter((document) => document.kind === 'architecture')
|
|
32
|
+
.map((document) => summarizeArchitectureDocument(document));
|
|
33
|
+
const delta = {
|
|
34
|
+
changedFiles,
|
|
35
|
+
claimedPaths: activeSession?.claimedPaths ?? [],
|
|
36
|
+
sessionRepos: activeSession?.repos ?? [],
|
|
37
|
+
featureSlugs: activeSession?.featureSlugs ?? [],
|
|
38
|
+
};
|
|
39
|
+
const affectedArchitectureDocs = findAffectedArchitectureDocs(architectureDocs, delta);
|
|
40
|
+
const packageOwnedChanges = changedFiles
|
|
41
|
+
.filter((file) => isPackageOwnedPath(file.normalizedPath))
|
|
42
|
+
.map((file) => file.raw);
|
|
43
|
+
const decisionStatus = summarizeDecisionStatus(scan, activeSession);
|
|
44
|
+
const recommendations = buildRecommendations({
|
|
45
|
+
changedFiles,
|
|
46
|
+
affectedArchitectureDocs,
|
|
47
|
+
packageOwnedChanges,
|
|
48
|
+
decisionStatus,
|
|
49
|
+
activeSession,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
rootDir: normalized.rootDir,
|
|
54
|
+
cwd: normalized.cwd,
|
|
55
|
+
session: activeSession
|
|
56
|
+
? {
|
|
57
|
+
id: activeSession.id,
|
|
58
|
+
workingOn: activeSession.workingOn,
|
|
59
|
+
decisionSnapshotHighestId: activeSession.decisionSnapshotHighestId,
|
|
60
|
+
}
|
|
61
|
+
: null,
|
|
62
|
+
delta: {
|
|
63
|
+
changedFiles: changedFiles.map((file) => file.raw),
|
|
64
|
+
claimedPaths: delta.claimedPaths,
|
|
65
|
+
sessionRepos: delta.sessionRepos,
|
|
66
|
+
featureSlugs: delta.featureSlugs,
|
|
67
|
+
},
|
|
68
|
+
architecture: {
|
|
69
|
+
affected: affectedArchitectureDocs,
|
|
70
|
+
needsNewDoc: changedFiles.some((file) => isImplementationLikePath(file.normalizedPath)) && affectedArchitectureDocs.length === 0,
|
|
71
|
+
},
|
|
72
|
+
decisions: decisionStatus,
|
|
73
|
+
packageOwnedChanges,
|
|
74
|
+
recommendations,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function renderDocsUpdatePlan(plan) {
|
|
79
|
+
const lines = [
|
|
80
|
+
'Docs update plan:',
|
|
81
|
+
`- Session: ${plan.session?.id ?? '(none selected)'}`,
|
|
82
|
+
`- Changed files: ${plan.delta.changedFiles.length > 0 ? plan.delta.changedFiles.join(', ') : 'none detected'}`,
|
|
83
|
+
`- Claimed paths: ${plan.delta.claimedPaths.length > 0 ? plan.delta.claimedPaths.join(', ') : 'none recorded'}`,
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
lines.push('Affected architecture docs:');
|
|
87
|
+
if (plan.architecture.affected.length === 0) {
|
|
88
|
+
lines.push(plan.architecture.needsNewDoc
|
|
89
|
+
? '- No matching architecture doc found for implementation-like changes; create a focused component doc or defer explicitly.'
|
|
90
|
+
: '- None detected from the current session delta.');
|
|
91
|
+
} else {
|
|
92
|
+
for (const doc of plan.architecture.affected) {
|
|
93
|
+
lines.push(`- ${doc.path}`);
|
|
94
|
+
for (const reason of doc.reasons) {
|
|
95
|
+
lines.push(` - ${reason}`);
|
|
96
|
+
}
|
|
97
|
+
if (doc.qualityWarnings.length > 0) {
|
|
98
|
+
lines.push(` - quality warnings: ${doc.qualityWarnings.map((warning) => warning.code).join(', ')}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
lines.push('Decision log:');
|
|
104
|
+
if (plan.decisions.newDecisionIds.length > 0) {
|
|
105
|
+
lines.push(`- New decisions since lane start: ${plan.decisions.newDecisionIds.map((id) => `D-${id}`).join(', ')}`);
|
|
106
|
+
} else if (plan.decisions.decisionSnapshotHighestId === null) {
|
|
107
|
+
lines.push('- No lane decision snapshot available.');
|
|
108
|
+
} else {
|
|
109
|
+
lines.push('- No new decisions detected since lane start.');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (plan.packageOwnedChanges.length > 0) {
|
|
113
|
+
lines.push('Package-owned generated/state surfaces:');
|
|
114
|
+
for (const filePath of plan.packageOwnedChanges) {
|
|
115
|
+
lines.push(`- ${filePath}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
lines.push('Recommended next actions:');
|
|
120
|
+
for (const recommendation of plan.recommendations) {
|
|
121
|
+
lines.push(`- ${recommendation}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return `${lines.join('\n')}\n`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function normalizeDocsUpdateOptions(options) {
|
|
128
|
+
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
129
|
+
return {
|
|
130
|
+
cwd,
|
|
131
|
+
rootDir: path.resolve(cwd, options.rootDir ?? '.compass'),
|
|
132
|
+
sessionId: normalizeOptionalString(options.sessionId),
|
|
133
|
+
changedFiles: Array.isArray(options.changedFiles) ? options.changedFiles : [],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function readActiveSession(rootDir, requestedSessionId) {
|
|
138
|
+
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
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!sessionId) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const sessionPath = path.join(activeRoot, sessionId, 'session.yaml');
|
|
156
|
+
try {
|
|
157
|
+
const data = parseSimpleYaml(await readFile(sessionPath, 'utf8'), { sourceName: sessionPath });
|
|
158
|
+
return {
|
|
159
|
+
id: sessionId,
|
|
160
|
+
workingOn: normalizeOptionalString(data.working_on),
|
|
161
|
+
repos: normalizeStringArray(data.repos),
|
|
162
|
+
claimedPaths: normalizeStringArray(data.claimed_paths),
|
|
163
|
+
featureSlugs: normalizeStringArray(data.feature_slugs),
|
|
164
|
+
decisionSnapshotHighestId: Number.isInteger(data.decision_snapshot?.highest_decision_id)
|
|
165
|
+
? data.decision_snapshot.highest_decision_id
|
|
166
|
+
: null,
|
|
167
|
+
};
|
|
168
|
+
} catch {
|
|
169
|
+
return {
|
|
170
|
+
id: sessionId,
|
|
171
|
+
workingOn: null,
|
|
172
|
+
repos: [],
|
|
173
|
+
claimedPaths: [],
|
|
174
|
+
featureSlugs: [],
|
|
175
|
+
decisionSnapshotHighestId: null,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function resolveChangedFiles(options) {
|
|
181
|
+
if (options.explicitChangedFiles.length > 0) {
|
|
182
|
+
return normalizeChangedFiles(options.explicitChangedFiles, options.repos);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const changedPaths = new Set();
|
|
186
|
+
for (const filePath of await readGitStatusPaths(options.cwd)) {
|
|
187
|
+
changedPaths.add(filePath);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const repoStatusResults = await Promise.all(
|
|
191
|
+
options.repos.map(async (repo) => {
|
|
192
|
+
const repoDir = resolveRepoWorkingDirectory(options.cwd, repo);
|
|
193
|
+
if (!repo?.id || !repoDir || repoDir === options.cwd) {
|
|
194
|
+
return [];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return (await readGitStatusPaths(repoDir)).map((filePath) => `${repo.id}:${filePath}`);
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
for (const filePath of repoStatusResults.flat()) {
|
|
202
|
+
changedPaths.add(filePath);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return normalizeChangedFiles(Array.from(changedPaths).sort((left, right) => left.localeCompare(right)), options.repos);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function readGitStatusPaths(cwd) {
|
|
209
|
+
try {
|
|
210
|
+
const { stdout } = await execFileAsync('git', ['-C', cwd, 'status', '--porcelain=v1', '--untracked-files=all'], {
|
|
211
|
+
timeout: 5000,
|
|
212
|
+
maxBuffer: 1024 * 1024,
|
|
213
|
+
});
|
|
214
|
+
return parseGitStatusPaths(stdout);
|
|
215
|
+
} catch {
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function resolveRepoWorkingDirectory(cwd, repo) {
|
|
221
|
+
if (!repo?.id) {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (repo.path) {
|
|
226
|
+
return path.resolve(cwd, repo.path);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return path.resolve(cwd, repo.id);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function parseGitStatusPaths(stdout) {
|
|
233
|
+
return stdout
|
|
234
|
+
.split(/\r?\n/)
|
|
235
|
+
.map((line) => line.trimEnd())
|
|
236
|
+
.filter(Boolean)
|
|
237
|
+
.map((line) => {
|
|
238
|
+
const rawPath = line.slice(3).trim();
|
|
239
|
+
const renameIndex = findUnquotedRenameSeparator(rawPath);
|
|
240
|
+
const pathPart = renameIndex >= 0 ? rawPath.slice(renameIndex + 4).trim() : rawPath;
|
|
241
|
+
return unquoteGitPorcelainPath(pathPart);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function findUnquotedRenameSeparator(value) {
|
|
246
|
+
let inQuotes = false;
|
|
247
|
+
let escaped = false;
|
|
248
|
+
|
|
249
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
250
|
+
const char = value[index];
|
|
251
|
+
if (escaped) {
|
|
252
|
+
escaped = false;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (inQuotes && char === '\\') {
|
|
257
|
+
escaped = true;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (char === '"') {
|
|
262
|
+
inQuotes = !inQuotes;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (!inQuotes && value.startsWith(' -> ', index)) {
|
|
267
|
+
return index;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return -1;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function unquoteGitPorcelainPath(value) {
|
|
275
|
+
const trimmed = value.trim();
|
|
276
|
+
if (!trimmed.startsWith('"') || !trimmed.endsWith('"')) {
|
|
277
|
+
return trimmed;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
let decoded = '';
|
|
281
|
+
let octalBytes = [];
|
|
282
|
+
const flushOctalBytes = () => {
|
|
283
|
+
if (octalBytes.length === 0) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
decoded += Buffer.from(octalBytes).toString('utf8');
|
|
288
|
+
octalBytes = [];
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
for (let index = 1; index < trimmed.length - 1; index += 1) {
|
|
292
|
+
const char = trimmed[index];
|
|
293
|
+
if (char !== '\\') {
|
|
294
|
+
flushOctalBytes();
|
|
295
|
+
decoded += char;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const next = trimmed[index + 1];
|
|
300
|
+
if (next === undefined) {
|
|
301
|
+
flushOctalBytes();
|
|
302
|
+
decoded += '\\';
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const octalMatch = trimmed.slice(index + 1).match(/^[0-7]{1,3}/);
|
|
307
|
+
if (octalMatch) {
|
|
308
|
+
octalBytes.push(Number.parseInt(octalMatch[0], 8));
|
|
309
|
+
index += octalMatch[0].length;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
flushOctalBytes();
|
|
314
|
+
const escapes = {
|
|
315
|
+
a: '\u0007',
|
|
316
|
+
b: '\b',
|
|
317
|
+
f: '\f',
|
|
318
|
+
n: '\n',
|
|
319
|
+
r: '\r',
|
|
320
|
+
t: '\t',
|
|
321
|
+
v: '\v',
|
|
322
|
+
'\\': '\\',
|
|
323
|
+
'"': '"',
|
|
324
|
+
};
|
|
325
|
+
decoded += escapes[next] ?? next;
|
|
326
|
+
index += 1;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
flushOctalBytes();
|
|
330
|
+
return decoded;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function normalizeChangedFiles(paths, repos) {
|
|
334
|
+
return paths
|
|
335
|
+
.map((raw) => normalizeOptionalString(raw))
|
|
336
|
+
.filter(Boolean)
|
|
337
|
+
.map((raw) => {
|
|
338
|
+
const normalizedPath = normalizePath(raw);
|
|
339
|
+
return {
|
|
340
|
+
raw,
|
|
341
|
+
normalizedPath,
|
|
342
|
+
repoPathCandidates: buildRepoPathCandidates(normalizedPath, repos),
|
|
343
|
+
};
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function buildRepoPathCandidates(filePath, repos) {
|
|
348
|
+
const candidates = new Set([filePath]);
|
|
349
|
+
const explicitRepoMatch = filePath.match(/^([^:/]+):(.+)$/);
|
|
350
|
+
if (explicitRepoMatch) {
|
|
351
|
+
const repoRelativePath = normalizePath(explicitRepoMatch[2]);
|
|
352
|
+
candidates.add(`${explicitRepoMatch[1]}:${repoRelativePath}`);
|
|
353
|
+
candidates.add(repoRelativePath);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (!explicitRepoMatch && repos.length === 1 && repos[0]?.id) {
|
|
357
|
+
candidates.add(`${repos[0].id}:${filePath}`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
for (const repo of repos) {
|
|
361
|
+
if (!repo?.id) {
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (filePath.startsWith(`${repo.id}/`)) {
|
|
365
|
+
candidates.add(`${repo.id}:${filePath.slice(repo.id.length + 1)}`);
|
|
366
|
+
}
|
|
367
|
+
if (repo.path) {
|
|
368
|
+
const repoPath = normalizePath(repo.path).replace(/^\.\//, '');
|
|
369
|
+
if (repoPath && filePath.startsWith(`${repoPath}/`)) {
|
|
370
|
+
candidates.add(`${repo.id}:${filePath.slice(repoPath.length + 1)}`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return Array.from(candidates);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function summarizeArchitectureDocument(document) {
|
|
379
|
+
const frontmatter = parseFrontmatter(document.content, { sourceName: document.path });
|
|
380
|
+
const data = frontmatter.data ?? {};
|
|
381
|
+
return {
|
|
382
|
+
path: document.path,
|
|
383
|
+
repoIds: [
|
|
384
|
+
...(typeof data.repo === 'string' ? [data.repo] : []),
|
|
385
|
+
...(Array.isArray(data.repos) ? data.repos.filter((repo) => typeof repo === 'string') : []),
|
|
386
|
+
],
|
|
387
|
+
domain: normalizeOptionalString(data.domain),
|
|
388
|
+
feature: normalizeOptionalString(data.feature),
|
|
389
|
+
component: normalizeOptionalString(data.component),
|
|
390
|
+
involvedFiles: extractInvolvedFiles(frontmatter.body),
|
|
391
|
+
warnings: document.warnings,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function extractInvolvedFiles(body) {
|
|
396
|
+
return Array.from(body.matchAll(/`([^`\n]+:[^`\n]+)`/g))
|
|
397
|
+
.map((match) => normalizePath(match[1]))
|
|
398
|
+
.filter(Boolean);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function findAffectedArchitectureDocs(docs, delta) {
|
|
402
|
+
return docs
|
|
403
|
+
.map((doc) => {
|
|
404
|
+
const reasons = [];
|
|
405
|
+
|
|
406
|
+
for (const changedFile of delta.changedFiles) {
|
|
407
|
+
if (pathMatchesDocument(changedFile, doc)) {
|
|
408
|
+
reasons.push(`matches changed file ${changedFile.raw}`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
for (const claimedPath of delta.claimedPaths) {
|
|
413
|
+
const normalizedClaim = normalizePath(claimedPath);
|
|
414
|
+
if (doc.involvedFiles.some((involvedFile) => pathsOverlap(involvedFile, normalizedClaim))) {
|
|
415
|
+
reasons.push(`matches lane claim ${claimedPath}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (delta.featureSlugs.some((feature) => slugify(doc.feature ?? '') === feature)) {
|
|
420
|
+
reasons.push(`matches lane feature ${delta.featureSlugs.filter((feature) => slugify(doc.feature ?? '') === feature).join(', ')}`);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
path: doc.path,
|
|
425
|
+
reasons: Array.from(new Set(reasons)),
|
|
426
|
+
qualityWarnings: doc.warnings,
|
|
427
|
+
};
|
|
428
|
+
})
|
|
429
|
+
.filter((doc) => doc.reasons.length > 0)
|
|
430
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function pathMatchesDocument(changedFile, doc) {
|
|
434
|
+
if (changedFile.normalizedPath === doc.path) {
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return changedFile.repoPathCandidates.some((candidate) =>
|
|
439
|
+
doc.involvedFiles.some((involvedFile) => pathsOverlap(involvedFile, candidate)),
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function pathsOverlap(left, right) {
|
|
444
|
+
const normalizedLeft = normalizePath(left);
|
|
445
|
+
const normalizedRight = normalizePath(right);
|
|
446
|
+
return (
|
|
447
|
+
normalizedLeft === normalizedRight ||
|
|
448
|
+
normalizedLeft.startsWith(`${normalizedRight}/`) ||
|
|
449
|
+
normalizedRight.startsWith(`${normalizedLeft}/`) ||
|
|
450
|
+
normalizedLeft.endsWith(`/${normalizedRight}`) ||
|
|
451
|
+
normalizedRight.endsWith(`/${normalizedLeft}`)
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function summarizeDecisionStatus(scan, activeSession) {
|
|
456
|
+
const decisionIds = scan.documents
|
|
457
|
+
.filter((document) => document.kind === 'decision')
|
|
458
|
+
.flatMap((document) => document.extracted?.decision_ids ?? [])
|
|
459
|
+
.sort((left, right) => left - right);
|
|
460
|
+
const highestDecisionId = decisionIds.length > 0 ? decisionIds[decisionIds.length - 1] : null;
|
|
461
|
+
const snapshot = activeSession?.decisionSnapshotHighestId ?? null;
|
|
462
|
+
|
|
463
|
+
return {
|
|
464
|
+
highestDecisionId,
|
|
465
|
+
decisionSnapshotHighestId: snapshot,
|
|
466
|
+
newDecisionIds: snapshot === null ? [] : decisionIds.filter((id) => id > snapshot),
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function buildRecommendations(options) {
|
|
471
|
+
const recommendations = [];
|
|
472
|
+
|
|
473
|
+
if (options.affectedArchitectureDocs.length > 0) {
|
|
474
|
+
recommendations.push('Review and update the affected architecture docs listed above, then close with --architecture-docs updated.');
|
|
475
|
+
} else if (options.changedFiles.length > 0 && options.affectedArchitectureDocs.length === 0) {
|
|
476
|
+
recommendations.push('No matching architecture doc was found for the session delta; create a focused doc, mark docs not-needed with evidence, or defer explicitly.');
|
|
477
|
+
} else {
|
|
478
|
+
recommendations.push('No architecture doc update is indicated by the current delta; close with --architecture-docs not-needed if that remains true.');
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (options.decisionStatus.newDecisionIds.length > 0) {
|
|
482
|
+
recommendations.push('Ensure affected architecture docs reference the new decisions where they change contracts or ownership.');
|
|
483
|
+
} else {
|
|
484
|
+
recommendations.push('Append a decision only if this session accepted a real architectural/product/process choice.');
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (options.packageOwnedChanges.length > 0) {
|
|
488
|
+
recommendations.push('Do not hand-edit package-owned state/generated surfaces; use refresh-workflow --dry-run/--apply or sync-agents as appropriate.');
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (options.activeSession) {
|
|
492
|
+
recommendations.push('Keep the active lane wip.md/handoff.md current; close-session will distill them into the finalized note.');
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return recommendations;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function isPackageOwnedPath(filePath) {
|
|
499
|
+
const normalized = normalizePath(filePath).replace(/^\.compass\//, '');
|
|
500
|
+
return PACKAGE_OWNED_PATH_PATTERNS.some((pattern) => pattern.test(normalized));
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function isImplementationLikePath(filePath) {
|
|
504
|
+
const normalized = stripRepoPrefix(filePath);
|
|
505
|
+
return !(
|
|
506
|
+
normalized.startsWith('architecture/') ||
|
|
507
|
+
normalized.startsWith('decisions/') ||
|
|
508
|
+
normalized.startsWith('sessions/') ||
|
|
509
|
+
isPackageOwnedPath(normalized)
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function stripRepoPrefix(filePath) {
|
|
514
|
+
const normalized = normalizePath(filePath);
|
|
515
|
+
const repoMatch = normalized.match(/^[^:/]+:(.+)$/);
|
|
516
|
+
return repoMatch ? normalizePath(repoMatch[1]) : normalized;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function normalizeStringArray(value) {
|
|
520
|
+
return Array.isArray(value)
|
|
521
|
+
? value.map((item) => normalizeOptionalString(item)).filter(Boolean)
|
|
522
|
+
: [];
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function normalizeOptionalString(value) {
|
|
526
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function normalizePath(value) {
|
|
530
|
+
return String(value).replace(/\\/g, '/').replace(/^\.\//, '');
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function slugify(value) {
|
|
534
|
+
return value
|
|
535
|
+
.toLowerCase()
|
|
536
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
537
|
+
.replace(/^-+|-+$/g, '');
|
|
538
|
+
}
|
package/src/index.js
CHANGED
|
@@ -5,6 +5,7 @@ export { refreshWorkflow } from './refresh-workflow.js';
|
|
|
5
5
|
export { getProjectStatus, renderStatusText, toStatusJson } from './status.js';
|
|
6
6
|
export { initializeProjectMemory } from './init.js';
|
|
7
7
|
export { preflightDocsReview } from './docs-review.js';
|
|
8
|
+
export { planDocsUpdate, renderDocsUpdatePlan } from './docs-update.js';
|
|
8
9
|
export {
|
|
9
10
|
applyPullExport,
|
|
10
11
|
pullExportProjectMemory,
|
package/src/project-memory.js
CHANGED
|
@@ -11,7 +11,9 @@ const NON_CANONICAL_ARCHITECTURE_FILES = new Set(['README.md']);
|
|
|
11
11
|
const VALID_MODES = new Set(['local-only', 'local-primary', 'hosted-only']);
|
|
12
12
|
const RECOMMENDED_ARCHITECTURE_SECTIONS = [
|
|
13
13
|
'Description',
|
|
14
|
+
'Review metadata',
|
|
14
15
|
'Details',
|
|
16
|
+
'Retrieval guidance',
|
|
15
17
|
'Next steps',
|
|
16
18
|
'Involved files',
|
|
17
19
|
];
|
|
@@ -446,6 +448,31 @@ function parseArchitectureDocument(options) {
|
|
|
446
448
|
}
|
|
447
449
|
}
|
|
448
450
|
|
|
451
|
+
if (!/^\s*-\s*Evidence:\s+\S/im.test(frontmatter.body)) {
|
|
452
|
+
warnings.push(
|
|
453
|
+
createWarning(
|
|
454
|
+
'architecture-missing-evidence-metadata',
|
|
455
|
+
'Architecture doc is missing review metadata "Evidence" with concrete repo:path references.',
|
|
456
|
+
),
|
|
457
|
+
);
|
|
458
|
+
} else if (/^\s*-\s*Evidence:\s*repo:path references inspected before making implementation claims\s*$/im.test(frontmatter.body)) {
|
|
459
|
+
warnings.push(
|
|
460
|
+
createWarning(
|
|
461
|
+
'architecture-generic-evidence-metadata',
|
|
462
|
+
'Architecture doc uses generic evidence metadata; list concrete repo:path references instead.',
|
|
463
|
+
),
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (!/^\s*-\s*Blindspots:\s+\S/im.test(frontmatter.body)) {
|
|
468
|
+
warnings.push(
|
|
469
|
+
createWarning(
|
|
470
|
+
'architecture-missing-blindspots-metadata',
|
|
471
|
+
'Architecture doc is missing review metadata "Blindspots".',
|
|
472
|
+
),
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
449
476
|
if (repoIds.length === 0 && /##\s+Involved files/i.test(frontmatter.body) && /`[^`]+\/[^`]+`/.test(frontmatter.body)) {
|
|
450
477
|
warnings.push(
|
|
451
478
|
createWarning(
|