helloloop 0.2.1 → 0.6.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 +3 -3
- package/README.md +297 -272
- package/hosts/claude/marketplace/plugins/helloloop/.claude-plugin/plugin.json +1 -1
- package/hosts/claude/marketplace/plugins/helloloop/commands/helloloop.md +19 -9
- package/hosts/claude/marketplace/plugins/helloloop/skills/helloloop/SKILL.md +12 -4
- package/hosts/gemini/extension/GEMINI.md +13 -4
- package/hosts/gemini/extension/commands/helloloop.toml +19 -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 +42 -7
- package/src/analyze_confirmation.mjs +108 -8
- package/src/analyze_prompt.mjs +17 -1
- package/src/analyze_user_input.mjs +321 -0
- package/src/analyzer.mjs +167 -42
- package/src/cli.mjs +34 -308
- package/src/cli_analyze_command.mjs +248 -0
- package/src/cli_args.mjs +106 -0
- package/src/cli_command_handlers.mjs +120 -0
- package/src/cli_context.mjs +31 -0
- package/src/cli_render.mjs +70 -0
- package/src/cli_support.mjs +95 -31
- package/src/completion_review.mjs +243 -0
- package/src/config.mjs +50 -0
- 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 +273 -0
- package/src/engine_metadata.mjs +79 -0
- package/src/engine_selection.mjs +335 -0
- package/src/engine_selection_failure.mjs +51 -0
- package/src/engine_selection_messages.mjs +119 -0
- package/src/engine_selection_probe.mjs +78 -0
- package/src/engine_selection_prompt.mjs +48 -0
- package/src/engine_selection_settings.mjs +38 -0
- package/src/guardrails.mjs +15 -4
- package/src/install.mjs +20 -266
- package/src/install_claude.mjs +189 -0
- package/src/install_codex.mjs +114 -0
- package/src/install_gemini.mjs +43 -0
- package/src/install_shared.mjs +90 -0
- package/src/process.mjs +482 -39
- package/src/prompt.mjs +9 -5
- package/src/prompt_session.mjs +40 -0
- package/src/rebuild.mjs +116 -0
- package/src/runner.mjs +3 -341
- package/src/runner_execute_task.mjs +301 -0
- package/src/runner_execution_support.mjs +155 -0
- package/src/runner_loop.mjs +106 -0
- package/src/runner_once.mjs +29 -0
- package/src/runner_status.mjs +104 -0
- package/src/runtime_recovery.mjs +301 -0
- package/src/shell_invocation.mjs +16 -0
- package/templates/analysis-output.schema.json +58 -1
- package/templates/policy.template.json +27 -0
- package/templates/project.template.json +2 -0
- package/templates/task-review-output.schema.json +70 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { formatList, tailText } from "./common.mjs";
|
|
4
|
+
import { runEngineTask } from "./process.mjs";
|
|
5
|
+
|
|
6
|
+
function section(title, content) {
|
|
7
|
+
if (!content || !String(content).trim()) {
|
|
8
|
+
return "";
|
|
9
|
+
}
|
|
10
|
+
return `## ${title}\n${String(content).trim()}\n`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function listSection(title, items) {
|
|
14
|
+
if (!Array.isArray(items) || !items.length) {
|
|
15
|
+
return "";
|
|
16
|
+
}
|
|
17
|
+
return section(title, formatList(items));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalizeDocEntry(doc) {
|
|
21
|
+
return String(doc || "").trim().replaceAll("\\", "/");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalizeDocList(items) {
|
|
25
|
+
const result = [];
|
|
26
|
+
const seen = new Set();
|
|
27
|
+
|
|
28
|
+
for (const item of items || []) {
|
|
29
|
+
const normalized = normalizeDocEntry(item);
|
|
30
|
+
if (!normalized) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const key = normalized.toLowerCase();
|
|
35
|
+
if (seen.has(key)) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
seen.add(key);
|
|
39
|
+
result.push(normalized);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function renderVerifyResultLines(verifyResult) {
|
|
46
|
+
if (!verifyResult?.results?.length) {
|
|
47
|
+
return [
|
|
48
|
+
"- 未执行额外验证命令",
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return verifyResult.results.map((item) => {
|
|
53
|
+
const stdoutTail = tailText(item.stdout, 6) || "无";
|
|
54
|
+
const stderrTail = tailText(item.stderr, 6) || "无";
|
|
55
|
+
return [
|
|
56
|
+
`- ${item.ok ? "通过" : "失败"}:${item.command}`,
|
|
57
|
+
` stdout: ${stdoutTail}`,
|
|
58
|
+
` stderr: ${stderrTail}`,
|
|
59
|
+
].join("\n");
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function buildTaskReviewPrompt({
|
|
64
|
+
task,
|
|
65
|
+
requiredDocs = [],
|
|
66
|
+
constraints = [],
|
|
67
|
+
repoStateText = "",
|
|
68
|
+
engineFinalMessage = "",
|
|
69
|
+
verifyResult = null,
|
|
70
|
+
}) {
|
|
71
|
+
const allDocs = normalizeDocList([
|
|
72
|
+
...requiredDocs,
|
|
73
|
+
...(task.docs || []),
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
return [
|
|
77
|
+
"你要做的是“任务完成复核”,目标是判断当前任务是否真的已经完成。",
|
|
78
|
+
"不要相信执行代理口头说“已完成”;必须直接检查仓库当前代码、测试和产物。",
|
|
79
|
+
"如果只是做到一半、只改了部分文件、或者验收条件仍缺项,都不能判定为完成。",
|
|
80
|
+
"",
|
|
81
|
+
section("当前任务", [
|
|
82
|
+
`- 标题:${task.title}`,
|
|
83
|
+
`- 编号:${task.id}`,
|
|
84
|
+
`- 目标:${task.goal || "按文档完成当前工作包。"}`,
|
|
85
|
+
`- 风险:${task.risk || "low"}`,
|
|
86
|
+
].join("\n")),
|
|
87
|
+
listSection("开发文档", allDocs),
|
|
88
|
+
listSection("涉及路径", task.paths || []),
|
|
89
|
+
listSection("验收条件", task.acceptance || []),
|
|
90
|
+
constraints.length ? listSection("项目约束", constraints) : "",
|
|
91
|
+
repoStateText ? section("当前仓库状态摘要", repoStateText) : "",
|
|
92
|
+
engineFinalMessage
|
|
93
|
+
? section("执行代理最后输出", tailText(engineFinalMessage, 20))
|
|
94
|
+
: "",
|
|
95
|
+
section("验证结果", renderVerifyResultLines(verifyResult).join("\n")),
|
|
96
|
+
section("判定规则", [
|
|
97
|
+
"1. 只有当所有验收条件都有明确仓库证据支撑时,才能输出 `complete`。",
|
|
98
|
+
"2. 只要有任一验收条件未满足、证据不足、或只能部分成立,就输出 `incomplete`。",
|
|
99
|
+
"3. 只有外部权限、环境损坏、文档缺口、不可获得依赖等真正硬阻塞,才允许输出 `blocked`。",
|
|
100
|
+
"4. “代理提前停止”“只完成一部分”“建议下次继续”都属于 `incomplete`,不是 `blocked`。",
|
|
101
|
+
"5. 不要给泛泛建议,`missing` 和 `nextAction` 必须直接指出还差什么。",
|
|
102
|
+
].join("\n")),
|
|
103
|
+
section("输出要求", [
|
|
104
|
+
"1. 严格输出 JSON,不要带 Markdown 代码块。",
|
|
105
|
+
"2. `verdict` 只能是 `complete`、`incomplete`、`blocked`。",
|
|
106
|
+
"3. `acceptanceChecks` 必须逐条覆盖本任务所有验收条件。",
|
|
107
|
+
"4. `acceptanceChecks[].status` 只能是 `met`、`not_met`、`uncertain`。",
|
|
108
|
+
"5. `acceptanceChecks[].evidence` 必须写具体证据或缺口,不能只写“已完成”。",
|
|
109
|
+
"6. 若 `verdict=complete`,则所有 `acceptanceChecks[].status` 必须都是 `met`,且 `missing` 为空。",
|
|
110
|
+
"7. 若 `verdict=blocked`,`blockerReason` 必须明确写出硬阻塞原因。",
|
|
111
|
+
].join("\n")),
|
|
112
|
+
].filter(Boolean).join("\n");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function normalizeAcceptanceCheck(check) {
|
|
116
|
+
return {
|
|
117
|
+
item: String(check?.item || "").trim(),
|
|
118
|
+
status: ["met", "not_met", "uncertain"].includes(String(check?.status || ""))
|
|
119
|
+
? String(check.status)
|
|
120
|
+
: "uncertain",
|
|
121
|
+
evidence: String(check?.evidence || "").trim() || "未提供可用证据。",
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function normalizeTaskReviewPayload(payload, task) {
|
|
126
|
+
const requestedAcceptance = Array.isArray(task?.acceptance)
|
|
127
|
+
? task.acceptance.map((item) => String(item || "").trim()).filter(Boolean)
|
|
128
|
+
: [];
|
|
129
|
+
const receivedChecks = Array.isArray(payload?.acceptanceChecks)
|
|
130
|
+
? payload.acceptanceChecks.map((item) => normalizeAcceptanceCheck(item)).filter((item) => item.item)
|
|
131
|
+
: [];
|
|
132
|
+
|
|
133
|
+
const normalizedChecks = requestedAcceptance.map((item) => {
|
|
134
|
+
const matched = receivedChecks.find((check) => check.item === item);
|
|
135
|
+
return matched || {
|
|
136
|
+
item,
|
|
137
|
+
status: "uncertain",
|
|
138
|
+
evidence: "复核结果未覆盖该验收条件。",
|
|
139
|
+
};
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const missing = Array.isArray(payload?.missing)
|
|
143
|
+
? payload.missing.map((item) => String(item || "").trim()).filter(Boolean)
|
|
144
|
+
: [];
|
|
145
|
+
const blockerReason = String(payload?.blockerReason || "").trim();
|
|
146
|
+
const requestedVerdict = ["complete", "incomplete", "blocked"].includes(String(payload?.verdict || ""))
|
|
147
|
+
? String(payload.verdict)
|
|
148
|
+
: "incomplete";
|
|
149
|
+
const hasUnmetAcceptance = normalizedChecks.some((item) => item.status !== "met");
|
|
150
|
+
const verdict = requestedVerdict === "complete" && (hasUnmetAcceptance || missing.length)
|
|
151
|
+
? "incomplete"
|
|
152
|
+
: requestedVerdict;
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
verdict,
|
|
156
|
+
summary: String(payload?.summary || "").trim() || "任务完成复核已结束。",
|
|
157
|
+
acceptanceChecks: normalizedChecks,
|
|
158
|
+
missing,
|
|
159
|
+
blockerReason,
|
|
160
|
+
nextAction: String(payload?.nextAction || "").trim() || "请根据缺口继续完成当前任务。",
|
|
161
|
+
isComplete: verdict === "complete" && !hasUnmetAcceptance && missing.length === 0,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function renderTaskReviewSummary(review) {
|
|
166
|
+
const acceptanceLines = review.acceptanceChecks.map((item) => (
|
|
167
|
+
`- ${item.status === "met" ? "已满足" : (item.status === "not_met" ? "未满足" : "待确认")}:${item.item};${item.evidence}`
|
|
168
|
+
));
|
|
169
|
+
|
|
170
|
+
return [
|
|
171
|
+
`任务复核结论:${review.summary}`,
|
|
172
|
+
...acceptanceLines,
|
|
173
|
+
...(review.missing.length
|
|
174
|
+
? review.missing.map((item) => `- 剩余缺口:${item}`)
|
|
175
|
+
: []),
|
|
176
|
+
review.blockerReason ? `- 硬阻塞:${review.blockerReason}` : "",
|
|
177
|
+
`- 下一动作:${review.nextAction}`,
|
|
178
|
+
].filter(Boolean).join("\n");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function reviewTaskCompletion({
|
|
182
|
+
engine,
|
|
183
|
+
context,
|
|
184
|
+
task,
|
|
185
|
+
requiredDocs = [],
|
|
186
|
+
constraints = [],
|
|
187
|
+
repoStateText = "",
|
|
188
|
+
engineFinalMessage = "",
|
|
189
|
+
verifyResult = null,
|
|
190
|
+
runDir,
|
|
191
|
+
policy = {},
|
|
192
|
+
}) {
|
|
193
|
+
const prompt = buildTaskReviewPrompt({
|
|
194
|
+
task,
|
|
195
|
+
requiredDocs,
|
|
196
|
+
constraints,
|
|
197
|
+
repoStateText,
|
|
198
|
+
engineFinalMessage,
|
|
199
|
+
verifyResult,
|
|
200
|
+
});
|
|
201
|
+
const schemaFile = path.join(context.templatesDir, "task-review-output.schema.json");
|
|
202
|
+
const reviewResult = await runEngineTask({
|
|
203
|
+
engine,
|
|
204
|
+
context,
|
|
205
|
+
prompt,
|
|
206
|
+
runDir,
|
|
207
|
+
policy,
|
|
208
|
+
executionMode: "analyze",
|
|
209
|
+
outputSchemaFile: schemaFile,
|
|
210
|
+
outputPrefix: `${engine}-task-review`,
|
|
211
|
+
skipGitRepoCheck: true,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
if (!reviewResult.ok) {
|
|
215
|
+
return {
|
|
216
|
+
ok: false,
|
|
217
|
+
code: "task_review_failed",
|
|
218
|
+
summary: reviewResult.stderr || reviewResult.stdout || "任务完成复核失败。",
|
|
219
|
+
raw: reviewResult,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let payload;
|
|
224
|
+
try {
|
|
225
|
+
payload = JSON.parse(reviewResult.finalMessage);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
return {
|
|
228
|
+
ok: false,
|
|
229
|
+
code: "invalid_task_review_json",
|
|
230
|
+
summary: `任务完成复核结果无法解析为 JSON:${String(error?.message || error || "")}`,
|
|
231
|
+
raw: reviewResult,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const review = normalizeTaskReviewPayload(payload, task);
|
|
236
|
+
return {
|
|
237
|
+
ok: true,
|
|
238
|
+
code: "task_reviewed",
|
|
239
|
+
review,
|
|
240
|
+
summary: renderTaskReviewSummary(review),
|
|
241
|
+
raw: reviewResult,
|
|
242
|
+
};
|
|
243
|
+
}
|
package/src/config.mjs
CHANGED
|
@@ -15,8 +15,21 @@ const defaultPolicy = {
|
|
|
15
15
|
maxLoopTasks: 4,
|
|
16
16
|
maxTaskAttempts: 2,
|
|
17
17
|
maxTaskStrategies: 4,
|
|
18
|
+
maxReanalysisPasses: 3,
|
|
18
19
|
stopOnFailure: false,
|
|
19
20
|
stopOnHighRisk: true,
|
|
21
|
+
runtimeRecovery: {
|
|
22
|
+
enabled: true,
|
|
23
|
+
allowEngineSwitch: false,
|
|
24
|
+
heartbeatIntervalSeconds: 60,
|
|
25
|
+
stallWarningSeconds: 900,
|
|
26
|
+
maxIdleSeconds: 2700,
|
|
27
|
+
killGraceSeconds: 10,
|
|
28
|
+
maxPhaseRecoveries: 4,
|
|
29
|
+
retryDelaysSeconds: [120, 300, 900, 1800],
|
|
30
|
+
retryOnUnknownFailure: true,
|
|
31
|
+
maxUnknownRecoveries: 1,
|
|
32
|
+
},
|
|
20
33
|
codex: {
|
|
21
34
|
model: "",
|
|
22
35
|
executable: "",
|
|
@@ -24,6 +37,20 @@ const defaultPolicy = {
|
|
|
24
37
|
dangerouslyBypassSandbox: false,
|
|
25
38
|
jsonOutput: true,
|
|
26
39
|
},
|
|
40
|
+
claude: {
|
|
41
|
+
model: "",
|
|
42
|
+
executable: "",
|
|
43
|
+
permissionMode: "bypassPermissions",
|
|
44
|
+
analysisPermissionMode: "plan",
|
|
45
|
+
outputFormat: "text",
|
|
46
|
+
},
|
|
47
|
+
gemini: {
|
|
48
|
+
model: "",
|
|
49
|
+
executable: "",
|
|
50
|
+
approvalMode: "yolo",
|
|
51
|
+
analysisApprovalMode: "plan",
|
|
52
|
+
outputFormat: "text",
|
|
53
|
+
},
|
|
27
54
|
};
|
|
28
55
|
|
|
29
56
|
const defaultPlanner = {
|
|
@@ -46,6 +73,21 @@ export function loadPolicy(context) {
|
|
|
46
73
|
...defaultPolicy.codex,
|
|
47
74
|
...(policy.codex || {}),
|
|
48
75
|
};
|
|
76
|
+
policy.claude = {
|
|
77
|
+
...defaultPolicy.claude,
|
|
78
|
+
...(policy.claude || {}),
|
|
79
|
+
};
|
|
80
|
+
policy.gemini = {
|
|
81
|
+
...defaultPolicy.gemini,
|
|
82
|
+
...(policy.gemini || {}),
|
|
83
|
+
};
|
|
84
|
+
policy.runtimeRecovery = {
|
|
85
|
+
...defaultPolicy.runtimeRecovery,
|
|
86
|
+
...(policy.runtimeRecovery || {}),
|
|
87
|
+
retryDelaysSeconds: Array.isArray(policy?.runtimeRecovery?.retryDelaysSeconds)
|
|
88
|
+
? policy.runtimeRecovery.retryDelaysSeconds
|
|
89
|
+
: defaultPolicy.runtimeRecovery.retryDelaysSeconds,
|
|
90
|
+
};
|
|
49
91
|
return policy;
|
|
50
92
|
}
|
|
51
93
|
|
|
@@ -54,6 +96,8 @@ export function loadProjectConfig(context) {
|
|
|
54
96
|
return {
|
|
55
97
|
requiredDocs: [],
|
|
56
98
|
constraints: [],
|
|
99
|
+
defaultEngine: "",
|
|
100
|
+
lastSelectedEngine: "",
|
|
57
101
|
planner: defaultPlanner,
|
|
58
102
|
};
|
|
59
103
|
}
|
|
@@ -62,6 +106,8 @@ export function loadProjectConfig(context) {
|
|
|
62
106
|
return {
|
|
63
107
|
requiredDocs: Array.isArray(config.requiredDocs) ? config.requiredDocs : [],
|
|
64
108
|
constraints: Array.isArray(config.constraints) ? config.constraints : [],
|
|
109
|
+
defaultEngine: typeof config.defaultEngine === "string" ? config.defaultEngine : "",
|
|
110
|
+
lastSelectedEngine: typeof config.lastSelectedEngine === "string" ? config.lastSelectedEngine : "",
|
|
65
111
|
planner: {
|
|
66
112
|
...defaultPlanner,
|
|
67
113
|
...(config.planner || {}),
|
|
@@ -87,6 +133,10 @@ export function saveBacklog(context, backlog) {
|
|
|
87
133
|
});
|
|
88
134
|
}
|
|
89
135
|
|
|
136
|
+
export function saveProjectConfig(context, config) {
|
|
137
|
+
writeJson(context.projectFile, config);
|
|
138
|
+
}
|
|
139
|
+
|
|
90
140
|
export function loadVerifyCommands(context) {
|
|
91
141
|
const raw = readTextIfExists(context.repoVerifyFile, "");
|
|
92
142
|
return raw
|
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
|
|