helloloop 0.3.1 → 0.7.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.
Files changed (57) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +4 -4
  3. package/README.md +194 -81
  4. package/hosts/claude/marketplace/plugins/helloloop/.claude-plugin/plugin.json +1 -1
  5. package/hosts/claude/marketplace/plugins/helloloop/commands/helloloop.md +17 -13
  6. package/hosts/claude/marketplace/plugins/helloloop/skills/helloloop/SKILL.md +12 -5
  7. package/hosts/gemini/extension/GEMINI.md +14 -7
  8. package/hosts/gemini/extension/commands/helloloop.toml +17 -12
  9. package/hosts/gemini/extension/gemini-extension.json +1 -1
  10. package/package.json +2 -2
  11. package/skills/helloloop/SKILL.md +18 -7
  12. package/src/analyze_confirmation.mjs +29 -5
  13. package/src/analyze_prompt.mjs +5 -1
  14. package/src/analyze_user_input.mjs +20 -2
  15. package/src/analyzer.mjs +130 -43
  16. package/src/cli.mjs +32 -492
  17. package/src/cli_analyze_command.mjs +248 -0
  18. package/src/cli_args.mjs +106 -0
  19. package/src/cli_command_handlers.mjs +120 -0
  20. package/src/cli_context.mjs +31 -0
  21. package/src/cli_render.mjs +70 -0
  22. package/src/cli_support.mjs +11 -14
  23. package/src/completion_review.mjs +243 -0
  24. package/src/config.mjs +51 -0
  25. package/src/discovery.mjs +21 -2
  26. package/src/discovery_prompt.mjs +2 -27
  27. package/src/email_notification.mjs +343 -0
  28. package/src/engine_metadata.mjs +79 -0
  29. package/src/engine_process_support.mjs +294 -0
  30. package/src/engine_selection.mjs +335 -0
  31. package/src/engine_selection_failure.mjs +51 -0
  32. package/src/engine_selection_messages.mjs +119 -0
  33. package/src/engine_selection_probe.mjs +78 -0
  34. package/src/engine_selection_prompt.mjs +48 -0
  35. package/src/engine_selection_settings.mjs +104 -0
  36. package/src/global_config.mjs +21 -0
  37. package/src/guardrails.mjs +15 -4
  38. package/src/install.mjs +6 -405
  39. package/src/install_claude.mjs +189 -0
  40. package/src/install_codex.mjs +114 -0
  41. package/src/install_gemini.mjs +43 -0
  42. package/src/install_shared.mjs +138 -0
  43. package/src/process.mjs +567 -100
  44. package/src/prompt.mjs +9 -5
  45. package/src/prompt_session.mjs +40 -0
  46. package/src/runner.mjs +3 -341
  47. package/src/runner_execute_task.mjs +255 -0
  48. package/src/runner_execution_support.mjs +146 -0
  49. package/src/runner_loop.mjs +106 -0
  50. package/src/runner_once.mjs +29 -0
  51. package/src/runner_status.mjs +104 -0
  52. package/src/runtime_recovery.mjs +302 -0
  53. package/src/shell_invocation.mjs +16 -0
  54. package/templates/analysis-output.schema.json +0 -1
  55. package/templates/policy.template.json +25 -0
  56. package/templates/project.template.json +2 -0
  57. 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,19 @@ 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
+ heartbeatIntervalSeconds: 60,
24
+ stallWarningSeconds: 900,
25
+ maxIdleSeconds: 2700,
26
+ killGraceSeconds: 10,
27
+ healthProbeTimeoutSeconds: 120,
28
+ hardRetryDelaysSeconds: [900, 900, 900, 900, 900],
29
+ softRetryDelaysSeconds: [900, 900, 900, 900, 900, 1800, 1800, 3600, 5400, 7200, 9000, 10800],
30
+ },
20
31
  codex: {
21
32
  model: "",
22
33
  executable: "",
@@ -24,6 +35,20 @@ const defaultPolicy = {
24
35
  dangerouslyBypassSandbox: false,
25
36
  jsonOutput: true,
26
37
  },
38
+ claude: {
39
+ model: "",
40
+ executable: "",
41
+ permissionMode: "bypassPermissions",
42
+ analysisPermissionMode: "plan",
43
+ outputFormat: "text",
44
+ },
45
+ gemini: {
46
+ model: "",
47
+ executable: "",
48
+ approvalMode: "yolo",
49
+ analysisApprovalMode: "plan",
50
+ outputFormat: "text",
51
+ },
27
52
  };
28
53
 
29
54
  const defaultPlanner = {
@@ -46,6 +71,24 @@ export function loadPolicy(context) {
46
71
  ...defaultPolicy.codex,
47
72
  ...(policy.codex || {}),
48
73
  };
74
+ policy.claude = {
75
+ ...defaultPolicy.claude,
76
+ ...(policy.claude || {}),
77
+ };
78
+ policy.gemini = {
79
+ ...defaultPolicy.gemini,
80
+ ...(policy.gemini || {}),
81
+ };
82
+ policy.runtimeRecovery = {
83
+ ...defaultPolicy.runtimeRecovery,
84
+ ...(policy.runtimeRecovery || {}),
85
+ hardRetryDelaysSeconds: Array.isArray(policy?.runtimeRecovery?.hardRetryDelaysSeconds)
86
+ ? policy.runtimeRecovery.hardRetryDelaysSeconds
87
+ : defaultPolicy.runtimeRecovery.hardRetryDelaysSeconds,
88
+ softRetryDelaysSeconds: Array.isArray(policy?.runtimeRecovery?.softRetryDelaysSeconds)
89
+ ? policy.runtimeRecovery.softRetryDelaysSeconds
90
+ : defaultPolicy.runtimeRecovery.softRetryDelaysSeconds,
91
+ };
49
92
  return policy;
50
93
  }
51
94
 
@@ -54,6 +97,8 @@ export function loadProjectConfig(context) {
54
97
  return {
55
98
  requiredDocs: [],
56
99
  constraints: [],
100
+ defaultEngine: "",
101
+ lastSelectedEngine: "",
57
102
  planner: defaultPlanner,
58
103
  };
59
104
  }
@@ -62,6 +107,8 @@ export function loadProjectConfig(context) {
62
107
  return {
63
108
  requiredDocs: Array.isArray(config.requiredDocs) ? config.requiredDocs : [],
64
109
  constraints: Array.isArray(config.constraints) ? config.constraints : [],
110
+ defaultEngine: typeof config.defaultEngine === "string" ? config.defaultEngine : "",
111
+ lastSelectedEngine: typeof config.lastSelectedEngine === "string" ? config.lastSelectedEngine : "",
65
112
  planner: {
66
113
  ...defaultPlanner,
67
114
  ...(config.planner || {}),
@@ -87,6 +134,10 @@ export function saveBacklog(context, backlog) {
87
134
  });
88
135
  }
89
136
 
137
+ export function saveProjectConfig(context, config) {
138
+ writeJson(context.projectFile, config);
139
+ }
140
+
90
141
  export function loadVerifyCommands(context) {
91
142
  const raw = readTextIfExists(context.repoVerifyFile, "");
92
143
  return raw
package/src/discovery.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import os from "node:os";
1
2
  import path from "node:path";
2
3
 
3
4
  import { expandDocumentEntries } from "./doc_loader.mjs";
@@ -49,6 +50,11 @@ const DOCS_SOURCE_META = {
49
50
  repo_docs: { label: "仓库 docs 目录", confidence: "medium" },
50
51
  };
51
52
 
53
+ function isImplicitHomeDirectory(targetPath) {
54
+ return Boolean(targetPath)
55
+ && path.resolve(targetPath) === path.resolve(os.homedir());
56
+ }
57
+
52
58
  function pushBasis(basis, message) {
53
59
  if (message && !basis.includes(message)) {
54
60
  basis.push(message);
@@ -127,6 +133,10 @@ export function discoverWorkspace(options = {}) {
127
133
  const explicitRepoRoot = options.repoRoot ? resolveAbsolute(options.repoRoot, cwd) : "";
128
134
  const explicitDocsPath = options.docsPath ? resolveAbsolute(options.docsPath, cwd) : "";
129
135
  const explicitInputPath = options.inputPath ? resolveAbsolute(options.inputPath, cwd) : "";
136
+ const treatCwdAsHomeWorkspace = !explicitRepoRoot
137
+ && !explicitDocsPath
138
+ && !explicitInputPath
139
+ && isImplicitHomeDirectory(cwd);
130
140
 
131
141
  if (explicitRepoRoot && !pathExists(explicitRepoRoot)) {
132
142
  if (!allowNewRepoRoot) {
@@ -196,13 +206,15 @@ export function discoverWorkspace(options = {}) {
196
206
  }
197
207
 
198
208
  if (!repoRoot && !docsEntries.length) {
199
- const cwdRepoRoot = findRepoRootFromPath(cwd);
209
+ const cwdRepoRoot = treatCwdAsHomeWorkspace ? "" : findRepoRootFromPath(cwd);
200
210
  if (cwdRepoRoot) {
201
211
  repoRoot = cwdRepoRoot;
202
212
  repoSource = "cwd_repo";
203
213
  pushBasis(repoBasis, "当前终端目录已经位于一个项目仓库内。");
204
214
  } else {
205
- const cwdClassified = classifyExplicitPath(cwd);
215
+ const cwdClassified = treatCwdAsHomeWorkspace
216
+ ? { kind: "workspace", absolutePath: cwd }
217
+ : classifyExplicitPath(cwd);
206
218
  if (cwdClassified.kind === "docs") {
207
219
  docsEntries = [cwdClassified.absolutePath];
208
220
  docsSource = "cwd_docs";
@@ -373,6 +385,13 @@ export function resolveRepoRoot(options = {}) {
373
385
  return { ok: false, message: renderMissingRepoMessage([classified.absolutePath], inferred.candidates) };
374
386
  }
375
387
 
388
+ if (!explicitRepoRoot && !explicitInputPath && isImplicitHomeDirectory(cwd)) {
389
+ return {
390
+ ok: false,
391
+ message: "当前目录看起来是用户主目录;HelloLoop 不会把主目录自动当作项目仓库。请切到项目目录,或传入一个项目路径/开发文档路径。",
392
+ };
393
+ }
394
+
376
395
  const repoRoot = findRepoRootFromPath(cwd);
377
396
  if (repoRoot) {
378
397
  return { ok: true, repoRoot };
@@ -1,6 +1,5 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { createInterface } from "node:readline/promises";
4
3
 
5
4
  import {
6
5
  listDocFilesInDirectory,
@@ -8,38 +7,14 @@ import {
8
7
  pathExists,
9
8
  resolveAbsolute,
10
9
  } from "./discovery_paths.mjs";
10
+ import { createPromptSession } from "./prompt_session.mjs";
11
11
 
12
12
  function toDisplayPath(targetPath) {
13
13
  return String(targetPath || "").replaceAll("\\", "/");
14
14
  }
15
15
 
16
16
  export function createDiscoveryPromptSession() {
17
- if (process.stdin.isTTY) {
18
- const readline = createInterface({
19
- input: process.stdin,
20
- output: process.stdout,
21
- });
22
- return {
23
- async question(promptText) {
24
- return readline.question(promptText);
25
- },
26
- close() {
27
- readline.close();
28
- },
29
- };
30
- }
31
-
32
- const bufferedAnswers = fs.readFileSync(0, "utf8").split(/\r?\n/);
33
- let answerIndex = 0;
34
- return {
35
- async question(promptText) {
36
- process.stdout.write(promptText);
37
- const answer = bufferedAnswers[answerIndex] ?? "";
38
- answerIndex += 1;
39
- return answer;
40
- },
41
- close() {},
42
- };
17
+ return createPromptSession();
43
18
  }
44
19
 
45
20
  function summarizeList(items, options = {}) {