@vibecompass/vibecompass 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,123 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.0 - 2026-09-30
4
+
5
+ The session brief ships as an opt-in experiment (D-368). In a baseline-versus-brief
6
+ evaluation that did not fully follow its own protocol, the brief arm met the numerical
7
+ recall targets and made none of the labeled forbidden authority claims, but lost nine
8
+ facts that agents without a brief found. That does not support turning it on by
9
+ default, so `start-session` and `continue-session` write a lane brief only when
10
+ `project.yaml` sets `brief.enabled: true`. `vibecompass brief` works on request
11
+ either way.
12
+
13
+ - Read model: architecture components now carry `retrieval_guidance` (the
14
+ `## Retrieval guidance` section) and `retrieval_scope` (the "Retrieval
15
+ scope" bullet under `## Review metadata`); both are `null` when absent.
16
+ Existing fields are unchanged.
17
+ - Add typed decision references and declared decision lineage
18
+ (`decision_lineage` on the read model, contract version 1). Architecture
19
+ docs `cites` decisions, session notes `references` them and `made` the ones
20
+ listed at the start of a top-level "Decisions made" bullet, and decision
21
+ entries declare `supersedes` / `amends` / `preserves` only from their own
22
+ wording, with extent (`full` / `partial` / `scoped` / `unqualified`) and
23
+ scope. Other lineage-like wording is `unknown`; mentions are `references`;
24
+ nothing is inferred as "governs" or "currently valid" (D-359). Every
25
+ relation carries source path, section, line, document and section hashes,
26
+ and explicit-or-inferred evidence (range interiors are inferred).
27
+ - Lineage stays conservative: fenced examples, another decision's restated,
28
+ reported, or uncertain claim ("D-020 supersedes D-010", "As D-020
29
+ supersedes …", "Documents how D-020 supersedes …"), contrast objects
30
+ ("separate from D-169"), hypothetical wording (may, would, if, unless, …),
31
+ and negated preservation ("cannot preserve") never certify a relation;
32
+ prohibited change ("cannot supersede", "cannot be superseded by <this
33
+ entry>") is preservation, and a requirement ("must retain") still
34
+ certifies preservation. An
35
+ `unknown` is kept beside certified relations unless one shares its object
36
+ (coordinated verbs). Historical retention ("kept for historical reference")
37
+ never overrides partial or scoped wording. Decision IDs must be positive
38
+ safe integers, so malformed ranges cannot loop.
39
+ - Recognize the optional structured lineage fields `**Supersedes:**`,
40
+ `**Partially supersedes:**`, `**Amends:**`, and `**Preserves:**` on new
41
+ decision entries (D-363); they win over prose for the targets they name, and
42
+ malformed items become diagnostics.
43
+ - Add `getDecisionLineage(readModel, id)` (declared successors transitively,
44
+ incoming and outgoing lineage, citations, and the notes that made it) and
45
+ export the pure extractors (`extractDecisionFileRelations`,
46
+ `extractSessionNoteRelations`, `extractArchitectureDocCitations`,
47
+ `scanDecisionReferences`, `buildDecisionLineageModel`) with shared fixtures
48
+ in `src/tests/fixtures/decision-lineage/` for the app port.
49
+ - Add `vibecompass brief` and the read-only session brief engine
50
+ (`buildSessionBrief`, `renderBrief`; plan task A3, D-359). Given a task, and
51
+ optionally files, feature slugs, claims, a lane, and a budget, it selects the
52
+ memory a session should read first — through the file-owner index (repo
53
+ aliases and workspace paths such as `vibecompass-app/src/…` both match),
54
+ lane-declared docs, feature slugs, and deterministic keyword matching on
55
+ titles, Description, and Retrieval guidance (no embeddings, no network, no
56
+ model) — and packs whole units (lane, decision-lineage, doc, note,
57
+ watch-out) into an estimated token budget (Unicode code points ÷ 4 over the
58
+ whole rendered brief; default 6,000, minimum 800). Every brief states
59
+ `complete`, `partial`, `incomplete` (with prioritized required reads, also
60
+ used for retrieval errors), or `no-match`, lists at most 12 follow-up reads
61
+ plus "+N more" (the full list is in `--json`), and never emits a decision
62
+ without every declared superseding or amending successor. Uncertified
63
+ lineage wording becomes a follow-up read, never a status. The command
64
+ writes nothing: no manifest refresh, state write, lane write, or agent-file
65
+ sync, so it is safe against a hand-maintained root.
66
+ - Session brief in the lane lifecycle (plan task A4, D-359, D-364, D-368),
67
+ opt-in: when `project.yaml` sets `brief.enabled: true`, `start-session` and
68
+ `continue-session` write `sessions/active/<lane-id>/brief.md` from the lane's working-on,
69
+ claims, and feature slugs, after every lifecycle write succeeded and inside
70
+ the same lock. `continue-session` keeps a current brief and regenerates a
71
+ missing or stale one, and lists it under "Read next". A brief failure is a
72
+ warning plus, when the file can be written, an `incomplete` brief naming the
73
+ reason; it never fails the command, and existing lifecycle refusals fail as
74
+ before. Without the setting (or with `brief.enabled: false`), lifecycle
75
+ commands print `Brief: off (…)`, write no brief, and leave a brief already
76
+ in the lane untouched; `--no-brief` (start and continue) skips generation
77
+ for one command on a root that opted in. Every other lifecycle effect is
78
+ unchanged. `vibecompass brief --write` writes or regenerates the lane brief
79
+ on request whatever the setting; `vibecompass brief --check [--json]`
80
+ reports whether it is stale, without writing.
81
+ - `brief.md` opens with a YAML header that binds its inputs: source root and
82
+ lane with the lane's session date and number, destination root and lane, a
83
+ digest of the canonical corpus it read, short hashes of the docs, decision
84
+ entries, and notes it drew text from, the lane scratch it read (`handoff.md`
85
+ bytes, the `session.yaml` fields it uses, the other active lanes), and the
86
+ repo alias map, plus ready-to-run check and refresh commands that keep the
87
+ root, lane, and source explicit. The body is exactly what
88
+ `vibecompass brief` prints for the lane. Staleness is computed from the
89
+ bindings, not stored: any hash, the session identity, or the alias map
90
+ differs, or the check cannot read what it needs; resume bookkeeping
91
+ (`resumed_at`, `resume_count`) never makes a brief stale. Memory that
92
+ cannot be read (invalid settings included) is a failed generation
93
+ (`generation: "failed"`; `brief --write` exits 1), while mandatory units
94
+ overflowing the budget is a successful `incomplete` brief.
95
+ - `project.yaml` gains an optional `brief:` mapping (D-364): `enabled`
96
+ (default `false`; `true` opts the root into lifecycle generation, D-368),
97
+ and `exclude`, a list of root-relative globs (`*`, `**`, `?`; case-insensitive)
98
+ naming canonical documents every brief must never read. Excluded files are
99
+ dropped from the scan before they are opened, so they contribute nothing to
100
+ selection, ranking, citation propagation, bindings, or output. Malformed or
101
+ unknown `brief` settings, or an unreadable `project.yaml`, fail closed: the
102
+ brief reads no canonical document and is `incomplete`; scans warn with
103
+ `project-brief-invalid`. `loadProjectReadModel` and other consumers are
104
+ unaffected.
105
+ - Dual-root source for dogfood roots (D-255, D-364): `continue-session`,
106
+ `brief --write`, and `brief --check` accept `--source-root <root>` (and
107
+ `--source-session <lane-id>`, which must equal the lane) to build the brief
108
+ from another memory root's matching lane — same id, session date, and
109
+ session number, or the brief is refused (`incomplete`, with the reason).
110
+ The source root is only read (no lock, manifest, lane, stamp, or agent-file
111
+ write) and is byte-identical afterwards; both roots' exclusions apply;
112
+ destination repo ids translate to the source's by matching remotes. The
113
+ option is never inferred: a brief built from another root is stale for a
114
+ run that does not name it, and that root is not read.
115
+ - Generated `context.md`, the managed agent block, and the scratch listings
116
+ now tell agents to read the lane's `brief.md`, when present, before planning,
117
+ and describe lifecycle generation as opt-in. The lane
118
+ `wip.md` template gains an optional `## Context used` section for the docs
119
+ and decisions the lane actually relied on.
120
+
3
121
  ## 0.15.0 - 2026-09-25
4
122
 
5
123
  - Add a layered local credential store for hosted sync tokens (D-355). Every
package/README.md CHANGED
@@ -143,6 +143,17 @@ npx -y @vibecompass/vibecompass@latest close-session --root .compass --session a
143
143
  # start-session --id <existing-lane> fails closed and points at continue-session.
144
144
  npx -y @vibecompass/vibecompass@latest continue-session auth-flow --root .compass --working-on "Auth flow, phase 2"
145
145
 
146
+ # Session brief in the lane (D-359, D-364), opt-in (D-368): with
147
+ # project.yaml `brief: { enabled: true }`, start-session and continue-session
148
+ # write sessions/active/<lane-id>/brief.md — the memory the lane's task should
149
+ # start from, with a YAML header binding the inputs it read. continue-session
150
+ # keeps a current brief and regenerates a stale one; a brief failure is a
151
+ # warning, never a failed start or resume. Without the setting nothing is
152
+ # written, and --no-brief skips it for one command. brief --write briefs any
153
+ # lane on request; `brief.exclude` lists root-relative globs no brief may read.
154
+ npx -y @vibecompass/vibecompass@latest brief --root .compass --session auth-flow --write # write or regenerate it
155
+ npx -y @vibecompass/vibecompass@latest brief --root .compass --session auth-flow --check # current or stale, and why
156
+
146
157
  # Optional git binding for a lane (D-281): create/reuse a branch in each bound
147
158
  # repo; --worktree provisions per-repo worktrees under
148
159
  # <workspace>/worktrees/<lane-id>/<repo-id> with the lane marker in the
@@ -186,6 +197,12 @@ npx -y @vibecompass/vibecompass@latest sync-agents --root .compass
186
197
  npx -y @vibecompass/vibecompass@latest append-decision --root .compass --target cross-cutting.md --entry staged-entry.md
187
198
  npx -y @vibecompass/vibecompass@latest refresh-decision-index --root .compass --check
188
199
 
200
+ # Session brief (read-only): the memory a task should start from, packed into
201
+ # an estimated token budget, with a complete/partial/incomplete/no-match status
202
+ # and follow-up reads. Decisions always appear with their declared successors.
203
+ npx -y @vibecompass/vibecompass@latest brief --root .compass --task "Change the refresh denial message" --files app:src/lib/entitlements.ts
204
+ npx -y @vibecompass/vibecompass@latest brief --root .compass --session auth-flow --budget 3000 --json
205
+
189
206
  # Targeted and comprehensive architecture docs maintenance
190
207
  npx -y @vibecompass/vibecompass@latest docs-update --root .compass --session auth-flow --changed app:src/auth/login.ts
191
208
  npx -y @vibecompass/vibecompass@latest docs-review --root .compass --guided
@@ -240,6 +257,38 @@ workflow files, updating handoffs, and applying reviewed changes. Use
240
257
  `docs update` / `vibecompass docs-update` for ordinary session-delta
241
258
  maintenance tied to the current lane.
242
259
 
260
+ ## Session Brief (opt-in)
261
+
262
+ `vibecompass brief` picks the architecture docs, decisions, and session notes a
263
+ task should start from, and packs them into a short markdown brief with a
264
+ `complete`, `partial`, `incomplete`, or `no-match` status and a list of
265
+ follow-up reads. It runs locally and deterministically: no network, no model,
266
+ no account. A decision always appears together with any later decision whose
267
+ own text explicitly declares that it replaces or amends it, and the brief never
268
+ claims that a decision governs your task. It cannot spot a replacement that is
269
+ only implied, so read the follow-up list too.
270
+
271
+ The brief in a session lane is an experiment you turn on (D-368):
272
+
273
+ ```yaml
274
+ # .compass/project.yaml
275
+ brief:
276
+ enabled: true
277
+ ```
278
+
279
+ With it on, `start-session` and `continue-session` write
280
+ `sessions/active/<lane-id>/brief.md`, and your AI tool reads it before
281
+ planning. Without it, nothing changes, and
282
+ `vibecompass brief --session <lane-id> --write` still briefs one lane on
283
+ request. `brief.exclude` lists files no brief may read.
284
+
285
+ The brief is opt-in because of the project's own evaluation, which did not
286
+ fully follow its protocol. Agents with a brief found about as much of the
287
+ relevant memory as agents without one, and made none of the specific false
288
+ authority claims the evaluation checked for. But they missed nine facts the
289
+ others found, in one case after the brief left out the key document. Treat a
290
+ brief as a starting point, not the whole answer.
291
+
243
292
  ## Hosted Sync
244
293
 
245
294
  Use hosted sync when you want browser review, team workflows, or hosted
@@ -336,6 +385,9 @@ import {
336
385
  scanProjectMemory,
337
386
  loadProjectReadModel,
338
387
  getProjectContext,
388
+ getDecisionLineage,
389
+ buildSessionBrief,
390
+ renderBrief,
339
391
  detectChangelogShapedLines,
340
392
  ARCHITECTURE_DOC_SOFT_SIZE_LIMIT_BYTES,
341
393
  } from "@vibecompass/vibecompass";
@@ -351,6 +403,25 @@ so external validators (for example the hosted proposal parser) stay in parity.
351
403
  `ARCHITECTURE_DOC_SOFT_SIZE_LIMIT_BYTES` is the byte-valued soft size budget
352
404
  (12000) both docs-review apply warnings and the docs-update size advisory read.
353
405
 
406
+ `loadProjectReadModel(root)` also returns `decision_lineage`: evidence-typed
407
+ relations between documents and decisions. Architecture docs `cites`
408
+ decisions; session notes `references` them and `made` the ones listed under
409
+ "Decisions made". Decision entries declare `supersedes`, `amends`, or
410
+ `preserves` only from their own wording, with an extent and scope; anything
411
+ the parser cannot certify is `unknown`. Every relation carries its source
412
+ path, section, line, and content hashes. `getDecisionLineage(readModel, id)`
413
+ returns one decision's incoming and outgoing lineage and its declared
414
+ successors, followed transitively. Nothing is ever reported as "governs" or
415
+ "currently valid". Architecture components also carry `retrieval_guidance`
416
+ and `retrieval_scope` when their docs define them.
417
+
418
+ `buildSessionBrief({ rootDir, task, files, featureSlugs, claims, laneId, budget })`
419
+ is the read-only engine behind `vibecompass brief`: it returns the selection
420
+ result (status, packed units, omitted units, follow-up reads, gaps, and the
421
+ estimated size), and `renderBrief(result)` turns it into markdown. It never
422
+ writes to the root, and it never reads a path the root's `project.yaml`
423
+ `brief.exclude` names.
424
+
354
425
  ## More Documentation
355
426
 
356
427
  - Developer guide: https://vibecompass.dev/developers
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibecompass/vibecompass",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Local-first project-memory core for VibeCompass",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Deterministic keyword matching for the session brief (plan task A3; D-359):
3
+ * a tokenizer with light suffix stemming and a BM25F-style scorer over a
4
+ * small fielded index. No embeddings, no network, no model — the same inputs
5
+ * always produce the same terms and scores.
6
+ */
7
+
8
+ const STOPWORDS = new Set(
9
+ (
10
+ 'a about above after again against all also am an and any are as at be because been before being below between both but by ' +
11
+ 'can cannot could did do does doing done down during each either etc even ever every few for from further had has have having ' +
12
+ 'he her here hers him his how however i if in into is it its itself just me more most much my neither no nor not now of off on ' +
13
+ 'once one only onto or other our ours out over own per same she should so some such than that the their theirs them then there ' +
14
+ 'these they this those though through thus to too under until up upon us very via was we were what whatever when where whether ' +
15
+ 'which while who whom whose why will with within without would yet you your yours'
16
+ ).split(' '),
17
+ );
18
+
19
+ // Instruction framing that says what to do, not what the task is about.
20
+ // Dropped from query text only; indexed memory keeps every content word.
21
+ const QUERY_FRAMING = new Set(
22
+ (
23
+ 'add answer anything change create edit everything afterward afterwards happen implement include including instead make ' +
24
+ 'modify need please rewrite something thing things walk want write'
25
+ ).split(' '),
26
+ );
27
+
28
+ const IRREGULAR = new Map([
29
+ ['paid', 'pay'],
30
+ ['made', 'make'],
31
+ ['built', 'build'],
32
+ ['ran', 'run'],
33
+ ['written', 'write'],
34
+ ['wrote', 'write'],
35
+ ['chosen', 'choose'],
36
+ ['chose', 'choose'],
37
+ ]);
38
+
39
+ // Ordered suffix rules: the first rule whose suffix matches (with at least
40
+ // `min` characters left) applies. A final `e` is dropped afterwards so
41
+ // "translate" and "translation" meet at "translat".
42
+ const SUFFIX_RULES = [
43
+ ['ational', 'ate', 3],
44
+ ['izations', 'ize', 3],
45
+ ['ization', 'ize', 3],
46
+ ['ations', 'ate', 3],
47
+ ['ation', 'ate', 3],
48
+ ['ements', '', 4],
49
+ ['ement', '', 4],
50
+ ['ments', '', 3],
51
+ ['ment', '', 3],
52
+ ['nesses', '', 3],
53
+ ['ness', '', 3],
54
+ ['ingly', '', 3],
55
+ ['ings', '', 3],
56
+ ['ing', '', 3],
57
+ ['edly', '', 3],
58
+ ['ies', 'y', 2],
59
+ ['ied', 'y', 2],
60
+ ['sses', 'ss', 2],
61
+ ['xes', 'x', 2],
62
+ ['ches', 'ch', 2],
63
+ ['shes', 'sh', 2],
64
+ ['ed', '', 3],
65
+ ['ly', '', 4],
66
+ ['s', '', 3],
67
+ ];
68
+
69
+ export function stemWord(word) {
70
+ const lower = word.toLowerCase();
71
+ if (IRREGULAR.has(lower)) return IRREGULAR.get(lower);
72
+ if (lower.length <= 3 || lower.endsWith('ss') || lower.endsWith('us') || lower.endsWith('is')) {
73
+ return lower;
74
+ }
75
+
76
+ let stem = lower;
77
+ for (const [suffix, replacement, min] of SUFFIX_RULES) {
78
+ if (stem.endsWith(suffix) && stem.length - suffix.length >= min) {
79
+ stem = stem.slice(0, stem.length - suffix.length) + replacement;
80
+ break;
81
+ }
82
+ }
83
+
84
+ // "running" → "runn" → "run"; keep "ll"/"ss"/"zz" doubles ("install", "access").
85
+ if (stem !== lower && /([b-df-hj-kmnp-rtv-y])\1$/.test(stem) && !/(ll|ss|zz)$/.test(stem)) {
86
+ stem = stem.slice(0, -1);
87
+ }
88
+ if (stem.length > 4 && stem.endsWith('e')) {
89
+ stem = stem.slice(0, -1);
90
+ }
91
+ return stem;
92
+ }
93
+
94
+ /** Raw lowercase word tokens: letters and digits, split on everything else. */
95
+ export function splitWords(text) {
96
+ return String(text ?? '')
97
+ .toLowerCase()
98
+ .split(/[^a-z0-9]+/)
99
+ .filter((word) => word.length >= 2 && !/^\d+$/.test(word));
100
+ }
101
+
102
+ /** Content terms for indexing: stopwords dropped, stemmed, order kept. */
103
+ export function indexTerms(text) {
104
+ const terms = [];
105
+ for (const word of splitWords(text)) {
106
+ if (STOPWORDS.has(word)) continue;
107
+ terms.push(stemWord(word));
108
+ }
109
+ return terms;
110
+ }
111
+
112
+ /** Unique query terms: stopwords and instruction framing dropped, stemmed. */
113
+ export function queryTerms(text) {
114
+ const seen = new Set();
115
+ const terms = [];
116
+ for (const word of splitWords(text)) {
117
+ if (STOPWORDS.has(word) || QUERY_FRAMING.has(word)) continue;
118
+ const stem = stemWord(word);
119
+ if (QUERY_FRAMING.has(stem) || seen.has(stem)) continue;
120
+ seen.add(stem);
121
+ terms.push(stem);
122
+ }
123
+ return terms;
124
+ }
125
+
126
+ /** The first raw word behind each query term, for disclosures ("japanes" → "Japanese"). */
127
+ export function queryTermOrigins(text) {
128
+ const origins = new Map();
129
+ for (const word of String(text ?? '').split(/[^A-Za-z0-9]+/)) {
130
+ if (word.length < 2 || /^\d+$/.test(word)) continue;
131
+ const stem = stemWord(word.toLowerCase());
132
+ if (!origins.has(stem)) origins.set(stem, word);
133
+ }
134
+ return origins;
135
+ }
136
+
137
+ const K1 = 1.2;
138
+
139
+ /**
140
+ * A fielded keyword index. `fields` maps a field name to `{ weight, b }`;
141
+ * each document supplies text per field.
142
+ */
143
+ export function createKeywordIndex(fields, documents) {
144
+ const entries = documents.map((document) => {
145
+ const perField = {};
146
+ for (const name of Object.keys(fields)) {
147
+ const terms = indexTerms(document.fields[name] ?? '');
148
+ const counts = new Map();
149
+ for (const term of terms) counts.set(term, (counts.get(term) ?? 0) + 1);
150
+ perField[name] = { counts, length: terms.length };
151
+ }
152
+ return { id: document.id, perField };
153
+ });
154
+
155
+ const averageLength = {};
156
+ for (const name of Object.keys(fields)) {
157
+ const total = entries.reduce((sum, entry) => sum + entry.perField[name].length, 0);
158
+ averageLength[name] = entries.length > 0 ? Math.max(1, total / entries.length) : 1;
159
+ }
160
+
161
+ const documentFrequency = new Map();
162
+ for (const entry of entries) {
163
+ const seen = new Set();
164
+ for (const name of Object.keys(fields)) {
165
+ for (const term of entry.perField[name].counts.keys()) seen.add(term);
166
+ }
167
+ for (const term of seen) documentFrequency.set(term, (documentFrequency.get(term) ?? 0) + 1);
168
+ }
169
+
170
+ const count = entries.length;
171
+ const idf = (term) => {
172
+ const frequency = documentFrequency.get(term) ?? 0;
173
+ return Math.log(1 + (count - frequency + 0.5) / (frequency + 0.5));
174
+ };
175
+
176
+ return {
177
+ count,
178
+ documentFrequency: (term) => documentFrequency.get(term) ?? 0,
179
+ idf,
180
+ /** `{ id, score, matched }` per document with any matched term. */
181
+ score(terms) {
182
+ const results = [];
183
+ for (const entry of entries) {
184
+ let score = 0;
185
+ const matched = [];
186
+ for (const term of terms) {
187
+ let pseudo = 0;
188
+ for (const [name, { weight, b }] of Object.entries(fields)) {
189
+ const field = entry.perField[name];
190
+ const frequency = field.counts.get(term);
191
+ if (!frequency) continue;
192
+ pseudo += (weight * frequency) / (1 - b + (b * field.length) / averageLength[name]);
193
+ }
194
+ if (pseudo === 0) continue;
195
+ matched.push(term);
196
+ score += idf(term) * ((pseudo * (K1 + 1)) / (K1 + pseudo));
197
+ }
198
+ if (matched.length > 0) results.push({ id: entry.id, score, matched });
199
+ }
200
+ return results;
201
+ },
202
+ };
203
+ }