helloloop 0.3.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.
Files changed (53) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +3 -3
  3. package/README.md +157 -81
  4. package/hosts/claude/marketplace/plugins/helloloop/.claude-plugin/plugin.json +1 -1
  5. package/hosts/claude/marketplace/plugins/helloloop/commands/helloloop.md +13 -10
  6. package/hosts/claude/marketplace/plugins/helloloop/skills/helloloop/SKILL.md +9 -4
  7. package/hosts/gemini/extension/GEMINI.md +12 -7
  8. package/hosts/gemini/extension/commands/helloloop.toml +14 -10
  9. package/hosts/gemini/extension/gemini-extension.json +1 -1
  10. package/package.json +2 -2
  11. package/skills/helloloop/SKILL.md +16 -6
  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 +50 -0
  25. package/src/discovery_prompt.mjs +2 -27
  26. package/src/engine_metadata.mjs +79 -0
  27. package/src/engine_selection.mjs +335 -0
  28. package/src/engine_selection_failure.mjs +51 -0
  29. package/src/engine_selection_messages.mjs +119 -0
  30. package/src/engine_selection_probe.mjs +78 -0
  31. package/src/engine_selection_prompt.mjs +48 -0
  32. package/src/engine_selection_settings.mjs +38 -0
  33. package/src/guardrails.mjs +15 -4
  34. package/src/install.mjs +6 -405
  35. package/src/install_claude.mjs +189 -0
  36. package/src/install_codex.mjs +114 -0
  37. package/src/install_gemini.mjs +43 -0
  38. package/src/install_shared.mjs +90 -0
  39. package/src/process.mjs +482 -39
  40. package/src/prompt.mjs +9 -5
  41. package/src/prompt_session.mjs +40 -0
  42. package/src/runner.mjs +3 -341
  43. package/src/runner_execute_task.mjs +301 -0
  44. package/src/runner_execution_support.mjs +155 -0
  45. package/src/runner_loop.mjs +106 -0
  46. package/src/runner_once.mjs +29 -0
  47. package/src/runner_status.mjs +104 -0
  48. package/src/runtime_recovery.mjs +301 -0
  49. package/src/shell_invocation.mjs +16 -0
  50. package/templates/analysis-output.schema.json +0 -1
  51. package/templates/policy.template.json +27 -0
  52. package/templates/project.template.json +2 -0
  53. 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
@@ -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 = {}) {
@@ -0,0 +1,79 @@
1
+ const ENGINE_METADATA = {
2
+ codex: {
3
+ name: "codex",
4
+ displayName: "Codex",
5
+ commandName: "codex",
6
+ hostDisplayName: "Codex CLI",
7
+ },
8
+ claude: {
9
+ name: "claude",
10
+ displayName: "Claude",
11
+ commandName: "claude",
12
+ hostDisplayName: "Claude Code",
13
+ },
14
+ gemini: {
15
+ name: "gemini",
16
+ displayName: "Gemini",
17
+ commandName: "gemini",
18
+ hostDisplayName: "Gemini CLI",
19
+ },
20
+ };
21
+
22
+ const HOST_METADATA = {
23
+ terminal: {
24
+ name: "terminal",
25
+ displayName: "终端",
26
+ },
27
+ codex: {
28
+ name: "codex",
29
+ displayName: "Codex",
30
+ },
31
+ claude: {
32
+ name: "claude",
33
+ displayName: "Claude",
34
+ },
35
+ gemini: {
36
+ name: "gemini",
37
+ displayName: "Gemini",
38
+ },
39
+ };
40
+
41
+ export function listKnownEngines() {
42
+ return Object.keys(ENGINE_METADATA);
43
+ }
44
+
45
+ export function normalizeEngineName(value) {
46
+ const normalized = String(value || "").trim().toLowerCase();
47
+ return ENGINE_METADATA[normalized] ? normalized : "";
48
+ }
49
+
50
+ export function isKnownEngine(value) {
51
+ return Boolean(normalizeEngineName(value));
52
+ }
53
+
54
+ export function getEngineMetadata(engine) {
55
+ return ENGINE_METADATA[normalizeEngineName(engine)] || null;
56
+ }
57
+
58
+ export function getEngineDisplayName(engine) {
59
+ return getEngineMetadata(engine)?.displayName || String(engine || "").trim();
60
+ }
61
+
62
+ export function normalizeHostContext(value) {
63
+ const normalized = String(value || "").trim().toLowerCase();
64
+ if (!normalized) {
65
+ return "terminal";
66
+ }
67
+ if (normalized === "shell") {
68
+ return "terminal";
69
+ }
70
+ return HOST_METADATA[normalized] ? normalized : "terminal";
71
+ }
72
+
73
+ export function getHostMetadata(hostContext) {
74
+ return HOST_METADATA[normalizeHostContext(hostContext)] || HOST_METADATA.terminal;
75
+ }
76
+
77
+ export function getHostDisplayName(hostContext) {
78
+ return getHostMetadata(hostContext).displayName;
79
+ }