helloloop 0.2.0 → 0.3.1
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +228 -279
- package/hosts/claude/marketplace/.claude-plugin/marketplace.json +3 -1
- package/hosts/claude/marketplace/plugins/helloloop/.claude-plugin/plugin.json +1 -1
- package/hosts/claude/marketplace/plugins/helloloop/commands/helloloop.md +16 -9
- package/hosts/claude/marketplace/plugins/helloloop/skills/helloloop/SKILL.md +4 -1
- package/hosts/gemini/extension/GEMINI.md +8 -4
- package/hosts/gemini/extension/commands/helloloop.toml +15 -8
- package/hosts/gemini/extension/gemini-extension.json +1 -1
- package/package.json +2 -2
- package/scripts/uninstall-home-plugin.ps1 +25 -0
- package/skills/helloloop/SKILL.md +28 -3
- package/src/analyze_confirmation.mjs +79 -3
- package/src/analyze_prompt.mjs +12 -0
- package/src/analyze_user_input.mjs +303 -0
- package/src/analyzer.mjs +38 -0
- package/src/cli.mjs +211 -25
- package/src/cli_support.mjs +114 -27
- package/src/discovery.mjs +243 -9
- package/src/discovery_inference.mjs +62 -18
- package/src/discovery_paths.mjs +143 -8
- package/src/discovery_prompt.mjs +298 -0
- package/src/install.mjs +218 -7
- package/src/rebuild.mjs +116 -0
- package/templates/analysis-output.schema.json +58 -0
package/src/discovery.mjs
CHANGED
|
@@ -8,25 +8,130 @@ import {
|
|
|
8
8
|
normalizeForRepo,
|
|
9
9
|
pathExists,
|
|
10
10
|
resolveAbsolute,
|
|
11
|
+
uniquePaths,
|
|
11
12
|
} from "./discovery_paths.mjs";
|
|
12
13
|
import {
|
|
13
14
|
inferDocsForRepo,
|
|
14
15
|
inferRepoFromDocs,
|
|
16
|
+
inspectWorkspaceDirectory,
|
|
15
17
|
renderMissingDocsMessage,
|
|
16
18
|
renderMissingRepoMessage,
|
|
17
19
|
} from "./discovery_inference.mjs";
|
|
18
20
|
|
|
19
21
|
export { findRepoRootFromPath } from "./discovery_paths.mjs";
|
|
20
22
|
|
|
23
|
+
const CONFIDENCE_LABELS = {
|
|
24
|
+
high: "高",
|
|
25
|
+
medium: "中",
|
|
26
|
+
low: "低",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const REPO_SOURCE_META = {
|
|
30
|
+
explicit_flag: { label: "命令参数", confidence: "high" },
|
|
31
|
+
explicit_input: { label: "命令附带路径", confidence: "high" },
|
|
32
|
+
new_repo_input: { label: "命令附带路径", confidence: "high" },
|
|
33
|
+
interactive: { label: "交互确认", confidence: "high" },
|
|
34
|
+
interactive_new_repo: { label: "交互确认", confidence: "high" },
|
|
35
|
+
cwd_repo: { label: "当前目录", confidence: "medium" },
|
|
36
|
+
workspace_single_repo: { label: "工作区唯一候选项目", confidence: "medium" },
|
|
37
|
+
docs_ancestor: { label: "文档同树回溯", confidence: "medium" },
|
|
38
|
+
doc_path_hint: { label: "文档中的路径线索", confidence: "medium" },
|
|
39
|
+
doc_repo_name_hint: { label: "文档中的仓库名线索", confidence: "low" },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const DOCS_SOURCE_META = {
|
|
43
|
+
explicit_flag: { label: "命令参数", confidence: "high" },
|
|
44
|
+
explicit_input: { label: "命令附带路径", confidence: "high" },
|
|
45
|
+
interactive: { label: "交互确认", confidence: "high" },
|
|
46
|
+
cwd_docs: { label: "当前目录", confidence: "medium" },
|
|
47
|
+
workspace_single_doc: { label: "工作区唯一文档候选", confidence: "medium" },
|
|
48
|
+
existing_state: { label: "已有 .helloloop 配置", confidence: "medium" },
|
|
49
|
+
repo_docs: { label: "仓库 docs 目录", confidence: "medium" },
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function pushBasis(basis, message) {
|
|
53
|
+
if (message && !basis.includes(message)) {
|
|
54
|
+
basis.push(message);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function repoSourceFromSelection(selectionSource, allowNewRepoRoot, repoRoot) {
|
|
59
|
+
if (selectionSource === "flag") {
|
|
60
|
+
return allowNewRepoRoot && repoRoot && !pathExists(repoRoot) ? "new_repo_input" : "explicit_flag";
|
|
61
|
+
}
|
|
62
|
+
if (selectionSource === "positional") {
|
|
63
|
+
return allowNewRepoRoot && repoRoot && !pathExists(repoRoot) ? "new_repo_input" : "explicit_input";
|
|
64
|
+
}
|
|
65
|
+
if (selectionSource === "interactive_new_repo") {
|
|
66
|
+
return "interactive_new_repo";
|
|
67
|
+
}
|
|
68
|
+
if (selectionSource === "interactive") {
|
|
69
|
+
return "interactive";
|
|
70
|
+
}
|
|
71
|
+
if (selectionSource === "workspace_single_repo") {
|
|
72
|
+
return "workspace_single_repo";
|
|
73
|
+
}
|
|
74
|
+
return "";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function docsSourceFromSelection(selectionSource) {
|
|
78
|
+
if (selectionSource === "flag") {
|
|
79
|
+
return "explicit_flag";
|
|
80
|
+
}
|
|
81
|
+
if (selectionSource === "positional") {
|
|
82
|
+
return "explicit_input";
|
|
83
|
+
}
|
|
84
|
+
if (selectionSource === "interactive") {
|
|
85
|
+
return "interactive";
|
|
86
|
+
}
|
|
87
|
+
if (selectionSource === "workspace_single_doc") {
|
|
88
|
+
return "workspace_single_doc";
|
|
89
|
+
}
|
|
90
|
+
return "";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function createResolution(kind, payload) {
|
|
94
|
+
const meta = kind === "repo" ? REPO_SOURCE_META : DOCS_SOURCE_META;
|
|
95
|
+
const selected = meta[payload.source] || { label: "自动判断", confidence: "medium" };
|
|
96
|
+
return {
|
|
97
|
+
...payload,
|
|
98
|
+
sourceLabel: selected.label,
|
|
99
|
+
confidence: selected.confidence,
|
|
100
|
+
confidenceLabel: CONFIDENCE_LABELS[selected.confidence] || "中",
|
|
101
|
+
basis: Array.isArray(payload.basis) ? payload.basis.filter(Boolean) : [],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function createRepoResolution(repoRoot, source, basis) {
|
|
106
|
+
return createResolution("repo", {
|
|
107
|
+
source,
|
|
108
|
+
path: repoRoot,
|
|
109
|
+
exists: pathExists(repoRoot),
|
|
110
|
+
basis,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function createDocsResolution(docsEntries, source, basis) {
|
|
115
|
+
return createResolution("docs", {
|
|
116
|
+
source,
|
|
117
|
+
entries: docsEntries,
|
|
118
|
+
basis,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
21
122
|
export function discoverWorkspace(options = {}) {
|
|
22
123
|
const cwd = path.resolve(options.cwd || process.cwd());
|
|
23
124
|
const configDirName = options.configDirName || ".helloloop";
|
|
125
|
+
const allowNewRepoRoot = Boolean(options.allowNewRepoRoot);
|
|
126
|
+
const selectionSources = options.selectionSources || {};
|
|
24
127
|
const explicitRepoRoot = options.repoRoot ? resolveAbsolute(options.repoRoot, cwd) : "";
|
|
25
128
|
const explicitDocsPath = options.docsPath ? resolveAbsolute(options.docsPath, cwd) : "";
|
|
26
129
|
const explicitInputPath = options.inputPath ? resolveAbsolute(options.inputPath, cwd) : "";
|
|
27
130
|
|
|
28
131
|
if (explicitRepoRoot && !pathExists(explicitRepoRoot)) {
|
|
29
|
-
|
|
132
|
+
if (!allowNewRepoRoot) {
|
|
133
|
+
return { ok: false, code: "missing_repo_path", message: `项目路径不存在:${explicitRepoRoot}` };
|
|
134
|
+
}
|
|
30
135
|
}
|
|
31
136
|
if (explicitDocsPath && !pathExists(explicitDocsPath)) {
|
|
32
137
|
return { ok: false, code: "missing_docs_path", message: `开发文档路径不存在:${explicitDocsPath}` };
|
|
@@ -36,6 +141,39 @@ export function discoverWorkspace(options = {}) {
|
|
|
36
141
|
let docsEntries = explicitDocsPath ? [explicitDocsPath] : [];
|
|
37
142
|
let docCandidates = [];
|
|
38
143
|
let repoCandidates = [];
|
|
144
|
+
let workspaceRoot = "";
|
|
145
|
+
let docsDerivedFromWorkspace = false;
|
|
146
|
+
let repoSource = repoSourceFromSelection(selectionSources.repo, allowNewRepoRoot, explicitRepoRoot);
|
|
147
|
+
let docsSource = docsSourceFromSelection(selectionSources.docs);
|
|
148
|
+
const repoBasis = [];
|
|
149
|
+
const docsBasis = [];
|
|
150
|
+
|
|
151
|
+
if (explicitRepoRoot && !repoSource) {
|
|
152
|
+
repoSource = allowNewRepoRoot && !pathExists(explicitRepoRoot) ? "new_repo_input" : "explicit_input";
|
|
153
|
+
}
|
|
154
|
+
if (explicitDocsPath && !docsSource) {
|
|
155
|
+
docsSource = "explicit_input";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (repoSource === "explicit_flag") {
|
|
159
|
+
pushBasis(repoBasis, "目标项目来自命令参数 `--repo`。");
|
|
160
|
+
} else if (repoSource === "explicit_input") {
|
|
161
|
+
pushBasis(repoBasis, "目标项目来自命令中显式提供的路径。");
|
|
162
|
+
} else if (repoSource === "new_repo_input") {
|
|
163
|
+
pushBasis(repoBasis, "目标项目来自显式提供的项目路径;该目录当前不存在,将按新项目创建。");
|
|
164
|
+
} else if (repoSource === "interactive_new_repo") {
|
|
165
|
+
pushBasis(repoBasis, "目标项目由用户在确认流程中指定;该目录当前不存在,将按新项目创建。");
|
|
166
|
+
} else if (repoSource === "interactive") {
|
|
167
|
+
pushBasis(repoBasis, "目标项目由用户在确认流程中手动指定。");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (docsSource === "explicit_flag") {
|
|
171
|
+
pushBasis(docsBasis, "开发文档来自命令参数 `--docs`。");
|
|
172
|
+
} else if (docsSource === "explicit_input") {
|
|
173
|
+
pushBasis(docsBasis, "开发文档来自命令中显式提供的路径。");
|
|
174
|
+
} else if (docsSource === "interactive") {
|
|
175
|
+
pushBasis(docsBasis, "开发文档由用户在确认流程中手动指定。");
|
|
176
|
+
}
|
|
39
177
|
|
|
40
178
|
if (!repoRoot && !docsEntries.length) {
|
|
41
179
|
const classified = classifyExplicitPath(explicitInputPath);
|
|
@@ -44,9 +182,16 @@ export function discoverWorkspace(options = {}) {
|
|
|
44
182
|
}
|
|
45
183
|
if (classified.kind === "docs") {
|
|
46
184
|
docsEntries = [classified.absolutePath];
|
|
185
|
+
docsSource = "explicit_input";
|
|
186
|
+
pushBasis(docsBasis, "开发文档来自命令中传入的单一路径。");
|
|
47
187
|
}
|
|
48
188
|
if (classified.kind === "repo") {
|
|
49
189
|
repoRoot = classified.absolutePath;
|
|
190
|
+
repoSource = "explicit_input";
|
|
191
|
+
pushBasis(repoBasis, "目标项目来自命令中传入的单一路径。");
|
|
192
|
+
}
|
|
193
|
+
if (classified.kind === "workspace" || classified.kind === "directory") {
|
|
194
|
+
workspaceRoot = classified.absolutePath;
|
|
50
195
|
}
|
|
51
196
|
}
|
|
52
197
|
|
|
@@ -54,17 +199,75 @@ export function discoverWorkspace(options = {}) {
|
|
|
54
199
|
const cwdRepoRoot = findRepoRootFromPath(cwd);
|
|
55
200
|
if (cwdRepoRoot) {
|
|
56
201
|
repoRoot = cwdRepoRoot;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
} else
|
|
60
|
-
|
|
202
|
+
repoSource = "cwd_repo";
|
|
203
|
+
pushBasis(repoBasis, "当前终端目录已经位于一个项目仓库内。");
|
|
204
|
+
} else {
|
|
205
|
+
const cwdClassified = classifyExplicitPath(cwd);
|
|
206
|
+
if (cwdClassified.kind === "docs") {
|
|
207
|
+
docsEntries = [cwdClassified.absolutePath];
|
|
208
|
+
docsSource = "cwd_docs";
|
|
209
|
+
pushBasis(docsBasis, "当前终端目录本身就是开发文档目录或文件。");
|
|
210
|
+
} else if (cwdClassified.kind === "repo") {
|
|
211
|
+
repoRoot = cwdClassified.absolutePath;
|
|
212
|
+
repoSource = "cwd_repo";
|
|
213
|
+
pushBasis(repoBasis, "当前终端目录本身就是项目仓库。");
|
|
214
|
+
} else if (cwdClassified.kind === "workspace" || cwdClassified.kind === "directory") {
|
|
215
|
+
workspaceRoot = cwdClassified.absolutePath;
|
|
216
|
+
} else if (looksLikeProjectRoot(cwd)) {
|
|
217
|
+
repoRoot = cwd;
|
|
218
|
+
repoSource = "cwd_repo";
|
|
219
|
+
pushBasis(repoBasis, "当前终端目录具备项目仓库特征。");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!repoRoot && !docsEntries.length && workspaceRoot) {
|
|
225
|
+
const workspace = inspectWorkspaceDirectory(workspaceRoot);
|
|
226
|
+
docCandidates = workspace.docCandidates;
|
|
227
|
+
repoCandidates = workspace.repoCandidates;
|
|
228
|
+
|
|
229
|
+
if (workspace.docsEntries.length === 1) {
|
|
230
|
+
docsEntries = workspace.docsEntries;
|
|
231
|
+
docsDerivedFromWorkspace = true;
|
|
232
|
+
docsSource = "workspace_single_doc";
|
|
233
|
+
pushBasis(docsBasis, "工作区扫描后只发现一个顶层开发文档入口。");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (workspace.repoCandidates.length === 1) {
|
|
237
|
+
repoRoot = workspace.repoCandidates[0];
|
|
238
|
+
repoSource = "workspace_single_repo";
|
|
239
|
+
pushBasis(repoBasis, "工作区扫描后只发现一个顶层项目候选目录。");
|
|
61
240
|
}
|
|
62
241
|
}
|
|
63
242
|
|
|
64
243
|
if (!repoRoot && docsEntries.length) {
|
|
244
|
+
if (docsDerivedFromWorkspace && repoCandidates.length > 1) {
|
|
245
|
+
return {
|
|
246
|
+
ok: false,
|
|
247
|
+
code: "missing_repo",
|
|
248
|
+
message: renderMissingRepoMessage(docsEntries, repoCandidates),
|
|
249
|
+
docsEntries,
|
|
250
|
+
docCandidates,
|
|
251
|
+
repoCandidates,
|
|
252
|
+
workspaceRoot,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
65
256
|
const inferred = inferRepoFromDocs(docsEntries, cwd);
|
|
66
257
|
repoRoot = inferred.repoRoot;
|
|
67
|
-
repoCandidates = inferred.candidates;
|
|
258
|
+
repoCandidates = uniquePaths([...repoCandidates, ...inferred.candidates]);
|
|
259
|
+
if (repoRoot) {
|
|
260
|
+
if (inferred.source === "ancestor") {
|
|
261
|
+
repoSource = "docs_ancestor";
|
|
262
|
+
pushBasis(repoBasis, "开发文档位于该仓库目录树内,已回溯到真实项目根目录。");
|
|
263
|
+
} else if (inferred.source === "doc_path_hint") {
|
|
264
|
+
repoSource = "doc_path_hint";
|
|
265
|
+
pushBasis(repoBasis, "开发文档内容中出现了指向该仓库的实际路径线索。");
|
|
266
|
+
} else if (inferred.source === "doc_repo_name_hint") {
|
|
267
|
+
repoSource = "doc_repo_name_hint";
|
|
268
|
+
pushBasis(repoBasis, "开发文档内容中出现了与该仓库名称一致的线索。");
|
|
269
|
+
}
|
|
270
|
+
}
|
|
68
271
|
}
|
|
69
272
|
|
|
70
273
|
if (!repoRoot) {
|
|
@@ -72,16 +275,35 @@ export function discoverWorkspace(options = {}) {
|
|
|
72
275
|
ok: false,
|
|
73
276
|
code: "missing_repo",
|
|
74
277
|
message: renderMissingRepoMessage(docsEntries, repoCandidates),
|
|
278
|
+
docsEntries,
|
|
279
|
+
docCandidates,
|
|
280
|
+
repoCandidates,
|
|
281
|
+
workspaceRoot,
|
|
75
282
|
};
|
|
76
283
|
}
|
|
77
284
|
|
|
78
|
-
|
|
285
|
+
const normalizedRepoRoot = findRepoRootFromPath(repoRoot) || repoRoot;
|
|
286
|
+
if (normalizedRepoRoot !== repoRoot) {
|
|
287
|
+
pushBasis(repoBasis, "已自动回溯到项目仓库根目录。");
|
|
288
|
+
}
|
|
289
|
+
repoRoot = normalizedRepoRoot;
|
|
79
290
|
docsEntries = docsEntries.map((entry) => normalizeForRepo(repoRoot, entry));
|
|
80
291
|
|
|
81
292
|
if (!docsEntries.length) {
|
|
82
293
|
const inferred = inferDocsForRepo(repoRoot, cwd, configDirName);
|
|
83
294
|
docsEntries = inferred.docsEntries;
|
|
84
295
|
docCandidates = inferred.candidates;
|
|
296
|
+
if (docsEntries.length) {
|
|
297
|
+
docsSource = inferred.source || docsSource;
|
|
298
|
+
if (inferred.source === "existing_state") {
|
|
299
|
+
pushBasis(docsBasis, "已复用 `.helloloop/project.json` 中记录的 requiredDocs。");
|
|
300
|
+
} else if (inferred.source === "cwd") {
|
|
301
|
+
docsSource = "cwd_docs";
|
|
302
|
+
pushBasis(docsBasis, "当前终端目录本身就是开发文档目录或文件。");
|
|
303
|
+
} else if (inferred.source === "repo_docs") {
|
|
304
|
+
pushBasis(docsBasis, "已使用目标仓库中的 `docs/` 目录作为默认开发文档入口。");
|
|
305
|
+
}
|
|
306
|
+
}
|
|
85
307
|
}
|
|
86
308
|
|
|
87
309
|
if (!docsEntries.length) {
|
|
@@ -89,8 +311,12 @@ export function discoverWorkspace(options = {}) {
|
|
|
89
311
|
ok: false,
|
|
90
312
|
code: "missing_docs",
|
|
91
313
|
repoRoot,
|
|
92
|
-
message: renderMissingDocsMessage(repoRoot),
|
|
314
|
+
message: renderMissingDocsMessage(repoRoot, docCandidates),
|
|
93
315
|
candidates: docCandidates,
|
|
316
|
+
docsEntries,
|
|
317
|
+
docCandidates,
|
|
318
|
+
repoCandidates,
|
|
319
|
+
workspaceRoot,
|
|
94
320
|
};
|
|
95
321
|
}
|
|
96
322
|
|
|
@@ -100,7 +326,11 @@ export function discoverWorkspace(options = {}) {
|
|
|
100
326
|
ok: false,
|
|
101
327
|
code: "invalid_docs",
|
|
102
328
|
repoRoot,
|
|
103
|
-
message: renderMissingDocsMessage(repoRoot),
|
|
329
|
+
message: renderMissingDocsMessage(repoRoot, docCandidates),
|
|
330
|
+
docsEntries,
|
|
331
|
+
docCandidates,
|
|
332
|
+
repoCandidates,
|
|
333
|
+
workspaceRoot,
|
|
104
334
|
};
|
|
105
335
|
}
|
|
106
336
|
|
|
@@ -109,6 +339,10 @@ export function discoverWorkspace(options = {}) {
|
|
|
109
339
|
repoRoot,
|
|
110
340
|
docsEntries,
|
|
111
341
|
resolvedDocs,
|
|
342
|
+
resolution: {
|
|
343
|
+
repo: createRepoResolution(repoRoot, repoSource || "cwd_repo", repoBasis),
|
|
344
|
+
docs: createDocsResolution(docsEntries, docsSource || "repo_docs", docsBasis),
|
|
345
|
+
},
|
|
112
346
|
};
|
|
113
347
|
}
|
|
114
348
|
|
|
@@ -4,10 +4,12 @@ import path from "node:path";
|
|
|
4
4
|
import { readJson } from "./common.mjs";
|
|
5
5
|
import { expandDocumentEntries } from "./doc_loader.mjs";
|
|
6
6
|
import {
|
|
7
|
+
findPreferredRepoRootFromPath,
|
|
7
8
|
findRepoRootFromPath,
|
|
8
9
|
isDocFile,
|
|
9
10
|
isDocsDirectory,
|
|
10
|
-
|
|
11
|
+
listDocFilesInDirectory,
|
|
12
|
+
listProjectCandidatesInDirectory,
|
|
11
13
|
normalizeForRepo,
|
|
12
14
|
pathExists,
|
|
13
15
|
resolveAbsolute,
|
|
@@ -48,7 +50,7 @@ function readDocPreview(docEntries, cwd) {
|
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
function normalizePathHint(rawValue) {
|
|
51
|
-
const trimmed = String(rawValue || "").trim().replace(/^["'`(<\[]+|[>"'`)\]
|
|
53
|
+
const trimmed = String(rawValue || "").trim().replace(/^[:"'`(<\[:]+|[>"'`)\].,;::]+$/g, "");
|
|
52
54
|
if (/^\/[A-Za-z]:[\\/]/.test(trimmed)) {
|
|
53
55
|
return trimmed.slice(1);
|
|
54
56
|
}
|
|
@@ -59,7 +61,7 @@ function extractPathHintsFromText(text) {
|
|
|
59
61
|
const hints = new Set();
|
|
60
62
|
const normalizedText = String(text || "");
|
|
61
63
|
const windowsMatches = normalizedText.match(/\/?[A-Za-z]:[\\/][^\s"'`<>()\]]+/g) || [];
|
|
62
|
-
const posixMatches = normalizedText.match(/(?:^|[\s("'
|
|
64
|
+
const posixMatches = normalizedText.match(/(?:^|[\s("'`::])\/(?!\/)[^\s"'`<>()\]]+/g) || [];
|
|
63
65
|
|
|
64
66
|
for (const rawMatch of [...windowsMatches, ...posixMatches]) {
|
|
65
67
|
const normalized = normalizePathHint(rawMatch);
|
|
@@ -86,15 +88,52 @@ function extractRepoNameHintsFromText(text) {
|
|
|
86
88
|
return [...hints];
|
|
87
89
|
}
|
|
88
90
|
|
|
89
|
-
function
|
|
90
|
-
if (!
|
|
91
|
+
function formatCandidates(title, candidates) {
|
|
92
|
+
if (!Array.isArray(candidates) || !candidates.length) {
|
|
91
93
|
return [];
|
|
92
94
|
}
|
|
93
95
|
|
|
94
|
-
return
|
|
96
|
+
return [
|
|
97
|
+
title,
|
|
98
|
+
...candidates.map((item, index) => `${index + 1}. ${path.basename(item)} — ${item.replaceAll("\\", "/")}`),
|
|
99
|
+
];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function inspectWorkspaceDirectory(directoryPath) {
|
|
103
|
+
if (!pathExists(directoryPath) || !fs.statSync(directoryPath).isDirectory()) {
|
|
104
|
+
return {
|
|
105
|
+
docCandidates: [],
|
|
106
|
+
docsEntries: [],
|
|
107
|
+
repoCandidates: [],
|
|
108
|
+
topLevelDirectories: [],
|
|
109
|
+
topLevelDocFiles: [],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const topLevelEntries = fs.readdirSync(directoryPath, { withFileTypes: true });
|
|
114
|
+
const namedDocsDirectories = topLevelEntries
|
|
115
|
+
.filter((entry) => (
|
|
116
|
+
entry.isDirectory()
|
|
117
|
+
&& ["doc", "docs", "documentation"].includes(entry.name.toLowerCase())
|
|
118
|
+
))
|
|
119
|
+
.map((entry) => path.join(directoryPath, entry.name))
|
|
120
|
+
.filter((candidate) => isDocsDirectory(candidate));
|
|
121
|
+
|
|
122
|
+
const topLevelDocFiles = listDocFilesInDirectory(directoryPath)
|
|
123
|
+
.filter((candidate) => path.basename(candidate).toLowerCase() !== "agents.md");
|
|
124
|
+
const docCandidates = uniquePaths([...topLevelDocFiles, ...namedDocsDirectories]);
|
|
125
|
+
const topLevelDirectories = topLevelEntries
|
|
95
126
|
.filter((entry) => entry.isDirectory())
|
|
96
|
-
.map((entry) => path.join(
|
|
97
|
-
|
|
127
|
+
.map((entry) => path.join(directoryPath, entry.name));
|
|
128
|
+
|
|
129
|
+
const repoCandidates = uniquePaths(listProjectCandidatesInDirectory(directoryPath));
|
|
130
|
+
return {
|
|
131
|
+
docCandidates,
|
|
132
|
+
docsEntries: docCandidates.length === 1 ? [docCandidates[0]] : [],
|
|
133
|
+
repoCandidates,
|
|
134
|
+
topLevelDirectories,
|
|
135
|
+
topLevelDocFiles,
|
|
136
|
+
};
|
|
98
137
|
}
|
|
99
138
|
|
|
100
139
|
export function inferRepoFromDocs(docEntries, cwd) {
|
|
@@ -117,7 +156,7 @@ export function inferRepoFromDocs(docEntries, cwd) {
|
|
|
117
156
|
.map((item) => resolveAbsolute(item, cwd))
|
|
118
157
|
.filter((candidate) => pathExists(candidate))
|
|
119
158
|
.map((candidate) => (fs.statSync(candidate).isDirectory() ? candidate : path.dirname(candidate)))
|
|
120
|
-
.map((candidate) =>
|
|
159
|
+
.map((candidate) => findPreferredRepoRootFromPath(candidate))
|
|
121
160
|
.filter(Boolean);
|
|
122
161
|
|
|
123
162
|
const uniquePathCandidates = uniquePaths(pathHintCandidates);
|
|
@@ -136,11 +175,15 @@ export function inferRepoFromDocs(docEntries, cwd) {
|
|
|
136
175
|
? absolutePath
|
|
137
176
|
: path.dirname(absolutePath);
|
|
138
177
|
const parent = path.dirname(directory);
|
|
139
|
-
|
|
178
|
+
const roots = [directory, parent];
|
|
179
|
+
if (["doc", "docs", "documentation"].includes(path.basename(directory).toLowerCase())) {
|
|
180
|
+
roots.push(path.dirname(parent));
|
|
181
|
+
}
|
|
182
|
+
return roots;
|
|
140
183
|
}),
|
|
141
184
|
);
|
|
142
185
|
const nearbyCandidates = uniquePaths(
|
|
143
|
-
searchRoots.flatMap((searchRoot) =>
|
|
186
|
+
searchRoots.flatMap((searchRoot) => listProjectCandidatesInDirectory(searchRoot)),
|
|
144
187
|
);
|
|
145
188
|
const namedCandidates = nearbyCandidates.filter((directoryPath) => (
|
|
146
189
|
repoNameHints.includes(path.basename(directoryPath).toLowerCase())
|
|
@@ -156,7 +199,7 @@ export function inferRepoFromDocs(docEntries, cwd) {
|
|
|
156
199
|
|
|
157
200
|
return {
|
|
158
201
|
repoRoot: "",
|
|
159
|
-
candidates: uniquePaths([...uniquePathCandidates, ...namedCandidates]),
|
|
202
|
+
candidates: uniquePaths([...uniquePathCandidates, ...namedCandidates, ...nearbyCandidates]),
|
|
160
203
|
source: "",
|
|
161
204
|
};
|
|
162
205
|
}
|
|
@@ -203,18 +246,19 @@ export function inferDocsForRepo(repoRoot, cwd, configDirName) {
|
|
|
203
246
|
export function renderMissingRepoMessage(docEntries, candidates) {
|
|
204
247
|
return [
|
|
205
248
|
"无法自动确定要开发的项目仓库路径。",
|
|
206
|
-
docEntries.length ? `已找到开发文档:${docEntries.
|
|
207
|
-
|
|
208
|
-
"
|
|
249
|
+
docEntries.length ? `已找到开发文档:${docEntries.map((item) => item.replaceAll("\\", "/")).join(",")}` : "",
|
|
250
|
+
...formatCandidates("候选项目:", candidates),
|
|
251
|
+
"可重新运行 `npx helloloop` 后按提示选择,或显式补充项目路径,例如:",
|
|
209
252
|
"npx helloloop --repo <PROJECT_ROOT>",
|
|
210
253
|
].filter(Boolean).join("\n");
|
|
211
254
|
}
|
|
212
255
|
|
|
213
|
-
export function renderMissingDocsMessage(repoRoot) {
|
|
256
|
+
export function renderMissingDocsMessage(repoRoot, candidates = []) {
|
|
214
257
|
return [
|
|
215
258
|
"无法自动确定开发文档位置。",
|
|
216
|
-
`已找到项目仓库:${repoRoot}`,
|
|
217
|
-
"
|
|
259
|
+
`已找到项目仓库:${repoRoot.replaceAll("\\", "/")}`,
|
|
260
|
+
...formatCandidates("候选开发文档:", candidates),
|
|
261
|
+
"可重新运行 `npx helloloop` 后按提示选择,或显式补充开发文档路径,例如:",
|
|
218
262
|
"npx helloloop --docs <DOCS_PATH>",
|
|
219
263
|
].join("\n");
|
|
220
264
|
}
|
package/src/discovery_paths.mjs
CHANGED
|
@@ -36,15 +36,80 @@ const PROJECT_DIR_MARKERS = [
|
|
|
36
36
|
"tests",
|
|
37
37
|
];
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
const IGNORED_PROJECT_SEGMENTS = new Set([
|
|
40
|
+
".cache",
|
|
41
|
+
".git",
|
|
42
|
+
".next",
|
|
43
|
+
".nuxt",
|
|
44
|
+
".pnpm",
|
|
45
|
+
".turbo",
|
|
46
|
+
".venv",
|
|
47
|
+
".yarn",
|
|
48
|
+
"__pycache__",
|
|
49
|
+
"build",
|
|
50
|
+
"cache",
|
|
51
|
+
"coverage",
|
|
52
|
+
"dist",
|
|
53
|
+
"node_modules",
|
|
54
|
+
"out",
|
|
55
|
+
"target",
|
|
56
|
+
"temp",
|
|
57
|
+
"tmp",
|
|
58
|
+
"vendor",
|
|
59
|
+
"venv",
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
function listImmediateDirectories(directoryPath) {
|
|
40
63
|
if (!pathExists(directoryPath) || !fs.statSync(directoryPath).isDirectory()) {
|
|
41
|
-
return
|
|
64
|
+
return [];
|
|
42
65
|
}
|
|
43
66
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
67
|
+
return fs.readdirSync(directoryPath, { withFileTypes: true })
|
|
68
|
+
.filter((entry) => entry.isDirectory())
|
|
69
|
+
.map((entry) => path.join(directoryPath, entry.name));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function hasProjectMarker(directoryPath) {
|
|
73
|
+
return PROJECT_MARKERS.some((name) => pathExists(path.join(directoryPath, name)));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function looksLikeStrongProjectRoot(directoryPath) {
|
|
77
|
+
return pathExists(directoryPath)
|
|
78
|
+
&& fs.statSync(directoryPath).isDirectory()
|
|
79
|
+
&& hasProjectMarker(directoryPath);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function listDocFilesInDirectory(directoryPath) {
|
|
83
|
+
if (!pathExists(directoryPath) || !fs.statSync(directoryPath).isDirectory()) {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return fs.readdirSync(directoryPath, { withFileTypes: true })
|
|
88
|
+
.filter((entry) => (
|
|
89
|
+
entry.isFile() && DOC_FILE_SUFFIX.has(path.extname(entry.name).toLowerCase())
|
|
90
|
+
))
|
|
91
|
+
.map((entry) => path.join(directoryPath, entry.name));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function directoryContainsDocs(directoryPath) {
|
|
95
|
+
return listDocFilesInDirectory(directoryPath).length > 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function hasIgnoredProjectBasename(targetPath) {
|
|
99
|
+
return IGNORED_PROJECT_SEGMENTS.has(path.basename(targetPath).toLowerCase());
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function choosePreferredCandidate(candidates, directory) {
|
|
103
|
+
return candidates.find((candidate) => {
|
|
104
|
+
const relativeToLeaf = path.relative(candidate, directory);
|
|
105
|
+
return relativeToLeaf
|
|
106
|
+
.split(/[\\/]+/)
|
|
107
|
+
.filter(Boolean)
|
|
108
|
+
.some((segment) => IGNORED_PROJECT_SEGMENTS.has(segment.toLowerCase()));
|
|
109
|
+
})
|
|
110
|
+
|| candidates.find((candidate) => !hasIgnoredProjectBasename(candidate))
|
|
111
|
+
|| candidates[0]
|
|
112
|
+
|| "";
|
|
48
113
|
}
|
|
49
114
|
|
|
50
115
|
function walkUpDirectories(startPath) {
|
|
@@ -86,13 +151,37 @@ export function looksLikeProjectRoot(directoryPath) {
|
|
|
86
151
|
return false;
|
|
87
152
|
}
|
|
88
153
|
|
|
89
|
-
if (
|
|
154
|
+
if (hasProjectMarker(directoryPath)) {
|
|
90
155
|
return true;
|
|
91
156
|
}
|
|
92
157
|
|
|
93
158
|
return PROJECT_DIR_MARKERS.some((name) => pathExists(path.join(directoryPath, name)));
|
|
94
159
|
}
|
|
95
160
|
|
|
161
|
+
export function listProjectCandidatesInDirectory(searchRoot) {
|
|
162
|
+
return listImmediateDirectories(searchRoot)
|
|
163
|
+
.filter((directoryPath) => (
|
|
164
|
+
looksLikeProjectRoot(directoryPath) && !hasIgnoredProjectBasename(directoryPath)
|
|
165
|
+
));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function looksLikeWorkspaceDirectory(directoryPath) {
|
|
169
|
+
if (!pathExists(directoryPath) || !fs.statSync(directoryPath).isDirectory()) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const repoCandidates = listProjectCandidatesInDirectory(directoryPath);
|
|
174
|
+
if (!repoCandidates.length) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const hasLooseDocs = directoryContainsDocs(directoryPath)
|
|
179
|
+
|| listImmediateDirectories(directoryPath)
|
|
180
|
+
.some((childPath) => DOC_DIR_NAMES.has(path.basename(childPath).toLowerCase()));
|
|
181
|
+
|
|
182
|
+
return repoCandidates.length > 1 || hasLooseDocs;
|
|
183
|
+
}
|
|
184
|
+
|
|
96
185
|
export function isDocsDirectory(directoryPath) {
|
|
97
186
|
if (!pathExists(directoryPath) || !fs.statSync(directoryPath).isDirectory()) {
|
|
98
187
|
return false;
|
|
@@ -103,6 +192,10 @@ export function isDocsDirectory(directoryPath) {
|
|
|
103
192
|
return true;
|
|
104
193
|
}
|
|
105
194
|
|
|
195
|
+
if (looksLikeWorkspaceDirectory(directoryPath)) {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
|
|
106
199
|
return directoryContainsDocs(directoryPath) && !looksLikeProjectRoot(directoryPath);
|
|
107
200
|
}
|
|
108
201
|
|
|
@@ -124,6 +217,39 @@ export function findRepoRootFromPath(startPath) {
|
|
|
124
217
|
return "";
|
|
125
218
|
}
|
|
126
219
|
|
|
220
|
+
export function findPreferredRepoRootFromPath(startPath) {
|
|
221
|
+
if (!pathExists(startPath)) {
|
|
222
|
+
return "";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const directory = fs.statSync(startPath).isDirectory()
|
|
226
|
+
? startPath
|
|
227
|
+
: path.dirname(startPath);
|
|
228
|
+
const strongCandidates = [];
|
|
229
|
+
const weakCandidates = [];
|
|
230
|
+
|
|
231
|
+
for (const current of walkUpDirectories(directory)) {
|
|
232
|
+
if (pathExists(path.join(current, ".git"))) {
|
|
233
|
+
return current;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (looksLikeStrongProjectRoot(current)) {
|
|
237
|
+
strongCandidates.push(current);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (looksLikeProjectRoot(current)) {
|
|
242
|
+
weakCandidates.push(current);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (strongCandidates.length) {
|
|
247
|
+
return choosePreferredCandidate(strongCandidates, directory);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return choosePreferredCandidate(weakCandidates, directory);
|
|
251
|
+
}
|
|
252
|
+
|
|
127
253
|
export function normalizeForRepo(repoRoot, targetPath) {
|
|
128
254
|
const relative = path.relative(repoRoot, targetPath);
|
|
129
255
|
if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
|
|
@@ -159,7 +285,16 @@ export function classifyExplicitPath(inputPath) {
|
|
|
159
285
|
}
|
|
160
286
|
|
|
161
287
|
if (fs.statSync(inputPath).isDirectory()) {
|
|
162
|
-
|
|
288
|
+
const repoRoot = findRepoRootFromPath(inputPath);
|
|
289
|
+
if (repoRoot) {
|
|
290
|
+
return { kind: "repo", absolutePath: repoRoot };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (looksLikeWorkspaceDirectory(inputPath)) {
|
|
294
|
+
return { kind: "workspace", absolutePath: inputPath };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return { kind: "directory", absolutePath: inputPath };
|
|
163
298
|
}
|
|
164
299
|
|
|
165
300
|
return { kind: "unsupported", absolutePath: inputPath };
|