@vibecompass/vibecompass 0.17.1 → 0.17.3

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,56 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.17.3 - 2026-10-12
4
+
5
+ A small sync release: `push` tells you what the hosted dashboard could not
6
+ show. The lane brief stays opt-in (D-368).
7
+
8
+ - `push` prints the server's projection warnings. The server builds the
9
+ dashboard in the same step as the push and reports anything it could not
10
+ build, such as a file whose decision links could not be read
11
+ (`lineage_extraction_failed`, D-371). `push` now prints each one as a
12
+ `Note: <code> (<path>): <message>` line after the run, so you learn about it
13
+ in the terminal instead of only from a dashboard notice. The push itself
14
+ still succeeded. A push with nothing to report prints no extra lines.
15
+
16
+ ## 0.17.2 - 2026-10-12
17
+
18
+ Lockstep release with the app's lineage port: section headings open a fence
19
+ only where masking does, commands leave nothing behind on an uninitialized root,
20
+ agent files show a repo's remote beside its local path, `init` ignores lane
21
+ scratch, and the scanner's section warnings read headings fence-aware. The
22
+ lane brief stays opt-in (D-368).
23
+
24
+ - Lineage: section headings in architecture docs and session notes now open a
25
+ fence only where masking does. A backtick fence's info string holds no
26
+ backtick, so a prose line that starts with backticks, such as
27
+ ```` ```a`b ````, no longer hides every later heading of the file. Before,
28
+ the decisions listed under a later `## Decisions made` heading were missed as
29
+ `made` relations, and later citations were filed under the wrong section.
30
+ Tilde fences may still hold backticks in their info string. A shared golden
31
+ fixture pair (`note-fence-opener`, `doc-fence-opener`) pins the rule for the
32
+ app port.
33
+ - Commands no longer leave directories behind on an uninitialized root. A
34
+ write command run against a missing root (or a folder holding no project
35
+ memory) left an empty `state/` directory, and the root folder itself, behind
36
+ before it failed. The cross-process lock still serializes every command
37
+ there, since `bootstrap` and `apply-export` from an external bundle can
38
+ create a root, but at release it removes the directories it made while they
39
+ are still empty. Session commands report the missing root instead of a
40
+ missing `project.yaml`.
41
+ - Agent files and `context.md` show a Git repo's remote when `project.yaml` also
42
+ records its local `path:`, as `<remote> (local path <path>)`. Before, any repo
43
+ with a path read as a local folder. Local-folder sources and repos without a
44
+ path read as before; run `vibecompass sync-agents` to refresh managed blocks.
45
+ - `init` adds `sessions/active/` to the root's `.gitignore` beside `state/`, so
46
+ a versioned memory root never commits local-only lane scratch (D-278). It
47
+ appends only the entries a `.gitignore` lacks. An existing root's
48
+ `.gitignore` is not changed; add the line yourself.
49
+ - Scanner: the recommended-section warnings for architecture docs and session
50
+ notes ignore headings inside fenced code, with the read model's fence-aware
51
+ reader. A fenced example `## Description` no longer hides a missing real
52
+ section.
53
+
3
54
  ## 0.17.1 - 2026-10-07
4
55
 
5
56
  Hardening release: decision lineage fails soft, the read model reads sections
package/README.md CHANGED
@@ -230,6 +230,9 @@ npx -y @vibecompass/vibecompass@latest docs-review --root .compass --rebuild --a
230
230
 
231
231
  Docs review writes derived state under `state/`, including scanned source
232
232
  inventory, accepted coverage, and the accepted documentation-plan projection.
233
+ `init` adds `state/` and `sessions/active/` (local-only lane scratch) to the
234
+ root's `.gitignore`. A command that fails in a folder that is not a project
235
+ memory root yet leaves nothing behind there.
233
236
 
234
237
  Generated agent files can also include managed VibeCompass blocks:
235
238
 
@@ -336,6 +339,11 @@ changed canonical project-memory files and the hosted dashboard should catch
336
339
  up. Hosted-only projects do not use a local authoritative push; update or
337
340
  refresh them through the hosted dashboard/proposal flow.
338
341
 
342
+ `push` prints a `Note: <code> (<path>): <message>` line for anything the
343
+ hosted dashboard could not build from your files, such as a file whose decision
344
+ links could not be read. The push itself succeeded; the note names the file so
345
+ you can check it.
346
+
339
347
  ### Where the token lives (D-355)
340
348
 
341
349
  Hosted commands look for the token in this order:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibecompass/vibecompass",
3
- "version": "0.17.1",
3
+ "version": "0.17.3",
4
4
  "description": "Local-first project-memory core for VibeCompass",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -74,9 +74,9 @@ export async function runCli(argv, io = createDefaultIo(), runtime = {}) {
74
74
  }
75
75
  io.stdout.write(`Wrote ${result.projectFilePath}\n`);
76
76
  if (result.gitignoreUpdated) {
77
- io.stdout.write(`Updated ${result.gitignorePath} to ignore state/\n`);
77
+ io.stdout.write(`Updated ${result.gitignorePath} to ignore state/ and sessions/active/\n`);
78
78
  } else {
79
- io.stdout.write(`${result.gitignorePath} already ignored state/\n`);
79
+ io.stdout.write(`${result.gitignorePath} already ignored state/ and sessions/active/\n`);
80
80
  }
81
81
  io.stdout.write(
82
82
  `Generated ${result.manifestPath} (${result.manifest.canonical.document_count} canonical docs, ${result.manifest.canonical.warning_count} warnings)\n`,
@@ -619,6 +619,10 @@ export async function runCli(argv, io = createDefaultIo(), runtime = {}) {
619
619
  if (result.staleProposalIds.length > 0) {
620
620
  io.stdout.write(`Stale proposals: ${result.staleProposalIds.join(', ')}\n`);
621
621
  }
622
+ for (const warning of result.warnings) {
623
+ const where = warning.path ? ` (${warning.path})` : '';
624
+ io.stdout.write(`Note: ${warning.code}${where}: ${warning.message}\n`);
625
+ }
622
626
  io.stdout.write(`Recorded ${result.manifestPath}\n`);
623
627
  io.stdout.write(`${result.message}\n`);
624
628
  return 0;
@@ -1577,7 +1577,7 @@ export function findFencedRanges(text) {
1577
1577
  }
1578
1578
  continue;
1579
1579
  }
1580
- if (marker && !(marker[1][0] === '`' && line.slice(line.indexOf(marker[1]) + marker[1].length).includes('`'))) {
1580
+ if (opensFence(marker, line)) {
1581
1581
  fence = { char: marker[1][0], length: marker[1].length, start: lineStart };
1582
1582
  }
1583
1583
  }
@@ -1585,6 +1585,13 @@ export function findFencedRanges(text) {
1585
1585
  return ranges;
1586
1586
  }
1587
1587
 
1588
+ // A fence opens on three or more backticks or tildes indented at most three
1589
+ // spaces; a backtick opener's info string holds no backtick, so ```a`b is
1590
+ // prose. Shared by masking and the section index.
1591
+ function opensFence(marker, line) {
1592
+ return Boolean(marker) && !(marker[1][0] === '`' && line.slice(line.indexOf(marker[1]) + marker[1].length).includes('`'));
1593
+ }
1594
+
1588
1595
  // A closing fence holds only the opener's character, at least as many times,
1589
1596
  // so ```~~~ never closes. Shared by masking and the section index.
1590
1597
  function closesFence(fence, marker, line) {
@@ -1686,7 +1693,7 @@ function buildSectionIndex(content, bodyStart) {
1686
1693
  }
1687
1694
  continue;
1688
1695
  }
1689
- if (fenceMatch) {
1696
+ if (opensFence(fenceMatch, line)) {
1690
1697
  fence = { char: fenceMatch[1][0], length: fenceMatch[1].length };
1691
1698
  continue;
1692
1699
  }
@@ -88,13 +88,15 @@ function renderProjectSnapshot(context) {
88
88
  return lines.length > 0 ? lines : ['- No project details recorded yet.'];
89
89
  }
90
90
 
91
+ // A local-folder source shows its folder; a Git repo shows its remote, plus
92
+ // the local path where one is recorded beside it (PC2).
91
93
  function formatRepoDescriptor(repo) {
92
- if (repo?.source === 'local' || repo?.path) {
94
+ if (repo?.source === 'local' || (repo?.path && !repo?.remote)) {
93
95
  return `local folder ${repo.path ?? '.'}`;
94
96
  }
95
97
 
96
98
  if (repo?.remote) {
97
- return repo.remote;
99
+ return repo.path ? `${repo.remote} (local path ${repo.path})` : repo.remote;
98
100
  }
99
101
 
100
102
  return 'source not recorded';
package/src/init.js CHANGED
@@ -34,7 +34,7 @@ export async function initializeProjectMemory(options) {
34
34
  const gitignorePath = path.join(normalized.rootDir, '.gitignore');
35
35
 
36
36
  await writeProjectFile(projectFilePath, normalized.projectConfig, normalized.force);
37
- const gitignoreUpdated = await ensureStateIgnored(gitignorePath);
37
+ const gitignoreUpdated = await ensureLocalOnlyPathsIgnored(gitignorePath);
38
38
  const scaffoldResult = await scaffoldInitFiles(normalized.bootstrap);
39
39
  const manifestResult = await writeStateManifest(normalized.rootDir, {
40
40
  generatedAt: normalized.generatedAt,
@@ -461,22 +461,25 @@ async function writeProjectFile(projectFilePath, projectConfig, force) {
461
461
  await writeFile(projectFilePath, source, 'utf8');
462
462
  }
463
463
 
464
- async function ensureStateIgnored(gitignorePath) {
465
- const stateEntry = 'state/';
464
+ // Local-only paths a versioned memory root never commits: derived state, and
465
+ // lane scratch under sessions/active/ (D-278; PC5).
466
+ const LOCAL_ONLY_IGNORE_ENTRIES = ['state/', 'sessions/active/'];
466
467
 
468
+ async function ensureLocalOnlyPathsIgnored(gitignorePath) {
467
469
  if (!(await fileExists(gitignorePath))) {
468
- await writeFile(gitignorePath, `${stateEntry}\n`, 'utf8');
470
+ await writeFile(gitignorePath, `${LOCAL_ONLY_IGNORE_ENTRIES.join('\n')}\n`, 'utf8');
469
471
  return true;
470
472
  }
471
473
 
472
474
  const current = await readFile(gitignorePath, 'utf8');
473
475
  const entries = current.split(/\r?\n/).map((line) => line.trim());
474
- if (entries.includes(stateEntry)) {
476
+ const missing = LOCAL_ONLY_IGNORE_ENTRIES.filter((entry) => !entries.includes(entry));
477
+ if (missing.length === 0) {
475
478
  return false;
476
479
  }
477
480
 
478
481
  const separator = current.length > 0 && !current.endsWith('\n') ? '\n' : '';
479
- await writeFile(gitignorePath, `${current}${separator}${stateEntry}\n`, 'utf8');
482
+ await writeFile(gitignorePath, `${current}${separator}${missing.join('\n')}\n`, 'utf8');
480
483
  return true;
481
484
  }
482
485
 
@@ -2,7 +2,7 @@
2
2
  * The one markdown section reader shared by the read model's component fields
3
3
  * (D-372) and the session brief (D-359): fence-aware, first section of a title
4
4
  * wins. The lineage extractor keeps its own section index
5
- * (`decision-lineage.js`), whose fence opener differs (`decision-lineage.md`).
5
+ * (`decision-lineage.js`), which follows the same opening and closing rules.
6
6
  */
7
7
 
8
8
  /**
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { validateBriefSettings } from './brief-settings.js';
4
4
  import { parseFrontmatter } from './frontmatter.js';
5
5
  import { sha256Text } from './hash.js';
6
+ import { splitSections } from './markdown-sections.js';
6
7
  import { parseSimpleYaml } from './simple-yaml.js';
7
8
 
8
9
  const PROJECT_FILE = 'project.yaml';
@@ -886,12 +887,11 @@ function findDuplicateDecisionErrors(documents) {
886
887
  return errors;
887
888
  }
888
889
 
890
+ // Level-2 section titles, lowercased, read fence-aware with the section reader
891
+ // the read model and the brief share (D-372; H10): a heading inside fenced
892
+ // code is an example, so it never satisfies a recommended section.
889
893
  function extractLevelTwoSections(content) {
890
- const sections = new Set();
891
- for (const match of content.matchAll(/^##\s+(.+)$/gm)) {
892
- sections.add(match[1].trim().toLowerCase());
893
- }
894
- return sections;
894
+ return new Set([...splitSections(content, 2).keys()].map((title) => title.trim().toLowerCase()));
895
895
  }
896
896
 
897
897
  // D-292 changelog-shape detector, shared by the scanner and the docs-review
package/src/scaffold.js CHANGED
@@ -503,12 +503,18 @@ ${repoLines}
503
503
  `;
504
504
  }
505
505
 
506
+ // A local-folder source shows its folder; a Git repo shows its remote, plus
507
+ // the local path where one is recorded beside it (PC2).
506
508
  function formatRepoDescriptor(repo) {
507
- if (repo.source === 'local' || repo.path) {
509
+ if (repo.source === 'local' || (repo.path && !repo.remote)) {
508
510
  return `local folder ${repo.path}`;
509
511
  }
510
512
 
511
- return repo.remote ?? 'source not recorded';
513
+ if (repo.remote) {
514
+ return repo.path ? `${repo.remote} (local path ${repo.path})` : repo.remote;
515
+ }
516
+
517
+ return 'source not recorded';
512
518
  }
513
519
 
514
520
  function generateArchitectureGuide(projectConfig) {
@@ -1,6 +1,6 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
2
  import { randomUUID } from 'node:crypto';
3
- import { mkdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { mkdir, readdir, readFile, realpath, rename, rm, rmdir, stat, writeFile } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
 
6
6
  /**
@@ -16,6 +16,15 @@ import path from 'node:path';
16
16
  * re-enter instead of deadlocking (e.g. close-session refreshing the
17
17
  * manifest, which is itself lock-wrapped)
18
18
  *
19
+ * Every command takes the disk lock, on an uninitialized root too: a locked
20
+ * command can create a root there (bootstrap, or apply-export from an external
21
+ * bundle), so skipping the lock would let two of them validate the same
22
+ * missing files and both write. The lock leaves nothing behind (PC1): at
23
+ * release it removes the directories it created to hold itself (state/, and
24
+ * the root when it was missing) while they are still empty, and a root the
25
+ * lock created still counts as missing to the command
26
+ * (`memoryRootCreatedByLock`).
27
+ *
19
28
  * This tier is intentionally local-only. Cross-machine serialization belongs
20
29
  * to git merge (local-primary teams) or the hosted database (hosted-only);
21
30
  * no distributed locking is ever built on top of this file.
@@ -28,6 +37,7 @@ const DEFAULT_ATTEMPTS = 120;
28
37
  const RETRY_BASE_DELAY_MS = 25;
29
38
  const RETRY_MAX_DELAY_MS = 100;
30
39
 
40
+ // Held root key -> { createdRoot }: whether this lock made the root directory.
31
41
  const heldRoots = new AsyncLocalStorage();
32
42
  const processQueues = new Map();
33
43
 
@@ -88,13 +98,14 @@ export async function withMemoryRootLock(rootDir, label, fn, options = {}) {
88
98
  await previous;
89
99
 
90
100
  try {
91
- const ownerToken = await acquireDiskLock(key, label, options);
101
+ const lock = await acquireDiskLock(key, label, options);
92
102
  try {
93
- const nextHeld = new Set(held ?? []);
94
- nextHeld.add(key);
103
+ const nextHeld = new Map(held ?? []);
104
+ nextHeld.set(key, { createdRoot: lock.createdRoot });
95
105
  return await heldRoots.run(nextHeld, fn);
96
106
  } finally {
97
- await releaseDiskLock(key, ownerToken);
107
+ await releaseDiskLock(key, lock.ownerToken);
108
+ await removeLockCreatedDirectories(key, lock.created);
98
109
  }
99
110
  } finally {
100
111
  releaseQueue();
@@ -104,6 +115,57 @@ export async function withMemoryRootLock(rootDir, label, fn, options = {}) {
104
115
  }
105
116
  }
106
117
 
118
+ /**
119
+ * Whether `rootDir` exists only because the lock this caller holds made it
120
+ * (PC1): the root was missing when the command started and still holds
121
+ * nothing but the lock, so a command that requires an existing root reports
122
+ * it missing instead of reading the lock's own directory as one. Once the
123
+ * command writes into it (bootstrap), it is a root in its own right. False
124
+ * outside a lock holder.
125
+ */
126
+ export async function memoryRootCreatedByLock(rootDir) {
127
+ const held = heldRoots.getStore();
128
+ if (!held) {
129
+ return false;
130
+ }
131
+ const heldKey = await findHeldMemoryRootKey(held, await canonicalizeMemoryRootKey(rootDir));
132
+ if (!heldKey || !held.get(heldKey).createdRoot) {
133
+ return false;
134
+ }
135
+ try {
136
+ const rootEntries = await readdir(heldKey);
137
+ const stateEntries = await readdir(path.dirname(memoryRootLockPath(heldKey)));
138
+ return rootEntries.every((entry) => entry === 'state') && stateEntries.every((entry) => entry.startsWith(LOCK_DIR_NAME));
139
+ } catch {
140
+ return false;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Removes the directories acquisition created (state/, then the root and any
146
+ * missing parents up to `created`) while each is still empty (PC1). A command
147
+ * that wrote memory there, or another holder's lock, keeps a directory
148
+ * non-empty, so it stays. Two commands racing on the same missing root can
149
+ * leave an empty directory when the second found it already made; it never
150
+ * holds memory.
151
+ */
152
+ async function removeLockCreatedDirectories(key, created) {
153
+ if (!created) {
154
+ return;
155
+ }
156
+ const top = path.resolve(created);
157
+ for (let dir = path.dirname(memoryRootLockPath(key)); ; dir = path.dirname(dir)) {
158
+ try {
159
+ await rmdir(dir);
160
+ } catch {
161
+ return;
162
+ }
163
+ if (dir === top || path.dirname(dir) === dir) {
164
+ return;
165
+ }
166
+ }
167
+ }
168
+
107
169
  async function canonicalizeMemoryRootKey(rootDir) {
108
170
  const resolved = path.resolve(rootDir);
109
171
  try {
@@ -126,7 +188,7 @@ async function findHeldMemoryRootKey(held, key) {
126
188
  return key;
127
189
  }
128
190
 
129
- for (const heldKey of held) {
191
+ for (const heldKey of held.keys()) {
130
192
  if (await memoryRootKeysReferToSamePath(heldKey, key)) {
131
193
  return heldKey;
132
194
  }
@@ -145,9 +207,11 @@ async function memoryRootKeysReferToSamePath(left, right) {
145
207
 
146
208
  async function acquireDiskLock(key, label, options) {
147
209
  const lockDir = memoryRootLockPath(key);
210
+ const stateDir = path.dirname(lockDir);
148
211
  const attempts = options.attempts ?? DEFAULT_ATTEMPTS;
149
212
  const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
150
- await mkdir(path.dirname(lockDir), { recursive: true });
213
+ // The topmost directory this acquisition created, if any (mkdir reports it).
214
+ let created = await mkdir(stateDir, { recursive: true });
151
215
 
152
216
  for (let attempt = 1; ; attempt += 1) {
153
217
  const ownerToken = randomUUID();
@@ -158,9 +222,17 @@ async function acquireDiskLock(key, label, options) {
158
222
  `${JSON.stringify({ pid: process.pid, token: ownerToken, label, acquired_at: new Date().toISOString() })}\n`,
159
223
  'utf8',
160
224
  );
161
- return ownerToken;
225
+ return {
226
+ ownerToken,
227
+ created,
228
+ createdRoot: Boolean(created) && path.resolve(created) !== path.resolve(stateDir),
229
+ };
162
230
  } catch (error) {
163
- if (error?.code !== 'EEXIST') {
231
+ if (error?.code === 'ENOENT') {
232
+ // A releasing holder removed the state/ (or root) it had created; make
233
+ // it again, and remove it at release in turn.
234
+ created = topmostDirectory(created, await mkdir(stateDir, { recursive: true }));
235
+ } else if (error?.code !== 'EEXIST') {
164
236
  throw error;
165
237
  }
166
238
  }
@@ -186,6 +258,13 @@ async function acquireDiskLock(key, label, options) {
186
258
  }
187
259
  }
188
260
 
261
+ function topmostDirectory(left, right) {
262
+ if (!left || !right) {
263
+ return left ?? right;
264
+ }
265
+ return path.resolve(left).length <= path.resolve(right).length ? left : right;
266
+ }
267
+
189
268
  /**
190
269
  * A lock is abandoned only when its owner process is provably gone (dead PID),
191
270
  * or the owner file never became readable within the grace window (a crash
package/src/session.js CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  removeLaneWorktreesAtClose,
17
17
  rollbackGitBinding,
18
18
  } from './git-binding.js';
19
- import { withMemoryRootLock } from './serialization.js';
19
+ import { memoryRootCreatedByLock, withMemoryRootLock } from './serialization.js';
20
20
  import { findDuplicateDecisionIds, formatDecisionId } from './decisions.js';
21
21
  import {
22
22
  assignLanePort,
@@ -1782,7 +1782,9 @@ function getLanePaths(normalized, sessionId) {
1782
1782
  }
1783
1783
 
1784
1784
  async function ensureInitializedProjectMemory(rootDir, options = {}) {
1785
- if (!(await fileExists(rootDir))) {
1785
+ // The lock makes a missing root to hold its state/ and removes it again at
1786
+ // release (PC1), so a root it created is still missing here.
1787
+ if (!(await fileExists(rootDir)) || (await memoryRootCreatedByLock(rootDir))) {
1786
1788
  throw new Error(
1787
1789
  `No project memory root was found at ${rootDir}. Run "vibecompass init" before using session commands.`,
1788
1790
  );
package/src/sync.js CHANGED
@@ -56,10 +56,32 @@ export async function pushProjectMemory(options = {}, environment = {}) {
56
56
  remoteRevisionId: response.remote_revision_id,
57
57
  appliedProposalIds: response.applied_proposal_ids ?? [],
58
58
  staleProposalIds: response.stale_proposal_ids ?? [],
59
+ warnings: readProjectionWarnings(response),
59
60
  message: `Pushed ${documents.length} canonical document${documents.length === 1 ? '' : 's'} to hosted project memory.`,
60
61
  };
61
62
  }
62
63
 
64
+ /**
65
+ * The hosted push projects the dashboard read model in the same transaction
66
+ * (D-239) and reports what it could not project as `projection.warnings`
67
+ * (`{ code, message, path? }`), such as `lineage_extraction_failed` (D-371).
68
+ * The push itself succeeded, so these are notes, not errors. A server that
69
+ * sends no projection block, or an entry without a message, yields nothing.
70
+ */
71
+ function readProjectionWarnings(response) {
72
+ const warnings = response?.projection?.warnings;
73
+ if (!Array.isArray(warnings)) {
74
+ return [];
75
+ }
76
+ return warnings
77
+ .filter((warning) => warning && typeof warning === 'object' && typeof warning.message === 'string')
78
+ .map((warning) => ({
79
+ code: typeof warning.code === 'string' && warning.code ? warning.code : 'projection_warning',
80
+ message: warning.message,
81
+ ...(typeof warning.path === 'string' && warning.path ? { path: warning.path } : {}),
82
+ }));
83
+ }
84
+
63
85
  export async function pullPreviewProjectMemory(options = {}, environment = {}) {
64
86
  const context = await loadSyncContext(options, environment);
65
87
  const existingManifest = await readJsonIfPresent(context.manifestPath);