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
@@ -9,7 +9,8 @@ description: 当用户希望 Codex 先分析仓库当前进度、生成确认单
9
9
 
10
10
  ## 强制入口规则
11
11
 
12
- - 用户显式调用 `$helloloop` / `#helloloop` / `helloloop:helloloop` 时,默认必须优先执行 `npx helloloop` 或 `npx helloloop <PATH>`。
12
+ - 用户显式调用 `$helloloop` / `#helloloop` / `helloloop:helloloop` 时,默认必须优先执行 `npx helloloop` 或 `npx helloloop <PATH>`;如果用户又明确指定了执行引擎,也允许使用 `npx helloloop codex|claude|gemini ...`。
13
+ - 用户没有明确指定执行引擎时,不允许由 skill 自行补成 `codex` / `claude` / `gemini`;必须让 `HelloLoop` 先完成引擎确认。
13
14
  - 不允许在对话里手工模拟 `HelloLoop` 的分析、确认单、backlog 编排和自动续跑流程来代替 CLI。
14
15
  - 只有在以下情况,才允许先停下来问用户而不是直接执行 CLI:
15
16
  1. 用户既没有给路径,当前目录也无法判断项目仓库或开发文档
@@ -19,10 +20,12 @@ description: 当用户希望 Codex 先分析仓库当前进度、生成确认单
19
20
 
20
21
  ## `$helloloop` 的默认执行映射
21
22
 
22
- - 当前目录已经是目标项目仓库或开发文档目录 → 先执行 `npx helloloop`
23
- - 用户给了单一路径 → 先执行 `npx helloloop <PATH>`
23
+ - 当前目录已经是目标项目仓库或开发文档目录 → 先执行 `npx helloloop --host-context codex`
24
+ - 用户给了单一路径 → 先执行 `npx helloloop --host-context codex <PATH>`
24
25
  - 用户明确只想先看分析和确认单 → 执行 `npx helloloop --dry-run`
25
26
  - 用户明确要求跳过确认直接开始 → 执行 `npx helloloop -y`
27
+ - 用户未明确指定执行引擎 → 保持命令里不带引擎首参数,让 `HelloLoop` 先做引擎确认;当前宿主只作为推荐依据
28
+ - 用户明确指定执行引擎 → 保留该引擎首参数;如果在 `Codex` 宿主内要求改用 `Claude` / `Gemini`,先确认,不允许静默切换
26
29
  - 用户在命令后附带了额外路径或自然语言要求 → 必须把这些附加内容一并传给主 CLI,不允许丢弃或手工改写
27
30
 
28
31
  ## 附加输入处理规则
@@ -57,7 +60,7 @@ description: 当用户希望 Codex 先分析仓库当前进度、生成确认单
57
60
  2. 打开目标项目仓库目录,或者打开开发文档所在目录。
58
61
  3. 运行 `npx helloloop` 或 `npx helloloop <PATH>`。
59
62
  4. 命中 `$helloloop` 后,优先按上面的默认执行映射直接调用 CLI。
60
- 5. `HelloLoop` 会先自动分析,再输出执行确认单。
63
+ 5. `HelloLoop` 会先明确执行引擎,再自动分析并输出执行确认单。
61
64
  6. 用户确认后,`HelloLoop` 才开始正式自动执行。
62
65
 
63
66
  如果无法自动判断仓库路径或开发文档路径,就停下来提示用户补充;`--repo` 和 `--docs` 只作为显式覆盖选项使用。
@@ -67,14 +70,21 @@ description: 当用户希望 Codex 先分析仓库当前进度、生成确认单
67
70
  - 代码是事实源,开发文档是目标源。
68
71
  - `HelloLoop` 会先分析当前真实进度,再生成或刷新 `.helloloop/backlog.json`。
69
72
  - 分析后会展示中文执行确认单,明确告知路径判断、语义理解、项目匹配、当前进度、待办任务、验证命令和执行边界。
70
- - 用户确认后,默认会持续执行到 backlog 清空,或开发文档的最终目标完成且测试、验收通过,或遇到硬阻塞。
71
- - 真正的代码分析与实现仍由本机 `codex` CLI 完成。
73
+ - 用户确认后,默认会持续执行到 backlog 清空且主线终态复核通过,或开发文档的最终目标完成且测试、验收通过,或遇到硬阻塞。
74
+ - 每个任务在执行引擎声称“完成”后,还必须再过一层任务完成复核;如果只是部分完成,继续当前主线任务,不直接结束。
75
+ - 用户需求明确且当前任务可直接完成时,必须一次性完成本轮应交付的全部工作;禁止做一半后用“如果你要”“是否继续”之类的话术停下,也禁止用客套套话收尾。
76
+ - 真正的代码分析与实现由本轮选中的 `codex` / `claude` / `gemini` CLI 原生完成。
77
+ - 如果当前引擎在运行中遇到 429、5xx、网络抖动、流中断或长时间卡死,必须优先按无人值守策略做同引擎自动恢复,不要中途停下来询问用户。
78
+ - 只有识别为 400 请求错误、登录/鉴权/订阅问题、本地 CLI 缺失或权限错误等硬阻塞时,才允许结束本轮自动执行。
72
79
  - `$helloloop` 的职责是把用户请求路由到主 CLI 流程,而不是在对话里手工复刻一套平行流程。
73
80
 
74
81
  ## 核心命令
75
82
 
76
83
  - `npx helloloop`
77
84
  - `npx helloloop <PATH>`
85
+ - `npx helloloop codex`
86
+ - `npx helloloop claude <PATH>`
87
+ - `npx helloloop gemini <PATH> <补充说明>`
78
88
  - `npx helloloop <PATH> <补充说明>`
79
89
  - `npx helloloop --dry-run`
80
90
  - `npx helloloop -y`
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { renderInputIssueLines, renderUserIntentLines } from "./analyze_user_input.mjs";
4
4
  import { analyzeExecution, summarizeBacklog } from "./backlog.mjs";
5
5
  import { loadPolicy, loadVerifyCommands } from "./config.mjs";
6
+ import { getEngineDisplayName } from "./engine_metadata.mjs";
6
7
 
7
8
  function toDisplayPath(repoRoot, targetPath) {
8
9
  const absolutePath = path.resolve(targetPath);
@@ -133,15 +134,35 @@ function renderRepoDecisionLines(analysis) {
133
134
  ];
134
135
  }
135
136
 
137
+ function renderEngineResolutionLines(engineResolution) {
138
+ if (!engineResolution?.engine) {
139
+ return [];
140
+ }
141
+
142
+ const availableDisplay = Array.isArray(engineResolution.availableEngines) && engineResolution.availableEngines.length
143
+ ? engineResolution.availableEngines.map((engine) => getEngineDisplayName(engine)).join("、")
144
+ : "未检测到其他可用引擎";
145
+ const basisText = Array.isArray(engineResolution.basis) && engineResolution.basis.length
146
+ ? engineResolution.basis.join(";")
147
+ : "无";
148
+
149
+ return [
150
+ "执行引擎:",
151
+ `- 当前宿主:${engineResolution.hostDisplayName || "终端"}`,
152
+ `- 本次引擎:${engineResolution.displayName || getEngineDisplayName(engineResolution.engine)}`,
153
+ `- 选择来源:${engineResolution.sourceLabel || "自动判断"}`,
154
+ `- 选择依据:${basisText}`,
155
+ `- 当前可用:${availableDisplay}`,
156
+ "- 故障处理:运行中若遇到 429、5xx、网络抖动、流中断或长时间卡死,HelloLoop 会优先按无人值守策略做同引擎自动恢复;只有明确硬阻塞才停止",
157
+ ];
158
+ }
159
+
136
160
  export function resolveAutoRunMaxTasks(backlog, options = {}) {
137
161
  const explicitMaxTasks = Number(options.maxTasks);
138
162
  if (Number.isFinite(explicitMaxTasks) && explicitMaxTasks > 0) {
139
163
  return explicitMaxTasks;
140
164
  }
141
-
142
- const summary = summarizeBacklog(backlog);
143
- const pendingTotal = summary.pending + summary.inProgress;
144
- return Math.max(1, pendingTotal);
165
+ return 0;
145
166
  }
146
167
 
147
168
  export function renderAnalyzeConfirmation(context, analysis, backlog, options = {}, discovery = {}) {
@@ -162,6 +183,7 @@ export function renderAnalyzeConfirmation(context, analysis, backlog, options =
162
183
  ...renderResolutionLines(discovery, context, docsDisplay),
163
184
  ...(userIntentLines.length ? ["", "本次命令补充输入:", ...formatList(userIntentLines)] : []),
164
185
  ...(inputIssueLines.length ? ["", "输入提示:", ...inputIssueLines] : []),
186
+ ...(renderEngineResolutionLines(options.engineResolution).length ? ["", ...renderEngineResolutionLines(options.engineResolution)] : []),
165
187
  ...(renderRequestInterpretationLines(analysis).length ? ["", ...renderRequestInterpretationLines(analysis)] : []),
166
188
  ...(renderRepoDecisionLines(analysis).length ? ["", ...renderRepoDecisionLines(analysis)] : []),
167
189
  "",
@@ -192,7 +214,9 @@ export function renderAnalyzeConfirmation(context, analysis, backlog, options =
192
214
  ? `- 当前阻塞:${execution.blockedReason}`
193
215
  : "- 当前阻塞:无",
194
216
  "- 偏差修正:按 backlog 优先级执行;如果分析识别出偏差修正任务,会先收口再继续后续开发",
195
- `- 自动推进:最多 ${autoRunMaxTasks} 个任务,直到 backlog 清空或遇到硬阻塞`,
217
+ autoRunMaxTasks > 0
218
+ ? `- 自动推进:最多 ${autoRunMaxTasks} 个任务;若主线终态复核仍发现缺口,则到达上限后暂停`
219
+ : "- 自动推进:持续执行,直到 backlog 清空且主线终态复核通过,或遇到硬阻塞",
196
220
  `- 单任务重试:每种策略最多 ${policy.maxTaskAttempts} 次,共 ${policy.maxTaskStrategies} 轮策略`,
197
221
  "",
198
222
  "待执行任务预览:",
@@ -3,6 +3,7 @@ import { renderUserIntentLines } from "./analyze_user_input.mjs";
3
3
  import {
4
4
  hasCustomProjectConstraints,
5
5
  listMandatoryGuardrails,
6
+ listMandatoryEngineeringPrinciples,
6
7
  resolveProjectConstraints,
7
8
  } from "./guardrails.mjs";
8
9
 
@@ -40,6 +41,7 @@ export function buildAnalysisPrompt({
40
41
  userIntent = {},
41
42
  }) {
42
43
  const mandatoryGuardrails = listMandatoryGuardrails();
44
+ const mandatoryEngineeringPrinciples = listMandatoryEngineeringPrinciples();
43
45
  const effectiveConstraints = resolveProjectConstraints(existingProjectConstraints);
44
46
  const usingFallbackConstraints = !hasCustomProjectConstraints(existingProjectConstraints);
45
47
  const userIntentLines = renderUserIntentLines(userIntent);
@@ -62,6 +64,7 @@ export function buildAnalysisPrompt({
62
64
  listSection("本次命令补充输入", userIntentLines),
63
65
  existingStateText ? section("已有状态摘要", existingStateText) : "",
64
66
  listSection("内建安全底线", mandatoryGuardrails),
67
+ listSection("强制编码与产出基线", mandatoryEngineeringPrinciples),
65
68
  listSection(usingFallbackConstraints ? "默认工程约束(文档未明确时也必须遵守)" : "已有项目约束", effectiveConstraints),
66
69
  existingBacklogText ? section("已有 backlog(供参考,可重组)", existingBacklogText) : "",
67
70
  section("文档内容摘录", renderDocPackets(docPackets)),
@@ -71,7 +74,7 @@ export function buildAnalysisPrompt({
71
74
  "3. `summary.implemented` 写已确认完成的关键能力。",
72
75
  "4. `summary.remaining` 写尚未完成的关键缺口。",
73
76
  "5. `summary.nextAction` 写最合理的下一步。",
74
- "6. `tasks` 总数控制在 4 到 12 个之间,优先覆盖真正剩余工作。",
77
+ "6. `tasks` 总数控制在 0 到 12 个之间;如果最终目标已经完成且没有剩余缺口,允许输出空数组。",
75
78
  "7. 每个任务必须包含:id、title、status、priority、risk、goal、docs、paths、acceptance。",
76
79
  "8. `status` 只能是 `done`、`pending`、`blocked`,不要输出 `in_progress` 或 `failed`。",
77
80
  "9. `docs` 必须引用本次文档入口中的相关路径;`paths` 必须写当前仓库内的实际目录或文件模式。",
@@ -82,6 +85,7 @@ export function buildAnalysisPrompt({
82
85
  "14. 当当前项目目录已存在,但代码结构/产品方向与开发文档目标明显冲突,且更合理的路径是清理后重建时,`repoDecision.compatibility` 设为 `conflict`,`repoDecision.action` 设为 `confirm_rebuild`。",
83
86
  "15. 当当前项目目录与开发文档目标一致或可以直接接续时,`repoDecision.compatibility` 设为 `compatible`,`repoDecision.action` 设为 `continue_existing`。",
84
87
  "16. 当本次项目目录原本不存在,或文档目标明显是从零开始新建项目时,`repoDecision.action` 可设为 `start_new`。",
88
+ "17. 只有在文档最终目标、关键能力与验收范围都已闭合时,才能输出空 `tasks`;此时 `summary.remaining` 也必须为空。",
85
89
  ].join("\n")),
86
90
  ].filter(Boolean).join("\n");
87
91
  }
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
 
3
3
  import { classifyExplicitPath, pathExists, resolveAbsolute } from "./discovery_paths.mjs";
4
+ import { normalizeEngineName } from "./engine_metadata.mjs";
4
5
 
5
6
  const DOC_SUFFIX = new Set([
6
7
  ".md",
@@ -145,7 +146,8 @@ export function normalizeAnalyzeOptions(rawOptions = {}, cwd = process.cwd()) {
145
146
  requiredDocs: Array.isArray(rawOptions.requiredDocs) ? [...rawOptions.requiredDocs] : [],
146
147
  constraints: Array.isArray(rawOptions.constraints) ? [...rawOptions.constraints] : [],
147
148
  };
148
- const positionals = Array.isArray(rawOptions.positionalArgs) ? rawOptions.positionalArgs : [];
149
+ const rawPositionals = Array.isArray(rawOptions.positionalArgs) ? rawOptions.positionalArgs : [];
150
+ const positionals = [...rawPositionals];
149
151
  const explicitRefs = [];
150
152
  const requestTokens = [];
151
153
  const issues = {
@@ -160,6 +162,18 @@ export function normalizeAnalyzeOptions(rawOptions = {}, cwd = process.cwd()) {
160
162
  let repoRoot = options.repoRoot ? resolveAbsolute(options.repoRoot, cwd) : "";
161
163
  let inputPath = options.inputPath ? resolveAbsolute(options.inputPath, cwd) : "";
162
164
  let allowNewRepoRoot = Boolean(options.allowNewRepoRoot);
165
+ let engine = normalizeEngineName(options.engine);
166
+ let engineSource = options.engineSource || (engine ? "flag" : "");
167
+
168
+ if (!engine && positionals.length) {
169
+ const firstToken = String(positionals[0] || "").trim();
170
+ const leadingEngine = normalizeEngineName(firstToken);
171
+ if (leadingEngine && firstToken.toLowerCase() === leadingEngine) {
172
+ engine = leadingEngine;
173
+ engineSource = "leading_positional";
174
+ positionals.shift();
175
+ }
176
+ }
163
177
 
164
178
  if (docsPath) {
165
179
  ensureSelectionSource(selectionSources, "docs", rawOptions.selectionSources?.docs || "flag");
@@ -246,15 +260,19 @@ export function normalizeAnalyzeOptions(rawOptions = {}, cwd = process.cwd()) {
246
260
  options.repoRoot = repoRoot;
247
261
  options.inputPath = inputPath;
248
262
  options.allowNewRepoRoot = allowNewRepoRoot;
263
+ options.engine = engine;
264
+ options.engineSource = engineSource;
249
265
  options.selectionSources = selectionSources;
250
266
  options.userRequestText = userRequestText;
251
267
  options.inputIssues = issues;
252
268
  options.userIntent = {
253
- rawPositionals: positionals,
269
+ rawPositionals,
254
270
  explicitRefs,
255
271
  requestText: userRequestText,
256
272
  issues,
257
273
  selectionSources,
274
+ explicitEngine: engine,
275
+ explicitEngineSource: engineSource,
258
276
  };
259
277
 
260
278
  return options;
package/src/analyzer.mjs CHANGED
@@ -6,7 +6,11 @@ import { loadPolicy, loadProjectConfig, scaffoldIfMissing, writeStateMarkdown, w
6
6
  import { createContext } from "./context.mjs";
7
7
  import { discoverWorkspace } from "./discovery.mjs";
8
8
  import { readDocumentPackets } from "./doc_loader.mjs";
9
- import { runCodexTask } from "./process.mjs";
9
+ import {
10
+ rememberEngineSelection,
11
+ resolveEngineSelection,
12
+ } from "./engine_selection.mjs";
13
+ import { runEngineTask } from "./process.mjs";
10
14
  import { buildAnalysisPrompt } from "./analyze_prompt.mjs";
11
15
 
12
16
  function renderAnalysisState(context, backlog, analysis) {
@@ -45,14 +49,20 @@ function sanitizeTask(task) {
45
49
  }
46
50
 
47
51
  function normalizeAnalysisPayload(payload, docsEntries) {
52
+ const summary = {
53
+ currentState: String(payload?.summary?.currentState || "").trim() || "已完成接续分析。",
54
+ implemented: Array.isArray(payload?.summary?.implemented) ? payload.summary.implemented.map((item) => String(item || "").trim()).filter(Boolean) : [],
55
+ remaining: Array.isArray(payload?.summary?.remaining) ? payload.summary.remaining.map((item) => String(item || "").trim()).filter(Boolean) : [],
56
+ nextAction: String(payload?.summary?.nextAction || "").trim() || "查看下一任务。",
57
+ };
48
58
  const tasks = Array.isArray(payload.tasks)
49
59
  ? payload.tasks.map((task) => sanitizeTask(task)).filter((task) => (
50
60
  task.id && task.title && task.goal && task.acceptance.length
51
61
  ))
52
62
  : [];
53
63
 
54
- if (!tasks.length) {
55
- throw new Error("Codex 分析结果无效:未生成可用任务。");
64
+ if (!tasks.length && summary.remaining.length) {
65
+ throw new Error("分析结果无效:仍存在剩余工作,但未生成可用任务。");
56
66
  }
57
67
 
58
68
  const requestInterpretation = payload?.requestInterpretation && typeof payload.requestInterpretation === "object"
@@ -80,12 +90,7 @@ function normalizeAnalysisPayload(payload, docsEntries) {
80
90
 
81
91
  return {
82
92
  project: String(payload.project || "").trim() || "helloloop-project",
83
- summary: {
84
- currentState: String(payload?.summary?.currentState || "").trim() || "已完成接续分析。",
85
- implemented: Array.isArray(payload?.summary?.implemented) ? payload.summary.implemented.map((item) => String(item || "").trim()).filter(Boolean) : [],
86
- remaining: Array.isArray(payload?.summary?.remaining) ? payload.summary.remaining.map((item) => String(item || "").trim()).filter(Boolean) : [],
87
- nextAction: String(payload?.summary?.nextAction || "").trim() || "查看下一任务。",
88
- },
93
+ summary,
89
94
  constraints: Array.isArray(payload.constraints)
90
95
  ? payload.constraints.map((item) => String(item || "").trim()).filter(Boolean)
91
96
  : [],
@@ -104,7 +109,7 @@ function normalizeAnalysisPayload(payload, docsEntries) {
104
109
  };
105
110
  }
106
111
 
107
- function buildAnalysisSummaryText(context, analysis, backlog) {
112
+ function buildAnalysisSummaryText(context, analysis, backlog, engineResolution) {
108
113
  const summary = summarizeBacklog(backlog);
109
114
  const nextTask = selectNextTask(backlog);
110
115
 
@@ -112,6 +117,7 @@ function buildAnalysisSummaryText(context, analysis, backlog) {
112
117
  "HelloLoop 已完成接续分析。",
113
118
  `项目仓库:${context.repoRoot}`,
114
119
  `开发文档:${analysis.requiredDocs.join(", ")}`,
120
+ `执行引擎:${engineResolution?.displayName || "未记录"}`,
115
121
  "",
116
122
  "当前进度:",
117
123
  analysis.summary.currentState,
@@ -125,31 +131,27 @@ function buildAnalysisSummaryText(context, analysis, backlog) {
125
131
  ].join("\n");
126
132
  }
127
133
 
128
- export async function analyzeWorkspace(options = {}) {
129
- const discovery = discoverWorkspace({
130
- cwd: options.cwd,
131
- inputPath: options.inputPath,
132
- repoRoot: options.repoRoot,
133
- docsPath: options.docsPath,
134
- configDirName: options.configDirName,
135
- allowNewRepoRoot: options.allowNewRepoRoot,
136
- });
137
-
138
- if (!discovery.ok) {
134
+ async function analyzeResolvedWorkspace(context, discovery, options = {}) {
135
+ scaffoldIfMissing(context);
136
+ const policy = loadPolicy(context);
137
+ let engineResolution = options.engineResolution?.ok
138
+ ? options.engineResolution
139
+ : await resolveEngineSelection({
140
+ context,
141
+ policy,
142
+ options,
143
+ interactive: !options.yes,
144
+ });
145
+ if (!engineResolution.ok) {
139
146
  return {
140
147
  ok: false,
141
- code: discovery.code,
142
- summary: discovery.message,
148
+ code: engineResolution.code,
149
+ summary: engineResolution.message,
143
150
  discovery,
151
+ engineResolution,
144
152
  };
145
153
  }
146
154
 
147
- const context = createContext({
148
- repoRoot: discovery.repoRoot,
149
- configDirName: options.configDirName,
150
- });
151
- scaffoldIfMissing(context);
152
-
153
155
  const existingProjectConfig = loadProjectConfig(context);
154
156
  const existingStateText = readTextIfExists(context.stateFile, "");
155
157
  const existingBacklogText = readTextIfExists(context.backlogFile, "");
@@ -170,38 +172,39 @@ export async function analyzeWorkspace(options = {}) {
170
172
  });
171
173
 
172
174
  const runDir = path.join(context.runsDir, `${nowIso().replaceAll(":", "-").replaceAll(".", "-")}-analysis`);
173
- const policy = loadPolicy(context);
174
175
  const schemaFile = path.join(context.templatesDir, "analysis-output.schema.json");
175
- const codexResult = await runCodexTask({
176
+ let analysisResult = await runEngineTask({
177
+ engine: engineResolution.engine,
176
178
  context,
177
179
  prompt,
178
180
  runDir,
179
- model: policy.codex.model,
180
- executable: policy.codex.executable,
181
- sandbox: policy.codex.sandbox,
182
- dangerouslyBypassSandbox: policy.codex.dangerouslyBypassSandbox,
181
+ policy,
182
+ executionMode: "analyze",
183
183
  outputSchemaFile: schemaFile,
184
- outputPrefix: "analysis",
185
- jsonOutput: false,
184
+ outputPrefix: `${engineResolution.engine}-analysis`,
186
185
  skipGitRepoCheck: true,
187
186
  });
188
187
 
189
- if (!codexResult.ok) {
188
+ if (!analysisResult.ok) {
190
189
  return {
191
190
  ok: false,
192
191
  code: "analysis_failed",
193
- summary: codexResult.stderr || codexResult.stdout || "Codex 接续分析失败。",
192
+ summary: analysisResult.stderr || analysisResult.stdout || `${engineResolution.displayName} 接续分析失败。`,
193
+ engineResolution,
194
+ discovery,
194
195
  };
195
196
  }
196
197
 
197
198
  let payload;
198
199
  try {
199
- payload = JSON.parse(codexResult.finalMessage);
200
+ payload = JSON.parse(analysisResult.finalMessage);
200
201
  } catch (error) {
201
202
  return {
202
203
  ok: false,
203
204
  code: "invalid_analysis_json",
204
- summary: `Codex 分析结果无法解析为 JSON:${String(error?.message || error || "")}`,
205
+ summary: `${engineResolution.displayName} 分析结果无法解析为 JSON:${String(error?.message || error || "")}`,
206
+ engineResolution,
207
+ discovery,
205
208
  };
206
209
  }
207
210
 
@@ -216,11 +219,14 @@ export async function analyzeWorkspace(options = {}) {
216
219
  const projectConfig = {
217
220
  requiredDocs: analysis.requiredDocs,
218
221
  constraints: analysis.constraints.length ? analysis.constraints : existingProjectConfig.constraints,
222
+ defaultEngine: existingProjectConfig.defaultEngine,
223
+ lastSelectedEngine: engineResolution.engine,
219
224
  planner: existingProjectConfig.planner,
220
225
  };
221
226
 
222
227
  writeJson(context.backlogFile, backlog);
223
228
  writeJson(context.projectFile, projectConfig);
229
+ rememberEngineSelection(context, engineResolution, options);
224
230
  writeStateMarkdown(context, renderAnalysisState(context, backlog, analysis));
225
231
  writeStatus(context, {
226
232
  ok: true,
@@ -231,16 +237,97 @@ export async function analyzeWorkspace(options = {}) {
231
237
  summary: summarizeBacklog(backlog),
232
238
  message: analysis.summary.currentState,
233
239
  });
234
- writeText(path.join(runDir, "analysis-summary.txt"), buildAnalysisSummaryText(context, analysis, backlog));
240
+ writeText(path.join(runDir, "analysis-summary.txt"), buildAnalysisSummaryText(context, analysis, backlog, engineResolution));
235
241
 
236
242
  return {
237
243
  ok: true,
238
244
  code: "analyzed",
239
245
  context,
240
246
  runDir,
247
+ engineResolution,
241
248
  analysis,
242
249
  backlog,
243
- summary: buildAnalysisSummaryText(context, analysis, backlog),
250
+ summary: buildAnalysisSummaryText(context, analysis, backlog, engineResolution),
244
251
  discovery,
245
252
  };
246
253
  }
254
+
255
+ function buildCurrentWorkspaceDiscovery(context, docsEntries) {
256
+ return {
257
+ ok: true,
258
+ repoRoot: context.repoRoot,
259
+ docsEntries,
260
+ resolvedDocs: docsEntries,
261
+ resolution: {
262
+ repo: {
263
+ source: "current_repo",
264
+ sourceLabel: "当前项目",
265
+ confidence: "high",
266
+ confidenceLabel: "高",
267
+ path: context.repoRoot,
268
+ exists: true,
269
+ basis: [
270
+ "已在当前项目基础上执行主线终态复核。",
271
+ ],
272
+ },
273
+ docs: {
274
+ source: "existing_state",
275
+ sourceLabel: "已有 .helloloop 配置",
276
+ confidence: "high",
277
+ confidenceLabel: "高",
278
+ entries: docsEntries,
279
+ basis: [
280
+ "已复用 `.helloloop/project.json` 中记录的 requiredDocs。",
281
+ ],
282
+ },
283
+ },
284
+ };
285
+ }
286
+
287
+ export async function reanalyzeCurrentWorkspace(context, options = {}) {
288
+ const existingProjectConfig = loadProjectConfig(context);
289
+ const docsEntries = Array.isArray(options.requiredDocs) && options.requiredDocs.length
290
+ ? options.requiredDocs
291
+ : existingProjectConfig.requiredDocs;
292
+
293
+ if (!docsEntries.length) {
294
+ return {
295
+ ok: false,
296
+ code: "missing_docs",
297
+ summary: "当前 `.helloloop/project.json` 未记录 requiredDocs,无法执行主线终态复核。",
298
+ discovery: null,
299
+ };
300
+ }
301
+
302
+ return analyzeResolvedWorkspace(
303
+ context,
304
+ buildCurrentWorkspaceDiscovery(context, docsEntries),
305
+ options,
306
+ );
307
+ }
308
+
309
+ export async function analyzeWorkspace(options = {}) {
310
+ const discovery = discoverWorkspace({
311
+ cwd: options.cwd,
312
+ inputPath: options.inputPath,
313
+ repoRoot: options.repoRoot,
314
+ docsPath: options.docsPath,
315
+ configDirName: options.configDirName,
316
+ allowNewRepoRoot: options.allowNewRepoRoot,
317
+ });
318
+
319
+ if (!discovery.ok) {
320
+ return {
321
+ ok: false,
322
+ code: discovery.code,
323
+ summary: discovery.message,
324
+ discovery,
325
+ };
326
+ }
327
+
328
+ const context = createContext({
329
+ repoRoot: discovery.repoRoot,
330
+ configDirName: options.configDirName,
331
+ });
332
+ return analyzeResolvedWorkspace(context, discovery, options);
333
+ }