@vibecompass/vibecompass 0.10.5 → 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 CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.10.6 - 2026-06-22
6
+
7
+ - Add `docs-update` as a session-delta documentation maintenance planner that maps changed files, lane claims, session repos/features, new decisions, and package-owned generated/state surfaces to targeted architecture/decision/session follow-up.
8
+ - Print the same docs-update plan during `close-session` / `end-session` before the required document-maintenance checkpoint, while keeping semantic document authorship with the closing agent or human.
9
+ - Breaking: `close-session` / `end-session` and the `closeProjectSession` API now require document-maintenance checkpoint statuses for architecture docs, the decision log, and session handoff/scratch maintenance before a lane can be finalized.
10
+ - Surface shared docs-review architecture quality warnings for missing review metadata, evidence, blindspots, retrieval guidance, and repo scope in scanner/status output.
11
+ - Refresh `state/manifest.json.active_sessions` after session start, switch, and close commands so package-managed roots do not keep stale active-lane summaries.
12
+
3
13
  ## 0.10.5 - 2026-06-20
4
14
 
5
15
  - Include compact docs-review evidence summaries in hosted review submissions, covering coverage score basis, source-inventory totals, documentation-plan totals, reconciliation deltas, producer stamps, and warning provenance.
package/README.md CHANGED
@@ -113,6 +113,9 @@ npx -y @vibecompass/vibecompass@latest close-session \
113
113
  --session feature-lane \
114
114
  --title "Feature Lane" \
115
115
  --completed "Shipped the slice" \
116
+ --architecture-docs updated \
117
+ --decision-log not-needed \
118
+ --session-maintenance updated \
116
119
  --next-step "Review and publish"
117
120
  ```
118
121
 
@@ -127,12 +130,14 @@ npx -y @vibecompass/vibecompass@latest status --root .compass
127
130
  npx -y @vibecompass/vibecompass@latest start-session --root .compass --id auth-flow --working-on "Auth flow"
128
131
  npx -y @vibecompass/vibecompass@latest list-sessions --root .compass
129
132
  npx -y @vibecompass/vibecompass@latest switch-session auth-flow --root .compass
130
- npx -y @vibecompass/vibecompass@latest close-session --root .compass --session auth-flow --title "Auth Flow" --completed "Built auth" --next-step "Review"
133
+ npx -y @vibecompass/vibecompass@latest docs-update --root .compass --session auth-flow
134
+ npx -y @vibecompass/vibecompass@latest close-session --root .compass --session auth-flow --title "Auth Flow" --completed "Built auth" --architecture-docs updated --decision-log not-needed --session-maintenance updated --next-step "Review"
131
135
 
132
136
  # Agent instruction files
133
137
  npx -y @vibecompass/vibecompass@latest sync-agents --root .compass
134
138
 
135
- # Architecture docs review
139
+ # Targeted and comprehensive architecture docs maintenance
140
+ npx -y @vibecompass/vibecompass@latest docs-update --root .compass --session auth-flow --changed app:src/auth/login.ts
136
141
  npx -y @vibecompass/vibecompass@latest docs-review --root .compass --guided
137
142
  npx -y @vibecompass/vibecompass@latest docs-review --root .compass --guided --source-root app=../app
138
143
  npx -y @vibecompass/vibecompass@latest docs-review --root .compass --apply-output
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibecompass/vibecompass",
3
- "version": "0.10.5",
3
+ "version": "0.10.6",
4
4
  "description": "Local-first project-memory core for VibeCompass",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -4,6 +4,7 @@ import { realpathSync } from 'node:fs';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  import { connectHostedProjectMemory, initializeProjectMemory, setDefaultSyncTarget } from './init.js';
6
6
  import { preflightDocsReview } from './docs-review.js';
7
+ import { planDocsUpdate, renderDocsUpdatePlan } from './docs-update.js';
7
8
  import { resolveConnectHostedCliOptions, resolveInitCliOptions } from './setup.js';
8
9
  import { closeProjectSession, listProjectSessions, startProjectSession, switchProjectSession } from './session.js';
9
10
  import { syncAgentInstructionFiles } from './generators/agent-files/index.js';
@@ -205,6 +206,20 @@ export async function runCli(argv, io = createDefaultIo(), runtime = {}) {
205
206
  return 0;
206
207
  }
207
208
 
209
+ if (parsed.command === 'docs-update') {
210
+ await writeCompatibilityPreflightWarnings(io, parsed.options, runtime);
211
+ const result = await planDocsUpdate({
212
+ ...parsed.options,
213
+ ...(runtime.cwd ? { cwd: runtime.cwd } : {}),
214
+ });
215
+ if (parsed.options.json) {
216
+ io.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
217
+ } else {
218
+ io.stdout.write(renderDocsUpdatePlan(result));
219
+ }
220
+ return 0;
221
+ }
222
+
208
223
  if (parsed.command === 'close-session' || parsed.command === 'end-session') {
209
224
  await writeCompatibilityPreflightWarnings(io, parsed.options, runtime);
210
225
  const result = await closeProjectSession({
@@ -214,12 +229,17 @@ export async function runCli(argv, io = createDefaultIo(), runtime = {}) {
214
229
  io.stdout.write(`Closed session ${result.sessionDate}-${result.sessionNumber}\n`);
215
230
  io.stdout.write(`Wrote ${result.sessionFilePath}\n`);
216
231
  io.stdout.write(`Updated ${result.claudePath}\n`);
232
+ if (result.docsUpdatePlan) {
233
+ io.stdout.write(renderDocsUpdatePlan(result.docsUpdatePlan));
234
+ }
235
+ writeDocumentMaintenanceCheckpoint(io, result.documentMaintenance);
217
236
  if (result.workflowGuidance.length > 0) {
218
237
  io.stdout.write('Workflow guidance:\n');
219
238
  for (const item of result.workflowGuidance) {
220
239
  io.stdout.write(`- ${item}\n`);
221
240
  }
222
241
  }
242
+ writeWarnings(io, result.warnings);
223
243
  writeAgentFileSyncResult(io, result.agentFileSync);
224
244
  return 0;
225
245
  }
@@ -250,6 +270,7 @@ export async function runCli(argv, io = createDefaultIo(), runtime = {}) {
250
270
  });
251
271
  io.stdout.write(`Current session lane: ${result.current}\n`);
252
272
  io.stdout.write(`Updated ${result.claudePath}\n`);
273
+ writeWarnings(io, result.warnings);
253
274
  writeAgentFileSyncResult(io, result.agentFileSync);
254
275
  return 0;
255
276
  }
@@ -457,6 +478,10 @@ export function parseCliArgs(argv) {
457
478
  return parseRefreshWorkflowArgs(rest);
458
479
  }
459
480
 
481
+ if (command === 'docs-update') {
482
+ return parseDocsUpdateArgs(rest);
483
+ }
484
+
460
485
  if (command === 'close-session' || command === 'end-session') {
461
486
  return parseCloseSessionArgs(rest);
462
487
  }
@@ -896,6 +921,7 @@ function parseCloseSessionArgs(argv) {
896
921
  models: [],
897
922
  blockers: [],
898
923
  nextSteps: [],
924
+ documentMaintenance: {},
899
925
  };
900
926
  // Repeatable CLI flags stay singular for readability, but map to plural arrays in the JS API.
901
927
 
@@ -949,6 +975,15 @@ function parseCloseSessionArgs(argv) {
949
975
  case '--next-session-should':
950
976
  parsed.nextSessionShould = value;
951
977
  break;
978
+ case '--architecture-docs':
979
+ parsed.documentMaintenance.architectureDocs = value;
980
+ break;
981
+ case '--decision-log':
982
+ parsed.documentMaintenance.decisionLog = value;
983
+ break;
984
+ case '--session-maintenance':
985
+ parsed.documentMaintenance.sessionMaintenance = value;
986
+ break;
952
987
  default:
953
988
  throw new Error(`Unknown flag "${token}".`);
954
989
  }
@@ -960,6 +995,49 @@ function parseCloseSessionArgs(argv) {
960
995
  };
961
996
  }
962
997
 
998
+ function parseDocsUpdateArgs(argv) {
999
+ const parsed = {
1000
+ changedFiles: [],
1001
+ };
1002
+
1003
+ for (let index = 0; index < argv.length; index += 1) {
1004
+ const token = argv[index];
1005
+ if (token === '--json') {
1006
+ parsed.json = true;
1007
+ continue;
1008
+ }
1009
+
1010
+ if (!token.startsWith('--')) {
1011
+ throw new Error(`Unexpected argument "${token}".`);
1012
+ }
1013
+
1014
+ const value = argv[index + 1];
1015
+ if (value === undefined) {
1016
+ throw new Error(`Flag "${token}" requires a value.`);
1017
+ }
1018
+ index += 1;
1019
+
1020
+ switch (token) {
1021
+ case '--root':
1022
+ parsed.rootDir = value;
1023
+ break;
1024
+ case '--session':
1025
+ parsed.sessionId = value;
1026
+ break;
1027
+ case '--changed':
1028
+ parsed.changedFiles.push(value);
1029
+ break;
1030
+ default:
1031
+ throw new Error(`Unknown flag "${token}".`);
1032
+ }
1033
+ }
1034
+
1035
+ return {
1036
+ command: 'docs-update',
1037
+ options: parsed,
1038
+ };
1039
+ }
1040
+
963
1041
  function parseListSessionsArgs(argv) {
964
1042
  const parsed = {};
965
1043
 
@@ -1464,6 +1542,17 @@ function writeRefreshWorkflowResult(io, result) {
1464
1542
  writeAgentFileSyncResult(io, result.agentFileSync);
1465
1543
  }
1466
1544
 
1545
+ function writeDocumentMaintenanceCheckpoint(io, documentMaintenance) {
1546
+ if (!documentMaintenance) {
1547
+ return;
1548
+ }
1549
+
1550
+ io.stdout.write('Document maintenance checkpoint:\n');
1551
+ io.stdout.write(`- Architecture docs: ${documentMaintenance.architectureDocs}\n`);
1552
+ io.stdout.write(`- Decision log: ${documentMaintenance.decisionLog}\n`);
1553
+ io.stdout.write(`- Session handoff/scratch: ${documentMaintenance.sessionMaintenance}\n`);
1554
+ }
1555
+
1467
1556
  async function writeCompatibilityPreflightWarnings(io, options = {}, runtime = {}) {
1468
1557
  const result = await inspectProjectCompatibility({
1469
1558
  ...options,
@@ -1513,9 +1602,10 @@ function usageText() {
1513
1602
  ' vibecompass sync-target [<name>] [options]',
1514
1603
  ' vibecompass status [options]',
1515
1604
  ' vibecompass refresh-workflow [--dry-run|--apply] [options]',
1605
+ ' vibecompass docs-update [--session <lane-id>] [options]',
1516
1606
  ' vibecompass start-session --id <lane-id> --working-on <text> [options]',
1517
- ' vibecompass close-session --title <text> --completed <text> --next-step <text> [options]',
1518
- ' vibecompass end-session --title <text> --completed <text> --next-step <text> [options] # alias',
1607
+ ' vibecompass close-session --title <text> --completed <text> --architecture-docs <status> --decision-log <status> --session-maintenance <status> [options]',
1608
+ ' vibecompass end-session --title <text> --completed <text> --architecture-docs <status> --decision-log <status> --session-maintenance <status> [options] # alias',
1519
1609
  ' vibecompass list-sessions [options]',
1520
1610
  ' vibecompass switch-session <id> [options]',
1521
1611
  ' vibecompass sync-agents [options]',
@@ -1578,6 +1668,12 @@ function usageText() {
1578
1668
  ' --update-package-stamp Update an existing project.yaml package_version stamp',
1579
1669
  ' --allow-downgrade-templates Allow applying templates with a CLI older than the root stamp',
1580
1670
  '',
1671
+ 'Docs-update options:',
1672
+ ' --root <path> Project-memory root. Defaults to .compass',
1673
+ ' --session <lane-id> Active session lane to inspect; defaults to current lane',
1674
+ ' --changed <repo:path|path> Repeatable explicit changed path; defaults to git status when omitted',
1675
+ ' --json Print the typed docs-update plan as JSON',
1676
+ '',
1581
1677
  'Start-session options:',
1582
1678
  ' --root <path> Project-memory root. Defaults to .compass',
1583
1679
  ' --tooling-root <path> Tooling root that contains CLAUDE.md. Defaults to cwd',
@@ -1602,6 +1698,9 @@ function usageText() {
1602
1698
  ' --model <text> Optional repeatable model contribution entry',
1603
1699
  ' --blocker <text> Repeatable blocker or open question',
1604
1700
  ' --next-step <text> Repeatable next-session step',
1701
+ ' --architecture-docs <status> Required checkpoint: updated|not-needed|deferred',
1702
+ ' --decision-log <status> Required checkpoint: updated|not-needed|deferred',
1703
+ ' --session-maintenance <status> Required checkpoint: updated|not-needed|deferred',
1605
1704
  ' --last-thing-completed <text> Optional override for the CLAUDE.md completed summary',
1606
1705
  ' --next-session-should <text> Optional override for the CLAUDE.md next-session summary',
1607
1706
  '',
@@ -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,
@@ -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(
package/src/scaffold.js CHANGED
@@ -216,7 +216,7 @@ This workspace uses VibeCompass project memory rooted at \`${rootRelativePath}\`
216
216
  ## Canonical files
217
217
  - \`${rootRelativePath}/project.yaml\` — machine-oriented project metadata
218
218
  - \`${rootRelativePath}/architecture/\` — canonical architecture docs
219
- - \`${rootRelativePath}/decisions/\` — canonical decision log
219
+ - \`${rootRelativePath}/decisions/\` — append-only decision log for this project-memory root
220
220
  - \`${rootRelativePath}/sessions/\` — finalized session notes
221
221
 
222
222
  ## Derived and scratch files
@@ -233,7 +233,7 @@ This workspace uses VibeCompass project memory rooted at \`${rootRelativePath}\`
233
233
  - The active lane scratch files live under \`${rootRelativePath}/sessions/active/<lane-id>/\`.
234
234
  - \`${rootRelativePath}/sessions/active/index.yaml\` is the authoritative current lane pointer; the tool-specific Current session block is a human-readable continuity summary, not the lane-selection source of truth.
235
235
  - Finalized sessions are append-only notes named \`${rootRelativePath}/sessions/YYYY-MM-DD-N-title.md\`; multiple sessions on the same day increment \`N\`.
236
- - Decisions remain append-only and independent from session notes. A session note may reference decisions, but the decision entry in \`${rootRelativePath}/decisions/\` is the durable source of truth.
236
+ - Decisions remain append-only and independent from session notes. A session note may reference decisions, but the decision entry in \`${rootRelativePath}/decisions/\` is the durable decision record for this root.
237
237
 
238
238
  ## Repos in scope
239
239
  ${repos}
@@ -248,7 +248,7 @@ ${repos}
248
248
  ${promptCommands}
249
249
 
250
250
  These are prompt commands for agent behavior, not \`vibecompass\` CLI subcommands.
251
- Reviewer handback is explicit: the reviewer ends the pass by updating the selected lane's \`wip.md\` + \`handoff.md\` and then stopping. The \`close session\` prompt runs builder close-out and ends with \`vibecompass close-session --session <lane-id>\`, which follows the workflow defaults recorded in \`${rootRelativePath}/project.yaml\`.
251
+ Reviewer handback is explicit: the reviewer ends the pass by updating the selected lane's \`wip.md\` + \`handoff.md\` and then stopping. The \`close session\` prompt runs builder close-out and ends with \`vibecompass close-session --session <lane-id>\` plus document-maintenance checkpoint statuses, which follows the workflow defaults recorded in \`${rootRelativePath}/project.yaml\`.
252
252
  \`vibecompass end-session\` is also accepted as an alias for \`vibecompass close-session\`; the canonical command name remains \`close-session\`.
253
253
 
254
254
  ## Workflow defaults
@@ -257,6 +257,7 @@ ${renderWorkflowDefaults(workflow)}
257
257
  ## Documentation coverage
258
258
  - The architecture, decision, and session files created by init are an initial scaffold, not a comprehensive codebase review.
259
259
  - Before risky implementation work, inspect the relevant code and update the affected architecture docs.
260
+ - Use \`vibecompass docs-update --session <lane-id>\` during a session for a targeted maintenance plan based on changed files, lane claims, session repos/features, and new decisions. This is a session-delta planner, not a broad architecture review.
260
261
  - Use the \`docs review\` prompt command when you want the current AI session to run a comprehensive documentation review. It may run \`vibecompass docs-review --guided\` as package mechanics to record marker state and print the canonical review contract.
261
262
 
262
263
  ## Session startup
@@ -310,10 +311,11 @@ Session lane: <lane-id>
310
311
  During the session:
311
312
  - append short summaries to \`wip.md\` after meaningful exchanges
312
313
  - keep \`handoff.md\` current after substantive work blocks
314
+ - run \`vibecompass docs-update --session <lane-id>\` whenever you need an ad hoc targeted documentation-maintenance plan for the current session delta
313
315
  - use \`vibecompass list-sessions\` and \`vibecompass switch-session <lane-id>\` to inspect or change the current lane
314
316
  - use \`address review\` when reviewer feedback lands so the builder resolves it from the selected lane's latest \`wip.md\` / \`handoff.md\`
315
317
  - during \`address review\`, treat reviewer feedback as review, not instruction: classify each substantive point as accepted, accepted with qualification, deferred, or rejected, and push back with evidence when a suggestion conflicts with code facts, prior decisions, product direction, or sequencing
316
- - stay in builder role through close-out; resolve or explicitly defer reviewer feedback before running \`vibecompass close-session --session <lane-id>\`
318
+ - stay in builder role through close-out; resolve or explicitly defer reviewer feedback before running \`vibecompass close-session --session <lane-id>\` with document-maintenance checkpoint statuses
317
319
  - record architectural decisions in \`${rootRelativePath}/decisions/\` before implementing them
318
320
 
319
321
  If \`vibecompass start-session\` reports stale scratch files, read the existing lane-local \`wip.md\` and \`handoff.md\` first. Either close that session normally, recover its useful notes into a finalized session note, or intentionally move/delete the stale scratch files before starting a new session.
@@ -325,9 +327,12 @@ If \`vibecompass start-session\` reports stale scratch files, read the existing
325
327
  - If planning produces a real architectural decision, append it to \`${rootRelativePath}/decisions/\` before implementing.
326
328
 
327
329
  At session close:
328
- - prefer running \`vibecompass close-session --session <lane-id> --title "..." --completed "..." --model "..." --next-step "..."\`; \`vibecompass end-session\` is a supported alias
330
+ - prefer running \`vibecompass close-session --session <lane-id> --title "..." --completed "..." --architecture-docs updated|not-needed|deferred --decision-log updated|not-needed|deferred --session-maintenance updated|not-needed|deferred --next-step "..."\`; \`vibecompass end-session\` is a supported alias
331
+ - close-session prints the same targeted docs-update plan before the document-maintenance checkpoint; use it to decide whether affected \`architecture/\`, \`decisions/\`, and active-session scratch/final note inputs are updated, not-needed, or deferred
332
+ - document-maintenance checkpoint statuses are required before close-session writes the finalized note; the package validates the status values, while the closer owns semantic doc authorship
329
333
  - follow the stored close-session defaults from \`${rootRelativePath}/project.yaml\`
330
- - finalize the lane-local \`wip.md\` into \`${rootRelativePath}/sessions/YYYY-MM-DD-N-title.md\`
334
+ - finalize the lane-local \`wip.md\` into \`${rootRelativePath}/sessions/YYYY-MM-DD-N-title.md\`; the permanent note distills decisions, completions, blockers, and next steps rather than preserving the full \`## Review log\`
335
+ - if a granular reviewer trail must remain durable, summarize it explicitly in the session note inputs or create a separate finalized session note before close-session deletes lane scratch files
331
336
  - delete the closed lane directory under \`${rootRelativePath}/sessions/active/<lane-id>/\`
332
337
  - refresh any affected architecture/decision docs
333
338
 
@@ -371,6 +376,10 @@ Initial project-memory scaffold for ${projectConfig.name}.
371
376
 
372
377
  ${projectConfig.description ? `${projectConfig.description}\n` : ''}This document is a starting map, not a comprehensive architecture review.
373
378
 
379
+ ## Review metadata
380
+ - Evidence: \`project.yaml\`, declared repo descriptors, and the files created by \`vibecompass init\`
381
+ - Blindspots: Runtime architecture, feature flows, persistence, integrations, deployment, observability, and test strategy are not reviewed by the init scaffold.
382
+
374
383
  ## Details
375
384
  - Mode: \`${projectConfig.mode}\`
376
385
  - Documentation coverage: initial scaffold
@@ -394,6 +403,11 @@ Not yet documented:
394
403
 
395
404
  Before changing any undocumented area, inspect the relevant code and update or add architecture docs.
396
405
 
406
+ ## Retrieval guidance
407
+ - Use this doc to understand the project-memory root shape, declared repositories, and the fact that the initial coverage is scaffold-only.
408
+ - Do not use this doc as evidence for runtime behavior, feature ownership, data flow, external integrations, or deployment behavior.
409
+ - Prefer more specific domain/feature/component docs once they exist, and run \`vibecompass docs-update --session <lane-id>\` during active work to find targeted maintenance needs.
410
+
397
411
  ## Next steps
398
412
  - Use the \`docs review\` prompt command for a comprehensive documentation review.
399
413
  - Replace this placeholder with domain/feature/component docs as the project map becomes evidence-backed.
@@ -487,10 +501,18 @@ architecture/<domain-slug>/<feature-slug>/<component>.md
487
501
 
488
502
  ## Recommended sections
489
503
  - \`## Description\`
504
+ - \`## Review metadata\`
490
505
  - \`## Details\`
506
+ - \`## Retrieval guidance\`
491
507
  - \`## Next steps\`
492
508
  - \`## Involved files\`
493
509
 
510
+ ## Shared quality bar
511
+ - Review metadata should record concrete evidence sources and blindspots.
512
+ - Retrieval guidance should say when to consult the doc, what it does not cover, and which related docs or decisions matter.
513
+ - Details should distinguish confirmed behavior from known gaps or open follow-up.
514
+ - Involved files should use concrete \`repo:path\` references that match the affected implementation surface.
515
+
494
516
  Each component doc is canonical. This README is only a convenience guide.
495
517
  `;
496
518
  }
@@ -600,6 +622,7 @@ function buildScaffoldAgentContext(options) {
600
622
  function renderWorkflowDefaults(workflow) {
601
623
  const lines = [
602
624
  `- reviewer handback: ${describeReviewerHandback(workflow)}`,
625
+ '- close-session: require document-maintenance checkpoint statuses for architecture docs, decision log, and session handoff/scratch',
603
626
  '- close-session: refresh any relevant architecture docs before finalizing the session',
604
627
  '- close-session: refresh any relevant decision files before finalizing the session',
605
628
  ];
package/src/session.js CHANGED
@@ -5,11 +5,34 @@ import { parseSimpleYaml } from './simple-yaml.js';
5
5
  import { buildCloseSessionGuidance, resolveWorkflowSettings } from './workflow.js';
6
6
  import { syncAgentInstructionFiles } from './generators/agent-files/index.js';
7
7
  import { START_MARKER } from './generators/agent-files/markers.js';
8
+ import { planDocsUpdate } from './docs-update.js';
9
+ // This lazy-only cycle is intentional: manifest.js reads lane state from this
10
+ // module, and session command handlers rewrite the derived manifest after
11
+ // lane mutations. Keep both sides free of top-level calls into the other.
12
+ import { writeStateManifest } from './manifest.js';
8
13
 
9
14
  const CURRENT_SESSION_REQUIRED_FIELDS = ['Date:', 'Working on:', 'Last thing completed:', 'Blockers:', 'Next session should:'];
10
15
  const WIP_HEADER_PATTERN = /^# WIP — (\d{4}-\d{2}-\d{2}) \(session (\d+)\)$/m;
11
16
  const SESSION_FILENAME_PATTERN = /^(\d{4}-\d{2}-\d{2})-(\d+)-([a-z0-9-]+)\.md$/i;
12
17
  const LANE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/;
18
+ const DOCUMENT_MAINTENANCE_STATUSES = new Set(['updated', 'not-needed', 'deferred']);
19
+ const DOCUMENT_MAINTENANCE_FIELDS = [
20
+ {
21
+ key: 'architectureDocs',
22
+ label: 'Architecture docs',
23
+ flag: '--architecture-docs',
24
+ },
25
+ {
26
+ key: 'decisionLog',
27
+ label: 'Decision log',
28
+ flag: '--decision-log',
29
+ },
30
+ {
31
+ key: 'sessionMaintenance',
32
+ label: 'Session handoff/scratch',
33
+ flag: '--session-maintenance',
34
+ },
35
+ ];
13
36
  const RESERVED_LANE_IDS = new Set([
14
37
  'active',
15
38
  'current',
@@ -114,6 +137,7 @@ export async function startProjectSession(options) {
114
137
  status: 'active',
115
138
  workingOn,
116
139
  }, { current: sessionId });
140
+ const manifestRefresh = await refreshStateManifestSafely(normalized.rootDir);
117
141
  const auditWarnings = await buildDocsReviewWarnings(normalized);
118
142
  const metadataWarnings = collectLaneMetadataWarnings(existingLanes);
119
143
  const agentFileSync = await syncAgentInstructionFilesSafely({
@@ -134,8 +158,10 @@ export async function startProjectSession(options) {
134
158
  warnings: [
135
159
  ...metadataWarnings,
136
160
  ...overlapWarnings,
161
+ ...manifestRefresh.warnings,
137
162
  ...auditWarnings,
138
163
  ],
164
+ manifest: manifestRefresh.manifest,
139
165
  agentFileSync,
140
166
  };
141
167
  }
@@ -162,6 +188,14 @@ export async function closeProjectSession(options) {
162
188
  const workflowSettings = await readWorkflowSettings(normalized.projectFilePath);
163
189
  const sessionId = await resolveExistingSessionId(normalized, options);
164
190
  const lanePaths = sessionId ? getLanePaths(normalized, sessionId) : normalized;
191
+ const documentMaintenance = normalizeDocumentMaintenanceCheckpoint(options?.documentMaintenance);
192
+ const docsUpdate = await planDocsUpdateSafely({
193
+ rootDir: normalized.rootDir,
194
+ cwd: normalized.cwd,
195
+ sessionId,
196
+ // Test injection seam; production callers should let close-session use the default planner.
197
+ planner: options?.docsUpdatePlanner,
198
+ });
165
199
 
166
200
  if (!(await fileExists(lanePaths.wipFilePath))) {
167
201
  throw new Error(
@@ -205,6 +239,7 @@ export async function closeProjectSession(options) {
205
239
  completed,
206
240
  decisions,
207
241
  models: models.length > 0 ? models : ['Not recorded.'],
242
+ documentMaintenance,
208
243
  blockers,
209
244
  nextSteps,
210
245
  }),
@@ -241,6 +276,7 @@ export async function closeProjectSession(options) {
241
276
  );
242
277
 
243
278
  await writeFile(normalized.claudePath, updatedClaude, 'utf8');
279
+ const manifestRefresh = await refreshStateManifestSafely(normalized.rootDir);
244
280
  const agentFileSync = await syncAgentInstructionFilesSafely({
245
281
  rootDir: normalized.rootDir,
246
282
  toolingRootDir: normalized.toolingRootDir,
@@ -256,7 +292,14 @@ export async function closeProjectSession(options) {
256
292
  sessionRelativePath,
257
293
  sessionDate: activeSession.sessionDate,
258
294
  sessionNumber: activeSession.sessionNumber,
295
+ documentMaintenance,
296
+ docsUpdatePlan: docsUpdate.plan,
259
297
  workflowGuidance: buildCloseSessionGuidance(workflowSettings),
298
+ warnings: [
299
+ ...docsUpdate.warnings,
300
+ ...manifestRefresh.warnings,
301
+ ],
302
+ manifest: manifestRefresh.manifest,
260
303
  agentFileSync,
261
304
  removedScratchFiles: [
262
305
  lanePaths.wipFilePath,
@@ -265,6 +308,46 @@ export async function closeProjectSession(options) {
265
308
  };
266
309
  }
267
310
 
311
+ function normalizeDocumentMaintenanceCheckpoint(value) {
312
+ const source = value && typeof value === 'object' && !Array.isArray(value)
313
+ ? value
314
+ : {};
315
+ const missing = [];
316
+ const invalid = [];
317
+ const checkpoint = {};
318
+
319
+ for (const field of DOCUMENT_MAINTENANCE_FIELDS) {
320
+ const normalized = normalizeOptionalString(source[field.key]);
321
+ if (!normalized) {
322
+ missing.push(`${field.flag} <updated|not-needed|deferred>`);
323
+ continue;
324
+ }
325
+
326
+ if (!DOCUMENT_MAINTENANCE_STATUSES.has(normalized)) {
327
+ invalid.push(`${field.flag}=${normalized}`);
328
+ continue;
329
+ }
330
+
331
+ checkpoint[field.key] = normalized;
332
+ }
333
+
334
+ if (missing.length > 0 || invalid.length > 0) {
335
+ const details = [
336
+ missing.length > 0
337
+ ? `Missing document-maintenance checkpoint status: ${missing.join(', ')}.`
338
+ : null,
339
+ invalid.length > 0
340
+ ? `Invalid document-maintenance checkpoint status: ${invalid.join(', ')}. Allowed values: updated, not-needed, deferred.`
341
+ : null,
342
+ 'The package validates the close-session checkpoint, but the closer remains responsible for semantic doc updates.',
343
+ ].filter(Boolean);
344
+
345
+ throw new Error(details.join(' '));
346
+ }
347
+
348
+ return checkpoint;
349
+ }
350
+
268
351
  export async function listProjectSessions(options = {}) {
269
352
  const normalized = normalizeSessionPaths(options);
270
353
  await ensureInitializedProjectMemory(normalized.rootDir, { allowMissingProjectFile: true });
@@ -300,6 +383,7 @@ export async function switchProjectSession(options = {}) {
300
383
  ),
301
384
  'utf8',
302
385
  );
386
+ const manifestRefresh = await refreshStateManifestSafely(normalized.rootDir);
303
387
  const agentFileSync = await syncAgentInstructionFilesSafely({
304
388
  rootDir: normalized.rootDir,
305
389
  toolingRootDir: normalized.toolingRootDir,
@@ -310,10 +394,50 @@ export async function switchProjectSession(options = {}) {
310
394
  claudePath: normalized.claudePath,
311
395
  current: sessionId,
312
396
  lanes,
397
+ warnings: manifestRefresh.warnings,
398
+ manifest: manifestRefresh.manifest,
313
399
  agentFileSync,
314
400
  };
315
401
  }
316
402
 
403
+ async function refreshStateManifestSafely(rootDir) {
404
+ try {
405
+ const manifest = await writeStateManifest(rootDir);
406
+ return {
407
+ manifest,
408
+ warnings: [],
409
+ };
410
+ } catch (error) {
411
+ return {
412
+ manifest: null,
413
+ warnings: [
414
+ `State manifest refresh skipped: ${error instanceof Error ? error.message : String(error)}`,
415
+ ],
416
+ };
417
+ }
418
+ }
419
+
420
+ async function planDocsUpdateSafely(options) {
421
+ try {
422
+ const planner = typeof options.planner === 'function' ? options.planner : planDocsUpdate;
423
+ return {
424
+ plan: await planner({
425
+ rootDir: options.rootDir,
426
+ cwd: options.cwd,
427
+ sessionId: options.sessionId,
428
+ }),
429
+ warnings: [],
430
+ };
431
+ } catch (error) {
432
+ return {
433
+ plan: null,
434
+ warnings: [
435
+ `Docs-update plan skipped: ${error instanceof Error ? error.message : String(error)}`,
436
+ ],
437
+ };
438
+ }
439
+ }
440
+
317
441
  async function syncAgentInstructionFilesSafely(options) {
318
442
  try {
319
443
  return await syncAgentInstructionFiles(options);
@@ -364,6 +488,7 @@ function normalizeSessionPaths(options) {
364
488
  : cwd;
365
489
 
366
490
  return {
491
+ cwd,
367
492
  rootDir,
368
493
  toolingRootDir,
369
494
  projectFilePath: path.resolve(rootDir, 'project.yaml'),
@@ -918,6 +1043,9 @@ ${renderBulletList(options.decisions, 'No new decisions were logged in this sess
918
1043
  ## Models used
919
1044
  ${renderBulletList(options.models)}
920
1045
 
1046
+ ## Document maintenance checkpoint
1047
+ ${renderDocumentMaintenanceCheckpoint(options.documentMaintenance)}
1048
+
921
1049
  ## Blockers / open questions
922
1050
  ${renderBulletList(options.blockers, 'No blocker remains.')}
923
1051
 
@@ -926,6 +1054,12 @@ ${renderOrderedList(options.nextSteps)}
926
1054
  `;
927
1055
  }
928
1056
 
1057
+ function renderDocumentMaintenanceCheckpoint(documentMaintenance) {
1058
+ return DOCUMENT_MAINTENANCE_FIELDS
1059
+ .map((field) => `- ${field.label}: ${documentMaintenance[field.key]}`)
1060
+ .join('\n');
1061
+ }
1062
+
929
1063
  function renderBulletList(items, fallback) {
930
1064
  if (items.length === 0) {
931
1065
  return `- ${fallback}`;
package/src/status.js CHANGED
@@ -5,6 +5,7 @@ import { sha256Text } from './hash.js';
5
5
  import { parseSimpleYaml } from './simple-yaml.js';
6
6
  import { listProjectSessions } from './session.js';
7
7
  import { syncAgentInstructionFiles } from './generators/agent-files/index.js';
8
+ import { scanProjectMemory } from './project-memory.js';
8
9
 
9
10
  export async function getProjectStatus(options = {}) {
10
11
  const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd();
@@ -18,11 +19,12 @@ export async function getProjectStatus(options = {}) {
18
19
  cwd,
19
20
  ...(options.packageVersion ? { packageVersion: options.packageVersion } : {}),
20
21
  });
21
- const [project, sessions, docsReview, agentFiles] = await Promise.all([
22
+ const [project, sessions, docsReview, agentFiles, projectMemory] = await Promise.all([
22
23
  readProjectSummary(compatibility.projectFilePath),
23
24
  readSessionsStatus(rootDir),
24
25
  readDocsReviewStatus(rootDir),
25
26
  readAgentFileStatus(rootDir, toolingRootDir),
27
+ readProjectMemoryStatus(rootDir),
26
28
  ]);
27
29
 
28
30
  return {
@@ -39,12 +41,14 @@ export async function getProjectStatus(options = {}) {
39
41
  sessions,
40
42
  docsReview,
41
43
  agentFiles,
44
+ projectMemory,
42
45
  recommendations: buildRecommendations({
43
46
  project,
44
47
  compatibility,
45
48
  sessions,
46
49
  docsReview,
47
50
  agentFiles,
51
+ projectMemory,
48
52
  }),
49
53
  };
50
54
  }
@@ -99,6 +103,9 @@ export function renderStatusText(status) {
99
103
  ? [`- Output drift: ${status.docsReview.outputDrift.reasons.join('; ')}`]
100
104
  : []),
101
105
  '',
106
+ 'Project memory:',
107
+ ...formatProjectMemoryStatus(status.projectMemory),
108
+ '',
102
109
  'Agent files:',
103
110
  ...formatAgentFileStatus(status.agentFiles),
104
111
  );
@@ -125,6 +132,7 @@ export function toStatusJson(status) {
125
132
  sessions: status.sessions,
126
133
  docsReview: status.docsReview,
127
134
  agentFiles: status.agentFiles,
135
+ projectMemory: status.projectMemory,
128
136
  recommendations: status.recommendations,
129
137
  };
130
138
  }
@@ -297,7 +305,48 @@ async function readAgentFileStatus(rootDir, toolingRootDir) {
297
305
  }
298
306
  }
299
307
 
300
- function buildRecommendations({ project, compatibility, sessions, docsReview, agentFiles }) {
308
+ async function readProjectMemoryStatus(rootDir) {
309
+ try {
310
+ const scan = await scanProjectMemory(rootDir);
311
+ const architectureDocs = scan.documents.filter((document) => document.kind === 'architecture');
312
+ const architectureWarnings = scan.warnings
313
+ .filter((warning) => warning.path?.startsWith('architecture/'))
314
+ .map((warning) => ({
315
+ path: warning.path,
316
+ code: warning.code,
317
+ message: warning.message,
318
+ }));
319
+
320
+ return {
321
+ status: scan.errors.length > 0 ? 'errors' : 'ok',
322
+ architectureDocCount: architectureDocs.length,
323
+ architectureWarningCount: architectureWarnings.length,
324
+ warningSample: architectureWarnings.slice(0, 10),
325
+ truncatedWarningCount: Math.max(0, architectureWarnings.length - 10),
326
+ errorCount: scan.errors.length,
327
+ errorSample: scan.errors.slice(0, 10).map((error) => ({
328
+ path: error.path,
329
+ code: error.code,
330
+ message: error.message,
331
+ })),
332
+ truncatedErrorCount: Math.max(0, scan.errors.length - 10),
333
+ };
334
+ } catch (error) {
335
+ return {
336
+ status: 'unavailable',
337
+ architectureDocCount: 0,
338
+ architectureWarningCount: 0,
339
+ warningSample: [],
340
+ truncatedWarningCount: 0,
341
+ errorCount: 0,
342
+ errorSample: [],
343
+ truncatedErrorCount: 0,
344
+ errorMessage: error instanceof Error ? error.message : 'Unable to scan project memory.',
345
+ };
346
+ }
347
+ }
348
+
349
+ function buildRecommendations({ project, compatibility, sessions, docsReview, agentFiles, projectMemory }) {
301
350
  const recommendations = [];
302
351
  if (project.status === 'unreadable') {
303
352
  recommendations.push('vibecompass init --guided');
@@ -320,6 +369,16 @@ function buildRecommendations({ project, compatibility, sessions, docsReview, ag
320
369
  recommendations.push('vibecompass sync-agents --dry-run');
321
370
  }
322
371
 
372
+ if (projectMemory.status === 'errors') {
373
+ recommendations.push('vibecompass status --json');
374
+ } else if (projectMemory.architectureWarningCount > 0) {
375
+ recommendations.push(
376
+ sessions.status === 'ok' && sessions.current
377
+ ? `vibecompass docs-update --session ${sessions.current}`
378
+ : 'vibecompass docs-review --guided',
379
+ );
380
+ }
381
+
323
382
  if (sessions.status === 'ok' && sessions.lanes.length === 0) {
324
383
  recommendations.push('vibecompass start-session --id LANE_ID --working-on "TASK"');
325
384
  } else if (sessions.status === 'ok' && sessions.lanes.length >= 2) {
@@ -334,6 +393,8 @@ function hasStatusDrift(status) {
334
393
  status.compatibility.warnings.length > 0 ||
335
394
  status.docsReview.status !== 'completed' ||
336
395
  Boolean(status.docsReview.outputDrift?.hasDrift) ||
396
+ status.projectMemory.status !== 'ok' ||
397
+ status.projectMemory.architectureWarningCount > 0 ||
337
398
  status.agentFiles.status !== 'ok' ||
338
399
  status.agentFiles.results.some((item) => item.changed || item.warning)
339
400
  );
@@ -390,6 +451,34 @@ function formatDocsReviewStatus(docsReview) {
390
451
  return `${docsReview.status}${docsReview.completedAt ? ` at ${docsReview.completedAt}` : ''}`;
391
452
  }
392
453
 
454
+ function formatProjectMemoryStatus(projectMemory) {
455
+ if (projectMemory.status === 'unavailable') {
456
+ return [`- unavailable (${projectMemory.errorMessage})`];
457
+ }
458
+
459
+ const lines = [
460
+ `- Architecture docs: ${projectMemory.architectureDocCount}`,
461
+ `- Architecture warnings: ${projectMemory.architectureWarningCount}`,
462
+ ];
463
+
464
+ for (const warning of projectMemory.warningSample) {
465
+ lines.push(`- ${warning.path}: ${warning.code} — ${warning.message}`);
466
+ }
467
+
468
+ if (projectMemory.truncatedWarningCount > 0) {
469
+ lines.push(`- ${projectMemory.truncatedWarningCount} more architecture warning(s) omitted; use --json for the full status sample.`);
470
+ }
471
+
472
+ if (projectMemory.errorCount > 0) {
473
+ lines.push(`- Scan errors: ${projectMemory.errorCount}`);
474
+ for (const error of projectMemory.errorSample) {
475
+ lines.push(`- ${error.path}: ${error.code} — ${error.message}`);
476
+ }
477
+ }
478
+
479
+ return lines;
480
+ }
481
+
393
482
  function formatAgentFileStatus(agentFiles) {
394
483
  if (agentFiles.status !== 'ok') {
395
484
  return [`- unavailable (${agentFiles.errorMessage})`];
package/src/workflow.js CHANGED
@@ -78,6 +78,7 @@ export function buildCloseSessionGuidance(workflowSettings) {
78
78
  const guidance = [];
79
79
 
80
80
  guidance.push('Reviewer handback stays in the selected sessions/active/<lane-id>/wip.md and handoff.md before close-session runs.');
81
+ guidance.push('Close-session requires document-maintenance checkpoint statuses for architecture docs, decision log, and session handoff/scratch: updated, not-needed, or deferred.');
81
82
 
82
83
  if (workflowSettings.closeSession.refreshArchitectureDocs) {
83
84
  guidance.push('Refresh any relevant architecture docs before finalizing the session.');
@@ -12,7 +12,8 @@ export const WORKFLOW_REGISTRY = [
12
12
  { trigger: 'planning mode', summary: 'optional prompt-level mode for scoping work before implementation' },
13
13
  { trigger: 'review handoff', summary: 'reviewer reads the selected lane handoff and appends findings' },
14
14
  { trigger: 'address review', summary: 'builder critically resolves reviewer feedback from the selected lane scratch files' },
15
- { trigger: 'close session', summary: 'builder runs the close-out checklist and ends with `vibecompass close-session --session <lane-id>`; `end session` and `vibecompass end-session` are accepted aliases' },
15
+ { trigger: 'docs update', summary: 'builder runs a targeted session-delta documentation maintenance plan with `vibecompass docs-update --session <lane-id>`' },
16
+ { trigger: 'close session', summary: 'builder runs the close-out checklist and ends with `vibecompass close-session --session <lane-id>` plus required document-maintenance checkpoint statuses; `end session` and `vibecompass end-session` are accepted aliases' },
16
17
  ],
17
18
  },
18
19
  {