helloloop 0.2.1 → 0.3.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.
@@ -0,0 +1,303 @@
1
+ import path from "node:path";
2
+
3
+ import { classifyExplicitPath, pathExists, resolveAbsolute } from "./discovery_paths.mjs";
4
+
5
+ const DOC_SUFFIX = new Set([
6
+ ".md",
7
+ ".markdown",
8
+ ".mdx",
9
+ ".txt",
10
+ ".rst",
11
+ ".adoc",
12
+ ]);
13
+
14
+ const ROLE_LABELS = {
15
+ docs: "开发文档",
16
+ repo: "项目路径",
17
+ input: "补充路径",
18
+ new_repo: "项目路径",
19
+ };
20
+
21
+ const SOURCE_LABELS = {
22
+ flag: "命令参数",
23
+ positional: "命令附带路径",
24
+ interactive: "交互确认",
25
+ interactive_new_repo: "交互确认",
26
+ workspace_single_doc: "工作区唯一文档候选",
27
+ workspace_single_repo: "工作区唯一项目候选",
28
+ };
29
+
30
+ function isProbablyPathToken(token) {
31
+ const value = String(token || "").trim();
32
+ if (!value) {
33
+ return false;
34
+ }
35
+
36
+ if (/^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value)) {
37
+ return true;
38
+ }
39
+
40
+ if (/^\.\.?([\\/]|$)/.test(value) || /^[~][\\/]/.test(value)) {
41
+ return true;
42
+ }
43
+
44
+ if (value.includes("/") || value.includes("\\")) {
45
+ return true;
46
+ }
47
+
48
+ return DOC_SUFFIX.has(path.extname(value).toLowerCase());
49
+ }
50
+
51
+ function normalizePathKey(targetPath) {
52
+ const resolved = path.resolve(String(targetPath || ""));
53
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
54
+ }
55
+
56
+ function isSamePath(left, right) {
57
+ return normalizePathKey(left) === normalizePathKey(right);
58
+ }
59
+
60
+ function createIssue(severity, message) {
61
+ return { severity, message: String(message || "").trim() };
62
+ }
63
+
64
+ function roleLabel(role) {
65
+ return ROLE_LABELS[role] || "路径";
66
+ }
67
+
68
+ function sourceLabel(source) {
69
+ return SOURCE_LABELS[source] || "输入";
70
+ }
71
+
72
+ function formatPathRef(ref) {
73
+ const label = roleLabel(ref.role);
74
+ const statusSuffix = ref.role === "new_repo" ? ",当前不存在,将按新项目创建" : "";
75
+ const suffix = ref.source ? `(来源:${sourceLabel(ref.source)}${statusSuffix})` : "";
76
+ return `${label}:${String(ref.absolutePath || "").replaceAll("\\", "/")}${suffix}`;
77
+ }
78
+
79
+ function ensureSelectionSource(selectionSources, role, source) {
80
+ if (!selectionSources[role] && source) {
81
+ selectionSources[role] = source;
82
+ }
83
+ }
84
+
85
+ function pushDistinctRef(refs, role, absolutePath, source) {
86
+ const existing = refs.find((item) => item.role === role && isSamePath(item.absolutePath, absolutePath));
87
+ if (!existing) {
88
+ refs.push({ role, absolutePath, source });
89
+ }
90
+ }
91
+
92
+ function pushRoleConflict(issues, role, acceptedPath, incomingPath, acceptedSource, incomingSource) {
93
+ if (isSamePath(acceptedPath, incomingPath)) {
94
+ return;
95
+ }
96
+
97
+ issues.blocking.push(createIssue(
98
+ "blocking",
99
+ `同时给出了多个${roleLabel(role)}:${String(acceptedPath).replaceAll("\\", "/")}(${sourceLabel(acceptedSource)}) 与 ${String(incomingPath).replaceAll("\\", "/")}(${sourceLabel(incomingSource)})。请只保留一个,或改用 ${role === "repo" ? "--repo" : "--docs"} 明确指定。`,
100
+ ));
101
+ }
102
+
103
+ function assignDocsPath({
104
+ absolutePath,
105
+ source,
106
+ explicitRefs,
107
+ issues,
108
+ selectionSources,
109
+ currentDocsPath,
110
+ }) {
111
+ if (!currentDocsPath) {
112
+ pushDistinctRef(explicitRefs, "docs", absolutePath, source);
113
+ ensureSelectionSource(selectionSources, "docs", source);
114
+ return absolutePath;
115
+ }
116
+
117
+ pushRoleConflict(issues, "docs", currentDocsPath, absolutePath, selectionSources.docs || source, source);
118
+ pushDistinctRef(explicitRefs, "docs", absolutePath, source);
119
+ return currentDocsPath;
120
+ }
121
+
122
+ function assignRepoRoot({
123
+ absolutePath,
124
+ source,
125
+ explicitRefs,
126
+ issues,
127
+ selectionSources,
128
+ currentRepoRoot,
129
+ allowNewRepoRoot = false,
130
+ }) {
131
+ if (!currentRepoRoot) {
132
+ pushDistinctRef(explicitRefs, allowNewRepoRoot ? "new_repo" : "repo", absolutePath, source);
133
+ ensureSelectionSource(selectionSources, "repo", source);
134
+ return absolutePath;
135
+ }
136
+
137
+ pushRoleConflict(issues, "repo", currentRepoRoot, absolutePath, selectionSources.repo || source, source);
138
+ pushDistinctRef(explicitRefs, allowNewRepoRoot ? "new_repo" : "repo", absolutePath, source);
139
+ return currentRepoRoot;
140
+ }
141
+
142
+ export function normalizeAnalyzeOptions(rawOptions = {}, cwd = process.cwd()) {
143
+ const options = {
144
+ ...rawOptions,
145
+ requiredDocs: Array.isArray(rawOptions.requiredDocs) ? [...rawOptions.requiredDocs] : [],
146
+ constraints: Array.isArray(rawOptions.constraints) ? [...rawOptions.constraints] : [],
147
+ };
148
+ const positionals = Array.isArray(rawOptions.positionalArgs) ? rawOptions.positionalArgs : [];
149
+ const explicitRefs = [];
150
+ const requestTokens = [];
151
+ const issues = {
152
+ blocking: [],
153
+ warnings: [],
154
+ };
155
+ const selectionSources = {
156
+ ...(rawOptions.selectionSources || {}),
157
+ };
158
+
159
+ let docsPath = options.docsPath ? resolveAbsolute(options.docsPath, cwd) : "";
160
+ let repoRoot = options.repoRoot ? resolveAbsolute(options.repoRoot, cwd) : "";
161
+ let inputPath = options.inputPath ? resolveAbsolute(options.inputPath, cwd) : "";
162
+ let allowNewRepoRoot = Boolean(options.allowNewRepoRoot);
163
+
164
+ if (docsPath) {
165
+ ensureSelectionSource(selectionSources, "docs", rawOptions.selectionSources?.docs || "flag");
166
+ }
167
+ if (repoRoot) {
168
+ ensureSelectionSource(selectionSources, "repo", rawOptions.selectionSources?.repo || "flag");
169
+ if (allowNewRepoRoot && !pathExists(repoRoot)) {
170
+ pushDistinctRef(explicitRefs, "new_repo", repoRoot, selectionSources.repo);
171
+ }
172
+ }
173
+
174
+ for (const token of positionals) {
175
+ if (!isProbablyPathToken(token)) {
176
+ requestTokens.push(token);
177
+ continue;
178
+ }
179
+
180
+ const absolutePath = resolveAbsolute(token, cwd);
181
+ if (pathExists(absolutePath)) {
182
+ const classified = classifyExplicitPath(absolutePath);
183
+ if (classified.kind === "docs") {
184
+ docsPath = assignDocsPath({
185
+ absolutePath: classified.absolutePath,
186
+ source: "positional",
187
+ explicitRefs,
188
+ issues,
189
+ selectionSources,
190
+ currentDocsPath: docsPath,
191
+ });
192
+ continue;
193
+ }
194
+ if (classified.kind === "repo") {
195
+ repoRoot = assignRepoRoot({
196
+ absolutePath: classified.absolutePath,
197
+ source: "positional",
198
+ explicitRefs,
199
+ issues,
200
+ selectionSources,
201
+ currentRepoRoot: repoRoot,
202
+ });
203
+ continue;
204
+ }
205
+ if ((classified.kind === "workspace" || classified.kind === "directory") && docsPath) {
206
+ repoRoot = assignRepoRoot({
207
+ absolutePath: classified.absolutePath,
208
+ source: "positional",
209
+ explicitRefs,
210
+ issues,
211
+ selectionSources,
212
+ currentRepoRoot: repoRoot,
213
+ });
214
+ continue;
215
+ }
216
+ if (!inputPath) {
217
+ inputPath = classified.absolutePath;
218
+ pushDistinctRef(explicitRefs, "input", classified.absolutePath, "positional");
219
+ continue;
220
+ }
221
+ } else if (!repoRoot) {
222
+ repoRoot = absolutePath;
223
+ allowNewRepoRoot = true;
224
+ ensureSelectionSource(selectionSources, "repo", "positional");
225
+ pushDistinctRef(explicitRefs, "new_repo", absolutePath, "positional");
226
+ continue;
227
+ } else {
228
+ pushRoleConflict(issues, "repo", repoRoot, absolutePath, selectionSources.repo || "positional", "positional");
229
+ pushDistinctRef(explicitRefs, "new_repo", absolutePath, "positional");
230
+ continue;
231
+ }
232
+
233
+ requestTokens.push(token);
234
+ }
235
+
236
+ if (options.dryRun && options.yes) {
237
+ issues.warnings.push(createIssue(
238
+ "warning",
239
+ "同时传入了 --dry-run 和 -y;本次仍按 --dry-run 只分析,不自动执行。",
240
+ ));
241
+ }
242
+
243
+ const userRequestText = requestTokens.join(" ").trim();
244
+
245
+ options.docsPath = docsPath;
246
+ options.repoRoot = repoRoot;
247
+ options.inputPath = inputPath;
248
+ options.allowNewRepoRoot = allowNewRepoRoot;
249
+ options.selectionSources = selectionSources;
250
+ options.userRequestText = userRequestText;
251
+ options.inputIssues = issues;
252
+ options.userIntent = {
253
+ rawPositionals: positionals,
254
+ explicitRefs,
255
+ requestText: userRequestText,
256
+ issues,
257
+ selectionSources,
258
+ };
259
+
260
+ return options;
261
+ }
262
+
263
+ export function hasBlockingInputIssues(inputIssues = {}) {
264
+ return Array.isArray(inputIssues.blocking) && inputIssues.blocking.length > 0;
265
+ }
266
+
267
+ export function renderInputIssueLines(inputIssues = {}) {
268
+ return [
269
+ ...(Array.isArray(inputIssues.blocking) ? inputIssues.blocking : []),
270
+ ...(Array.isArray(inputIssues.warnings) ? inputIssues.warnings : []),
271
+ ].map((item) => `- ${item.message}`);
272
+ }
273
+
274
+ export function renderBlockingInputIssueMessage(inputIssues = {}) {
275
+ const lines = renderInputIssueLines({
276
+ blocking: Array.isArray(inputIssues.blocking) ? inputIssues.blocking : [],
277
+ });
278
+ if (!lines.length) {
279
+ return "";
280
+ }
281
+
282
+ return [
283
+ "检测到命令输入存在冲突:",
284
+ ...lines,
285
+ "",
286
+ "请整理后重试;建议只保留一个开发文档路径和一个项目路径,必要时改用 `--docs` / `--repo` 明确指定。",
287
+ ].join("\n");
288
+ }
289
+
290
+ export function renderUserIntentLines(userIntent = {}) {
291
+ const lines = [];
292
+ const explicitRefs = Array.isArray(userIntent.explicitRefs) ? userIntent.explicitRefs : [];
293
+ const requestText = String(userIntent.requestText || "").trim();
294
+
295
+ if (explicitRefs.length) {
296
+ lines.push(...explicitRefs.map((ref) => formatPathRef(ref)));
297
+ }
298
+ if (requestText) {
299
+ lines.push(`附加要求:${requestText}`);
300
+ }
301
+
302
+ return lines;
303
+ }
package/src/analyzer.mjs CHANGED
@@ -55,6 +55,29 @@ function normalizeAnalysisPayload(payload, docsEntries) {
55
55
  throw new Error("Codex 分析结果无效:未生成可用任务。");
56
56
  }
57
57
 
58
+ const requestInterpretation = payload?.requestInterpretation && typeof payload.requestInterpretation === "object"
59
+ ? {
60
+ summary: String(payload.requestInterpretation.summary || "").trim(),
61
+ priorities: Array.isArray(payload.requestInterpretation.priorities)
62
+ ? payload.requestInterpretation.priorities.map((item) => String(item || "").trim()).filter(Boolean)
63
+ : [],
64
+ cautions: Array.isArray(payload.requestInterpretation.cautions)
65
+ ? payload.requestInterpretation.cautions.map((item) => String(item || "").trim()).filter(Boolean)
66
+ : [],
67
+ }
68
+ : null;
69
+ const repoDecision = payload?.repoDecision && typeof payload.repoDecision === "object"
70
+ ? {
71
+ compatibility: ["compatible", "conflict", "uncertain"].includes(String(payload.repoDecision.compatibility || ""))
72
+ ? String(payload.repoDecision.compatibility)
73
+ : "compatible",
74
+ action: ["continue_existing", "confirm_rebuild", "start_new"].includes(String(payload.repoDecision.action || ""))
75
+ ? String(payload.repoDecision.action)
76
+ : "continue_existing",
77
+ reason: String(payload.repoDecision.reason || "").trim(),
78
+ }
79
+ : null;
80
+
58
81
  return {
59
82
  project: String(payload.project || "").trim() || "helloloop-project",
60
83
  summary: {
@@ -66,6 +89,16 @@ function normalizeAnalysisPayload(payload, docsEntries) {
66
89
  constraints: Array.isArray(payload.constraints)
67
90
  ? payload.constraints.map((item) => String(item || "").trim()).filter(Boolean)
68
91
  : [],
92
+ requestInterpretation: requestInterpretation && (
93
+ requestInterpretation.summary
94
+ || requestInterpretation.priorities.length
95
+ || requestInterpretation.cautions.length
96
+ )
97
+ ? requestInterpretation
98
+ : null,
99
+ repoDecision: repoDecision && repoDecision.reason
100
+ ? repoDecision
101
+ : null,
69
102
  tasks,
70
103
  requiredDocs: docsEntries,
71
104
  };
@@ -99,6 +132,7 @@ export async function analyzeWorkspace(options = {}) {
99
132
  repoRoot: options.repoRoot,
100
133
  docsPath: options.docsPath,
101
134
  configDirName: options.configDirName,
135
+ allowNewRepoRoot: options.allowNewRepoRoot,
102
136
  });
103
137
 
104
138
  if (!discovery.ok) {
@@ -106,6 +140,7 @@ export async function analyzeWorkspace(options = {}) {
106
140
  ok: false,
107
141
  code: discovery.code,
108
142
  summary: discovery.message,
143
+ discovery,
109
144
  };
110
145
  }
111
146
 
@@ -125,11 +160,13 @@ export async function analyzeWorkspace(options = {}) {
125
160
 
126
161
  const prompt = buildAnalysisPrompt({
127
162
  repoRoot: context.repoRoot,
163
+ repoOriginallyExisted: discovery?.resolution?.repo?.exists !== false,
128
164
  docsEntries: discovery.docsEntries,
129
165
  docPackets,
130
166
  existingStateText,
131
167
  existingBacklogText,
132
168
  existingProjectConstraints: existingProjectConfig.constraints,
169
+ userIntent: options.userIntent,
133
170
  });
134
171
 
135
172
  const runDir = path.join(context.runsDir, `${nowIso().replaceAll(":", "-").replaceAll(".", "-")}-analysis`);
@@ -204,5 +241,6 @@ export async function analyzeWorkspace(options = {}) {
204
241
  analysis,
205
242
  backlog,
206
243
  summary: buildAnalysisSummaryText(context, analysis, backlog),
244
+ discovery,
207
245
  };
208
246
  }