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,155 @@
1
+ import {
2
+ rememberEngineSelection,
3
+ resolveEngineSelection,
4
+ } from "./engine_selection.mjs";
5
+ import { nowIso } from "./common.mjs";
6
+ import {
7
+ loadBacklog,
8
+ loadPolicy,
9
+ loadProjectConfig,
10
+ loadRepoStateText,
11
+ loadVerifyCommands,
12
+ saveBacklog,
13
+ } from "./config.mjs";
14
+ import { getTask, selectNextTask, unresolvedDependencies, updateTask } from "./backlog.mjs";
15
+ import { makeRunDir } from "./runner_status.mjs";
16
+ import { resolveRuntimeRecoveryPolicy } from "./runtime_recovery.mjs";
17
+
18
+ function resolveTask(backlog, options) {
19
+ if (options.taskId) {
20
+ const task = getTask(backlog, options.taskId);
21
+ if (!task) {
22
+ throw new Error(`未找到任务:${options.taskId}`);
23
+ }
24
+ return task;
25
+ }
26
+ return selectNextTask(backlog, options);
27
+ }
28
+
29
+ export async function resolveExecutionSetup(context, options = {}) {
30
+ const policy = loadPolicy(context);
31
+ const projectConfig = loadProjectConfig(context);
32
+ const backlog = loadBacklog(context);
33
+ const task = resolveTask(backlog, options);
34
+ if (!task) {
35
+ return {
36
+ idleResult: { ok: true, kind: "idle", task: null },
37
+ };
38
+ }
39
+
40
+ const unresolved = unresolvedDependencies(backlog, task);
41
+ if (unresolved.length) {
42
+ throw new Error(`任务 ${task.id} 仍有未完成依赖:${unresolved.join(", ")}`);
43
+ }
44
+
45
+ const verifyCommands = Array.isArray(task.verify) && task.verify.length
46
+ ? task.verify
47
+ : loadVerifyCommands(context);
48
+ const maxAttemptsPerStrategy = Math.max(1, Number(options.maxAttempts || policy.maxTaskAttempts || 1));
49
+ const configuredStrategies = Math.max(1, Number(options.maxStrategies || policy.maxTaskStrategies || 1));
50
+ const engineResolution = options.engineResolution?.ok
51
+ ? options.engineResolution
52
+ : await resolveEngineSelection({
53
+ context,
54
+ policy,
55
+ options,
56
+ interactive: !options.yes,
57
+ });
58
+
59
+ return {
60
+ context,
61
+ options,
62
+ policy,
63
+ backlog,
64
+ projectConfig,
65
+ repoStateText: loadRepoStateText(context),
66
+ task,
67
+ verifyCommands,
68
+ runDir: makeRunDir(context, task.id),
69
+ requiredDocs: [...(projectConfig.requiredDocs || []), ...(options.requiredDocs || [])],
70
+ constraints: [...(projectConfig.constraints || []), ...(options.constraints || [])],
71
+ maxAttemptsPerStrategy,
72
+ maxStrategies: policy.stopOnFailure ? 1 : configuredStrategies,
73
+ engineResolution,
74
+ };
75
+ }
76
+
77
+ function updateTaskAndBuildResult(execution, status, result) {
78
+ updateTask(execution.backlog, execution.task.id, {
79
+ status,
80
+ finishedAt: nowIso(),
81
+ lastFailure: result.ok ? "" : (result.summary || ""),
82
+ attempts: result.attempts,
83
+ });
84
+ saveBacklog(execution.context, execution.backlog);
85
+ return result;
86
+ }
87
+
88
+ export function buildFailureResult(execution, kind, summary, attempts, engineResolution) {
89
+ return updateTaskAndBuildResult(execution, "failed", {
90
+ ok: false,
91
+ kind,
92
+ task: execution.task,
93
+ runDir: execution.runDir,
94
+ summary,
95
+ attempts,
96
+ engineResolution,
97
+ });
98
+ }
99
+
100
+ export function buildBlockedResult(execution, summary, attempts, engineResolution) {
101
+ return updateTaskAndBuildResult(execution, "blocked", {
102
+ ok: false,
103
+ kind: "task-blocked",
104
+ task: execution.task,
105
+ runDir: execution.runDir,
106
+ summary,
107
+ attempts,
108
+ engineResolution,
109
+ });
110
+ }
111
+
112
+ export function buildDoneResult(execution, finalMessage, attempts, engineResolution) {
113
+ return updateTaskAndBuildResult(execution, "done", {
114
+ ok: true,
115
+ kind: "done",
116
+ task: execution.task,
117
+ runDir: execution.runDir,
118
+ finalMessage,
119
+ attempts,
120
+ engineResolution,
121
+ });
122
+ }
123
+
124
+ export async function maybeSwitchEngine(execution, engineResolution, previousFailure, phaseLabel) {
125
+ const recoveryPolicy = resolveRuntimeRecoveryPolicy(execution.policy);
126
+ if (!recoveryPolicy.allowEngineSwitch) {
127
+ return null;
128
+ }
129
+ return null;
130
+ }
131
+
132
+ export function recordFailure(failureHistory, strategyIndex, attemptIndex, kind, summary) {
133
+ failureHistory.push({
134
+ strategyIndex,
135
+ attemptIndex,
136
+ kind,
137
+ summary,
138
+ });
139
+ }
140
+
141
+ export function buildAttemptState(runDir, strategyIndex, attemptIndex, makeAttemptDir) {
142
+ return {
143
+ strategyIndex,
144
+ attemptIndex,
145
+ attemptDir: makeAttemptDir(runDir, strategyIndex, attemptIndex),
146
+ };
147
+ }
148
+
149
+ export function bumpFailureForNextStrategy(previousFailure, maxAttemptsPerStrategy) {
150
+ return [
151
+ previousFailure,
152
+ "",
153
+ `上一种策略已连续失败 ${maxAttemptsPerStrategy} 次。下一轮必须明确更换实现或排查思路,不能重复原路径。`,
154
+ ].join("\n").trim();
155
+ }
@@ -0,0 +1,106 @@
1
+ import { selectNextTask, summarizeBacklog } from "./backlog.mjs";
2
+ import { loadBacklog, loadPolicy } from "./config.mjs";
3
+ import { reanalyzeCurrentWorkspace } from "./analyzer.mjs";
4
+ import { runOnce } from "./runner_once.mjs";
5
+
6
+ function shouldRunMainlineReanalysis(options, summary, reanalysisPasses, maxReanalysisPasses) {
7
+ return Boolean(options.fullAutoMainline)
8
+ && summary.pending === 0
9
+ && summary.inProgress === 0
10
+ && summary.failed === 0
11
+ && summary.blocked === 0
12
+ && reanalysisPasses < maxReanalysisPasses;
13
+ }
14
+
15
+ function buildMainlineReopenedResult(nextTask, engineResolution) {
16
+ return {
17
+ ok: true,
18
+ kind: "mainline-reopened",
19
+ task: null,
20
+ summary: [
21
+ "主线终态复核发现仍有剩余工作,已自动重建 backlog 并继续推进。",
22
+ `下一任务:${nextTask.title}`,
23
+ ].join("\n"),
24
+ engineResolution,
25
+ };
26
+ }
27
+
28
+ function buildMainlineCompleteResult(engineResolution) {
29
+ return {
30
+ ok: true,
31
+ kind: "mainline-complete",
32
+ task: null,
33
+ summary: "主线终态复核通过:开发文档目标已闭合,没有发现新的剩余任务。",
34
+ engineResolution,
35
+ };
36
+ }
37
+
38
+ function buildMainlineFailureResult(continuation, engineResolution) {
39
+ return {
40
+ ok: false,
41
+ kind: "mainline-reanalysis-failed",
42
+ task: null,
43
+ summary: continuation.summary || "主线终态复核失败。",
44
+ engineResolution,
45
+ };
46
+ }
47
+
48
+ export async function runLoop(context, options = {}) {
49
+ const policy = loadPolicy(context);
50
+ const explicitMaxTasks = Number(options.maxTasks);
51
+ const maxTasks = Number.isFinite(explicitMaxTasks) && explicitMaxTasks > 0
52
+ ? explicitMaxTasks
53
+ : (options.fullAutoMainline ? Number.POSITIVE_INFINITY : Math.max(1, Number(policy.maxLoopTasks || 1)));
54
+ const maxReanalysisPasses = Math.max(0, Number(options.maxReanalysisPasses || policy.maxReanalysisPasses || 0));
55
+ const results = [];
56
+ let engineResolution = options.engineResolution || null;
57
+ let completedTasks = 0;
58
+ let reanalysisPasses = 0;
59
+
60
+ while (completedTasks < maxTasks) {
61
+ const result = await runOnce(context, { ...options, engineResolution });
62
+ results.push(result);
63
+ if (result.engineResolution?.ok) {
64
+ engineResolution = result.engineResolution;
65
+ }
66
+ if (options.dryRun || !result.ok || !result.task) {
67
+ break;
68
+ }
69
+
70
+ completedTasks += 1;
71
+ const backlog = loadBacklog(context);
72
+ if (selectNextTask(backlog, options)) {
73
+ continue;
74
+ }
75
+
76
+ const summary = summarizeBacklog(backlog);
77
+ if (!shouldRunMainlineReanalysis(options, summary, reanalysisPasses, maxReanalysisPasses)) {
78
+ break;
79
+ }
80
+
81
+ reanalysisPasses += 1;
82
+ const continuation = await reanalyzeCurrentWorkspace(context, {
83
+ ...options,
84
+ engineResolution,
85
+ yes: true,
86
+ });
87
+ if (continuation.engineResolution?.ok) {
88
+ engineResolution = continuation.engineResolution;
89
+ }
90
+ if (!continuation.ok) {
91
+ results.push(buildMainlineFailureResult(continuation, engineResolution));
92
+ break;
93
+ }
94
+
95
+ const continuedNextTask = selectNextTask(loadBacklog(context), options);
96
+ if (continuedNextTask) {
97
+ results.push(buildMainlineReopenedResult(continuedNextTask, engineResolution));
98
+ continue;
99
+ }
100
+
101
+ results.push(buildMainlineCompleteResult(engineResolution));
102
+ break;
103
+ }
104
+
105
+ return results;
106
+ }
@@ -0,0 +1,29 @@
1
+ import { selectNextTask, summarizeBacklog } from "./backlog.mjs";
2
+ import { loadBacklog, writeStateMarkdown, writeStatus } from "./config.mjs";
3
+ import { executeSingleTask } from "./runner_execute_task.mjs";
4
+ import { renderStatusMarkdown } from "./runner_status.mjs";
5
+
6
+ export async function runOnce(context, options = {}) {
7
+ const result = await executeSingleTask(context, options);
8
+ const backlog = loadBacklog(context);
9
+ const summary = summarizeBacklog(backlog);
10
+ const nextTask = selectNextTask(backlog, options);
11
+
12
+ writeStatus(context, {
13
+ ok: result.ok,
14
+ stage: result.kind,
15
+ taskId: result.task?.id || null,
16
+ taskTitle: result.task?.title || "",
17
+ runDir: result.runDir || "",
18
+ summary,
19
+ message: result.summary || result.finalMessage || "",
20
+ });
21
+ writeStateMarkdown(context, renderStatusMarkdown(context, {
22
+ summary,
23
+ currentTask: result.task,
24
+ lastResult: result.ok ? "本轮成功" : (result.summary || result.kind),
25
+ nextTask,
26
+ }));
27
+
28
+ return result;
29
+ }
@@ -0,0 +1,104 @@
1
+ import path from "node:path";
2
+
3
+ import { sanitizeId, tailText, timestampForFile } from "./common.mjs";
4
+ import { renderTaskSummary, selectNextTask, summarizeBacklog } from "./backlog.mjs";
5
+ import { loadBacklog } from "./config.mjs";
6
+
7
+ export function makeRunDir(context, taskId) {
8
+ return path.join(context.runsDir, `${timestampForFile()}-${sanitizeId(taskId)}`);
9
+ }
10
+
11
+ export function makeAttemptDir(runDir, strategyIndex, attemptIndex) {
12
+ return path.join(
13
+ runDir,
14
+ `strategy-${String(strategyIndex).padStart(2, "0")}-attempt-${String(attemptIndex).padStart(2, "0")}`,
15
+ );
16
+ }
17
+
18
+ export function isHardStopFailure(kind, summary) {
19
+ const normalized = String(summary || "").toLowerCase();
20
+ if (!normalized) {
21
+ return false;
22
+ }
23
+ if (kind === "engine" && normalized.includes("enoent")) {
24
+ return true;
25
+ }
26
+
27
+ return [
28
+ "command not found",
29
+ "is not recognized",
30
+ "无法将",
31
+ "找不到路径",
32
+ "no such file or directory",
33
+ "permission denied",
34
+ "access is denied",
35
+ ].some((signal) => normalized.includes(signal));
36
+ }
37
+
38
+ export function buildFailureSummary(kind, payload) {
39
+ if (kind !== "engine") {
40
+ return payload.summary;
41
+ }
42
+
43
+ return [
44
+ `${payload.displayName} 执行失败,退出码:${payload.code}`,
45
+ payload.recoverySummary || "",
46
+ "",
47
+ "stdout 尾部:",
48
+ tailText(payload.stdout, 60),
49
+ "",
50
+ "stderr 尾部:",
51
+ tailText(payload.stderr, 60),
52
+ ].join("\n").trim();
53
+ }
54
+
55
+ export function buildExhaustedSummary({
56
+ failureHistory,
57
+ maxStrategies,
58
+ maxAttemptsPerStrategy,
59
+ }) {
60
+ const lastFailure = failureHistory.at(-1)?.summary || "未知失败。";
61
+ return [
62
+ `已按 Ralph Loop 执行 ${maxStrategies} 轮策略、每轮最多 ${maxAttemptsPerStrategy} 次重试,当前任务仍未收敛。`,
63
+ "",
64
+ "最后一次失败信息:",
65
+ lastFailure,
66
+ ].join("\n").trim();
67
+ }
68
+
69
+ export function renderStatusMarkdown(context, { summary, currentTask, lastResult, nextTask }) {
70
+ return [
71
+ "## 当前状态",
72
+ `- backlog 文件:${context.backlogFile.replaceAll("\\", "/")}`,
73
+ `- 总任务数:${summary.total}`,
74
+ `- 已完成:${summary.done}`,
75
+ `- 待处理:${summary.pending}`,
76
+ `- 进行中:${summary.inProgress}`,
77
+ `- 失败:${summary.failed}`,
78
+ `- 阻塞:${summary.blocked}`,
79
+ `- 当前任务:${currentTask ? currentTask.title : "无"}`,
80
+ `- 最近结果:${lastResult || "暂无"}`,
81
+ `- 下一建议:${nextTask ? nextTask.title : "暂无可执行任务"}`,
82
+ ].join("\n");
83
+ }
84
+
85
+ export function renderStatusText(context, options = {}) {
86
+ const backlog = loadBacklog(context);
87
+ const summary = summarizeBacklog(backlog);
88
+ const nextTask = selectNextTask(backlog, options);
89
+
90
+ return [
91
+ "HelloLoop 状态",
92
+ "============",
93
+ `仓库:${context.repoRoot}`,
94
+ `总任务:${summary.total}`,
95
+ `已完成:${summary.done}`,
96
+ `待处理:${summary.pending}`,
97
+ `进行中:${summary.inProgress}`,
98
+ `失败:${summary.failed}`,
99
+ `阻塞:${summary.blocked}`,
100
+ "",
101
+ nextTask ? "下一任务:" : "下一任务:无",
102
+ nextTask ? renderTaskSummary(nextTask) : "",
103
+ ].filter(Boolean).join("\n");
104
+ }
@@ -0,0 +1,301 @@
1
+ import { getEngineDisplayName } from "./engine_metadata.mjs";
2
+ import { tailText } from "./common.mjs";
3
+
4
+ const defaultRuntimeRecoveryPolicy = {
5
+ enabled: true,
6
+ allowEngineSwitch: false,
7
+ heartbeatIntervalSeconds: 60,
8
+ stallWarningSeconds: 900,
9
+ maxIdleSeconds: 2700,
10
+ killGraceSeconds: 10,
11
+ maxPhaseRecoveries: 4,
12
+ retryDelaysSeconds: [120, 300, 900, 1800],
13
+ retryOnUnknownFailure: true,
14
+ maxUnknownRecoveries: 1,
15
+ };
16
+
17
+ const HARD_STOP_MATCHERS = [
18
+ {
19
+ code: "invalid_request",
20
+ reason: "当前错误更像请求、参数、协议或输出格式问题,继续原样自动重试大概率无效。",
21
+ patterns: [
22
+ " 400 ",
23
+ "400 bad request",
24
+ "bad request",
25
+ "invalid request",
26
+ "invalid argument",
27
+ "invalid_argument",
28
+ "failed to parse",
29
+ "parse error",
30
+ "malformed",
31
+ "schema validation",
32
+ "json schema",
33
+ "unexpected argument",
34
+ "unknown option",
35
+ ],
36
+ },
37
+ {
38
+ code: "auth",
39
+ reason: "当前错误更像登录、鉴权、订阅或权限问题,需要先修复环境。",
40
+ patterns: [
41
+ "401",
42
+ "403",
43
+ "unauthorized",
44
+ "forbidden",
45
+ "not authenticated",
46
+ "authentication",
47
+ "login",
48
+ "sign in",
49
+ "api key",
50
+ "token",
51
+ "subscription",
52
+ "insufficient permissions",
53
+ ],
54
+ },
55
+ {
56
+ code: "environment",
57
+ reason: "当前错误更像本地 CLI 缺失、权限不足或文件系统问题,继续自动重试没有意义。",
58
+ patterns: [
59
+ "command not found",
60
+ "is not recognized",
61
+ "enoent",
62
+ "no such file or directory",
63
+ "permission denied",
64
+ "access is denied",
65
+ ],
66
+ },
67
+ ];
68
+
69
+ const RECOVERABLE_MATCHERS = [
70
+ {
71
+ code: "rate_limit",
72
+ reason: "当前引擎可能遇到配额、限流或临时容量不足。",
73
+ patterns: [
74
+ "429",
75
+ "rate limit",
76
+ "too many requests",
77
+ "quota",
78
+ "credit",
79
+ "usage limit",
80
+ "capacity",
81
+ "overloaded",
82
+ "insufficient balance",
83
+ "try again later",
84
+ ],
85
+ },
86
+ {
87
+ code: "server",
88
+ reason: "当前引擎可能遇到临时服务端错误。",
89
+ patterns: [
90
+ "500",
91
+ "501",
92
+ "502",
93
+ "503",
94
+ "504",
95
+ "internal server error",
96
+ "bad gateway",
97
+ "service unavailable",
98
+ "gateway timeout",
99
+ "server error",
100
+ "upstream",
101
+ "temporarily unavailable",
102
+ ],
103
+ },
104
+ {
105
+ code: "network",
106
+ reason: "当前引擎可能遇到临时网络、连接或结果流中断。",
107
+ patterns: [
108
+ "network error",
109
+ "fetch failed",
110
+ "econnreset",
111
+ "etimedout",
112
+ "timed out",
113
+ "timeout",
114
+ "connection reset",
115
+ "connection aborted",
116
+ "connection closed",
117
+ "stream closed",
118
+ "socket hang up",
119
+ "transport error",
120
+ "broken pipe",
121
+ "http2",
122
+ ],
123
+ },
124
+ ];
125
+
126
+ function normalizeSeconds(value, fallback) {
127
+ const numberValue = Number(value);
128
+ if (!Number.isFinite(numberValue) || numberValue < 0) {
129
+ return fallback;
130
+ }
131
+ return numberValue;
132
+ }
133
+
134
+ function normalizeSecondsList(value, fallback) {
135
+ if (!Array.isArray(value) || !value.length) {
136
+ return fallback;
137
+ }
138
+ const normalized = value
139
+ .map((item) => normalizeSeconds(item, -1))
140
+ .filter((item) => item >= 0);
141
+ return normalized.length ? normalized : fallback;
142
+ }
143
+
144
+ function normalizeText(value) {
145
+ return String(value || "").toLowerCase();
146
+ }
147
+
148
+ function hasMatcher(normalizedText, matcher) {
149
+ return matcher.patterns.some((pattern) => normalizedText.includes(String(pattern).toLowerCase()));
150
+ }
151
+
152
+ export function resolveRuntimeRecoveryPolicy(policy = {}) {
153
+ const configured = policy?.runtimeRecovery || {};
154
+ return {
155
+ enabled: configured.enabled !== false,
156
+ allowEngineSwitch: configured.allowEngineSwitch === true,
157
+ heartbeatIntervalSeconds: normalizeSeconds(
158
+ configured.heartbeatIntervalSeconds,
159
+ defaultRuntimeRecoveryPolicy.heartbeatIntervalSeconds,
160
+ ),
161
+ stallWarningSeconds: normalizeSeconds(
162
+ configured.stallWarningSeconds,
163
+ defaultRuntimeRecoveryPolicy.stallWarningSeconds,
164
+ ),
165
+ maxIdleSeconds: normalizeSeconds(
166
+ configured.maxIdleSeconds,
167
+ defaultRuntimeRecoveryPolicy.maxIdleSeconds,
168
+ ),
169
+ killGraceSeconds: normalizeSeconds(
170
+ configured.killGraceSeconds,
171
+ defaultRuntimeRecoveryPolicy.killGraceSeconds,
172
+ ),
173
+ maxPhaseRecoveries: Math.max(
174
+ 0,
175
+ Math.trunc(normalizeSeconds(configured.maxPhaseRecoveries, defaultRuntimeRecoveryPolicy.maxPhaseRecoveries)),
176
+ ),
177
+ retryDelaysSeconds: normalizeSecondsList(
178
+ configured.retryDelaysSeconds,
179
+ defaultRuntimeRecoveryPolicy.retryDelaysSeconds,
180
+ ),
181
+ retryOnUnknownFailure: configured.retryOnUnknownFailure !== false,
182
+ maxUnknownRecoveries: Math.max(
183
+ 0,
184
+ Math.trunc(normalizeSeconds(configured.maxUnknownRecoveries, defaultRuntimeRecoveryPolicy.maxUnknownRecoveries)),
185
+ ),
186
+ };
187
+ }
188
+
189
+ export function selectRuntimeRecoveryDelayMs(recoveryPolicy, nextRecoveryIndex) {
190
+ const delays = Array.isArray(recoveryPolicy?.retryDelaysSeconds) && recoveryPolicy.retryDelaysSeconds.length
191
+ ? recoveryPolicy.retryDelaysSeconds
192
+ : defaultRuntimeRecoveryPolicy.retryDelaysSeconds;
193
+ const offset = Math.max(0, Number(nextRecoveryIndex || 1) - 1);
194
+ const seconds = delays[Math.min(offset, delays.length - 1)] || 0;
195
+ return Math.max(0, seconds) * 1000;
196
+ }
197
+
198
+ export function classifyRuntimeRecoveryFailure({
199
+ result = {},
200
+ recoveryPolicy = defaultRuntimeRecoveryPolicy,
201
+ recoveryCount = 0,
202
+ } = {}) {
203
+ const normalized = normalizeText([
204
+ result.stderr,
205
+ result.stdout,
206
+ result.finalMessage,
207
+ result.watchdogReason,
208
+ ].filter(Boolean).join("\n"));
209
+
210
+ if (result.watchdogTriggered || result.idleTimeout) {
211
+ return {
212
+ recoverable: true,
213
+ code: "watchdog_idle",
214
+ reason: "当前进程长时间没有可见进展,HelloLoop 已按看门狗策略终止并准备同引擎恢复。",
215
+ };
216
+ }
217
+
218
+ for (const matcher of HARD_STOP_MATCHERS) {
219
+ if (hasMatcher(normalized, matcher)) {
220
+ return {
221
+ recoverable: false,
222
+ code: matcher.code,
223
+ reason: matcher.reason,
224
+ };
225
+ }
226
+ }
227
+
228
+ for (const matcher of RECOVERABLE_MATCHERS) {
229
+ if (hasMatcher(normalized, matcher)) {
230
+ return {
231
+ recoverable: true,
232
+ code: matcher.code,
233
+ reason: matcher.reason,
234
+ };
235
+ }
236
+ }
237
+
238
+ const emptyFailure = !normalized.trim() && !result.ok;
239
+ if (emptyFailure) {
240
+ return {
241
+ recoverable: recoveryCount < (recoveryPolicy.maxUnknownRecoveries || 0),
242
+ code: "empty_failure",
243
+ reason: "当前失败没有返回可判定的错误文本,HelloLoop 将按无人值守策略先尝试一次同引擎恢复。",
244
+ };
245
+ }
246
+
247
+ if (recoveryPolicy.retryOnUnknownFailure && recoveryCount < (recoveryPolicy.maxUnknownRecoveries || 0)) {
248
+ return {
249
+ recoverable: true,
250
+ code: "unknown_failure",
251
+ reason: "当前错误类型无法稳定归类,HelloLoop 将按无人值守策略先尝试一次同引擎恢复。",
252
+ };
253
+ }
254
+
255
+ return {
256
+ recoverable: false,
257
+ code: "unknown_failure",
258
+ reason: "当前错误无法判断为可安全自动恢复,已停止本轮自动恢复。",
259
+ };
260
+ }
261
+
262
+ export function buildRuntimeRecoveryPrompt({
263
+ basePrompt,
264
+ engine,
265
+ phaseLabel,
266
+ failure,
267
+ result = {},
268
+ nextRecoveryIndex,
269
+ maxRecoveries,
270
+ }) {
271
+ return [
272
+ basePrompt,
273
+ "",
274
+ "## HelloLoop 自动恢复上下文",
275
+ `- 执行引擎:${getEngineDisplayName(engine)}`,
276
+ `- 当前阶段:${phaseLabel}`,
277
+ `- 自动恢复序号:${nextRecoveryIndex}/${maxRecoveries}`,
278
+ `- 恢复原因:${failure?.reason || "当前引擎在上一轮执行中断,需要在同一主线下继续恢复。"}`,
279
+ "",
280
+ "你必须把当前仓库视为唯一事实源,直接复用已经完成的修改、进度和中间结果。",
281
+ "不要从头重做,不要另起一套实现,不要等待用户,不要把“下一步建议”当成交付。",
282
+ "先快速检查仓库当前状态与最近失败点,然后从中断位置继续完成本轮任务。",
283
+ "",
284
+ "最近失败片段:",
285
+ `- stdout 尾部:${tailText(result.stdout, 10) || "无"}`,
286
+ `- stderr 尾部:${tailText(result.stderr, 10) || "无"}`,
287
+ ].join("\n");
288
+ }
289
+
290
+ export function renderRuntimeRecoverySummary(recoveryHistory = []) {
291
+ if (!Array.isArray(recoveryHistory) || !recoveryHistory.length) {
292
+ return "";
293
+ }
294
+
295
+ return [
296
+ `HelloLoop 已按无人值守策略进行 ${recoveryHistory.length} 次同引擎自动恢复。`,
297
+ ...recoveryHistory.map((item) => (
298
+ `- 第 ${item.recoveryIndex} 次恢复:${item.reason}(等待 ${item.delaySeconds} 秒)`
299
+ )),
300
+ ].join("\n");
301
+ }