@vibecompass/vibecompass 0.3.4 → 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 +166 -9
- package/package.json +1 -1
- package/src/cli.js +263 -0
- package/src/compatibility.js +308 -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 +251 -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,10 +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';
|
|
6
7
|
import { getSupportedAgentFilePaths } from './generators/agent-files/index.js';
|
|
7
8
|
import { END_MARKER, START_MARKER } from './generators/agent-files/markers.js';
|
|
9
|
+
import { parseSimpleYaml } from './simple-yaml.js';
|
|
8
10
|
|
|
9
11
|
const execFileAsync = promisify(execFile);
|
|
10
12
|
|
|
@@ -22,6 +24,24 @@ export async function resolveInitCliOptions(options, environment = {}) {
|
|
|
22
24
|
let guidedSummary = null;
|
|
23
25
|
|
|
24
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
|
+
|
|
25
45
|
const prompter = createPrompter(environment.io, environment.runtime);
|
|
26
46
|
try {
|
|
27
47
|
const guided = await completeGuidedInitOptions(resolved, {
|
|
@@ -60,6 +80,7 @@ export async function resolveInitCliOptions(options, environment = {}) {
|
|
|
60
80
|
mode: resolved.mode,
|
|
61
81
|
repos: resolved.repos,
|
|
62
82
|
force: resolved.force,
|
|
83
|
+
replaceActiveLanes: resolved.replaceActiveLanes,
|
|
63
84
|
sync: resolved.sync,
|
|
64
85
|
bootstrap: resolved.bootstrap,
|
|
65
86
|
generatedAt: resolved.generatedAt,
|
|
@@ -121,6 +142,212 @@ export async function resolveConnectHostedCliOptions(options, environment = {})
|
|
|
121
142
|
};
|
|
122
143
|
}
|
|
123
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
|
+
|
|
124
351
|
export function applyPlacementDefaults(options, environment = {}) {
|
|
125
352
|
const cwd = environment.cwd ? path.resolve(environment.cwd) : process.cwd();
|
|
126
353
|
const resolved = cloneInitOptions(options);
|
|
@@ -494,6 +721,16 @@ async function readGitInfo(cwd) {
|
|
|
494
721
|
}
|
|
495
722
|
}
|
|
496
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
|
+
|
|
497
734
|
async function discoverChildGitRepos(cwd) {
|
|
498
735
|
let entries = [];
|
|
499
736
|
try {
|
|
@@ -1078,6 +1315,15 @@ async function directoryExists(directoryPath) {
|
|
|
1078
1315
|
}
|
|
1079
1316
|
}
|
|
1080
1317
|
|
|
1318
|
+
async function fileExists(filePath) {
|
|
1319
|
+
try {
|
|
1320
|
+
await access(filePath);
|
|
1321
|
+
return true;
|
|
1322
|
+
} catch {
|
|
1323
|
+
return false;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1081
1327
|
async function isSameDirectory(left, right) {
|
|
1082
1328
|
try {
|
|
1083
1329
|
const [leftRealPath, rightRealPath] = await Promise.all([
|
|
@@ -1104,3 +1350,8 @@ function toRelativeIfInsideCwd(targetPath, cwd) {
|
|
|
1104
1350
|
|
|
1105
1351
|
return relative;
|
|
1106
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
|
+
}
|