@zhushanwen/pi-subagent-workflow 4.0.0 → 5.0.0-dev.0

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.
@@ -345,3 +345,321 @@ describe("多种错误同时存在", () => {
345
345
  expect(lines).toEqual(sorted);
346
346
  });
347
347
  });
348
+
349
+ // ── agent description / meta.phases / phase 一致性(新增 warning 检查)─
350
+
351
+ describe("agent() 缺 description/label — warning", () => {
352
+ it("agent 无 description/label → 1 warning", () => {
353
+ const src = `await agent({ prompt: "x" });\n`;
354
+ const result = lintScript(src);
355
+
356
+ const descWarnings = warnings(result.findings).filter((w) =>
357
+ /description.*unnamed/i.test(w.message));
358
+ expect(descWarnings).toHaveLength(1);
359
+ expect(descWarnings[0].line).toBe(1);
360
+ });
361
+
362
+ it("agent 有 description → 0 此类 warning", () => {
363
+ const src = `await agent({ prompt: "x", description: "review-diff" });\n`;
364
+ const result = lintScript(src);
365
+
366
+ expect(warnings(result.findings).some((w) => /description.*unnamed/i.test(w.message)))
367
+ .toBe(false);
368
+ });
369
+
370
+ it("agent 有 label(description 别名)→ 0 此类 warning", () => {
371
+ const src = `await agent({ prompt: "x", label: "review-diff" });\n`;
372
+ const result = lintScript(src);
373
+
374
+ expect(warnings(result.findings).some((w) => /description.*unnamed/i.test(w.message)))
375
+ .toBe(false);
376
+ });
377
+ });
378
+
379
+ // ── MF-4 重构核心回归:字符串剔除 / 非字面量实参跳过 ──────────────
380
+ //
381
+ // review-fix-loop L281 误报根因:字符串字面量里的 "agent(s)" 被 `\bagent\s*\(` 命中,
382
+ // 误开 agent 调用范围;三连误报根因:agent(callVar) 非字面量实参在调用点静态不可见。
383
+ // forEachAgentCallRange 对两者都有专门分支(stripStringsAndComments / argTail 非 `{` 跳过),
384
+ // 下面用例锁定这些新增逻辑(MF-3)。
385
+
386
+ describe("MF-4 回归:字符串字面量里的 agent(...) 不误触发", () => {
387
+ it("`const s = \"agent(s)\";` 字符串不触发 description 检测(review-fix-loop L281 回归)", () => {
388
+ const src = [
389
+ `const s = "agent(s)";`,
390
+ `await agent({ prompt: "x", description: "d" });`,
391
+ ``,
392
+ ].join("\n");
393
+ const result = lintScript(src);
394
+
395
+ // 字符串不产生幻影调用范围:真实调用只有 1 个且有 description → 0 warning
396
+ expect(warnings(result.findings).filter((w) => /description.*unnamed/i.test(w.message)))
397
+ .toHaveLength(0);
398
+ // 也不产生 outputSchema 等连带误报
399
+ expect(errors(result.findings)).toHaveLength(0);
400
+ });
401
+
402
+ it("字符串字面量里含完整调用形态 agent({...}) 同样不误触发(剔除逻辑真正生效的用例)", () => {
403
+ // 若 stripStringsAndComments 失效,此串会被当成字面量调用(argTail 以 { 开头)
404
+ // 并因缺 description 报 warning——本用例即失败
405
+ const src = [
406
+ `const s = "agent({ prompt: 'x' })";`,
407
+ `await agent({ prompt: "x", description: "d" });`,
408
+ ``,
409
+ ].join("\n");
410
+ const result = lintScript(src);
411
+
412
+ expect(warnings(result.findings).filter((w) => /description.*unnamed/i.test(w.message)))
413
+ .toHaveLength(0);
414
+ });
415
+
416
+ it("模板串内 agent({...}) 完整调用形态不误触发(反引号剔除路径)", () => {
417
+ // MF-4 既有用例只覆盖双引号字面量;workflow 脚本 prompt 常用模板串(含 ${} 插值)。
418
+ // 若 `...` 反引号剔除失效,模板串里的 agent({...}) 会开幻影调用范围并误报缺 description
419
+ const src = [
420
+ "const prompt = `agent({ prompt: '${x}' })`;",
421
+ `await agent({ prompt: "x", description: "d" });`,
422
+ ``,
423
+ ].join("\n");
424
+ const result = lintScript(src);
425
+
426
+ expect(warnings(result.findings).filter((w) => /description.*unnamed/i.test(w.message)))
427
+ .toHaveLength(0);
428
+ expect(errors(result.findings)).toHaveLength(0);
429
+ });
430
+
431
+ it("块注释内 agent({...}) 完整调用形态不误触发(/* */ 剔除路径)", () => {
432
+ // 行首 /* 注释由行级跳过兜底;行尾块注释(非行首前缀)走 stripStringsAndComments 的
433
+ // /* */ 剔除路径——若失效,注释里的 agent({...}) 会开幻影调用范围并误报缺 description
434
+ const src = [
435
+ `/* agent({ prompt: 'x' }) */`,
436
+ `const s = "x"; /* agent({ prompt: 'y' }) */`,
437
+ `await agent({ prompt: "x", description: "d" });`,
438
+ ``,
439
+ ].join("\n");
440
+ const result = lintScript(src);
441
+
442
+ expect(warnings(result.findings).filter((w) => /description.*unnamed/i.test(w.message)))
443
+ .toHaveLength(0);
444
+ expect(errors(result.findings)).toHaveLength(0);
445
+ });
446
+ });
447
+
448
+ describe("MF-4 回归:非字面量实参 agent(callVar) / agent(expr()) 跳过", () => {
449
+ it("agent(callVar) 不产生 description warning,且不误伤后续真实 agent 调用", () => {
450
+ const src = [
451
+ `const call = { prompt: "x" };`,
452
+ `await agent(call);`,
453
+ `await agent({ prompt: "y", description: "d" });`,
454
+ ``,
455
+ ].join("\n");
456
+ const result = lintScript(src);
457
+
458
+ // 三连误报根因:非字面量实参被当成缺 description 报 warning
459
+ expect(warnings(result.findings).filter((w) => /description.*unnamed/i.test(w.message)))
460
+ .toHaveLength(0);
461
+ });
462
+
463
+ it("agent(expr()) 表达式实参同样跳过", () => {
464
+ const src = [
465
+ `await agent(buildCall());`,
466
+ `await agent({ prompt: "y", description: "d" });`,
467
+ ``,
468
+ ].join("\n");
469
+ const result = lintScript(src);
470
+
471
+ expect(warnings(result.findings).filter((w) => /description.*unnamed/i.test(w.message)))
472
+ .toHaveLength(0);
473
+ });
474
+
475
+ it("非字面量实参 + 后续真实调用缺 description → 只对真实调用报 1 条", () => {
476
+ // 跳过逻辑不能吞掉后面真正缺 description 的调用
477
+ const src = [
478
+ `await agent(call);`,
479
+ `await agent({ prompt: "y" });`,
480
+ ``,
481
+ ].join("\n");
482
+ const result = lintScript(src);
483
+
484
+ const descWarnings = warnings(result.findings).filter((w) => /description.*unnamed/i.test(w.message));
485
+ expect(descWarnings).toHaveLength(1);
486
+ expect(descWarnings[0].line).toBe(2);
487
+ });
488
+ });
489
+
490
+ // ── MF-4 回归:checkAgentDescription 的两个跳过分支 ────────────────
491
+
492
+ describe("MF-4 回归:展开形态 / schema 内嵌 description", () => {
493
+ it("agent({ ...call, agent: ... }) 展开形态 → 0 description warning(review-fix-loop 三连误报根因修复点)", () => {
494
+ const src = [
495
+ `const call = { prompt: "x", model: "m" };`,
496
+ `await agent({ ...call, agent: "reviewer" });`,
497
+ ``,
498
+ ].join("\n");
499
+ const result = lintScript(src);
500
+
501
+ // 展开形态 description 来自运行时对象、调用点静态不可见——无法验证即不报
502
+ expect(warnings(result.findings).filter((w) => /description.*unnamed/i.test(w.message)))
503
+ .toHaveLength(0);
504
+ });
505
+
506
+ it("多行 agent 调用内 schema properties 含 description 字符串 → 仍报「无 description」warning(I-10 回归)", () => {
507
+ const src = [
508
+ `await agent({`,
509
+ ` prompt: "x",`,
510
+ ` schema: {`,
511
+ ` type: "object",`,
512
+ ` properties: {`,
513
+ ` result: { type: "string", description: "the result" },`,
514
+ ` },`,
515
+ ` },`,
516
+ `});`,
517
+ ``,
518
+ ].join("\n");
519
+ const result = lintScript(src);
520
+
521
+ // schema 内嵌的 description 是 JSON Schema 字段说明,不是 agent 选项——
522
+ // 若被误判为「已提供」则漏报(无 warning),本用例锁定仍报 warning
523
+ const descWarnings = warnings(result.findings).filter((w) => /description.*unnamed/i.test(w.message));
524
+ expect(descWarnings).toHaveLength(1);
525
+ expect(descWarnings[0].line).toBe(1);
526
+ });
527
+
528
+ it("schema 块剔除后,真正的 agent 选项 description 仍被识别(不误伤)", () => {
529
+ const src = [
530
+ `await agent({`,
531
+ ` prompt: "x",`,
532
+ ` schema: { properties: { r: { description: "d" } } },`,
533
+ ` description: "review-diff",`,
534
+ `});`,
535
+ ``,
536
+ ].join("\n");
537
+ const result = lintScript(src);
538
+
539
+ expect(warnings(result.findings).filter((w) => /description.*unnamed/i.test(w.message)))
540
+ .toHaveLength(0);
541
+ });
542
+ });
543
+
544
+ describe("meta.phases 非字符串数组 — warning", () => {
545
+ it("phases: [{...}] 对象数组 → warning", () => {
546
+ const src = [
547
+ `const meta = { phases: [{ title: "a" }, { title: "b" }] };`,
548
+ `await agent({ prompt: "x", description: "d" });`,
549
+ ``,
550
+ ].join("\n");
551
+ const result = lintScript(src);
552
+
553
+ expect(warnings(result.findings).some((w) => /meta\.phases.*string array/i.test(w.message)))
554
+ .toBe(true);
555
+ });
556
+
557
+ it("phases: ['a'] 字符串数组 → 0 此类 warning", () => {
558
+ const src = [
559
+ `const meta = { phases: ["a"] };`,
560
+ `phase("a");`,
561
+ `await agent({ prompt: "x", description: "d" });`,
562
+ ``,
563
+ ].join("\n");
564
+ const result = lintScript(src);
565
+
566
+ expect(warnings(result.findings).some((w) => /meta\.phases.*string array/i.test(w.message)))
567
+ .toBe(false);
568
+ });
569
+
570
+ it("多行对象数组(phases: [ 换行 { ... })→ warning(MF-5 跨行匹配)", () => {
571
+ const src = [
572
+ `const meta = { phases: [`,
573
+ ` { title: "a" },`,
574
+ ` { title: "b" },`,
575
+ `] };`,
576
+ `await agent({ prompt: "x", description: "d" });`,
577
+ ``,
578
+ ].join("\n");
579
+ const result = lintScript(src);
580
+
581
+ expect(warnings(result.findings).some((w) => /meta\.phases.*string array/i.test(w.message)))
582
+ .toBe(true);
583
+ });
584
+
585
+ it("多行字符串数组(phases: [ 换行 'a' ])→ 0 此类 warning", () => {
586
+ const src = [
587
+ `const meta = { phases: [`,
588
+ ` "a",`,
589
+ `] };`,
590
+ `phase("a");`,
591
+ `await agent({ prompt: "x", description: "d" });`,
592
+ ``,
593
+ ].join("\n");
594
+ const result = lintScript(src);
595
+
596
+ expect(warnings(result.findings).some((w) => /meta\.phases.*string array/i.test(w.message)))
597
+ .toBe(false);
598
+ });
599
+ });
600
+
601
+ describe("声明 phases 与 phase() 调用一致性 — warning", () => {
602
+ it("声明 + 调用一致 → 0 此类 warning", () => {
603
+ const src = [
604
+ `const meta = { phases: ["review"] };`,
605
+ `phase("review");`,
606
+ `await agent({ prompt: "x", description: "d" });`,
607
+ ``,
608
+ ].join("\n");
609
+ const result = lintScript(src);
610
+
611
+ expect(warnings(result.findings).some((w) =>
612
+ /never set via phase|called but not in meta\.phases/i.test(w.message))).toBe(false);
613
+ });
614
+
615
+ it("声明了但从不 phase() 调用 → warning", () => {
616
+ const src = [
617
+ `const meta = { phases: ["review", "fix"] };`,
618
+ `await agent({ prompt: "x", description: "d" });`,
619
+ ``,
620
+ ].join("\n");
621
+ const result = lintScript(src);
622
+
623
+ expect(warnings(result.findings).some((w) => /never set via phase/i.test(w.message)))
624
+ .toBe(true);
625
+ });
626
+
627
+ it("phase() 调用了但未声明 → warning", () => {
628
+ const src = [
629
+ `const meta = { phases: ["review"] };`,
630
+ `phase("fix");`,
631
+ `await agent({ prompt: "x", description: "d" });`,
632
+ ``,
633
+ ].join("\n");
634
+ const result = lintScript(src);
635
+
636
+ expect(warnings(result.findings).some((w) => /called but not in meta\.phases/i.test(w.message)))
637
+ .toBe(true);
638
+ });
639
+
640
+ it("对象数组 phases 不触发一致性 warning(由 checkMetaPhases 负责)", () => {
641
+ // 对象数组场景:checkPhaseConsistency 应跳过提取,不产生 never-set warning
642
+ const src = [
643
+ `const meta = { phases: [{ title: "a" }] };`,
644
+ `await agent({ prompt: "x", description: "d" });`,
645
+ ``,
646
+ ].join("\n");
647
+ const result = lintScript(src);
648
+
649
+ expect(warnings(result.findings).some((w) => /never set via phase/i.test(w.message)))
650
+ .toBe(false);
651
+ });
652
+
653
+ it("无 phases 声明 + 无 phase() 调用 → 0 warning(skip 分支)", () => {
654
+ // checkPhaseConsistency 的 declared/called 都为空 → 提前 return(脚本不使用 phase 机制)。
655
+ // 断言用全量 warning 而非 message 正则过滤——skip 分支若产生 spurious warning 也会被捕获
656
+ const src = [
657
+ `const meta = { name: "x" };`,
658
+ `await agent({ prompt: "x", description: "d" });`,
659
+ ``,
660
+ ].join("\n");
661
+ const result = lintScript(src);
662
+
663
+ expect(warnings(result.findings)).toHaveLength(0);
664
+ });
665
+ });
@@ -16,6 +16,9 @@
16
16
  * 4. readFileSync/writeFileSync 传状态 → 脆弱(warning)
17
17
  * 5. unlinkSync 清理状态 → 与 subprocess 文件读竞态(warning)
18
18
  * 6. 顶层未 await 的异步 IIFE + 内部调 agent/parallel/pipeline → 子进程被提前 kill(error)
19
+ * 7. agent() 缺 description/label → TUI /workflows 显示 '(unnamed)'(warning)
20
+ * 8. meta.phases 非字符串数组(对象数组等)→ 引擎忽略(warning)
21
+ * 9. meta.phases 声明与 phase() 调用不一致 → 运行时分组与声明脱节(warning)
19
22
  *
20
23
  * 层归属:Engine。
21
24
  *
@@ -110,20 +113,44 @@ function checkLine(lineText: string, lineNum: number): LintFinding[] {
110
113
  }
111
114
 
112
115
  /**
113
- * 找出 source 中所有 agent 调用跨度,检查错误的选项 key。
116
+ * 剔除字符串字面量与注释内容(MF-4)。逐行处理,不跨行。
117
+ * 用途:`\bagent\s*\(` 不命中字符串里的 "agent(s)"(review-fix-loop L281 误报根因);
118
+ * checkAgentDescription 的 `description\s*:` 不把 schema 内嵌 description 字符串当已提供。
119
+ */
120
+ function stripStringsAndComments(line: string): string {
121
+ return line
122
+ .replace(/"(?:\\.|[^"\\])*"/g, "")
123
+ .replace(/'(?:\\.|[^'\\])*'/g, "")
124
+ .replace(/`(?:\\.|[^`\\])*`/g, "")
125
+ .replace(/\/\/.*$/, "")
126
+ .replace(/\/\*[\s\S]*?\*\//g, "");
127
+ }
128
+
129
+ /**
130
+ * 遍历 source 中所有 agent 调用的行范围,对每个调用执行 callback。
131
+ *
132
+ * agent 调用可能跨多行,通过括号配对定位起止行:
133
+ * agent({
134
+ * prompt: ...,
135
+ * })
114
136
  *
115
- * agent 调用可能跨多行:
116
- * agent({
117
- * prompt: ...,
118
- * outputSchema, error: 应为 schema
119
- * })
137
+ * 单行 agent 调用(如 `agent({ prompt: 'x' })`)的 startLine === endLine。
138
+ * 与 checkAgentCalls / checkAgentDescription 共享同一套范围定义,确保
139
+ * outputSchema 检查与 description 检查覆盖完全相同的调用集合(含 parallel/pipeline
140
+ * 内嵌的 agent() 调用——它们同样被 `\bagent\s*\(` 匹配)。
120
141
  *
121
- * 定位 agent 调用边界,检查 outputSchema 是否作为 key(非 value 如 `schema: outputSchema`)。
142
+ * 匹配前逐行剔除字符串/注释内容(MF-4):字符串字面量里的 "agent(s)" 不再误触发。
143
+ * 非字面量实参(agent(callVar) / agent(expr))跳过不回调:description 等选项在调用点
144
+ * 静态不可见,checkAgentDescription 无法验证运行时构造的调用——继续报 warning 即误报
145
+ * (review-fix-loop 的 agent(call) 三连误报根因)。
146
+ *
147
+ * @param callback (startLine, endLine) 0-based 行号
122
148
  */
123
- function checkAgentCalls(source: string): LintFinding[] {
124
- const findings: LintFinding[] = [];
149
+ function forEachAgentCallRange(
150
+ source: string,
151
+ callback: (startLine: number, endLine: number) => void,
152
+ ): void {
125
153
  const lines = source.split("\n");
126
-
127
154
  let inAgentCall = false;
128
155
  let depth = 0;
129
156
  let agentStartLine = -1;
@@ -137,37 +164,58 @@ function checkAgentCalls(source: string): LintFinding[] {
137
164
  continue;
138
165
  }
139
166
 
167
+ // 匹配/计括号都用剔除字符串与注释后的行,避免字面量内容干扰
168
+ const codeLine = stripStringsAndComments(line);
169
+
140
170
  // 检测 agent 调用开始
141
- if (!inAgentCall && /\bagent\s*\(/.test(line)) {
171
+ if (!inAgentCall && /\bagent\s*\(/.test(codeLine)) {
142
172
  inAgentCall = true;
143
173
  depth = 0;
144
174
  agentStartLine = i;
145
175
  // 从 agent( 开始计括号
146
- const afterAgent = line.replace(/^.*?\bagent\s*\(/, "(");
176
+ const afterAgent = codeLine.replace(/^.*?\bagent\s*\(/, "(");
177
+ // 非字面量实参(agent(callVar) / agent(expr))→ 跳过(见函数头注释)。
178
+ // 形如 `agent(` 换行 `{` 的多行字面量调用(argTail 为空)保留原追踪行为。
179
+ const argTail = afterAgent.trimStart().slice(1).trimStart();
180
+ if (argTail.length > 0 && !argTail.startsWith("{")) {
181
+ inAgentCall = false;
182
+ continue;
183
+ }
147
184
  for (const ch of afterAgent) {
148
185
  if (ch === "(" || ch === "{" || ch === "[") depth++;
149
186
  if (ch === ")" || ch === "}" || ch === "]") depth--;
150
187
  }
151
188
  if (depth <= 0) {
152
189
  // 单行 agent 调用
153
- checkAgentCallOptions(lines, agentStartLine, i, findings);
190
+ callback(agentStartLine, i);
154
191
  inAgentCall = false;
155
192
  }
156
193
  continue;
157
194
  }
158
195
 
159
196
  if (inAgentCall) {
160
- for (const ch of line) {
197
+ for (const ch of codeLine) {
161
198
  if (ch === "(" || ch === "{" || ch === "[") depth++;
162
199
  if (ch === ")" || ch === "}" || ch === "]") depth--;
163
200
  }
164
201
  if (depth <= 0) {
165
- checkAgentCallOptions(lines, agentStartLine, i, findings);
202
+ callback(agentStartLine, i);
166
203
  inAgentCall = false;
167
204
  }
168
205
  }
169
206
  }
207
+ }
170
208
 
209
+ /**
210
+ * 找出 source 中所有 agent 调用跨度,检查错误的选项 key(outputSchema)。
211
+ * 范围遍历委托 forEachAgentCallRange。
212
+ */
213
+ function checkAgentCalls(source: string): LintFinding[] {
214
+ const lines = source.split("\n");
215
+ const findings: LintFinding[] = [];
216
+ forEachAgentCallRange(source, (startLine, endLine) => {
217
+ checkAgentCallOptions(lines, startLine, endLine, findings);
218
+ });
171
219
  return findings;
172
220
  }
173
221
 
@@ -213,6 +261,79 @@ function checkAgentCallOptions(
213
261
  }
214
262
  }
215
263
 
264
+ /**
265
+ * 剔除 agent 选项对象里的 schema 块(`schema: {...}` 嵌套对象,括号配对)。
266
+ * 用于 checkAgentDescription:JSON Schema 的 properties 里常见 `description:` 字段
267
+ * (schema 文档字段,不是 agent 选项)——只剔字符串字面量时该 key 仍保留,会把
268
+ * 内嵌 description 误判为「已提供」导致漏报(I-10 修正的剩余部分)。
269
+ * 输入为已剔除字符串/注释的 range(无引号内容干扰,括号配对安全)。
270
+ */
271
+ function stripSchemaBlocks(text: string): string {
272
+ let out = "";
273
+ let i = 0;
274
+ while (i < text.length) {
275
+ const m = text.slice(i).match(/\bschema\s*:\s*\{/);
276
+ if (!m || m.index === undefined) {
277
+ out += text.slice(i);
278
+ break;
279
+ }
280
+ const start = i + m.index;
281
+ const braceIdx = start + m[0].lastIndexOf("{");
282
+ out += text.slice(i, start);
283
+ let depth = 0;
284
+ let j = braceIdx;
285
+ for (; j < text.length; j++) {
286
+ if (text[j] === "{") depth++;
287
+ else if (text[j] === "}") {
288
+ depth--;
289
+ if (depth === 0) {
290
+ j++;
291
+ break;
292
+ }
293
+ }
294
+ }
295
+ i = j;
296
+ }
297
+ return out;
298
+ }
299
+
300
+ /**
301
+ * 检查 agent 调用是否提供 description(或其别名 label)。
302
+ *
303
+ * worker-script-builder 取 `firstArg.label || firstArg.description` 作 TUI 显示名,
304
+ * 两者都缺 → node.agent 为空 → /workflows 视图显示 '(unnamed)'。
305
+ *
306
+ * 实现复用 forEachAgentCallRange 的范围定义,在范围内检测 `description:` 或 `label:`
307
+ * 作为对象 key。两层剔除保证只认 agent 选项层的真正 key:① 字符串字面量(
308
+ * stripStringsAndComments,MF-4);② schema 块(stripSchemaBlocks——schema 内嵌的
309
+ * `description:` 是 JSON Schema 字段说明不是 agent 选项,不剔除会漏报)。
310
+ */
311
+ function checkAgentDescription(source: string): LintFinding[] {
312
+ const lines = source.split("\n");
313
+ const findings: LintFinding[] = [];
314
+ forEachAgentCallRange(source, (startLine, endLine) => {
315
+ // range 逐行剔除字符串字面量(MF-4)后再剔除 schema 块:schema 内嵌的
316
+ // `description:` 对象 key(JSON Schema 字段说明)不再被误判为「已提供」——
317
+ // 只有 agent 选项层的真正 description/label key 才算数(修正 I-10 漏报方向)。
318
+ const range = stripSchemaBlocks(lines.slice(startLine, endLine + 1).map(stripStringsAndComments).join("\n"));
319
+ // 对象以展开开头(agent({ ...call, ... })):description 来自运行时对象、调用点静态
320
+ // 不可见——无法验证即不报(review-fix-loop 的 agent({ ...call, agent: ... }) 即此形态)。
321
+ if (/\{\s*\.\.\./.test(range)) return;
322
+ // 匹配 description 或 label 作为对象 key(后跟冒号)
323
+ if (!/\b(description|label)\s*:/.test(range)) {
324
+ findings.push({
325
+ severity: "warning",
326
+ line: startLine + 1,
327
+ message:
328
+ "agent() call without `description` (or `label`) will show as '(unnamed)' in TUI.",
329
+ suggestion:
330
+ "Add `description: 'kebab-case-name'` to agent() opts for readable /workflows display.",
331
+ });
332
+ }
333
+ });
334
+ return findings;
335
+ }
336
+
216
337
  // ── 顶层未 await 的异步 IIFE 检测 ───────────────────────────
217
338
 
218
339
  /**
@@ -348,6 +469,99 @@ function analyzeIIFE(source: string, iifeStart: number): LintFinding | undefined
348
469
  };
349
470
  }
350
471
 
472
+ // ── 显示性检查(description / phase)───────────────────────────
473
+
474
+ /**
475
+ * 检查 meta.phases 是否字符串数组。
476
+ *
477
+ * 引擎 buildPhaseGroups 只按运行时 node.phase 分组,不读 meta.phases 声明。但 meta.phases
478
+ * 仍用于文档/一致性检查(见 checkPhaseConsistency),且 SSOT 约定为字符串数组。对象数组
479
+ * (如 [{title,detail}])是常见误写,提醒作者改为字符串数组。
480
+ */
481
+ function checkMetaPhases(source: string): LintFinding[] {
482
+ const findings: LintFinding[] = [];
483
+ // 跨行匹配(\s* 含换行):`phases: [` 与 `{` 换行分离的多行对象数组同样命中。
484
+ // 原逐行匹配只命中「`[` 与 `{` 同行」,最常见的格式化写法(phases: [ 换行 { ... })零检出(MF-5)。
485
+ // 行号从 match index 反推。字符串数组(phases: [\n "a")不匹配 \s*\{,不会误报。
486
+ for (const m of source.matchAll(/phases\s*:\s*\[\s*\{/g)) {
487
+ if (m.index === undefined) continue;
488
+ const lineNum = source.slice(0, m.index).split("\n").length;
489
+ findings.push({
490
+ severity: "warning",
491
+ line: lineNum,
492
+ message:
493
+ "`meta.phases` should be a string array like ['phase1','phase2']. Object arrays are ignored by the engine.",
494
+ suggestion:
495
+ "Use `phases: ['analyze','fix']`. Engine groups nodes by runtime `phase()` calls, not by `meta.phases` declarations.",
496
+ });
497
+ }
498
+ return findings;
499
+ }
500
+
501
+ /**
502
+ * 检查 meta.phases 声明与 phase() 调用的一致性。
503
+ *
504
+ * - 声明了但从未 phase() 调用 → warning(运行时分组用不上,声明形同虚设)
505
+ * - phase() 调用了但未声明 → warning(声明遗漏,meta.phases 失去文档价值)
506
+ *
507
+ * 两者都为空时跳过(脚本不使用 phase 机制,不报)。
508
+ */
509
+ function checkPhaseConsistency(source: string): LintFinding[] {
510
+ const findings: LintFinding[] = [];
511
+
512
+ // 提取 meta.phases 声明的字符串 + 声明所在行号
513
+ const declared = new Map<string, number>();
514
+ const phasesArrayMatch = source.match(/phases\s*:\s*\[[^\]]*\]/);
515
+ if (phasesArrayMatch && phasesArrayMatch.index !== undefined) {
516
+ const inner = phasesArrayMatch[0];
517
+ // 对象数组(如 [{title,detail}])由 checkMetaPhases 单独报,这里跳过提取,
518
+ // 避免从对象字段里误抽出字符串作 declared。
519
+ if (!/\[\s*\{/.test(inner)) {
520
+ const phasesLine = source.slice(0, phasesArrayMatch.index).split("\n").length;
521
+ for (const m of inner.matchAll(/['"]([^'"]+)['"]/g)) {
522
+ if (!declared.has(m[1])) declared.set(m[1], phasesLine);
523
+ }
524
+ }
525
+ }
526
+
527
+ // 提取所有 phase() 调用实参 + 首次出现的行号
528
+ const called = new Map<string, number>();
529
+ for (const m of source.matchAll(/\bphase\s*\(\s*['"]([^'"]+)['"]/g)) {
530
+ if (m.index === undefined) continue;
531
+ const lineNum = source.slice(0, m.index).split("\n").length;
532
+ if (!called.has(m[1])) called.set(m[1], lineNum);
533
+ }
534
+
535
+ // 两者都为空 → 跳过(脚本不使用 phase 机制)
536
+ if (declared.size === 0 && called.size === 0) return [];
537
+
538
+ // 声明了但从未 phase() 调用
539
+ for (const [name, line] of declared) {
540
+ if (!called.has(name)) {
541
+ findings.push({
542
+ severity: "warning",
543
+ line,
544
+ message: `declared phase '${name}' never set via phase().`,
545
+ suggestion: `Add phase('${name}') before the agent() calls belonging to this phase, or remove it from meta.phases.`,
546
+ });
547
+ }
548
+ }
549
+
550
+ // 调用了但未声明
551
+ for (const [name, line] of called) {
552
+ if (!declared.has(name)) {
553
+ findings.push({
554
+ severity: "warning",
555
+ line,
556
+ message: `phase('${name}') called but not in meta.phases.`,
557
+ suggestion: `Add '${name}' to meta.phases array, e.g. phases: [..., '${name}'].`,
558
+ });
559
+ }
560
+ }
561
+
562
+ return findings;
563
+ }
564
+
351
565
  /**
352
566
  * 静态检查 workflow 脚本合法性。
353
567
  *
@@ -377,6 +591,12 @@ export function lintScript(source: string): LintResult {
377
591
  // 最终靠 worker-host → handleReturn → release → abort 的调用栈定位。
378
592
  findings.push(...checkBareAsyncIIFE(source));
379
593
 
594
+ // 显示性检查(warning):agent 缺 description / meta.phases 形式 / phase 一致性。
595
+ // 目的:让 TUI /workflows 视图避免 unnamed agent 与 (unnamed) phase 分组。
596
+ findings.push(...checkAgentDescription(source));
597
+ findings.push(...checkMetaPhases(source));
598
+ findings.push(...checkPhaseConsistency(source));
599
+
380
600
  // 按行号排序,稳定输出
381
601
  findings.sort((a, b) => a.line - b.line);
382
602
 
@@ -58,13 +58,16 @@ workflow run map-reduce --args itemsJson=/path/to/items.json --args operation=".
58
58
  workflow run review-fix-loop --args targetType=git-diff target=main \
59
59
  --args batch1=fallow-scan --args batch2=reviewer --args autoCommit=true
60
60
  workflow run review-fix-loop --args targetType=file target=/path/to/doc.md \
61
- --args batch1=reviewer
61
+ --args batch1=doc-reviewer
62
62
  ```
63
63
 
64
64
  - `targetType` 枚举:`git-diff`(target=base ref)/ `file`(target=路径)/ `dir`(target=目录)/ `text`(target=自由描述)
65
65
  - `batch1..batchN`:批串行,批内并行 review → aggregate → fix → 重审直到 clean;批次用于前置依赖(如 `fallow-scan` 静态分析先行,后续审查才有意义)
66
- - 批内某 agent 无 must-fix 后后续轮跳过(`skipCleanAgents`,默认 true);`recheckAfterFix=true` 可在 fix 后重派全批做回归防护
67
- - agent 项支持:AgentRegistry 名(如 `reviewer`)/ 自定义 .md 文件路径(如 `batch1=/path/to/reviewer.md`)/ 内置 `fallow-scan`
66
+ - 批内某 agent 无 must-fix 后后续轮跳过(`skipCleanAgents` 默认 true + `recheckAfterFix` 默认 false):clean agent 下轮跳过不重派;显式传 `recheckAfterFix=true` 启用强回归模式——fix 后重派全批,clean agent 走限定 prompt(只审 modifiedFiles ∪ 自检关联点,不诱导全量重扫)
67
+ - agent 项支持:AgentRegistry 名(如 `reviewer`)/ 自定义 .md 文件路径(如 `batch1=/path/to/reviewer.md`)/ 内置 `fallow-scan` / **内置 `doc-reviewer`**(文档场景推荐:`targetType=file/dir` + `batch1=doc-reviewer`,四遍审查方法论:事实锚点核实/逻辑断言验证/落地清单完备性/边界与迁移;无 write 工具,报告经 schema 返回由 workflow 落盘)
68
+ - `fixAgent`(可选):fix 阶段加载指定 agent(内置名或 .md 路径);代码场景可在该 agent.md 内写 verify 命令(typecheck/test 实测)当轮拦截编译类回归。⚠️ agent.md 内写的 verify 命令**必须确认能在目标项目可运行**(target 的包管理器/目录结构未知),否则命令失败会误报 fix 状态
69
+ - `maxFixAttempts`(可选,默认 2):needs-redesign 阈值。问题经 maxFixAttempts 次修复仍未收敛(regressed)→ 终止该批,terminated="needs-redesign"(结构性问题需人工介入,非继续补丁能解决)
70
+ - `convergeNewIssues`(可选,默认 1)+ `convergeRounds`(可选,默认 2):新发现率收敛阈值。连续 convergeRounds 轮新发现问题 ≤ convergeNewIssues **且**无 open/regressed 活跃条目 → terminated="converged"(推进下一批)。收敛不等于问题全清——需同时满足无活跃条目才终止
68
71
  - ⚠️ **fix 阶段会修改文件;`autoCommit` 默认 false(不 commit)**,需要提交时显式 `autoCommit=true`
69
72
 
70
73
  ## 编排 API