@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.
- package/CHANGELOG.md +183 -0
- package/README.md +111 -8
- package/package.json +1 -1
- package/src/brief-keywords.js +203 -0
- package/src/brief-lifecycle.js +638 -0
- package/src/brief-render.js +341 -0
- package/src/brief-settings.js +160 -0
- package/src/brief.js +1557 -0
- package/src/cli.js +731 -7
- package/src/credential-store.js +856 -0
- package/src/decision-lineage.js +1717 -0
- package/src/docs-review.js +28 -4
- package/src/generators/agent-files/template.js +2 -1
- package/src/index.js +44 -0
- package/src/login.js +257 -0
- package/src/mode-transition.js +8 -2
- package/src/project-memory.js +30 -4
- package/src/read-model.js +92 -2
- package/src/scaffold.js +10 -2
- package/src/secret-prompt.js +64 -0
- package/src/session.js +30 -0
- package/src/status.js +54 -9
- package/src/sync.js +12 -4
package/src/docs-review.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|
package/src/mode-transition.js
CHANGED
|
@@ -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
|
|
194
|
+
const resolution = await resolveSyncCredential(binding, credentialStoreOptionsFrom(environment));
|
|
195
|
+
const credential = resolution.credential;
|
|
190
196
|
if (!credential) {
|
|
191
|
-
throw new Error(
|
|
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') {
|
package/src/project-memory.js
CHANGED
|
@@ -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
|
-
|
|
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) =>
|
|
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) =>
|
|
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) &&
|
|
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
|
-
|
|
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
|
-
|
|
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;
|