@vibecompass/vibecompass 0.14.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.
@@ -19,6 +19,7 @@ import {
19
19
  writeDocsReviewSourceInventory,
20
20
  } from './source-inventory.js';
21
21
  import { readSyncCursor, resolveSyncBinding } from './sync-binding.js';
22
+ import { formatMissingCredentialError, resolveSyncCredential } from './credential-store.js';
22
23
  import { PACKAGE_VERSION } from './version.js';
23
24
  import { withMemoryRootLock } from './serialization.js';
24
25
  import { listDecisionFileNames, readNextDecisionId } from './decisions.js';
@@ -89,6 +90,7 @@ export async function preflightDocsReview(options = {}, environment = {}) {
89
90
  rootDir,
90
91
  statePath,
91
92
  env: environment.env ?? process.env,
93
+ credentialStore: environment.runtime?.credentialStore ?? null,
92
94
  fetch: environment.runtime?.fetch ?? globalThis.fetch,
93
95
  });
94
96
  }
@@ -137,6 +139,7 @@ export async function preflightDocsReview(options = {}, environment = {}) {
137
139
  const hosted = options.submitHosted
138
140
  ? await submitHostedDocsReview({
139
141
  env,
142
+ credentialStore: environment.runtime?.credentialStore ?? null,
140
143
  project,
141
144
  reviewConfig,
142
145
  reviewPrompt,
@@ -1588,9 +1591,20 @@ async function submitHostedDocsReview(options) {
1588
1591
  throw new Error('docs-review --submit-hosted requires sync.credential_env_var in project.yaml.');
1589
1592
  }
1590
1593
 
1591
- const credential = normalizeOptionalString(options.env?.[credentialEnvVar]);
1594
+ // D-355: env override, then the local credential store.
1595
+ const submitBinding = {
1596
+ apiUrl: options.runtime.api_url,
1597
+ projectId: options.runtime.project_id,
1598
+ credentialEnvVar,
1599
+ target: options.runtime.sync_target ?? null,
1600
+ };
1601
+ const submitResolution = await resolveSyncCredential(submitBinding, {
1602
+ env: options.env ?? process.env,
1603
+ ...(options.credentialStore ?? {}),
1604
+ });
1605
+ const credential = submitResolution.credential;
1592
1606
  if (!credential) {
1593
- throw new Error(`docs-review --submit-hosted requires ${credentialEnvVar}.`);
1607
+ throw new Error(formatMissingCredentialError(submitBinding, { action: 'docs-review --submit-hosted', detail: submitResolution.detail }));
1594
1608
  }
1595
1609
 
1596
1610
  const endpoint = new URL(
@@ -1758,9 +1772,19 @@ async function pollHostedDocsReview(options) {
1758
1772
  throw new Error('docs-review --poll-hosted requires runtime.credential_env_var in state/docs-review.json.');
1759
1773
  }
1760
1774
 
1761
- const credential = normalizeOptionalString(options.env?.[credentialEnvVar]);
1775
+ const pollBinding = {
1776
+ apiUrl: runtime.api_url,
1777
+ projectId: runtime.project_id,
1778
+ credentialEnvVar,
1779
+ target: runtime.sync_target ?? null,
1780
+ };
1781
+ const pollResolution = await resolveSyncCredential(pollBinding, {
1782
+ env: options.env ?? process.env,
1783
+ ...(options.credentialStore ?? {}),
1784
+ });
1785
+ const credential = pollResolution.credential;
1762
1786
  if (!credential) {
1763
- throw new Error(`docs-review --poll-hosted requires ${credentialEnvVar}.`);
1787
+ throw new Error(formatMissingCredentialError(pollBinding, { action: 'docs-review --poll-hosted', detail: pollResolution.detail }));
1764
1788
  }
1765
1789
 
1766
1790
  const endpoint = new URL(
@@ -37,7 +37,8 @@ export function renderSharedInstructionBody(context, options = {}) {
37
37
  `1. Read \`${contextPath}\`.`,
38
38
  `2. Read the latest finalized session note under \`${context.rootDir}/sessions/\`.`,
39
39
  `3. If present, read \`${context.rootDir}/sessions/active/index.yaml\` (the lane inventory), then the selected lane's \`wip.md\` and \`handoff.md\`. The selected lane comes from an explicit \`--session\`, the nearest \`.vibecompass-lane.yaml\` worktree marker (walking up from cwd), or the single active lane.`,
40
- `4. Read relevant \`${context.rootDir}/architecture/\` and \`${context.rootDir}/decisions/\` docs before editing implementation.`,
40
+ '4. If present, read the selected lane\'s `brief.md` before planning: the generated session brief (D-359, D-364) selects the docs and decisions for the lane\'s task and says whether it is complete, partial, incomplete, or no-match.',
41
+ `5. Read relevant \`${context.rootDir}/architecture/\` and \`${context.rootDir}/decisions/\` docs before editing implementation, starting from the brief's units and follow-up reads.`,
41
42
  '',
42
43
  '## Prompt Commands',
43
44
  ...renderPromptCommandLines({ rootRelativePath: context.rootDir }),
package/src/index.js CHANGED
@@ -7,6 +7,16 @@ export { initializeProjectMemory } from './init.js';
7
7
  export { preflightDocsReview } from './docs-review.js';
8
8
  export { planDocsUpdate, renderDocsUpdatePlan } from './docs-update.js';
9
9
  export { demoteHosted, promoteHosted } from './mode-transition.js';
10
+ export { loginHosted } from './login.js';
11
+ export {
12
+ describeCredentialSource,
13
+ formatMissingCredentialError,
14
+ listSyncCredentials,
15
+ removeSyncCredential,
16
+ resolveCredentialStoreDir,
17
+ resolveSyncCredential,
18
+ storeSyncCredential,
19
+ } from './credential-store.js';
10
20
  export {
11
21
  adoptRemoteHead,
12
22
  applyPullExport,
@@ -37,5 +47,39 @@ export {
37
47
  getProjectContext,
38
48
  getFeatureContext,
39
49
  getDecisionLog,
50
+ getDecisionLineage,
40
51
  getFileContext,
41
52
  } from './read-model.js';
53
+ export {
54
+ DECISION_LINEAGE_CONTRACT_VERSION,
55
+ DECISION_RELATION_TYPES,
56
+ DECLARED_LINEAGE_RELATIONS,
57
+ buildDecisionLineageModel,
58
+ collectDeclaredSuccessors,
59
+ extractArchitectureDocCitations,
60
+ extractDecisionFileRelations,
61
+ extractSessionNoteRelations,
62
+ parseDecisionEntries,
63
+ scanDecisionReferences,
64
+ } from './decision-lineage.js';
65
+ export {
66
+ BRIEF_CONTRACT_VERSION,
67
+ BRIEF_DEFAULT_BUDGET,
68
+ BRIEF_FOLLOW_UP_CAP,
69
+ BRIEF_MIN_BUDGET,
70
+ BRIEF_OVERVIEW_PATH,
71
+ BRIEF_RESERVE_TOKENS,
72
+ BRIEF_STATUSES,
73
+ buildSessionBrief,
74
+ estimateBriefTokens,
75
+ resolveBriefRequest,
76
+ } from './brief.js';
77
+ export { renderBrief } from './brief-render.js';
78
+ export {
79
+ LANE_BRIEF_FILENAME,
80
+ LANE_BRIEF_FORMAT,
81
+ generateLaneBrief,
82
+ inspectLaneBrief,
83
+ laneBriefPath,
84
+ } from './brief-lifecycle.js';
85
+ export { validateBriefSettings } from './brief-settings.js';
package/src/login.js ADDED
@@ -0,0 +1,257 @@
1
+ import { execFile as nodeExecFile } from 'node:child_process';
2
+ import { readFile } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { connectHostedProjectMemory } from './init.js';
6
+ import { parseSimpleYaml } from './simple-yaml.js';
7
+ import { resolveSyncBinding } from './sync-binding.js';
8
+ import {
9
+ credentialStoreOptionsFrom,
10
+ normalizeApiUrl,
11
+ storeSyncCredential,
12
+ } from './credential-store.js';
13
+ import { PACKAGE_VERSION } from './version.js';
14
+
15
+ /**
16
+ * Browser device-code sign-in for hosted sync (D-355).
17
+ *
18
+ * The CLI asks the hosted app for a device code, shows (and opens) the
19
+ * verification link plus a short user code, and polls until the signed-in
20
+ * project owner approves the code in the dashboard. The claim response is the
21
+ * only place the plaintext token ever exists: the CLI stores it in the local
22
+ * credential store and, when the root has no sync binding yet, writes the
23
+ * non-secret binding into project.yaml exactly as `connect-hosted` would.
24
+ */
25
+
26
+ export const DEFAULT_HOSTED_API_URL = 'https://vibecompass.dev';
27
+ export const DEFAULT_SYNC_CREDENTIAL_ENV_VAR = 'VIBECOMPASS_SYNC_TOKEN';
28
+ export const DEVICE_AUTH_START_ROUTE = 'api/cli/device-auth';
29
+ export const DEVICE_AUTH_TOKEN_ROUTE = 'api/cli/device-auth/token';
30
+ const DEFAULT_POLL_INTERVAL_SECONDS = 5;
31
+ const DEFAULT_POLL_TIMEOUT_SECONDS = 600;
32
+ const REQUEST_TIMEOUT_MS = 15_000;
33
+ const VALID_MODES = new Set(['local-only', 'local-primary', 'hosted-only']);
34
+
35
+ export async function loginHosted(options = {}, environment = {}) {
36
+ const cwd = environment.cwd ? path.resolve(environment.cwd) : process.cwd();
37
+ const rootDir = path.resolve(cwd, options.rootDir ?? '.compass');
38
+ const projectFilePath = path.join(rootDir, 'project.yaml');
39
+ const io = environment.io ?? {};
40
+ const write = (text) => (io.stdout ?? process.stdout).write(text);
41
+ const fetchImpl = environment.runtime?.fetch ?? globalThis.fetch;
42
+ if (typeof fetchImpl !== 'function') {
43
+ throw new Error('login requires a fetch implementation.');
44
+ }
45
+ const sleep = environment.runtime?.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
46
+ const now = environment.runtime?.now ?? (() => Date.now());
47
+
48
+ let project;
49
+ try {
50
+ project = parseSimpleYaml(await readFile(projectFilePath, 'utf8'), { sourceName: projectFilePath });
51
+ } catch (error) {
52
+ if (error && typeof error === 'object' && error.code === 'ENOENT') {
53
+ throw new Error(`No project.yaml found in ${rootDir}. Run "vibecompass init" first, then "vibecompass login".`);
54
+ }
55
+ throw error;
56
+ }
57
+ if (!VALID_MODES.has(project.mode)) {
58
+ throw new Error('login requires project.yaml mode to be local-only, local-primary, or hosted-only.');
59
+ }
60
+
61
+ const binding = resolveLoginBinding(project, options);
62
+ const apiUrl = binding?.apiUrl ?? options.apiUrl ?? DEFAULT_HOSTED_API_URL;
63
+ if (binding && options.apiUrl && normalizeApiUrl(options.apiUrl) !== normalizeApiUrl(binding.apiUrl)) {
64
+ throw new Error(
65
+ `--api-url ${options.apiUrl} does not match the bound hosted URL ${binding.apiUrl}${binding.target ? ` for target ${binding.target}` : ''}. `
66
+ + 'Pass --sync-target <name> to sign in to a different environment, or rebind with connect-hosted.',
67
+ );
68
+ }
69
+ const projectHint = binding?.projectId ?? options.projectId ?? null;
70
+ const credentialEnvVar = binding?.credentialEnvVar ?? options.credentialEnvVar ?? DEFAULT_SYNC_CREDENTIAL_ENV_VAR;
71
+
72
+ // Every network wait (start, each poll, and body reads) is bounded by the
73
+ // overall deadline so a stalled server can never outlive --poll-timeout.
74
+ const requestedTimeoutSeconds = positiveNumber(options.pollTimeoutSeconds, DEFAULT_POLL_TIMEOUT_SECONDS);
75
+ let deadline = now() + requestedTimeoutSeconds * 1000;
76
+ const timing = { now, deadline: () => deadline };
77
+
78
+ const start = await postJson(fetchImpl, apiUrl, DEVICE_AUTH_START_ROUTE, {
79
+ ...(projectHint ? { project_id: projectHint } : {}),
80
+ label: options.label ?? os.hostname(),
81
+ package_version: PACKAGE_VERSION,
82
+ }, timing);
83
+ if (!start.ok) {
84
+ throw new Error(`Hosted sign-in could not start (HTTP ${start.status}${start.body?.error ? `: ${start.body.error}` : ''}). Check the hosted URL ${apiUrl} and try again.`);
85
+ }
86
+ const deviceCode = requireString(start.body, 'device_code');
87
+ const userCode = requireString(start.body, 'user_code');
88
+ const verificationUri = requireString(start.body, 'verification_uri_complete', 'verification_uri');
89
+ const expiresIn = positiveNumber(start.body.expires_in, DEFAULT_POLL_TIMEOUT_SECONDS);
90
+ let intervalMs = positiveNumber(start.body.interval, DEFAULT_POLL_INTERVAL_SECONDS) * 1000;
91
+
92
+ write('Sign in to VibeCompass\n');
93
+ write(`Open: ${verificationUri}\n`);
94
+ write(`Code: ${userCode}\n`);
95
+ if (!options.noBrowser) {
96
+ const opened = await openBrowser(verificationUri, environment);
97
+ write(opened ? 'Opening your browser…\n' : 'Could not open a browser automatically; paste the link into any browser.\n');
98
+ }
99
+ write(`Waiting for approval in the dashboard (expires in ${Math.round(expiresIn / 60)} minutes)…\n`);
100
+
101
+ deadline = Math.min(deadline, now() + expiresIn * 1000);
102
+ let claim = null;
103
+ while (!claim) {
104
+ const remaining = deadline - now();
105
+ if (remaining <= 0) {
106
+ throw new Error('Timed out waiting for the sign-in to be approved. Run "vibecompass login" again.');
107
+ }
108
+ await sleep(Math.min(intervalMs, remaining));
109
+ const poll = await postJson(fetchImpl, apiUrl, DEVICE_AUTH_TOKEN_ROUTE, { device_code: deviceCode }, timing);
110
+ if (poll.ok && poll.body?.status === 'approved') {
111
+ claim = poll.body;
112
+ break;
113
+ }
114
+ const code = typeof poll.body?.error === 'string' ? poll.body.error : null;
115
+ if (code === 'authorization_pending') {
116
+ continue;
117
+ }
118
+ if (code === 'slow_down') {
119
+ intervalMs += 5000;
120
+ continue;
121
+ }
122
+ if (code === 'expired_token') {
123
+ throw new Error('The sign-in code expired before it was approved. Run "vibecompass login" again.');
124
+ }
125
+ if (code === 'access_denied') {
126
+ throw new Error('The sign-in request was denied in the dashboard.');
127
+ }
128
+ throw new Error(`Hosted sign-in failed (HTTP ${poll.status}${code ? `: ${code}` : ''}).`);
129
+ }
130
+
131
+ const token = requireString(claim, 'token');
132
+ const approvedProject = claim.project && typeof claim.project === 'object' ? claim.project : {};
133
+ const projectId = requireString(approvedProject, 'id');
134
+ const projectName = typeof approvedProject.name === 'string' ? approvedProject.name : projectId;
135
+
136
+ const stored = await storeSyncCredential(
137
+ {
138
+ apiUrl,
139
+ projectId,
140
+ token,
141
+ label: `VibeCompass sync token (${projectName})`,
142
+ source: 'login',
143
+ backend: options.credentialStore,
144
+ },
145
+ credentialStoreOptionsFrom(environment),
146
+ );
147
+
148
+ const warnings = [];
149
+ let bindingWritten = null;
150
+ if (!binding) {
151
+ bindingWritten = await connectHostedProjectMemory({
152
+ cwd,
153
+ rootDir: options.rootDir ?? '.compass',
154
+ ...(options.syncTarget ? { targetName: options.syncTarget } : {}),
155
+ sync: { apiUrl, projectId, credentialEnvVar },
156
+ });
157
+ } else if (binding.projectId !== projectId) {
158
+ warnings.push(
159
+ `This root is bound to project ${binding.projectId}${binding.target ? ` (target ${binding.target})` : ''}, but you approved ${projectName} (${projectId}). `
160
+ + `The token is stored for the approved project; rebind with "vibecompass connect-hosted${binding.target ? ` --target ${binding.target}` : ''} --sync-api-url ${apiUrl} --sync-project-id ${projectId} --sync-credential-env-var ${credentialEnvVar}" if that was intended.`,
161
+ );
162
+ }
163
+
164
+ return {
165
+ apiUrl,
166
+ project: { id: projectId, name: projectName },
167
+ credential: claim.credential && typeof claim.credential === 'object' ? claim.credential : null,
168
+ stored,
169
+ binding: bindingWritten,
170
+ previousBinding: binding,
171
+ mode: bindingWritten?.mode ?? project.mode,
172
+ warnings,
173
+ };
174
+ }
175
+
176
+ function resolveLoginBinding(project, options) {
177
+ try {
178
+ return resolveSyncBinding(project, options.syncTarget ?? null);
179
+ } catch (error) {
180
+ // An unknown --sync-target may be created by login only when the caller
181
+ // is explicit about which hosted environment it points at (D-236: a typo
182
+ // must never silently land in another environment).
183
+ if (options.syncTarget && options.apiUrl) {
184
+ return null;
185
+ }
186
+ throw new Error(`${error.message} To create it through login, add --api-url <hosted url>.`);
187
+ }
188
+ }
189
+
190
+ async function postJson(fetchImpl, apiUrl, routeName, body, timing) {
191
+ const endpoint = new URL(routeName, apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`);
192
+ const remaining = timing ? timing.deadline() - timing.now() : REQUEST_TIMEOUT_MS;
193
+ if (remaining <= 0) {
194
+ throw new Error('Timed out waiting for the sign-in to be approved. Run "vibecompass login" again.');
195
+ }
196
+ const controller = new AbortController();
197
+ const timer = setTimeout(() => controller.abort(), Math.min(REQUEST_TIMEOUT_MS, remaining));
198
+ try {
199
+ let response;
200
+ try {
201
+ response = await fetchImpl(endpoint.href, {
202
+ method: 'POST',
203
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
204
+ body: JSON.stringify(body),
205
+ signal: controller.signal,
206
+ });
207
+ } catch (error) {
208
+ if (controller.signal.aborted) {
209
+ throw new Error(`Hosted sign-in request to ${endpoint.origin} timed out. Check the connection and run "vibecompass login" again.`);
210
+ }
211
+ throw new Error(`Could not reach ${endpoint.origin} (${error?.message ?? 'network error'}).`);
212
+ }
213
+ let parsed = null;
214
+ try {
215
+ parsed = typeof response.json === 'function' ? await response.json() : null;
216
+ } catch {
217
+ if (controller.signal.aborted) {
218
+ throw new Error(`Hosted sign-in response from ${endpoint.origin} stalled. Check the connection and run "vibecompass login" again.`);
219
+ }
220
+ parsed = null;
221
+ }
222
+ return { ok: Boolean(response.ok), status: response.status, body: parsed && typeof parsed === 'object' ? parsed : {} };
223
+ } finally {
224
+ clearTimeout(timer);
225
+ }
226
+ }
227
+
228
+ async function openBrowser(url, environment) {
229
+ const platform = environment.runtime?.platform ?? process.platform;
230
+ const execFileImpl = environment.runtime?.execFile ?? nodeExecFile;
231
+ const [file, args] = platform === 'darwin'
232
+ ? ['open', [url]]
233
+ : platform === 'win32'
234
+ ? ['cmd', ['/c', 'start', '', url]]
235
+ : ['xdg-open', [url]];
236
+ return new Promise((resolve) => {
237
+ try {
238
+ execFileImpl(file, args, { windowsHide: true }, (error) => resolve(!error));
239
+ } catch {
240
+ resolve(false);
241
+ }
242
+ });
243
+ }
244
+
245
+ function requireString(source, ...keys) {
246
+ for (const key of keys) {
247
+ const value = source?.[key];
248
+ if (typeof value === 'string' && value.trim() !== '') {
249
+ return value.trim();
250
+ }
251
+ }
252
+ throw new Error(`Hosted sign-in response is missing ${keys[0]}.`);
253
+ }
254
+
255
+ function positiveNumber(value, fallback) {
256
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
257
+ }
@@ -2,6 +2,11 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { parseSimpleYaml } from './simple-yaml.js';
4
4
  import { resolveSyncBinding } from './sync-binding.js';
5
+ import {
6
+ credentialStoreOptionsFrom,
7
+ formatMissingCredentialError,
8
+ resolveSyncCredential,
9
+ } from './credential-store.js';
5
10
  import { withMemoryRootLock } from './serialization.js';
6
11
  import { adoptRemoteHead, pushProjectMemory } from './sync.js';
7
12
 
@@ -186,9 +191,10 @@ async function loadModeAgnosticSyncContext(rootDir, options, environment) {
186
191
  if (!binding) {
187
192
  throw new Error('Mode transitions require a sync binding in project.yaml (run connect-hosted first).');
188
193
  }
189
- const credential = ((environment.env ?? process.env)[binding.credentialEnvVar] ?? '').trim();
194
+ const resolution = await resolveSyncCredential(binding, credentialStoreOptionsFrom(environment));
195
+ const credential = resolution.credential;
190
196
  if (!credential) {
191
- throw new Error(`Mode transitions require ${binding.credentialEnvVar} to be set.`);
197
+ throw new Error(formatMissingCredentialError(binding, { action: 'This mode transition', detail: resolution.detail }));
192
198
  }
193
199
  const fetchImpl = environment.runtime?.fetch ?? globalThis.fetch;
194
200
  if (typeof fetchImpl !== 'function') {
@@ -1,5 +1,6 @@
1
1
  import { readFile, readdir } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { validateBriefSettings } from './brief-settings.js';
3
4
  import { parseFrontmatter } from './frontmatter.js';
4
5
  import { sha256Text } from './hash.js';
5
6
  import { parseSimpleYaml } from './simple-yaml.js';
@@ -44,6 +45,7 @@ const KNOWN_PROJECT_FIELDS = new Set([
44
45
  'default_branch',
45
46
  'sync',
46
47
  'runtime',
48
+ 'brief',
47
49
  'metadata',
48
50
  ]);
49
51
  // D-282 runtime-isolation settings; deep value validation (ranges, absolute
@@ -73,9 +75,16 @@ const SECRET_BEARING_SYNC_FIELDS = [
73
75
  'access_token',
74
76
  ];
75
77
 
76
- export async function scanProjectMemory(rootDir) {
78
+ /**
79
+ * Scans a memory root's canonical documents. `options.exclude` is an optional
80
+ * predicate over root-relative paths (D-364, session brief exclusions): an
81
+ * excluded markdown file is dropped from the directory walk before it is
82
+ * opened, so it is never read. `project.yaml` is always read.
83
+ */
84
+ export async function scanProjectMemory(rootDir, options = {}) {
77
85
  const errors = [];
78
86
  const documents = [];
87
+ const isExcluded = typeof options.exclude === 'function' ? options.exclude : () => false;
79
88
 
80
89
  const projectPath = path.join(rootDir, PROJECT_FILE);
81
90
  const projectDocument = await parseProjectDocument(rootDir, projectPath);
@@ -87,7 +96,8 @@ export async function scanProjectMemory(rootDir) {
87
96
  const architectureDocuments = await parseMarkdownTree({
88
97
  rootDir,
89
98
  directoryName: 'architecture',
90
- shouldInclude: (filename) => !NON_CANONICAL_ARCHITECTURE_FILES.has(filename),
99
+ shouldInclude: (filename, relativePath) =>
100
+ !NON_CANONICAL_ARCHITECTURE_FILES.has(filename) && !isExcluded(relativePath),
91
101
  parseDocument: (documentPath, relativePath, content) =>
92
102
  parseArchitectureDocument({
93
103
  content,
@@ -99,7 +109,8 @@ export async function scanProjectMemory(rootDir) {
99
109
  const decisionDocuments = await parseMarkdownTree({
100
110
  rootDir,
101
111
  directoryName: 'decisions',
102
- shouldInclude: (filename) => !NON_CANONICAL_DECISION_FILES.has(filename),
112
+ shouldInclude: (filename, relativePath) =>
113
+ !NON_CANONICAL_DECISION_FILES.has(filename) && !isExcluded(relativePath),
103
114
  parseDocument: (documentPath, relativePath, content) =>
104
115
  parseDecisionDocument({
105
116
  content,
@@ -115,7 +126,9 @@ export async function scanProjectMemory(rootDir) {
115
126
  // fail-close the whole scan as a malformed session note. The filename set
116
127
  // still covers the legacy root-level wip.md/handoff.md.
117
128
  shouldInclude: (filename, relativePath) =>
118
- !NON_CANONICAL_SESSION_FILES.has(filename) && !relativePath.startsWith('sessions/active/'),
129
+ !NON_CANONICAL_SESSION_FILES.has(filename) &&
130
+ !relativePath.startsWith('sessions/active/') &&
131
+ !isExcluded(relativePath),
119
132
  parseDocument: (documentPath, relativePath, content) =>
120
133
  parseSessionDocument({
121
134
  content,
@@ -371,6 +384,19 @@ function validateProjectData(data, relativePath) {
371
384
  }
372
385
  }
373
386
 
387
+ // D-364 session brief settings; the brief fails closed on any problem, so
388
+ // each one is surfaced here rather than silently ignored.
389
+ if (data.brief !== undefined) {
390
+ for (const problem of validateBriefSettings(data.brief).problems) {
391
+ warnings.push(
392
+ createWarning(
393
+ 'project-brief-invalid',
394
+ `project.yaml brief settings: ${problem}. Session briefs read no canonical document until this is fixed (D-364).`,
395
+ ),
396
+ );
397
+ }
398
+ }
399
+
374
400
  if (data.metadata !== undefined && JSON.stringify(data.metadata).length > 500) {
375
401
  warnings.push(
376
402
  createWarning(
package/src/read-model.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { buildDecisionLineageModel, collectDeclaredSuccessors } from './decision-lineage.js';
3
4
  import { parseFrontmatter } from './frontmatter.js';
4
5
  import { generateStateManifest } from './manifest.js';
5
6
  import { scanProjectMemory } from './project-memory.js';
@@ -8,7 +9,19 @@ const SECTION_PATTERN = /^##\s+(.+)$/gm;
8
9
  const DECISION_HEADING_PATTERN = /^###\s+D-(\d{3,})\s+—\s+(.+)$/gm;
9
10
 
10
11
  export async function loadProjectReadModel(rootDir) {
11
- const scanResult = await scanProjectMemory(rootDir);
12
+ return (await loadProjectReadModelWithDocuments(rootDir)).readModel;
13
+ }
14
+
15
+ /**
16
+ * The read model plus the scanned canonical documents (`{ path, kind,
17
+ * content, … }`) it was built from, for read-only consumers such as the
18
+ * session brief that need section text the read model does not keep.
19
+ * `options.exclude` (a root-relative path predicate) keeps excluded documents
20
+ * out of the scan before they are read (D-364); `loadProjectReadModel` never
21
+ * excludes.
22
+ */
23
+ export async function loadProjectReadModelWithDocuments(rootDir, options = {}) {
24
+ const scanResult = await scanProjectMemory(rootDir, { exclude: options.exclude });
12
25
  if (scanResult.errors.length > 0) {
13
26
  const details = scanResult.errors.map((error) => `${error.path}: ${error.message}`).join('\n');
14
27
  throw new Error(`Cannot build read model with canonical parse errors.\n${details}`);
@@ -35,8 +48,9 @@ export async function loadProjectReadModel(rootDir) {
35
48
  const decisions = buildDecisionList(scanResult);
36
49
  const sessions = buildSessionList(scanResult);
37
50
  const fileOwners = buildFileOwnershipIndex(features);
51
+ const decisionLineage = buildDecisionLineageModel(scanResult.documents);
38
52
 
39
- return {
53
+ const readModel = {
40
54
  freshness,
41
55
  repo_aliases: repoAliases,
42
56
  manifest_state: {
@@ -51,7 +65,10 @@ export async function loadProjectReadModel(rootDir) {
51
65
  decisions,
52
66
  sessions,
53
67
  file_owners: fileOwners,
68
+ decision_lineage: decisionLineage,
54
69
  };
70
+
71
+ return { readModel, documents: scanResult.documents };
55
72
  }
56
73
 
57
74
  export function getProjectContext(readModel, options = {}) {
@@ -90,6 +107,50 @@ export function getDecisionLog(readModel, options = {}) {
90
107
  };
91
108
  }
92
109
 
110
+ /**
111
+ * Typed lineage for one decision (plan task A2; D-359). `outgoing` is what
112
+ * this decision's own text declares about earlier decisions, `incoming` is
113
+ * what later decisions declare about it, and `declared_successors` follows
114
+ * declared `supersedes`/`amends` transitively. Mentions stay mentions: no
115
+ * relation here certifies that a decision governs anything or is currently
116
+ * valid.
117
+ */
118
+ export function getDecisionLineage(readModel, decisionId, options = {}) {
119
+ const id = typeof decisionId === 'number' ? decisionId : Number(String(decisionId ?? '').replace(/^D-/i, ''));
120
+ if (!Number.isInteger(id) || id <= 0) {
121
+ return null;
122
+ }
123
+
124
+ const citationLimit = Math.max(1, Math.min(options.citationLimit ?? 50, 500));
125
+ const relations = readModel.decision_lineage.relations;
126
+ const decision = readModel.decisions.find((entry) => entry.decision_id === id) ?? null;
127
+ const lineageRelations = new Set(['supersedes', 'amends', 'preserves', 'unknown']);
128
+ const outgoing = relations.filter(
129
+ (relation) => relation.source_decision_id === id && lineageRelations.has(relation.relation),
130
+ );
131
+ const incoming = relations.filter(
132
+ (relation) =>
133
+ relation.target_decision_id === id && relation.source_kind === 'decision' && lineageRelations.has(relation.relation),
134
+ );
135
+ const mentions = relations.filter(
136
+ (relation) => relation.target_decision_id === id && ['cites', 'references', 'made'].includes(relation.relation),
137
+ );
138
+
139
+ return {
140
+ freshness: readModel.freshness,
141
+ decision_id: id,
142
+ exists: Boolean(decision),
143
+ title: decision?.title ?? null,
144
+ path: decision?.path ?? null,
145
+ outgoing,
146
+ incoming,
147
+ declared_successors: collectDeclaredSuccessors(relations, id),
148
+ made_in: mentions.filter((relation) => relation.relation === 'made').map((relation) => relation.source_path),
149
+ cited_by: mentions.filter((relation) => relation.relation !== 'made').slice(0, citationLimit),
150
+ cited_by_total: mentions.filter((relation) => relation.relation !== 'made').length,
151
+ };
152
+ }
153
+
93
154
  export function getFileContext(readModel, filepath) {
94
155
  const normalizedPath = normalizeLookupPath(filepath, readModel.repo_aliases, readModel.project.repos);
95
156
  const owners = readModel.file_owners.get(normalizedPath) ?? [];
@@ -194,6 +255,8 @@ function buildComponentRecord(document, repoAliases, projectRepos) {
194
255
  description: normalizeSectionText(sections.get('Description')),
195
256
  details: normalizeSectionText(sections.get('Details')),
196
257
  next_steps: normalizeSectionText(sections.get('Next steps')),
258
+ retrieval_guidance: normalizeSectionText(sections.get('Retrieval guidance')),
259
+ retrieval_scope: extractReviewMetadataField(sections.get('Review metadata'), 'Retrieval scope'),
197
260
  involved_files: involvedFiles,
198
261
  warnings: document.warnings,
199
262
  warning_count: document.warnings.length,
@@ -529,6 +592,33 @@ function normalizeSectionText(value) {
529
592
  return value ? value.trim() : null;
530
593
  }
531
594
 
595
+ // One bullet from the `## Review metadata` list ("- Retrieval scope: …"),
596
+ // including indented continuation lines; null when the section or bullet is
597
+ // missing or empty.
598
+ function extractReviewMetadataField(section, label) {
599
+ if (!section) {
600
+ return null;
601
+ }
602
+
603
+ const lines = section.split(/\r?\n/);
604
+ const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
605
+ const start = lines.findIndex((line) => new RegExp(`^\\s*[-*]\\s+${escaped}:`, 'i').test(line));
606
+ if (start === -1) {
607
+ return null;
608
+ }
609
+
610
+ const parts = [lines[start].replace(new RegExp(`^\\s*[-*]\\s+${escaped}:\\s*`, 'i'), '')];
611
+ for (const line of lines.slice(start + 1)) {
612
+ if (!/^\s+\S/.test(line) || /^\s*[-*]\s+/.test(line)) {
613
+ break;
614
+ }
615
+ parts.push(line.trim());
616
+ }
617
+
618
+ const value = parts.join(' ').trim();
619
+ return value || null;
620
+ }
621
+
532
622
  function extractLabeledValue(body, label) {
533
623
  const pattern = new RegExp(`\\*\\*${label}:\\*\\*\\s+(.+)`);
534
624
  return body.match(pattern)?.[1]?.trim() ?? null;