@vibecompass/vibecompass 0.3.3 → 0.4.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/README.md +185 -11
- package/package.json +1 -1
- package/src/cli.js +288 -0
- package/src/compatibility.js +308 -0
- package/src/generators/agent-files/index.js +57 -1
- package/src/generators/agent-files/markers.js +10 -1
- package/src/generators/agent-files/template.js +4 -0
- package/src/index.js +5 -1
- package/src/init.js +42 -2
- package/src/manifest.js +16 -4
- package/src/read-model.js +17 -1
- package/src/refresh-workflow.js +233 -0
- package/src/scaffold.js +38 -18
- package/src/setup.js +336 -0
- package/src/status.js +350 -0
- package/src/version.js +6 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { inspectProjectCompatibility } from './compatibility.js';
|
|
4
|
+
import { prepareStateManifest, writeStateManifest } from './manifest.js';
|
|
5
|
+
import { serializeProjectConfig } from './project-yaml.js';
|
|
6
|
+
import { buildWorkflowScaffoldFiles } from './scaffold.js';
|
|
7
|
+
import { parseSimpleYaml } from './simple-yaml.js';
|
|
8
|
+
import { syncAgentInstructionFiles } from './generators/agent-files/index.js';
|
|
9
|
+
import { PACKAGE_VERSION } from './version.js';
|
|
10
|
+
|
|
11
|
+
export async function refreshWorkflow(options = {}) {
|
|
12
|
+
const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd();
|
|
13
|
+
const rootDir = path.resolve(cwd, options.rootDir ?? '.compass');
|
|
14
|
+
const toolingRootDir = options.toolingRootDir
|
|
15
|
+
? path.resolve(cwd, options.toolingRootDir)
|
|
16
|
+
: cwd;
|
|
17
|
+
const apply = Boolean(options.apply);
|
|
18
|
+
const dryRun = !apply;
|
|
19
|
+
|
|
20
|
+
const compatibility = await inspectProjectCompatibility({ rootDir, cwd });
|
|
21
|
+
if (apply && compatibility.package.status === 'ahead' && !options.allowDowngradeTemplates) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
'This root was refreshed by a newer VibeCompass package. Pass --allow-downgrade-templates to apply older templates with this CLI.',
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const projectFilePath = path.join(rootDir, 'project.yaml');
|
|
28
|
+
const projectConfig = await readProjectConfig(projectFilePath);
|
|
29
|
+
const rootRelativePath = toPosix(path.relative(toolingRootDir, rootDir) || '.');
|
|
30
|
+
const contextRelativeToToolingRoot = toPosix(
|
|
31
|
+
path.relative(toolingRootDir, path.join(rootDir, 'context.md')) || 'context.md',
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const workflowFiles = await planWorkflowFiles({
|
|
35
|
+
rootDir,
|
|
36
|
+
toolingRootDir,
|
|
37
|
+
projectConfig,
|
|
38
|
+
rootRelativePath,
|
|
39
|
+
contextRelativeToToolingRoot,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (apply) {
|
|
43
|
+
await applyWorkflowFiles(workflowFiles);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const projectStamp = planProjectStamp(projectConfig, compatibility, {
|
|
47
|
+
updatePackageStamp: Boolean(options.updatePackageStamp),
|
|
48
|
+
});
|
|
49
|
+
if (apply && projectStamp.changed) {
|
|
50
|
+
await mkdir(path.dirname(projectFilePath), { recursive: true });
|
|
51
|
+
await writeFile(projectFilePath, serializeProjectConfig(projectStamp.projectConfig), 'utf8');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const manifest = apply
|
|
55
|
+
? await writeStateManifest(rootDir)
|
|
56
|
+
: await prepareStateManifest(rootDir);
|
|
57
|
+
|
|
58
|
+
const agentFileSync = await syncAgentInstructionFiles({
|
|
59
|
+
rootDir,
|
|
60
|
+
toolingRootDir,
|
|
61
|
+
dryRun,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
rootDir,
|
|
66
|
+
toolingRootDir,
|
|
67
|
+
dryRun,
|
|
68
|
+
applied: apply,
|
|
69
|
+
compatibility,
|
|
70
|
+
projectFile: {
|
|
71
|
+
path: projectFilePath,
|
|
72
|
+
status: projectStamp.status,
|
|
73
|
+
changed: projectStamp.changed,
|
|
74
|
+
packageVersion: projectStamp.packageVersion,
|
|
75
|
+
warning: projectStamp.warning,
|
|
76
|
+
},
|
|
77
|
+
workflowFiles: workflowFiles.map((file) => ({
|
|
78
|
+
kind: file.kind,
|
|
79
|
+
path: file.path,
|
|
80
|
+
relativePath: toPosix(path.relative(rootDir, file.path)),
|
|
81
|
+
status: file.status,
|
|
82
|
+
changed: file.changed,
|
|
83
|
+
warning: file.warning,
|
|
84
|
+
})),
|
|
85
|
+
manifest: {
|
|
86
|
+
path: manifest.manifestPath,
|
|
87
|
+
status: apply ? 'updated' : 'planned-update',
|
|
88
|
+
documentCount: manifest.manifest.canonical.document_count,
|
|
89
|
+
warningCount: manifest.manifest.canonical.warning_count,
|
|
90
|
+
},
|
|
91
|
+
agentFileSync,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function readProjectConfig(projectFilePath) {
|
|
96
|
+
try {
|
|
97
|
+
return parseSimpleYaml(await readFile(projectFilePath, 'utf8'), {
|
|
98
|
+
sourceName: projectFilePath,
|
|
99
|
+
});
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
|
102
|
+
throw new Error(`No project.yaml found in ${path.dirname(projectFilePath)}. Run "vibecompass init" first.`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function planWorkflowFiles(options) {
|
|
110
|
+
const scaffold = buildWorkflowScaffoldFiles(options);
|
|
111
|
+
const files = [];
|
|
112
|
+
|
|
113
|
+
for (const file of scaffold.files) {
|
|
114
|
+
const existingContent = await readExistingFile(file.path);
|
|
115
|
+
const safety = resolveWorkflowFileSafety(file, existingContent);
|
|
116
|
+
const changed = safety.status !== 'skipped' && existingContent !== file.content;
|
|
117
|
+
files.push({
|
|
118
|
+
...file,
|
|
119
|
+
existingContent,
|
|
120
|
+
status: changed ? (existingContent === null ? 'create' : 'update') : safety.status,
|
|
121
|
+
changed,
|
|
122
|
+
warning: safety.warning,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return files;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function resolveWorkflowFileSafety(file, existingContent) {
|
|
130
|
+
if (existingContent === null) {
|
|
131
|
+
return { status: 'create', warning: null };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (existingContent === file.content) {
|
|
135
|
+
return { status: 'unchanged', warning: null };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (file.kind === 'context') {
|
|
139
|
+
return { status: 'update', warning: null };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (isGeneratedWorkflowGuide(file.kind, existingContent)) {
|
|
143
|
+
return { status: 'update', warning: null };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
status: 'skipped',
|
|
148
|
+
warning: 'Existing workflow guide does not match a VibeCompass-generated guide shape; left untouched.',
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isGeneratedWorkflowGuide(kind, content) {
|
|
153
|
+
// These sentinel strings are the safety boundary for refreshing guide files.
|
|
154
|
+
// Keep them in lockstep with the scaffold guide templates.
|
|
155
|
+
if (kind === 'architecture-guide') {
|
|
156
|
+
return content.includes('Each component doc is canonical. This README is only a convenience guide.');
|
|
157
|
+
}
|
|
158
|
+
if (kind === 'decisions-guide') {
|
|
159
|
+
return content.includes('This README is guidance only; canonical decision content lives in the domain files.');
|
|
160
|
+
}
|
|
161
|
+
if (kind === 'sessions-guide') {
|
|
162
|
+
return content.includes('Those scratch files are session-scoped working artifacts, not finalized history.');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Refresh writes are intentionally non-atomic, matching init/scaffold behavior.
|
|
169
|
+
async function applyWorkflowFiles(files) {
|
|
170
|
+
for (const file of files) {
|
|
171
|
+
if (!file.changed || file.status === 'skipped') {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
await mkdir(path.dirname(file.path), { recursive: true });
|
|
176
|
+
await writeFile(file.path, file.content, 'utf8');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function planProjectStamp(projectConfig, compatibility, options) {
|
|
181
|
+
const currentMetadata = projectConfig.metadata && typeof projectConfig.metadata === 'object'
|
|
182
|
+
? projectConfig.metadata
|
|
183
|
+
: {};
|
|
184
|
+
const currentStamp = currentMetadata.package_version;
|
|
185
|
+
const hasStringStamp = typeof currentStamp === 'string' && currentStamp.trim() !== '';
|
|
186
|
+
const missingStamp = currentStamp === undefined || currentStamp === null || currentStamp === '';
|
|
187
|
+
const shouldStamp = missingStamp || Boolean(options.updatePackageStamp);
|
|
188
|
+
|
|
189
|
+
if (!shouldStamp) {
|
|
190
|
+
return {
|
|
191
|
+
projectConfig,
|
|
192
|
+
status: 'unchanged',
|
|
193
|
+
changed: false,
|
|
194
|
+
packageVersion: hasStringStamp ? currentStamp : null,
|
|
195
|
+
warning: ['behind', 'invalid'].includes(compatibility.package.status)
|
|
196
|
+
? 'Existing package stamp left untouched; pass --update-package-stamp to update it.'
|
|
197
|
+
: null,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const nextConfig = {
|
|
202
|
+
...projectConfig,
|
|
203
|
+
metadata: {
|
|
204
|
+
...currentMetadata,
|
|
205
|
+
package_version: PACKAGE_VERSION,
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
const changed = currentStamp !== PACKAGE_VERSION;
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
projectConfig: nextConfig,
|
|
212
|
+
status: changed ? (missingStamp ? 'stamp-create' : 'stamp-update') : 'unchanged',
|
|
213
|
+
changed,
|
|
214
|
+
packageVersion: PACKAGE_VERSION,
|
|
215
|
+
warning: null,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function readExistingFile(filePath) {
|
|
220
|
+
try {
|
|
221
|
+
return await readFile(filePath, 'utf8');
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function toPosix(value) {
|
|
232
|
+
return value.split(path.sep).join('/');
|
|
233
|
+
}
|
package/src/scaffold.js
CHANGED
|
@@ -53,27 +53,13 @@ export async function scaffoldInitFiles(options) {
|
|
|
53
53
|
};
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
const
|
|
56
|
+
const workflowScaffold = buildWorkflowScaffoldFiles(options);
|
|
57
|
+
const contextFilePath = workflowScaffold.contextFilePath;
|
|
57
58
|
// context.md is a derived artifact owned by the package; regenerate it on rerun.
|
|
58
|
-
await writeFile(contextFilePath,
|
|
59
|
+
await writeFile(contextFilePath, workflowScaffold.context.content, 'utf8');
|
|
59
60
|
createdFiles.push(contextFilePath);
|
|
60
61
|
|
|
61
|
-
const
|
|
62
|
-
{
|
|
63
|
-
path: path.join(options.rootDir, 'architecture', 'README.md'),
|
|
64
|
-
content: generateArchitectureGuide(options.projectConfig),
|
|
65
|
-
},
|
|
66
|
-
{
|
|
67
|
-
path: path.join(options.rootDir, 'decisions', 'README.md'),
|
|
68
|
-
content: generateDecisionsGuide(options.projectConfig),
|
|
69
|
-
},
|
|
70
|
-
{
|
|
71
|
-
path: path.join(options.rootDir, 'sessions', 'README.md'),
|
|
72
|
-
content: generateSessionsGuide(options.projectConfig),
|
|
73
|
-
},
|
|
74
|
-
];
|
|
75
|
-
|
|
76
|
-
for (const file of workflowFiles) {
|
|
62
|
+
for (const file of workflowScaffold.guides) {
|
|
77
63
|
const outcome = await writeIfMissing(file.path, file.content);
|
|
78
64
|
if (outcome.created) {
|
|
79
65
|
createdFiles.push(file.path);
|
|
@@ -113,6 +99,40 @@ export async function scaffoldInitFiles(options) {
|
|
|
113
99
|
};
|
|
114
100
|
}
|
|
115
101
|
|
|
102
|
+
export function buildWorkflowScaffoldFiles(options) {
|
|
103
|
+
const contextFilePath = path.join(options.rootDir, 'context.md');
|
|
104
|
+
const context = {
|
|
105
|
+
kind: 'context',
|
|
106
|
+
path: contextFilePath,
|
|
107
|
+
content: generateContextMarkdown(options),
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const guides = [
|
|
111
|
+
{
|
|
112
|
+
kind: 'architecture-guide',
|
|
113
|
+
path: path.join(options.rootDir, 'architecture', 'README.md'),
|
|
114
|
+
content: generateArchitectureGuide(options.projectConfig),
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
kind: 'decisions-guide',
|
|
118
|
+
path: path.join(options.rootDir, 'decisions', 'README.md'),
|
|
119
|
+
content: generateDecisionsGuide(options.projectConfig),
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
kind: 'sessions-guide',
|
|
123
|
+
path: path.join(options.rootDir, 'sessions', 'README.md'),
|
|
124
|
+
content: generateSessionsGuide(options.projectConfig),
|
|
125
|
+
},
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
contextFilePath,
|
|
130
|
+
context,
|
|
131
|
+
guides,
|
|
132
|
+
files: [context, ...guides],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
116
136
|
async function writeIfMissing(filePath, content) {
|
|
117
137
|
if (await fileExists(filePath)) {
|
|
118
138
|
return { created: false };
|
package/src/setup.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { access, readFile, readdir, realpath } from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { createInterface } from 'node:readline/promises';
|
|
5
6
|
import { promisify } from 'node:util';
|
|
7
|
+
import { getSupportedAgentFilePaths } from './generators/agent-files/index.js';
|
|
8
|
+
import { END_MARKER, START_MARKER } from './generators/agent-files/markers.js';
|
|
9
|
+
import { parseSimpleYaml } from './simple-yaml.js';
|
|
6
10
|
|
|
7
11
|
const execFileAsync = promisify(execFile);
|
|
8
12
|
|
|
@@ -20,6 +24,24 @@ export async function resolveInitCliOptions(options, environment = {}) {
|
|
|
20
24
|
let guidedSummary = null;
|
|
21
25
|
|
|
22
26
|
if (resolved.guided) {
|
|
27
|
+
if (!resolved.force) {
|
|
28
|
+
const existingProject = await resolveExistingGuidedProject(resolved, {
|
|
29
|
+
cwd,
|
|
30
|
+
io: environment.io,
|
|
31
|
+
runtime: environment.runtime,
|
|
32
|
+
});
|
|
33
|
+
if (existingProject) {
|
|
34
|
+
return {
|
|
35
|
+
existingProject,
|
|
36
|
+
initOptions: null,
|
|
37
|
+
placementPattern: null,
|
|
38
|
+
guidedSummary: null,
|
|
39
|
+
agentFileSyncPlan: null,
|
|
40
|
+
sessionPlan: null,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
23
45
|
const prompter = createPrompter(environment.io, environment.runtime);
|
|
24
46
|
try {
|
|
25
47
|
const guided = await completeGuidedInitOptions(resolved, {
|
|
@@ -58,6 +80,7 @@ export async function resolveInitCliOptions(options, environment = {}) {
|
|
|
58
80
|
mode: resolved.mode,
|
|
59
81
|
repos: resolved.repos,
|
|
60
82
|
force: resolved.force,
|
|
83
|
+
replaceActiveLanes: resolved.replaceActiveLanes,
|
|
61
84
|
sync: resolved.sync,
|
|
62
85
|
bootstrap: resolved.bootstrap,
|
|
63
86
|
generatedAt: resolved.generatedAt,
|
|
@@ -68,6 +91,14 @@ export async function resolveInitCliOptions(options, environment = {}) {
|
|
|
68
91
|
},
|
|
69
92
|
placementPattern: resolved.placementPattern ?? null,
|
|
70
93
|
guidedSummary,
|
|
94
|
+
agentFileSyncPlan: resolved.adoptExistingAgentFiles
|
|
95
|
+
? {
|
|
96
|
+
rootDir: resolved.rootDir,
|
|
97
|
+
toolingRootDir: resolved.toolingRootDir,
|
|
98
|
+
adoptExisting: true,
|
|
99
|
+
existingOnly: true,
|
|
100
|
+
}
|
|
101
|
+
: null,
|
|
71
102
|
sessionPlan: resolved.startSession
|
|
72
103
|
? {
|
|
73
104
|
sessionId: resolved.sessionId,
|
|
@@ -111,6 +142,212 @@ export async function resolveConnectHostedCliOptions(options, environment = {})
|
|
|
111
142
|
};
|
|
112
143
|
}
|
|
113
144
|
|
|
145
|
+
async function resolveExistingGuidedProject(options, environment) {
|
|
146
|
+
const candidates = await findExistingGuidedProjectRoots(options, environment.cwd);
|
|
147
|
+
if (candidates.length === 0) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (candidates.length === 1) {
|
|
152
|
+
if (candidates[0].discovery === 'ancestor' && canPrompt(environment)) {
|
|
153
|
+
const prompter = createPrompter(environment.io, environment.runtime);
|
|
154
|
+
try {
|
|
155
|
+
const useExistingRoot = await askConfirm(
|
|
156
|
+
prompter,
|
|
157
|
+
`Found existing VibeCompass project memory root at ${candidates[0].displayPath}. Show its info instead of initializing a new root here?`,
|
|
158
|
+
true,
|
|
159
|
+
);
|
|
160
|
+
if (!useExistingRoot) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
} finally {
|
|
164
|
+
await prompter.close();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return readExistingGuidedProject(candidates[0], environment.cwd);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
status: 'ambiguous',
|
|
173
|
+
candidates,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function findExistingGuidedProjectRoots(options, cwd) {
|
|
178
|
+
const candidateRoots = options.rootDir
|
|
179
|
+
? [{
|
|
180
|
+
rootDir: path.resolve(cwd, options.rootDir),
|
|
181
|
+
discovery: 'explicit',
|
|
182
|
+
}]
|
|
183
|
+
: await buildDefaultExistingRootCandidates(cwd);
|
|
184
|
+
|
|
185
|
+
const existing = [];
|
|
186
|
+
const seen = new Set();
|
|
187
|
+
for (const candidate of candidateRoots) {
|
|
188
|
+
const rootDir = candidate.rootDir;
|
|
189
|
+
if (seen.has(rootDir)) {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
seen.add(rootDir);
|
|
193
|
+
|
|
194
|
+
if (await fileExists(path.join(rootDir, 'project.yaml'))) {
|
|
195
|
+
existing.push({
|
|
196
|
+
rootDir,
|
|
197
|
+
displayPath: toRelativeIfInsideCwd(rootDir, cwd),
|
|
198
|
+
discovery: candidate.discovery,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return existing;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function buildDefaultExistingRootCandidates(cwd) {
|
|
207
|
+
const candidates = [
|
|
208
|
+
{
|
|
209
|
+
rootDir: path.resolve(cwd, '.compass'),
|
|
210
|
+
discovery: 'direct',
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
rootDir: path.resolve(cwd),
|
|
214
|
+
discovery: 'direct',
|
|
215
|
+
},
|
|
216
|
+
];
|
|
217
|
+
const ancestorStopDir = await resolveAncestorSearchStopDir(cwd);
|
|
218
|
+
if (path.resolve(cwd) === ancestorStopDir) {
|
|
219
|
+
return candidates;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
for (const directory of ancestorDirectories(path.dirname(cwd), ancestorStopDir)) {
|
|
223
|
+
candidates.push(
|
|
224
|
+
{
|
|
225
|
+
rootDir: path.join(directory, '.compass'),
|
|
226
|
+
discovery: 'ancestor',
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
rootDir: directory,
|
|
230
|
+
discovery: 'ancestor',
|
|
231
|
+
},
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return candidates;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function resolveAncestorSearchStopDir(cwd) {
|
|
239
|
+
const homeDir = path.resolve(os.homedir());
|
|
240
|
+
if (path.resolve(cwd) === homeDir) {
|
|
241
|
+
return homeDir;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const gitRoot = await readGitRoot(cwd);
|
|
245
|
+
if (!gitRoot) {
|
|
246
|
+
return isPathInside(cwd, homeDir) || path.resolve(cwd) === homeDir
|
|
247
|
+
? homeDir
|
|
248
|
+
: path.parse(cwd).root;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const gitRootParent = path.dirname(path.resolve(gitRoot));
|
|
252
|
+
if (isPathInside(gitRootParent, homeDir) || gitRootParent === homeDir) {
|
|
253
|
+
return gitRootParent;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return isPathInside(cwd, homeDir) || path.resolve(cwd) === homeDir
|
|
257
|
+
? homeDir
|
|
258
|
+
: path.parse(cwd).root;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function ancestorDirectories(startDir, stopDir) {
|
|
262
|
+
const directories = [];
|
|
263
|
+
let current = path.resolve(startDir);
|
|
264
|
+
const stop = path.resolve(stopDir);
|
|
265
|
+
while (true) {
|
|
266
|
+
directories.push(current);
|
|
267
|
+
if (current === stop || current === path.dirname(current)) {
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
current = path.dirname(current);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return directories;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function readExistingGuidedProject(candidate, cwd) {
|
|
277
|
+
const projectFilePath = path.join(candidate.rootDir, 'project.yaml');
|
|
278
|
+
const activeSummary = await readActiveLaneSummary(candidate.rootDir);
|
|
279
|
+
try {
|
|
280
|
+
const projectConfig = parseSimpleYaml(await readFile(projectFilePath, 'utf8'), {
|
|
281
|
+
sourceName: projectFilePath,
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
status: 'ok',
|
|
286
|
+
rootDir: candidate.rootDir,
|
|
287
|
+
displayPath: candidate.displayPath ?? toRelativeIfInsideCwd(candidate.rootDir, cwd),
|
|
288
|
+
projectFilePath,
|
|
289
|
+
projectConfig,
|
|
290
|
+
activeSummary,
|
|
291
|
+
};
|
|
292
|
+
} catch (error) {
|
|
293
|
+
return {
|
|
294
|
+
status: 'unreadable',
|
|
295
|
+
rootDir: candidate.rootDir,
|
|
296
|
+
displayPath: candidate.displayPath ?? toRelativeIfInsideCwd(candidate.rootDir, cwd),
|
|
297
|
+
projectFilePath,
|
|
298
|
+
errorMessage: error instanceof Error ? error.message : 'Unable to read project.yaml.',
|
|
299
|
+
activeSummary,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function readActiveLaneSummary(rootDir) {
|
|
305
|
+
const activeDir = path.join(rootDir, 'sessions', 'active');
|
|
306
|
+
let count = 0;
|
|
307
|
+
try {
|
|
308
|
+
const entries = await readdir(activeDir, { withFileTypes: true });
|
|
309
|
+
for (const entry of entries) {
|
|
310
|
+
if (!entry.isDirectory()) {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const laneDir = path.join(activeDir, entry.name);
|
|
315
|
+
if (
|
|
316
|
+
(await fileExists(path.join(laneDir, 'session.yaml'))) ||
|
|
317
|
+
(await fileExists(path.join(laneDir, 'wip.md')))
|
|
318
|
+
) {
|
|
319
|
+
count += 1;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
} catch {
|
|
323
|
+
count = 0;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
let current = null;
|
|
327
|
+
try {
|
|
328
|
+
const indexPath = path.join(activeDir, 'index.yaml');
|
|
329
|
+
const activeIndex = parseSimpleYaml(await readFile(indexPath, 'utf8'), {
|
|
330
|
+
sourceName: indexPath,
|
|
331
|
+
});
|
|
332
|
+
current = typeof activeIndex.current === 'string' && activeIndex.current !== 'null'
|
|
333
|
+
? activeIndex.current
|
|
334
|
+
: null;
|
|
335
|
+
} catch {
|
|
336
|
+
current = null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return { count, current };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function canPrompt(environment) {
|
|
343
|
+
if (typeof environment.runtime?.prompt === 'function') {
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const input = environment.runtime?.stdin ?? environment.io?.stdin ?? process.stdin;
|
|
348
|
+
return Boolean(input?.isTTY);
|
|
349
|
+
}
|
|
350
|
+
|
|
114
351
|
export function applyPlacementDefaults(options, environment = {}) {
|
|
115
352
|
const cwd = environment.cwd ? path.resolve(environment.cwd) : process.cwd();
|
|
116
353
|
const resolved = cloneInitOptions(options);
|
|
@@ -336,6 +573,22 @@ async function completeGuidedInitOptions(options, environment) {
|
|
|
336
573
|
}
|
|
337
574
|
}
|
|
338
575
|
|
|
576
|
+
if (resolved.adoptExistingAgentFiles === undefined) {
|
|
577
|
+
const adoptionCandidates = await findExistingUnmarkedAgentFiles({
|
|
578
|
+
cwd,
|
|
579
|
+
toolingRootDir: resolved.toolingRootDir,
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
if (adoptionCandidates.length > 0) {
|
|
583
|
+
writeAgentAdoptionPreview(prompter, adoptionCandidates);
|
|
584
|
+
resolved.adoptExistingAgentFiles = await askConfirm(
|
|
585
|
+
prompter,
|
|
586
|
+
'Append VibeCompass managed sections to existing agent instruction files?',
|
|
587
|
+
false,
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
339
592
|
if (!resolved.startSession) {
|
|
340
593
|
resolved.startSession = await askConfirm(
|
|
341
594
|
prompter,
|
|
@@ -468,6 +721,16 @@ async function readGitInfo(cwd) {
|
|
|
468
721
|
}
|
|
469
722
|
}
|
|
470
723
|
|
|
724
|
+
async function readGitRoot(cwd) {
|
|
725
|
+
try {
|
|
726
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', '--show-toplevel'], { cwd });
|
|
727
|
+
const root = stdout.trim();
|
|
728
|
+
return root ? path.resolve(root) : null;
|
|
729
|
+
} catch {
|
|
730
|
+
return null;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
471
734
|
async function discoverChildGitRepos(cwd) {
|
|
472
735
|
let entries = [];
|
|
473
736
|
try {
|
|
@@ -558,6 +821,65 @@ function reposMatchDetected(repos, detectedRepos) {
|
|
|
558
821
|
return repos.every((repo) => detectedKeys.has(`${repo.id}\n${repo.remote}`));
|
|
559
822
|
}
|
|
560
823
|
|
|
824
|
+
async function findExistingUnmarkedAgentFiles(context) {
|
|
825
|
+
const toolingRootDir = path.resolve(context.cwd, context.toolingRootDir ?? '.');
|
|
826
|
+
const candidates = [];
|
|
827
|
+
|
|
828
|
+
for (const relativePath of getSupportedAgentFilePaths()) {
|
|
829
|
+
const filePath = path.join(toolingRootDir, relativePath);
|
|
830
|
+
let content = null;
|
|
831
|
+
try {
|
|
832
|
+
content = await readFile(filePath, 'utf8');
|
|
833
|
+
} catch {
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
if (content.includes(START_MARKER) || content.includes(END_MARKER)) {
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
candidates.push({
|
|
842
|
+
relativePath,
|
|
843
|
+
conflicts: scanPotentialAgentWorkflowConflicts(content),
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
return candidates;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function writeAgentAdoptionPreview(prompter, candidates) {
|
|
851
|
+
prompter.write('Existing agent instruction files without VibeCompass markers:\n');
|
|
852
|
+
for (const candidate of candidates) {
|
|
853
|
+
prompter.write(`- ${candidate.relativePath}: VibeCompass can append a managed block at the end of this file.\n`);
|
|
854
|
+
if (candidate.conflicts.length > 0) {
|
|
855
|
+
prompter.write(` possible workflow overlap on lines ${candidate.conflicts.map((conflict) => conflict.line).join(', ')}\n`);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
prompter.write('Content outside the managed block stays user-owned and will not be rewritten.\n');
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function scanPotentialAgentWorkflowConflicts(content) {
|
|
862
|
+
const patterns = [
|
|
863
|
+
'session',
|
|
864
|
+
'handoff',
|
|
865
|
+
'decisions',
|
|
866
|
+
'wip.md',
|
|
867
|
+
'review handoff',
|
|
868
|
+
'close-session',
|
|
869
|
+
'address review',
|
|
870
|
+
'builder',
|
|
871
|
+
'reviewer',
|
|
872
|
+
];
|
|
873
|
+
|
|
874
|
+
return content
|
|
875
|
+
.split(/\r?\n/)
|
|
876
|
+
.map((line, index) => ({
|
|
877
|
+
line: index + 1,
|
|
878
|
+
normalized: line.toLowerCase(),
|
|
879
|
+
}))
|
|
880
|
+
.filter((entry) => patterns.some((pattern) => entry.normalized.includes(pattern)));
|
|
881
|
+
}
|
|
882
|
+
|
|
561
883
|
async function promptForRepos(prompter, cwd) {
|
|
562
884
|
const repoCount = await askInteger(prompter, 'How many repos belong to this logical project?', {
|
|
563
885
|
defaultValue: 1,
|
|
@@ -993,6 +1315,15 @@ async function directoryExists(directoryPath) {
|
|
|
993
1315
|
}
|
|
994
1316
|
}
|
|
995
1317
|
|
|
1318
|
+
async function fileExists(filePath) {
|
|
1319
|
+
try {
|
|
1320
|
+
await access(filePath);
|
|
1321
|
+
return true;
|
|
1322
|
+
} catch {
|
|
1323
|
+
return false;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
|
|
996
1327
|
async function isSameDirectory(left, right) {
|
|
997
1328
|
try {
|
|
998
1329
|
const [leftRealPath, rightRealPath] = await Promise.all([
|
|
@@ -1019,3 +1350,8 @@ function toRelativeIfInsideCwd(targetPath, cwd) {
|
|
|
1019
1350
|
|
|
1020
1351
|
return relative;
|
|
1021
1352
|
}
|
|
1353
|
+
|
|
1354
|
+
function isPathInside(candidatePath, parentPath) {
|
|
1355
|
+
const relative = path.relative(path.resolve(parentPath), path.resolve(candidatePath));
|
|
1356
|
+
return Boolean(relative) && !relative.startsWith('..') && !path.isAbsolute(relative);
|
|
1357
|
+
}
|