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.
Files changed (58) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +3 -3
  3. package/README.md +297 -272
  4. package/hosts/claude/marketplace/plugins/helloloop/.claude-plugin/plugin.json +1 -1
  5. package/hosts/claude/marketplace/plugins/helloloop/commands/helloloop.md +19 -9
  6. package/hosts/claude/marketplace/plugins/helloloop/skills/helloloop/SKILL.md +12 -4
  7. package/hosts/gemini/extension/GEMINI.md +13 -4
  8. package/hosts/gemini/extension/commands/helloloop.toml +19 -8
  9. package/hosts/gemini/extension/gemini-extension.json +1 -1
  10. package/package.json +2 -2
  11. package/scripts/uninstall-home-plugin.ps1 +25 -0
  12. package/skills/helloloop/SKILL.md +42 -7
  13. package/src/analyze_confirmation.mjs +108 -8
  14. package/src/analyze_prompt.mjs +17 -1
  15. package/src/analyze_user_input.mjs +321 -0
  16. package/src/analyzer.mjs +167 -42
  17. package/src/cli.mjs +34 -308
  18. package/src/cli_analyze_command.mjs +248 -0
  19. package/src/cli_args.mjs +106 -0
  20. package/src/cli_command_handlers.mjs +120 -0
  21. package/src/cli_context.mjs +31 -0
  22. package/src/cli_render.mjs +70 -0
  23. package/src/cli_support.mjs +95 -31
  24. package/src/completion_review.mjs +243 -0
  25. package/src/config.mjs +50 -0
  26. package/src/discovery.mjs +243 -9
  27. package/src/discovery_inference.mjs +62 -18
  28. package/src/discovery_paths.mjs +143 -8
  29. package/src/discovery_prompt.mjs +273 -0
  30. package/src/engine_metadata.mjs +79 -0
  31. package/src/engine_selection.mjs +335 -0
  32. package/src/engine_selection_failure.mjs +51 -0
  33. package/src/engine_selection_messages.mjs +119 -0
  34. package/src/engine_selection_probe.mjs +78 -0
  35. package/src/engine_selection_prompt.mjs +48 -0
  36. package/src/engine_selection_settings.mjs +38 -0
  37. package/src/guardrails.mjs +15 -4
  38. package/src/install.mjs +20 -266
  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 +90 -0
  43. package/src/process.mjs +482 -39
  44. package/src/prompt.mjs +9 -5
  45. package/src/prompt_session.mjs +40 -0
  46. package/src/rebuild.mjs +116 -0
  47. package/src/runner.mjs +3 -341
  48. package/src/runner_execute_task.mjs +301 -0
  49. package/src/runner_execution_support.mjs +155 -0
  50. package/src/runner_loop.mjs +106 -0
  51. package/src/runner_once.mjs +29 -0
  52. package/src/runner_status.mjs +104 -0
  53. package/src/runtime_recovery.mjs +301 -0
  54. package/src/shell_invocation.mjs +16 -0
  55. package/templates/analysis-output.schema.json +58 -1
  56. package/templates/policy.template.json +27 -0
  57. package/templates/project.template.json +2 -0
  58. package/templates/task-review-output.schema.json +70 -0
@@ -0,0 +1,321 @@
1
+ import path from "node:path";
2
+
3
+ import { classifyExplicitPath, pathExists, resolveAbsolute } from "./discovery_paths.mjs";
4
+ import { normalizeEngineName } from "./engine_metadata.mjs";
5
+
6
+ const DOC_SUFFIX = new Set([
7
+ ".md",
8
+ ".markdown",
9
+ ".mdx",
10
+ ".txt",
11
+ ".rst",
12
+ ".adoc",
13
+ ]);
14
+
15
+ const ROLE_LABELS = {
16
+ docs: "开发文档",
17
+ repo: "项目路径",
18
+ input: "补充路径",
19
+ new_repo: "项目路径",
20
+ };
21
+
22
+ const SOURCE_LABELS = {
23
+ flag: "命令参数",
24
+ positional: "命令附带路径",
25
+ interactive: "交互确认",
26
+ interactive_new_repo: "交互确认",
27
+ workspace_single_doc: "工作区唯一文档候选",
28
+ workspace_single_repo: "工作区唯一项目候选",
29
+ };
30
+
31
+ function isProbablyPathToken(token) {
32
+ const value = String(token || "").trim();
33
+ if (!value) {
34
+ return false;
35
+ }
36
+
37
+ if (/^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value)) {
38
+ return true;
39
+ }
40
+
41
+ if (/^\.\.?([\\/]|$)/.test(value) || /^[~][\\/]/.test(value)) {
42
+ return true;
43
+ }
44
+
45
+ if (value.includes("/") || value.includes("\\")) {
46
+ return true;
47
+ }
48
+
49
+ return DOC_SUFFIX.has(path.extname(value).toLowerCase());
50
+ }
51
+
52
+ function normalizePathKey(targetPath) {
53
+ const resolved = path.resolve(String(targetPath || ""));
54
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
55
+ }
56
+
57
+ function isSamePath(left, right) {
58
+ return normalizePathKey(left) === normalizePathKey(right);
59
+ }
60
+
61
+ function createIssue(severity, message) {
62
+ return { severity, message: String(message || "").trim() };
63
+ }
64
+
65
+ function roleLabel(role) {
66
+ return ROLE_LABELS[role] || "路径";
67
+ }
68
+
69
+ function sourceLabel(source) {
70
+ return SOURCE_LABELS[source] || "输入";
71
+ }
72
+
73
+ function formatPathRef(ref) {
74
+ const label = roleLabel(ref.role);
75
+ const statusSuffix = ref.role === "new_repo" ? ",当前不存在,将按新项目创建" : "";
76
+ const suffix = ref.source ? `(来源:${sourceLabel(ref.source)}${statusSuffix})` : "";
77
+ return `${label}:${String(ref.absolutePath || "").replaceAll("\\", "/")}${suffix}`;
78
+ }
79
+
80
+ function ensureSelectionSource(selectionSources, role, source) {
81
+ if (!selectionSources[role] && source) {
82
+ selectionSources[role] = source;
83
+ }
84
+ }
85
+
86
+ function pushDistinctRef(refs, role, absolutePath, source) {
87
+ const existing = refs.find((item) => item.role === role && isSamePath(item.absolutePath, absolutePath));
88
+ if (!existing) {
89
+ refs.push({ role, absolutePath, source });
90
+ }
91
+ }
92
+
93
+ function pushRoleConflict(issues, role, acceptedPath, incomingPath, acceptedSource, incomingSource) {
94
+ if (isSamePath(acceptedPath, incomingPath)) {
95
+ return;
96
+ }
97
+
98
+ issues.blocking.push(createIssue(
99
+ "blocking",
100
+ `同时给出了多个${roleLabel(role)}:${String(acceptedPath).replaceAll("\\", "/")}(${sourceLabel(acceptedSource)}) 与 ${String(incomingPath).replaceAll("\\", "/")}(${sourceLabel(incomingSource)})。请只保留一个,或改用 ${role === "repo" ? "--repo" : "--docs"} 明确指定。`,
101
+ ));
102
+ }
103
+
104
+ function assignDocsPath({
105
+ absolutePath,
106
+ source,
107
+ explicitRefs,
108
+ issues,
109
+ selectionSources,
110
+ currentDocsPath,
111
+ }) {
112
+ if (!currentDocsPath) {
113
+ pushDistinctRef(explicitRefs, "docs", absolutePath, source);
114
+ ensureSelectionSource(selectionSources, "docs", source);
115
+ return absolutePath;
116
+ }
117
+
118
+ pushRoleConflict(issues, "docs", currentDocsPath, absolutePath, selectionSources.docs || source, source);
119
+ pushDistinctRef(explicitRefs, "docs", absolutePath, source);
120
+ return currentDocsPath;
121
+ }
122
+
123
+ function assignRepoRoot({
124
+ absolutePath,
125
+ source,
126
+ explicitRefs,
127
+ issues,
128
+ selectionSources,
129
+ currentRepoRoot,
130
+ allowNewRepoRoot = false,
131
+ }) {
132
+ if (!currentRepoRoot) {
133
+ pushDistinctRef(explicitRefs, allowNewRepoRoot ? "new_repo" : "repo", absolutePath, source);
134
+ ensureSelectionSource(selectionSources, "repo", source);
135
+ return absolutePath;
136
+ }
137
+
138
+ pushRoleConflict(issues, "repo", currentRepoRoot, absolutePath, selectionSources.repo || source, source);
139
+ pushDistinctRef(explicitRefs, allowNewRepoRoot ? "new_repo" : "repo", absolutePath, source);
140
+ return currentRepoRoot;
141
+ }
142
+
143
+ export function normalizeAnalyzeOptions(rawOptions = {}, cwd = process.cwd()) {
144
+ const options = {
145
+ ...rawOptions,
146
+ requiredDocs: Array.isArray(rawOptions.requiredDocs) ? [...rawOptions.requiredDocs] : [],
147
+ constraints: Array.isArray(rawOptions.constraints) ? [...rawOptions.constraints] : [],
148
+ };
149
+ const rawPositionals = Array.isArray(rawOptions.positionalArgs) ? rawOptions.positionalArgs : [];
150
+ const positionals = [...rawPositionals];
151
+ const explicitRefs = [];
152
+ const requestTokens = [];
153
+ const issues = {
154
+ blocking: [],
155
+ warnings: [],
156
+ };
157
+ const selectionSources = {
158
+ ...(rawOptions.selectionSources || {}),
159
+ };
160
+
161
+ let docsPath = options.docsPath ? resolveAbsolute(options.docsPath, cwd) : "";
162
+ let repoRoot = options.repoRoot ? resolveAbsolute(options.repoRoot, cwd) : "";
163
+ let inputPath = options.inputPath ? resolveAbsolute(options.inputPath, cwd) : "";
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
+ }
177
+
178
+ if (docsPath) {
179
+ ensureSelectionSource(selectionSources, "docs", rawOptions.selectionSources?.docs || "flag");
180
+ }
181
+ if (repoRoot) {
182
+ ensureSelectionSource(selectionSources, "repo", rawOptions.selectionSources?.repo || "flag");
183
+ if (allowNewRepoRoot && !pathExists(repoRoot)) {
184
+ pushDistinctRef(explicitRefs, "new_repo", repoRoot, selectionSources.repo);
185
+ }
186
+ }
187
+
188
+ for (const token of positionals) {
189
+ if (!isProbablyPathToken(token)) {
190
+ requestTokens.push(token);
191
+ continue;
192
+ }
193
+
194
+ const absolutePath = resolveAbsolute(token, cwd);
195
+ if (pathExists(absolutePath)) {
196
+ const classified = classifyExplicitPath(absolutePath);
197
+ if (classified.kind === "docs") {
198
+ docsPath = assignDocsPath({
199
+ absolutePath: classified.absolutePath,
200
+ source: "positional",
201
+ explicitRefs,
202
+ issues,
203
+ selectionSources,
204
+ currentDocsPath: docsPath,
205
+ });
206
+ continue;
207
+ }
208
+ if (classified.kind === "repo") {
209
+ repoRoot = assignRepoRoot({
210
+ absolutePath: classified.absolutePath,
211
+ source: "positional",
212
+ explicitRefs,
213
+ issues,
214
+ selectionSources,
215
+ currentRepoRoot: repoRoot,
216
+ });
217
+ continue;
218
+ }
219
+ if ((classified.kind === "workspace" || classified.kind === "directory") && docsPath) {
220
+ repoRoot = assignRepoRoot({
221
+ absolutePath: classified.absolutePath,
222
+ source: "positional",
223
+ explicitRefs,
224
+ issues,
225
+ selectionSources,
226
+ currentRepoRoot: repoRoot,
227
+ });
228
+ continue;
229
+ }
230
+ if (!inputPath) {
231
+ inputPath = classified.absolutePath;
232
+ pushDistinctRef(explicitRefs, "input", classified.absolutePath, "positional");
233
+ continue;
234
+ }
235
+ } else if (!repoRoot) {
236
+ repoRoot = absolutePath;
237
+ allowNewRepoRoot = true;
238
+ ensureSelectionSource(selectionSources, "repo", "positional");
239
+ pushDistinctRef(explicitRefs, "new_repo", absolutePath, "positional");
240
+ continue;
241
+ } else {
242
+ pushRoleConflict(issues, "repo", repoRoot, absolutePath, selectionSources.repo || "positional", "positional");
243
+ pushDistinctRef(explicitRefs, "new_repo", absolutePath, "positional");
244
+ continue;
245
+ }
246
+
247
+ requestTokens.push(token);
248
+ }
249
+
250
+ if (options.dryRun && options.yes) {
251
+ issues.warnings.push(createIssue(
252
+ "warning",
253
+ "同时传入了 --dry-run 和 -y;本次仍按 --dry-run 只分析,不自动执行。",
254
+ ));
255
+ }
256
+
257
+ const userRequestText = requestTokens.join(" ").trim();
258
+
259
+ options.docsPath = docsPath;
260
+ options.repoRoot = repoRoot;
261
+ options.inputPath = inputPath;
262
+ options.allowNewRepoRoot = allowNewRepoRoot;
263
+ options.engine = engine;
264
+ options.engineSource = engineSource;
265
+ options.selectionSources = selectionSources;
266
+ options.userRequestText = userRequestText;
267
+ options.inputIssues = issues;
268
+ options.userIntent = {
269
+ rawPositionals,
270
+ explicitRefs,
271
+ requestText: userRequestText,
272
+ issues,
273
+ selectionSources,
274
+ explicitEngine: engine,
275
+ explicitEngineSource: engineSource,
276
+ };
277
+
278
+ return options;
279
+ }
280
+
281
+ export function hasBlockingInputIssues(inputIssues = {}) {
282
+ return Array.isArray(inputIssues.blocking) && inputIssues.blocking.length > 0;
283
+ }
284
+
285
+ export function renderInputIssueLines(inputIssues = {}) {
286
+ return [
287
+ ...(Array.isArray(inputIssues.blocking) ? inputIssues.blocking : []),
288
+ ...(Array.isArray(inputIssues.warnings) ? inputIssues.warnings : []),
289
+ ].map((item) => `- ${item.message}`);
290
+ }
291
+
292
+ export function renderBlockingInputIssueMessage(inputIssues = {}) {
293
+ const lines = renderInputIssueLines({
294
+ blocking: Array.isArray(inputIssues.blocking) ? inputIssues.blocking : [],
295
+ });
296
+ if (!lines.length) {
297
+ return "";
298
+ }
299
+
300
+ return [
301
+ "检测到命令输入存在冲突:",
302
+ ...lines,
303
+ "",
304
+ "请整理后重试;建议只保留一个开发文档路径和一个项目路径,必要时改用 `--docs` / `--repo` 明确指定。",
305
+ ].join("\n");
306
+ }
307
+
308
+ export function renderUserIntentLines(userIntent = {}) {
309
+ const lines = [];
310
+ const explicitRefs = Array.isArray(userIntent.explicitRefs) ? userIntent.explicitRefs : [];
311
+ const requestText = String(userIntent.requestText || "").trim();
312
+
313
+ if (explicitRefs.length) {
314
+ lines.push(...explicitRefs.map((ref) => formatPathRef(ref)));
315
+ }
316
+ if (requestText) {
317
+ lines.push(`附加要求:${requestText}`);
318
+ }
319
+
320
+ return lines;
321
+ }
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,33 +49,67 @@ 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
 
68
+ const requestInterpretation = payload?.requestInterpretation && typeof payload.requestInterpretation === "object"
69
+ ? {
70
+ summary: String(payload.requestInterpretation.summary || "").trim(),
71
+ priorities: Array.isArray(payload.requestInterpretation.priorities)
72
+ ? payload.requestInterpretation.priorities.map((item) => String(item || "").trim()).filter(Boolean)
73
+ : [],
74
+ cautions: Array.isArray(payload.requestInterpretation.cautions)
75
+ ? payload.requestInterpretation.cautions.map((item) => String(item || "").trim()).filter(Boolean)
76
+ : [],
77
+ }
78
+ : null;
79
+ const repoDecision = payload?.repoDecision && typeof payload.repoDecision === "object"
80
+ ? {
81
+ compatibility: ["compatible", "conflict", "uncertain"].includes(String(payload.repoDecision.compatibility || ""))
82
+ ? String(payload.repoDecision.compatibility)
83
+ : "compatible",
84
+ action: ["continue_existing", "confirm_rebuild", "start_new"].includes(String(payload.repoDecision.action || ""))
85
+ ? String(payload.repoDecision.action)
86
+ : "continue_existing",
87
+ reason: String(payload.repoDecision.reason || "").trim(),
88
+ }
89
+ : null;
90
+
58
91
  return {
59
92
  project: String(payload.project || "").trim() || "helloloop-project",
60
- summary: {
61
- currentState: String(payload?.summary?.currentState || "").trim() || "已完成接续分析。",
62
- implemented: Array.isArray(payload?.summary?.implemented) ? payload.summary.implemented.map((item) => String(item || "").trim()).filter(Boolean) : [],
63
- remaining: Array.isArray(payload?.summary?.remaining) ? payload.summary.remaining.map((item) => String(item || "").trim()).filter(Boolean) : [],
64
- nextAction: String(payload?.summary?.nextAction || "").trim() || "查看下一任务。",
65
- },
93
+ summary,
66
94
  constraints: Array.isArray(payload.constraints)
67
95
  ? payload.constraints.map((item) => String(item || "").trim()).filter(Boolean)
68
96
  : [],
97
+ requestInterpretation: requestInterpretation && (
98
+ requestInterpretation.summary
99
+ || requestInterpretation.priorities.length
100
+ || requestInterpretation.cautions.length
101
+ )
102
+ ? requestInterpretation
103
+ : null,
104
+ repoDecision: repoDecision && repoDecision.reason
105
+ ? repoDecision
106
+ : null,
69
107
  tasks,
70
108
  requiredDocs: docsEntries,
71
109
  };
72
110
  }
73
111
 
74
- function buildAnalysisSummaryText(context, analysis, backlog) {
112
+ function buildAnalysisSummaryText(context, analysis, backlog, engineResolution) {
75
113
  const summary = summarizeBacklog(backlog);
76
114
  const nextTask = selectNextTask(backlog);
77
115
 
@@ -79,6 +117,7 @@ function buildAnalysisSummaryText(context, analysis, backlog) {
79
117
  "HelloLoop 已完成接续分析。",
80
118
  `项目仓库:${context.repoRoot}`,
81
119
  `开发文档:${analysis.requiredDocs.join(", ")}`,
120
+ `执行引擎:${engineResolution?.displayName || "未记录"}`,
82
121
  "",
83
122
  "当前进度:",
84
123
  analysis.summary.currentState,
@@ -92,29 +131,27 @@ function buildAnalysisSummaryText(context, analysis, backlog) {
92
131
  ].join("\n");
93
132
  }
94
133
 
95
- export async function analyzeWorkspace(options = {}) {
96
- const discovery = discoverWorkspace({
97
- cwd: options.cwd,
98
- inputPath: options.inputPath,
99
- repoRoot: options.repoRoot,
100
- docsPath: options.docsPath,
101
- configDirName: options.configDirName,
102
- });
103
-
104
- 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) {
105
146
  return {
106
147
  ok: false,
107
- code: discovery.code,
108
- summary: discovery.message,
148
+ code: engineResolution.code,
149
+ summary: engineResolution.message,
150
+ discovery,
151
+ engineResolution,
109
152
  };
110
153
  }
111
154
 
112
- const context = createContext({
113
- repoRoot: discovery.repoRoot,
114
- configDirName: options.configDirName,
115
- });
116
- scaffoldIfMissing(context);
117
-
118
155
  const existingProjectConfig = loadProjectConfig(context);
119
156
  const existingStateText = readTextIfExists(context.stateFile, "");
120
157
  const existingBacklogText = readTextIfExists(context.backlogFile, "");
@@ -125,46 +162,49 @@ export async function analyzeWorkspace(options = {}) {
125
162
 
126
163
  const prompt = buildAnalysisPrompt({
127
164
  repoRoot: context.repoRoot,
165
+ repoOriginallyExisted: discovery?.resolution?.repo?.exists !== false,
128
166
  docsEntries: discovery.docsEntries,
129
167
  docPackets,
130
168
  existingStateText,
131
169
  existingBacklogText,
132
170
  existingProjectConstraints: existingProjectConfig.constraints,
171
+ userIntent: options.userIntent,
133
172
  });
134
173
 
135
174
  const runDir = path.join(context.runsDir, `${nowIso().replaceAll(":", "-").replaceAll(".", "-")}-analysis`);
136
- const policy = loadPolicy(context);
137
175
  const schemaFile = path.join(context.templatesDir, "analysis-output.schema.json");
138
- const codexResult = await runCodexTask({
176
+ let analysisResult = await runEngineTask({
177
+ engine: engineResolution.engine,
139
178
  context,
140
179
  prompt,
141
180
  runDir,
142
- model: policy.codex.model,
143
- executable: policy.codex.executable,
144
- sandbox: policy.codex.sandbox,
145
- dangerouslyBypassSandbox: policy.codex.dangerouslyBypassSandbox,
181
+ policy,
182
+ executionMode: "analyze",
146
183
  outputSchemaFile: schemaFile,
147
- outputPrefix: "analysis",
148
- jsonOutput: false,
184
+ outputPrefix: `${engineResolution.engine}-analysis`,
149
185
  skipGitRepoCheck: true,
150
186
  });
151
187
 
152
- if (!codexResult.ok) {
188
+ if (!analysisResult.ok) {
153
189
  return {
154
190
  ok: false,
155
191
  code: "analysis_failed",
156
- summary: codexResult.stderr || codexResult.stdout || "Codex 接续分析失败。",
192
+ summary: analysisResult.stderr || analysisResult.stdout || `${engineResolution.displayName} 接续分析失败。`,
193
+ engineResolution,
194
+ discovery,
157
195
  };
158
196
  }
159
197
 
160
198
  let payload;
161
199
  try {
162
- payload = JSON.parse(codexResult.finalMessage);
200
+ payload = JSON.parse(analysisResult.finalMessage);
163
201
  } catch (error) {
164
202
  return {
165
203
  ok: false,
166
204
  code: "invalid_analysis_json",
167
- summary: `Codex 分析结果无法解析为 JSON:${String(error?.message || error || "")}`,
205
+ summary: `${engineResolution.displayName} 分析结果无法解析为 JSON:${String(error?.message || error || "")}`,
206
+ engineResolution,
207
+ discovery,
168
208
  };
169
209
  }
170
210
 
@@ -179,11 +219,14 @@ export async function analyzeWorkspace(options = {}) {
179
219
  const projectConfig = {
180
220
  requiredDocs: analysis.requiredDocs,
181
221
  constraints: analysis.constraints.length ? analysis.constraints : existingProjectConfig.constraints,
222
+ defaultEngine: existingProjectConfig.defaultEngine,
223
+ lastSelectedEngine: engineResolution.engine,
182
224
  planner: existingProjectConfig.planner,
183
225
  };
184
226
 
185
227
  writeJson(context.backlogFile, backlog);
186
228
  writeJson(context.projectFile, projectConfig);
229
+ rememberEngineSelection(context, engineResolution, options);
187
230
  writeStateMarkdown(context, renderAnalysisState(context, backlog, analysis));
188
231
  writeStatus(context, {
189
232
  ok: true,
@@ -194,15 +237,97 @@ export async function analyzeWorkspace(options = {}) {
194
237
  summary: summarizeBacklog(backlog),
195
238
  message: analysis.summary.currentState,
196
239
  });
197
- writeText(path.join(runDir, "analysis-summary.txt"), buildAnalysisSummaryText(context, analysis, backlog));
240
+ writeText(path.join(runDir, "analysis-summary.txt"), buildAnalysisSummaryText(context, analysis, backlog, engineResolution));
198
241
 
199
242
  return {
200
243
  ok: true,
201
244
  code: "analyzed",
202
245
  context,
203
246
  runDir,
247
+ engineResolution,
204
248
  analysis,
205
249
  backlog,
206
- summary: buildAnalysisSummaryText(context, analysis, backlog),
250
+ summary: buildAnalysisSummaryText(context, analysis, backlog, engineResolution),
251
+ discovery,
207
252
  };
208
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
+ }