helloloop 0.1.1 → 0.1.2
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/.codex-plugin/plugin.json +6 -6
- package/LICENSE +176 -0
- package/README.md +106 -115
- package/package.json +7 -3
- package/skills/helloloop/SKILL.md +35 -31
- package/src/analyze_prompt.mjs +75 -0
- package/src/analyzer.mjs +208 -0
- package/src/cli.mjs +74 -10
- package/src/discovery.mjs +155 -0
- package/src/discovery_inference.mjs +220 -0
- package/src/discovery_paths.mjs +166 -0
- package/src/guardrails.mjs +59 -0
- package/src/install.mjs +1 -7
- package/src/process.mjs +31 -129
- package/src/prompt.mjs +10 -12
- package/src/shell_invocation.mjs +211 -0
- package/templates/analysis-output.schema.json +144 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { formatList } from "./common.mjs";
|
|
2
|
+
import {
|
|
3
|
+
hasCustomProjectConstraints,
|
|
4
|
+
listMandatoryGuardrails,
|
|
5
|
+
resolveProjectConstraints,
|
|
6
|
+
} from "./guardrails.mjs";
|
|
7
|
+
|
|
8
|
+
function section(title, content) {
|
|
9
|
+
if (!content || !String(content).trim()) {
|
|
10
|
+
return "";
|
|
11
|
+
}
|
|
12
|
+
return `## ${title}\n${String(content).trim()}\n`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function listSection(title, items) {
|
|
16
|
+
if (!Array.isArray(items) || !items.length) {
|
|
17
|
+
return "";
|
|
18
|
+
}
|
|
19
|
+
return section(title, formatList(items));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function renderDocPackets(docPackets) {
|
|
23
|
+
return docPackets.map((packet) => [
|
|
24
|
+
`### 文档:${packet.path}`,
|
|
25
|
+
packet.truncated ? "(已截断)" : "",
|
|
26
|
+
"",
|
|
27
|
+
packet.content,
|
|
28
|
+
].filter(Boolean).join("\n")).join("\n\n");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function buildAnalysisPrompt({
|
|
32
|
+
repoRoot,
|
|
33
|
+
docsEntries,
|
|
34
|
+
docPackets,
|
|
35
|
+
existingStateText = "",
|
|
36
|
+
existingBacklogText = "",
|
|
37
|
+
existingProjectConstraints = [],
|
|
38
|
+
}) {
|
|
39
|
+
const mandatoryGuardrails = listMandatoryGuardrails();
|
|
40
|
+
const effectiveConstraints = resolveProjectConstraints(existingProjectConstraints);
|
|
41
|
+
const usingFallbackConstraints = !hasCustomProjectConstraints(existingProjectConstraints);
|
|
42
|
+
|
|
43
|
+
return [
|
|
44
|
+
"你要为一个本地代码仓库做“接续开发分析”。",
|
|
45
|
+
"目标不是直接改代码,而是判断当前已经做到哪里,并生成后续可执行 backlog。",
|
|
46
|
+
"",
|
|
47
|
+
"必须遵守以下原则:",
|
|
48
|
+
"1. 代码是事实源,开发文档是目标源。",
|
|
49
|
+
"2. 判断“已完成 / 部分完成 / 未完成”时,以仓库当前真实代码、测试、目录结构为准,不能盲信文档。",
|
|
50
|
+
"3. 生成的任务必须颗粒度足够,能直接进入开发,不允许输出“继续开发”这类空泛任务。",
|
|
51
|
+
"4. 只关注当前目标仓库,不要把其他仓库的任务混进来;如果文档覆盖多仓库,只提取当前仓库相关任务。",
|
|
52
|
+
"5. 如果某项工作依赖其他仓库或外部输入,允许输出 `blocked` 任务,但必须明确阻塞原因。",
|
|
53
|
+
"",
|
|
54
|
+
section("目标仓库", `- 路径:${repoRoot.replaceAll("\\", "/")}`),
|
|
55
|
+
section("开发文档入口", docsEntries.map((item) => `- ${item}`).join("\n")),
|
|
56
|
+
existingStateText ? section("已有状态摘要", existingStateText) : "",
|
|
57
|
+
listSection("内建安全底线", mandatoryGuardrails),
|
|
58
|
+
listSection(usingFallbackConstraints ? "默认工程约束(文档未明确时也必须遵守)" : "已有项目约束", effectiveConstraints),
|
|
59
|
+
existingBacklogText ? section("已有 backlog(供参考,可重组)", existingBacklogText) : "",
|
|
60
|
+
section("文档内容摘录", renderDocPackets(docPackets)),
|
|
61
|
+
section("输出要求", [
|
|
62
|
+
"1. 严格输出 JSON,不要带 Markdown 代码块。",
|
|
63
|
+
"2. `summary.currentState` 要清晰描述当前仓库真实进度。",
|
|
64
|
+
"3. `summary.implemented` 写已确认完成的关键能力。",
|
|
65
|
+
"4. `summary.remaining` 写尚未完成的关键缺口。",
|
|
66
|
+
"5. `summary.nextAction` 写最合理的下一步。",
|
|
67
|
+
"6. `tasks` 总数控制在 4 到 12 个之间,优先覆盖真正剩余工作。",
|
|
68
|
+
"7. 每个任务必须包含:id、title、status、priority、risk、goal、docs、paths、acceptance。",
|
|
69
|
+
"8. `status` 只能是 `done`、`pending`、`blocked`,不要输出 `in_progress` 或 `failed`。",
|
|
70
|
+
"9. `docs` 必须引用本次文档入口中的相关路径;`paths` 必须写当前仓库内的实际目录或文件模式。",
|
|
71
|
+
"10. `acceptance` 必须可验证,不要写空话。",
|
|
72
|
+
"11. `constraints` 只写从项目文档或现有项目配置中提炼出的项目特有约束;不要重复内建安全底线。",
|
|
73
|
+
].join("\n")),
|
|
74
|
+
].filter(Boolean).join("\n");
|
|
75
|
+
}
|
package/src/analyzer.mjs
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { summarizeBacklog, selectNextTask } from "./backlog.mjs";
|
|
4
|
+
import { nowIso, writeJson, writeText, readTextIfExists } from "./common.mjs";
|
|
5
|
+
import { loadPolicy, loadProjectConfig, scaffoldIfMissing, writeStateMarkdown, writeStatus } from "./config.mjs";
|
|
6
|
+
import { createContext } from "./context.mjs";
|
|
7
|
+
import { discoverWorkspace } from "./discovery.mjs";
|
|
8
|
+
import { readDocumentPackets } from "./doc_loader.mjs";
|
|
9
|
+
import { runCodexTask } from "./process.mjs";
|
|
10
|
+
import { buildAnalysisPrompt } from "./analyze_prompt.mjs";
|
|
11
|
+
|
|
12
|
+
function renderAnalysisState(context, backlog, analysis) {
|
|
13
|
+
const summary = summarizeBacklog(backlog);
|
|
14
|
+
const nextTask = selectNextTask(backlog);
|
|
15
|
+
|
|
16
|
+
return [
|
|
17
|
+
"## 当前状态",
|
|
18
|
+
`- backlog 文件:${path.relative(context.repoRoot, context.backlogFile).replaceAll("\\", "/")}`,
|
|
19
|
+
`- 总任务数:${summary.total}`,
|
|
20
|
+
`- 已完成:${summary.done}`,
|
|
21
|
+
`- 待处理:${summary.pending}`,
|
|
22
|
+
`- 进行中:${summary.inProgress}`,
|
|
23
|
+
`- 失败:${summary.failed}`,
|
|
24
|
+
`- 阻塞:${summary.blocked}`,
|
|
25
|
+
`- 当前任务:${nextTask ? nextTask.title : "无"}`,
|
|
26
|
+
`- 最近结果:${analysis.summary.currentState}`,
|
|
27
|
+
`- 下一建议:${analysis.summary.nextAction}`,
|
|
28
|
+
].join("\n");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function sanitizeTask(task) {
|
|
32
|
+
return {
|
|
33
|
+
id: String(task.id || "").trim(),
|
|
34
|
+
title: String(task.title || "").trim(),
|
|
35
|
+
status: ["done", "blocked"].includes(String(task.status || "")) ? String(task.status) : "pending",
|
|
36
|
+
priority: ["P0", "P1", "P2", "P3"].includes(String(task.priority || "")) ? String(task.priority) : "P2",
|
|
37
|
+
risk: ["medium", "high", "critical"].includes(String(task.risk || "")) ? String(task.risk) : "low",
|
|
38
|
+
goal: String(task.goal || "").trim(),
|
|
39
|
+
docs: Array.isArray(task.docs) ? task.docs.map((item) => String(item || "").trim()).filter(Boolean) : [],
|
|
40
|
+
paths: Array.isArray(task.paths) ? task.paths.map((item) => String(item || "").trim()).filter(Boolean) : [],
|
|
41
|
+
acceptance: Array.isArray(task.acceptance) ? task.acceptance.map((item) => String(item || "").trim()).filter(Boolean) : [],
|
|
42
|
+
dependsOn: Array.isArray(task.dependsOn) ? task.dependsOn.map((item) => String(item || "").trim()).filter(Boolean) : [],
|
|
43
|
+
verify: Array.isArray(task.verify) ? task.verify.map((item) => String(item || "").trim()).filter(Boolean) : [],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizeAnalysisPayload(payload, docsEntries) {
|
|
48
|
+
const tasks = Array.isArray(payload.tasks)
|
|
49
|
+
? payload.tasks.map((task) => sanitizeTask(task)).filter((task) => (
|
|
50
|
+
task.id && task.title && task.goal && task.acceptance.length
|
|
51
|
+
))
|
|
52
|
+
: [];
|
|
53
|
+
|
|
54
|
+
if (!tasks.length) {
|
|
55
|
+
throw new Error("Codex 分析结果无效:未生成可用任务。");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
project: String(payload.project || "").trim() || "helloloop-project",
|
|
60
|
+
summary: {
|
|
61
|
+
currentState: String(payload?.summary?.currentState || "").trim() || "已完成接续分析。",
|
|
62
|
+
implemented: Array.isArray(payload?.summary?.implemented) ? payload.summary.implemented.map((item) => String(item || "").trim()).filter(Boolean) : [],
|
|
63
|
+
remaining: Array.isArray(payload?.summary?.remaining) ? payload.summary.remaining.map((item) => String(item || "").trim()).filter(Boolean) : [],
|
|
64
|
+
nextAction: String(payload?.summary?.nextAction || "").trim() || "查看下一任务。",
|
|
65
|
+
},
|
|
66
|
+
constraints: Array.isArray(payload.constraints)
|
|
67
|
+
? payload.constraints.map((item) => String(item || "").trim()).filter(Boolean)
|
|
68
|
+
: [],
|
|
69
|
+
tasks,
|
|
70
|
+
requiredDocs: docsEntries,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function buildAnalysisSummaryText(context, analysis, backlog) {
|
|
75
|
+
const summary = summarizeBacklog(backlog);
|
|
76
|
+
const nextTask = selectNextTask(backlog);
|
|
77
|
+
|
|
78
|
+
return [
|
|
79
|
+
"HelloLoop 已完成接续分析。",
|
|
80
|
+
`项目仓库:${context.repoRoot}`,
|
|
81
|
+
`开发文档:${analysis.requiredDocs.join(", ")}`,
|
|
82
|
+
"",
|
|
83
|
+
"当前进度:",
|
|
84
|
+
analysis.summary.currentState,
|
|
85
|
+
"",
|
|
86
|
+
`任务统计:done ${summary.done} / pending ${summary.pending} / blocked ${summary.blocked}`,
|
|
87
|
+
nextTask ? `下一任务:${nextTask.title}` : "下一任务:暂无可执行任务",
|
|
88
|
+
"",
|
|
89
|
+
"下一步建议:",
|
|
90
|
+
`- npx helloloop next`,
|
|
91
|
+
`- npx helloloop run-once`,
|
|
92
|
+
].join("\n");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function analyzeWorkspace(options = {}) {
|
|
96
|
+
const discovery = discoverWorkspace({
|
|
97
|
+
cwd: options.cwd,
|
|
98
|
+
inputPath: options.inputPath,
|
|
99
|
+
repoRoot: options.repoRoot,
|
|
100
|
+
docsPath: options.docsPath,
|
|
101
|
+
configDirName: options.configDirName,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (!discovery.ok) {
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
code: discovery.code,
|
|
108
|
+
summary: discovery.message,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const context = createContext({
|
|
113
|
+
repoRoot: discovery.repoRoot,
|
|
114
|
+
configDirName: options.configDirName,
|
|
115
|
+
});
|
|
116
|
+
scaffoldIfMissing(context);
|
|
117
|
+
|
|
118
|
+
const existingProjectConfig = loadProjectConfig(context);
|
|
119
|
+
const existingStateText = readTextIfExists(context.stateFile, "");
|
|
120
|
+
const existingBacklogText = readTextIfExists(context.backlogFile, "");
|
|
121
|
+
const docPackets = readDocumentPackets(context.repoRoot, discovery.docsEntries, {
|
|
122
|
+
maxCharsPerFile: 18000,
|
|
123
|
+
maxTotalChars: 90000,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const prompt = buildAnalysisPrompt({
|
|
127
|
+
repoRoot: context.repoRoot,
|
|
128
|
+
docsEntries: discovery.docsEntries,
|
|
129
|
+
docPackets,
|
|
130
|
+
existingStateText,
|
|
131
|
+
existingBacklogText,
|
|
132
|
+
existingProjectConstraints: existingProjectConfig.constraints,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const runDir = path.join(context.runsDir, `${nowIso().replaceAll(":", "-").replaceAll(".", "-")}-analysis`);
|
|
136
|
+
const policy = loadPolicy(context);
|
|
137
|
+
const schemaFile = path.join(context.templatesDir, "analysis-output.schema.json");
|
|
138
|
+
const codexResult = await runCodexTask({
|
|
139
|
+
context,
|
|
140
|
+
prompt,
|
|
141
|
+
runDir,
|
|
142
|
+
model: policy.codex.model,
|
|
143
|
+
executable: policy.codex.executable,
|
|
144
|
+
sandbox: policy.codex.sandbox,
|
|
145
|
+
dangerouslyBypassSandbox: policy.codex.dangerouslyBypassSandbox,
|
|
146
|
+
outputSchemaFile: schemaFile,
|
|
147
|
+
outputPrefix: "analysis",
|
|
148
|
+
jsonOutput: false,
|
|
149
|
+
skipGitRepoCheck: true,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (!codexResult.ok) {
|
|
153
|
+
return {
|
|
154
|
+
ok: false,
|
|
155
|
+
code: "analysis_failed",
|
|
156
|
+
summary: codexResult.stderr || codexResult.stdout || "Codex 接续分析失败。",
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let payload;
|
|
161
|
+
try {
|
|
162
|
+
payload = JSON.parse(codexResult.finalMessage);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
code: "invalid_analysis_json",
|
|
167
|
+
summary: `Codex 分析结果无法解析为 JSON:${String(error?.message || error || "")}`,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const analysis = normalizeAnalysisPayload(payload, discovery.docsEntries);
|
|
172
|
+
const backlog = {
|
|
173
|
+
version: 1,
|
|
174
|
+
project: analysis.project,
|
|
175
|
+
updatedAt: nowIso(),
|
|
176
|
+
tasks: analysis.tasks,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const projectConfig = {
|
|
180
|
+
requiredDocs: analysis.requiredDocs,
|
|
181
|
+
constraints: analysis.constraints.length ? analysis.constraints : existingProjectConfig.constraints,
|
|
182
|
+
planner: existingProjectConfig.planner,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
writeJson(context.backlogFile, backlog);
|
|
186
|
+
writeJson(context.projectFile, projectConfig);
|
|
187
|
+
writeStateMarkdown(context, renderAnalysisState(context, backlog, analysis));
|
|
188
|
+
writeStatus(context, {
|
|
189
|
+
ok: true,
|
|
190
|
+
stage: "analyzed",
|
|
191
|
+
taskId: null,
|
|
192
|
+
taskTitle: "",
|
|
193
|
+
runDir,
|
|
194
|
+
summary: summarizeBacklog(backlog),
|
|
195
|
+
message: analysis.summary.currentState,
|
|
196
|
+
});
|
|
197
|
+
writeText(path.join(runDir, "analysis-summary.txt"), buildAnalysisSummaryText(context, analysis, backlog));
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
ok: true,
|
|
201
|
+
code: "analyzed",
|
|
202
|
+
context,
|
|
203
|
+
runDir,
|
|
204
|
+
analysis,
|
|
205
|
+
backlog,
|
|
206
|
+
summary: buildAnalysisSummaryText(context, analysis, backlog),
|
|
207
|
+
};
|
|
208
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -3,14 +3,36 @@ import { spawnSync } from "node:child_process";
|
|
|
3
3
|
|
|
4
4
|
import { createContext } from "./context.mjs";
|
|
5
5
|
import { fileExists } from "./common.mjs";
|
|
6
|
+
import { analyzeWorkspace } from "./analyzer.mjs";
|
|
6
7
|
import { scaffoldIfMissing } from "./config.mjs";
|
|
8
|
+
import { resolveRepoRoot } from "./discovery.mjs";
|
|
7
9
|
import { installPluginBundle } from "./install.mjs";
|
|
8
10
|
import { runLoop, runOnce, renderStatusText } from "./runner.mjs";
|
|
9
11
|
|
|
10
12
|
const REPO_ROOT_PLACEHOLDER = "<REPO_ROOT>";
|
|
13
|
+
const DOCS_PATH_PLACEHOLDER = "<DOCS_PATH>";
|
|
14
|
+
const KNOWN_COMMANDS = new Set([
|
|
15
|
+
"analyze",
|
|
16
|
+
"install",
|
|
17
|
+
"init",
|
|
18
|
+
"status",
|
|
19
|
+
"next",
|
|
20
|
+
"run-once",
|
|
21
|
+
"run-loop",
|
|
22
|
+
"doctor",
|
|
23
|
+
"help",
|
|
24
|
+
"--help",
|
|
25
|
+
"-h",
|
|
26
|
+
]);
|
|
11
27
|
|
|
12
28
|
function parseArgs(argv) {
|
|
13
|
-
const [
|
|
29
|
+
const [first = "", ...restArgs] = argv;
|
|
30
|
+
const command = !first
|
|
31
|
+
? "analyze"
|
|
32
|
+
: (KNOWN_COMMANDS.has(first) ? first : "analyze");
|
|
33
|
+
const rest = !first
|
|
34
|
+
? []
|
|
35
|
+
: (KNOWN_COMMANDS.has(first) ? restArgs : argv);
|
|
14
36
|
const options = {
|
|
15
37
|
requiredDocs: [],
|
|
16
38
|
constraints: [],
|
|
@@ -26,10 +48,12 @@ function parseArgs(argv) {
|
|
|
26
48
|
else if (arg === "--max-attempts") { options.maxAttempts = Number(rest[index + 1]); index += 1; }
|
|
27
49
|
else if (arg === "--max-strategies") { options.maxStrategies = Number(rest[index + 1]); index += 1; }
|
|
28
50
|
else if (arg === "--repo") { options.repoRoot = rest[index + 1]; index += 1; }
|
|
51
|
+
else if (arg === "--docs") { options.docsPath = rest[index + 1]; index += 1; }
|
|
29
52
|
else if (arg === "--codex-home") { options.codexHome = rest[index + 1]; index += 1; }
|
|
30
53
|
else if (arg === "--config-dir") { options.configDirName = rest[index + 1]; index += 1; }
|
|
31
54
|
else if (arg === "--required-doc") { options.requiredDocs.push(rest[index + 1]); index += 1; }
|
|
32
55
|
else if (arg === "--constraint") { options.constraints.push(rest[index + 1]); index += 1; }
|
|
56
|
+
else if (!options.inputPath) { options.inputPath = arg; }
|
|
33
57
|
else {
|
|
34
58
|
throw new Error(`未知参数:${arg}`);
|
|
35
59
|
}
|
|
@@ -40,9 +64,10 @@ function parseArgs(argv) {
|
|
|
40
64
|
|
|
41
65
|
function helpText() {
|
|
42
66
|
return [
|
|
43
|
-
"用法:helloloop
|
|
67
|
+
"用法:helloloop [command] [path] [options]",
|
|
44
68
|
"",
|
|
45
69
|
"命令:",
|
|
70
|
+
" analyze 自动发现仓库与开发文档,分析当前进度并生成/刷新 .helloloop(默认)",
|
|
46
71
|
" install 安装插件到 Codex Home(适合 npx / npm bin 分发)",
|
|
47
72
|
" init 初始化 .helloloop 配置",
|
|
48
73
|
" status 查看 backlog 与下一任务",
|
|
@@ -53,7 +78,8 @@ function helpText() {
|
|
|
53
78
|
"",
|
|
54
79
|
"选项:",
|
|
55
80
|
" --codex-home <dir> Codex Home,install 默认使用 ~/.codex",
|
|
56
|
-
" --repo <dir>
|
|
81
|
+
" --repo <dir> 高级选项:显式指定项目仓库根目录",
|
|
82
|
+
" --docs <dir|file> 高级选项:显式指定开发文档目录或文件",
|
|
57
83
|
" --config-dir <dir> 配置目录,默认 .helloloop",
|
|
58
84
|
" --dry-run 只生成提示与预览,不真正调用 codex",
|
|
59
85
|
" --task-id <id> 指定任务 id",
|
|
@@ -73,8 +99,9 @@ function printHelp() {
|
|
|
73
99
|
function renderFollowupExamples() {
|
|
74
100
|
return [
|
|
75
101
|
"下一步示例:",
|
|
76
|
-
`npx helloloop
|
|
77
|
-
|
|
102
|
+
`npx helloloop`,
|
|
103
|
+
`npx helloloop next`,
|
|
104
|
+
`如需显式补充路径:npx helloloop --repo ${REPO_ROOT_PLACEHOLDER} --docs ${DOCS_PATH_PLACEHOLDER}`,
|
|
78
105
|
].join("\n");
|
|
79
106
|
}
|
|
80
107
|
|
|
@@ -159,6 +186,23 @@ async function runDoctor(context) {
|
|
|
159
186
|
}
|
|
160
187
|
}
|
|
161
188
|
|
|
189
|
+
function resolveContextFromOptions(options) {
|
|
190
|
+
const resolvedRepo = resolveRepoRoot({
|
|
191
|
+
cwd: process.cwd(),
|
|
192
|
+
repoRoot: options.repoRoot,
|
|
193
|
+
inputPath: options.inputPath,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
if (!resolvedRepo.ok) {
|
|
197
|
+
throw new Error(resolvedRepo.message);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return createContext({
|
|
201
|
+
repoRoot: resolvedRepo.repoRoot,
|
|
202
|
+
configDirName: options.configDirName,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
162
206
|
export async function runCli(argv) {
|
|
163
207
|
const { command, options } = parseArgs(argv);
|
|
164
208
|
|
|
@@ -167,12 +211,11 @@ export async function runCli(argv) {
|
|
|
167
211
|
return;
|
|
168
212
|
}
|
|
169
213
|
|
|
170
|
-
const context = createContext({
|
|
171
|
-
repoRoot: options.repoRoot,
|
|
172
|
-
configDirName: options.configDirName,
|
|
173
|
-
});
|
|
174
|
-
|
|
175
214
|
if (command === "install") {
|
|
215
|
+
const context = createContext({
|
|
216
|
+
repoRoot: options.repoRoot,
|
|
217
|
+
configDirName: options.configDirName,
|
|
218
|
+
});
|
|
176
219
|
const result = installPluginBundle({
|
|
177
220
|
bundleRoot: context.bundleRoot,
|
|
178
221
|
codexHome: options.codexHome,
|
|
@@ -185,6 +228,27 @@ export async function runCli(argv) {
|
|
|
185
228
|
return;
|
|
186
229
|
}
|
|
187
230
|
|
|
231
|
+
if (command === "analyze") {
|
|
232
|
+
const result = await analyzeWorkspace({
|
|
233
|
+
cwd: process.cwd(),
|
|
234
|
+
inputPath: options.inputPath,
|
|
235
|
+
repoRoot: options.repoRoot,
|
|
236
|
+
docsPath: options.docsPath,
|
|
237
|
+
configDirName: options.configDirName,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
if (!result.ok) {
|
|
241
|
+
console.error(result.summary);
|
|
242
|
+
process.exitCode = 1;
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
console.log(result.summary);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const context = resolveContextFromOptions(options);
|
|
251
|
+
|
|
188
252
|
if (command === "init") {
|
|
189
253
|
const created = scaffoldIfMissing(context);
|
|
190
254
|
if (!created.length) {
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { expandDocumentEntries } from "./doc_loader.mjs";
|
|
4
|
+
import {
|
|
5
|
+
classifyExplicitPath,
|
|
6
|
+
findRepoRootFromPath,
|
|
7
|
+
looksLikeProjectRoot,
|
|
8
|
+
normalizeForRepo,
|
|
9
|
+
pathExists,
|
|
10
|
+
resolveAbsolute,
|
|
11
|
+
} from "./discovery_paths.mjs";
|
|
12
|
+
import {
|
|
13
|
+
inferDocsForRepo,
|
|
14
|
+
inferRepoFromDocs,
|
|
15
|
+
renderMissingDocsMessage,
|
|
16
|
+
renderMissingRepoMessage,
|
|
17
|
+
} from "./discovery_inference.mjs";
|
|
18
|
+
|
|
19
|
+
export { findRepoRootFromPath } from "./discovery_paths.mjs";
|
|
20
|
+
|
|
21
|
+
export function discoverWorkspace(options = {}) {
|
|
22
|
+
const cwd = path.resolve(options.cwd || process.cwd());
|
|
23
|
+
const configDirName = options.configDirName || ".helloloop";
|
|
24
|
+
const explicitRepoRoot = options.repoRoot ? resolveAbsolute(options.repoRoot, cwd) : "";
|
|
25
|
+
const explicitDocsPath = options.docsPath ? resolveAbsolute(options.docsPath, cwd) : "";
|
|
26
|
+
const explicitInputPath = options.inputPath ? resolveAbsolute(options.inputPath, cwd) : "";
|
|
27
|
+
|
|
28
|
+
if (explicitRepoRoot && !pathExists(explicitRepoRoot)) {
|
|
29
|
+
return { ok: false, code: "missing_repo_path", message: `项目路径不存在:${explicitRepoRoot}` };
|
|
30
|
+
}
|
|
31
|
+
if (explicitDocsPath && !pathExists(explicitDocsPath)) {
|
|
32
|
+
return { ok: false, code: "missing_docs_path", message: `开发文档路径不存在:${explicitDocsPath}` };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let repoRoot = explicitRepoRoot;
|
|
36
|
+
let docsEntries = explicitDocsPath ? [explicitDocsPath] : [];
|
|
37
|
+
let docCandidates = [];
|
|
38
|
+
let repoCandidates = [];
|
|
39
|
+
|
|
40
|
+
if (!repoRoot && !docsEntries.length) {
|
|
41
|
+
const classified = classifyExplicitPath(explicitInputPath);
|
|
42
|
+
if (classified.kind === "missing") {
|
|
43
|
+
return { ok: false, code: "missing_input_path", message: `路径不存在:${classified.absolutePath}` };
|
|
44
|
+
}
|
|
45
|
+
if (classified.kind === "docs") {
|
|
46
|
+
docsEntries = [classified.absolutePath];
|
|
47
|
+
}
|
|
48
|
+
if (classified.kind === "repo") {
|
|
49
|
+
repoRoot = classified.absolutePath;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!repoRoot && !docsEntries.length) {
|
|
54
|
+
const cwdRepoRoot = findRepoRootFromPath(cwd);
|
|
55
|
+
if (cwdRepoRoot) {
|
|
56
|
+
repoRoot = cwdRepoRoot;
|
|
57
|
+
} else if (classifyExplicitPath(cwd).kind === "docs") {
|
|
58
|
+
docsEntries = [cwd];
|
|
59
|
+
} else if (looksLikeProjectRoot(cwd)) {
|
|
60
|
+
repoRoot = cwd;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!repoRoot && docsEntries.length) {
|
|
65
|
+
const inferred = inferRepoFromDocs(docsEntries, cwd);
|
|
66
|
+
repoRoot = inferred.repoRoot;
|
|
67
|
+
repoCandidates = inferred.candidates;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!repoRoot) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
code: "missing_repo",
|
|
74
|
+
message: renderMissingRepoMessage(docsEntries, repoCandidates),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
repoRoot = findRepoRootFromPath(repoRoot) || repoRoot;
|
|
79
|
+
docsEntries = docsEntries.map((entry) => normalizeForRepo(repoRoot, entry));
|
|
80
|
+
|
|
81
|
+
if (!docsEntries.length) {
|
|
82
|
+
const inferred = inferDocsForRepo(repoRoot, cwd, configDirName);
|
|
83
|
+
docsEntries = inferred.docsEntries;
|
|
84
|
+
docCandidates = inferred.candidates;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!docsEntries.length) {
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
code: "missing_docs",
|
|
91
|
+
repoRoot,
|
|
92
|
+
message: renderMissingDocsMessage(repoRoot),
|
|
93
|
+
candidates: docCandidates,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const resolvedDocs = expandDocumentEntries(repoRoot, docsEntries);
|
|
98
|
+
if (!resolvedDocs.length) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
code: "invalid_docs",
|
|
102
|
+
repoRoot,
|
|
103
|
+
message: renderMissingDocsMessage(repoRoot),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
ok: true,
|
|
109
|
+
repoRoot,
|
|
110
|
+
docsEntries,
|
|
111
|
+
resolvedDocs,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function resolveRepoRoot(options = {}) {
|
|
116
|
+
const cwd = path.resolve(options.cwd || process.cwd());
|
|
117
|
+
const explicitRepoRoot = options.repoRoot ? resolveAbsolute(options.repoRoot, cwd) : "";
|
|
118
|
+
const explicitInputPath = options.inputPath ? resolveAbsolute(options.inputPath, cwd) : "";
|
|
119
|
+
|
|
120
|
+
if (explicitRepoRoot) {
|
|
121
|
+
if (!pathExists(explicitRepoRoot)) {
|
|
122
|
+
return { ok: false, message: `项目路径不存在:${explicitRepoRoot}` };
|
|
123
|
+
}
|
|
124
|
+
return { ok: true, repoRoot: findRepoRootFromPath(explicitRepoRoot) || explicitRepoRoot };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const classified = classifyExplicitPath(explicitInputPath);
|
|
128
|
+
if (classified.kind === "missing") {
|
|
129
|
+
return { ok: false, message: `路径不存在:${classified.absolutePath}` };
|
|
130
|
+
}
|
|
131
|
+
if (classified.kind === "repo") {
|
|
132
|
+
return { ok: true, repoRoot: findRepoRootFromPath(classified.absolutePath) || classified.absolutePath };
|
|
133
|
+
}
|
|
134
|
+
if (classified.kind === "docs") {
|
|
135
|
+
const inferred = inferRepoFromDocs([classified.absolutePath], cwd);
|
|
136
|
+
if (inferred.repoRoot) {
|
|
137
|
+
return { ok: true, repoRoot: inferred.repoRoot };
|
|
138
|
+
}
|
|
139
|
+
return { ok: false, message: renderMissingRepoMessage([classified.absolutePath], inferred.candidates) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const repoRoot = findRepoRootFromPath(cwd);
|
|
143
|
+
if (repoRoot) {
|
|
144
|
+
return { ok: true, repoRoot };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (looksLikeProjectRoot(cwd)) {
|
|
148
|
+
return { ok: true, repoRoot: cwd };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
ok: false,
|
|
153
|
+
message: "当前目录不是项目仓库。请切到项目目录,或传入一个项目路径/开发文档路径。",
|
|
154
|
+
};
|
|
155
|
+
}
|