minimal-agent 0.12.0 → 0.13.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.
Files changed (74) hide show
  1. package/package.json +3 -2
  2. package/plugins/workflow-runner/src/expressions.js +29 -8
  3. package/plugins/workflow-runner/src/expressions.ts +28 -10
  4. package/plugins/workflow-runner/src/index.js +45 -17
  5. package/plugins/workflow-runner/src/index.ts +44 -18
  6. package/plugins/workflow-runner/src/recovery.js +9 -2
  7. package/plugins/workflow-runner/src/recovery.ts +9 -2
  8. package/plugins/workflow-runner/src/runner.js +31 -5
  9. package/plugins/workflow-runner/src/runner.ts +33 -5
  10. package/plugins/workflow-runner/src/stepExecutors/assert.js +2 -1
  11. package/plugins/workflow-runner/src/stepExecutors/assert.ts +3 -1
  12. package/plugins/workflow-runner/src/stepExecutors/tool.js +29 -10
  13. package/plugins/workflow-runner/src/stepExecutors/tool.ts +29 -11
  14. package/plugins/workflow-runner/src/stepExecutors/verify.js +6 -3
  15. package/plugins/workflow-runner/src/stepExecutors/verify.ts +7 -3
  16. package/plugins/workflow-runner/src/taskIdentity.js +197 -21
  17. package/plugins/workflow-runner/src/taskIdentity.ts +201 -20
  18. package/plugins/workflow-runner/src/types.js +113 -7
  19. package/plugins/workflow-runner/src/types.ts +111 -7
  20. package/plugins/workflow-runner/src/workflowState.js +455 -54
  21. package/plugins/workflow-runner/src/workflowState.ts +508 -63
  22. package/plugins/workflow-runner/test/cli.e2e.test.ts +20 -0
  23. package/plugins/workflow-runner/test/e2eNewLayout.test.ts +314 -0
  24. package/plugins/workflow-runner/test/engineRobustness.test.ts +9 -1
  25. package/plugins/workflow-runner/test/expressions.test.ts +42 -0
  26. package/plugins/workflow-runner/test/illustrate.load.test.ts +2 -2
  27. package/plugins/workflow-runner/test/parseWorkflowArgs.test.ts +19 -0
  28. package/plugins/workflow-runner/test/ppt.load.test.ts +15 -15
  29. package/plugins/workflow-runner/test/ralphLoop.e2e.test.ts +3 -2
  30. package/plugins/workflow-runner/test/research.load.test.ts +328 -0
  31. package/plugins/workflow-runner/test/runnerPreflight.test.ts +68 -11
  32. package/plugins/workflow-runner/test/stateLifecycle.test.ts +60 -12
  33. package/plugins/workflow-runner/test/taskArbitration.test.ts +19 -3
  34. package/plugins/workflow-runner/test/taskLifecycle.test.ts +61 -7
  35. package/plugins/workflow-runner/test/webppt.load.test.ts +4 -4
  36. package/plugins/workflow-runner/test/word.load.test.ts +9 -9
  37. package/skills/skill-creator/scripts/run_eval.py +3 -2
  38. package/src/cli/print.js +17 -0
  39. package/src/cli/print.ts +25 -0
  40. package/src/config/configService.js +24 -0
  41. package/src/config/configService.ts +23 -0
  42. package/src/infra/verification.js +25 -18
  43. package/src/infra/verification.ts +50 -18
  44. package/src/plugins/pluginRunner.js +18 -7
  45. package/src/plugins/pluginRunner.ts +20 -6
  46. package/src/prompts/policyRegistry.js +28 -1
  47. package/src/prompts/policyRegistry.ts +28 -1
  48. package/src/tools/advisor/advisor.js +76 -9
  49. package/src/tools/advisor/advisor.ts +79 -9
  50. package/src/tools/bash/bash.js +51 -6
  51. package/src/tools/bash/bash.ts +56 -7
  52. package/src/tools/powershell/powershell.js +18 -1
  53. package/src/tools/powershell/powershell.ts +18 -2
  54. package/src/tools/read/read.js +9 -4
  55. package/src/tools/read/read.ts +9 -4
  56. package/src/tools/shared/permanentDeleteGuard.js +34 -3
  57. package/src/tools/shared/permanentDeleteGuard.ts +43 -6
  58. package/src/tools/webbrowser/browserControl.js +5 -1
  59. package/src/tools/webbrowser/browserControl.ts +7 -1
  60. package/src/tools/webbrowser/webbrowser.js +8 -4
  61. package/src/tools/webbrowser/webbrowser.ts +8 -4
  62. package/src/types.ts +10 -4
  63. package/workflows/adaptive-research.yaml +5 -5
  64. package/workflows/book-review-short.yaml +5 -5
  65. package/workflows/e2e-write-greet.yaml +2 -2
  66. package/workflows/illustrate.yaml +12 -12
  67. package/workflows/plan.yaml +18 -18
  68. package/workflows/ppt.yaml +201 -201
  69. package/workflows/ralph-loop.yaml +3 -1
  70. package/workflows/research.md +449 -0
  71. package/workflows/research.yaml +873 -0
  72. package/workflows/webppt.yaml +47 -47
  73. package/workflows/word.yaml +80 -80
  74. package/workflows/youtube-shorts.yaml +25 -25
@@ -67,9 +67,10 @@ describe('任务身份生命周期(L2 completed / L3 new-task 清产物)', (
67
67
  writeFileSync(join(tmp, 'PLAN.md'), '# plan');
68
68
  await s1.markDone();
69
69
  await s1.archive();
70
- // 归档后活动目录已改名 .done(E6 后按名隔离)
71
- expect(existsSync(join(tmp, '.minimal-agent-workflow', 'plan'))).toBe(false);
72
- expect(existsSync(join(tmp, '.minimal-agent-workflow', 'plan.done'))).toBe(true);
70
+ // 归档后活动实例被改名 .done(workflow 级父目录保留——里面可能有多个实例)
71
+ expect(existsSync(join(tmp, '.minimal-agent-workflow', 'plan'))).toBe(true);
72
+ expect(existsSync(s1.dir + '.done')).toBe(true);
73
+ expect(existsSync(s1.dir)).toBe(false);
73
74
 
74
75
  // 第二次:同 goal 重跑 → completed(经 .done 检测),报真实产物
75
76
  const s2 = new WorkflowState(tmp, 'plan');
@@ -104,8 +105,9 @@ describe('任务身份生命周期(L2 completed / L3 new-task 清产物)', (
104
105
  const s2 = new WorkflowState(tmp, 'plan');
105
106
  const r2 = await s2.init('plan', { goal: '完全不相关的目标B' }, STEPS, 1, ['PLAN.md']);
106
107
  expect(r2.mode).toBe('new-task');
107
- expect(r2.cleanedArtifacts).toContain('PLAN.md');
108
108
  expect(existsSync(join(tmp, 'PLAN.md'))).toBe(false);
109
+ expect(r2.archivedTo).toBeDefined();
110
+ expect(existsSync(join(tmp, r2.archivedTo as string, 'PLAN.md'))).toBe(true);
109
111
  });
110
112
 
111
113
  it('legacy 状态文件(无 artifacts 字段)+ 同名 workflow 换 goal → new-task 用本次声明清单兜底清理', async () => {
@@ -128,8 +130,9 @@ describe('任务身份生命周期(L2 completed / L3 new-task 清产物)', (
128
130
  const r = await s.init('plan', { goal: '全新目标' }, STEPS, 1, ['PLAN.md']);
129
131
  expect(r.mode).toBe('new-task');
130
132
  // 兜底清理生效:同名 workflow 的声明产物语义稳定,按本次声明清单清掉残留
131
- expect(r.cleanedArtifacts).toContain('PLAN.md');
132
133
  expect(existsSync(join(tmp, 'PLAN.md'))).toBe(false);
134
+ expect(r.archivedTo).toBeDefined();
135
+ expect(existsSync(join(tmp, r.archivedTo as string, 'PLAN.md'))).toBe(true);
133
136
  });
134
137
 
135
138
  it('/new 后孤儿产物:无任何状态但声明产物已存在 → fresh 启动时清理(全新任务从干净状态开始)', async () => {
@@ -141,8 +144,59 @@ describe('任务身份生命周期(L2 completed / L3 new-task 清产物)', (
141
144
  const r = await s.init('plan', { goal: '目标A' }, STEPS, 1, ['PLAN.md']);
142
145
  expect(r.mode).toBe('fresh');
143
146
  expect(existsSync(join(tmp, 'PLAN.md'))).toBe(false);
144
- // 可见化:runner 据此报「已清理旧产物」warning
145
- expect(r.cleanedArtifacts).toEqual(['PLAN.md']);
147
+ expect(r.archivedTo).toBeDefined();
148
+ expect(existsSync(join(tmp, r.archivedTo as string, 'PLAN.md'))).toBe(true);
149
+ });
150
+
151
+ it('A4:归档保留相对路径结构,只归档声明产物、不碰未声明文件', async () => {
152
+ const { mkdirSync } = await import('node:fs');
153
+ mkdirSync(join(tmp, 'out'), { recursive: true });
154
+ const s1 = new WorkflowState(tmp, 'plan');
155
+ await s1.init('plan', { goal: '目标A' }, STEPS, 1, ['out/PLAN.md']);
156
+ writeFileSync(join(tmp, 'out', 'PLAN.md'), '# A');
157
+ writeFileSync(join(tmp, 'out', 'USER-OWN.md'), '# 用户自己的文件');
158
+
159
+ const s2 = new WorkflowState(tmp, 'plan');
160
+ const r2 = await s2.init('plan', { goal: '目标B' }, STEPS, 1, ['out/PLAN.md']);
161
+ expect(r2.mode).toBe('new-task');
162
+ expect(existsSync(join(tmp, 'out', 'PLAN.md'))).toBe(false);
163
+ expect(existsSync(join(tmp, 'out', 'USER-OWN.md'))).toBe(true);
164
+ expect(existsSync(join(tmp, r2.archivedTo as string, 'out', 'PLAN.md'))).toBe(true);
165
+ });
166
+
167
+ it('B8:声明产物支持 glob(out/*.md),new-task 清理时展开为实际文件归档', async () => {
168
+ const { mkdirSync } = await import('node:fs');
169
+ mkdirSync(join(tmp, 'out'), { recursive: true });
170
+ const s1 = new WorkflowState(tmp, 'research');
171
+ await s1.init('research', { goal: '目标A' }, STEPS, 1, ['out/deep-dive-*.md']);
172
+ writeFileSync(join(tmp, 'out', 'deep-dive-a.md'), '# A');
173
+ writeFileSync(join(tmp, 'out', 'deep-dive-b.md'), '# B');
174
+ writeFileSync(join(tmp, 'out', 'notes.txt'), '# 不匹配 md');
175
+
176
+ const s2 = new WorkflowState(tmp, 'research');
177
+ const r2 = await s2.init('research', { goal: '目标B' }, STEPS, 1, ['out/deep-dive-*.md']);
178
+ expect(r2.mode).toBe('new-task');
179
+ expect(existsSync(join(tmp, 'out', 'deep-dive-a.md'))).toBe(false);
180
+ expect(existsSync(join(tmp, 'out', 'deep-dive-b.md'))).toBe(false);
181
+ expect(existsSync(join(tmp, 'out', 'notes.txt'))).toBe(true);
182
+ const trash = join(tmp, r2.archivedTo as string);
183
+ expect(existsSync(join(trash, 'out', 'deep-dive-a.md'))).toBe(true);
184
+ expect(existsSync(join(trash, 'out', 'deep-dive-b.md'))).toBe(true);
185
+ });
186
+
187
+ it('B8:越界 glob(../x*)不展开不清理(fail-closed)', async () => {
188
+ writeFileSync(join(tmp, 'PLAN.md'), '# inside');
189
+ const outside = mkdtempSync(join(tmpdir(), 'wf-outside-'));
190
+ try {
191
+ writeFileSync(join(outside, 'secret.md'), '# outside');
192
+ const s = new WorkflowState(tmp, 'plan');
193
+ const r = await s.init('plan', { goal: '目标A' }, STEPS, 1, ['../x*.md', 'PLAN.md']);
194
+ expect(r.mode).toBe('fresh');
195
+ expect(existsSync(join(outside, 'secret.md'))).toBe(true);
196
+ expect(existsSync(join(tmp, 'PLAN.md'))).toBe(false);
197
+ } finally {
198
+ rmSync(outside, { recursive: true, force: true });
199
+ }
146
200
  });
147
201
 
148
202
  it('resume 恢复点用最后完成步的【索引】而非 checkpoint 计数(when 跳过步不再被重复执行)', async () => {
@@ -70,11 +70,11 @@ describe('workflows/webppt.yaml', () => {
70
70
  expect(String(s.title)).toContain('/11');
71
71
  }
72
72
 
73
- // 收尾报告 echo;artifacts 只列最终交付物、不在被 rm 的 ./build 下
73
+ // 收尾报告 echo;artifacts 只列最终交付物、不在被 rm 的 out/webppt/${session}/build 下
74
74
  expect(def.steps.find((s) => s.id === 'final_report')?.type).toBe('echo');
75
75
  const declared = def.artifacts ?? [];
76
- expect(declared).toContain('./out/deck.pptx');
77
- for (const a of declared) expect(a.startsWith('./build')).toBe(false);
76
+ expect(declared).toContain('out/webppt/${session}/deck.pptx');
77
+ for (const a of declared) expect(a.startsWith('out/webppt/${session}/build')).toBe(false);
78
78
  });
79
79
 
80
80
  it('需求分析步:style 为空时必问视觉样式(VI 选项 + 自定义输入),AskUserQuestion 可用', async () => {
@@ -199,7 +199,7 @@ describe('workflows/webppt.yaml', () => {
199
199
  // 配图分流闸门:image_plan_lint 的 --outline 直接吃 design.json
200
200
  const gate = def.steps.find((s) => s.id === 'stage_img_route_gate');
201
201
  expect(String(gate?.agent)).toContain('image_plan_lint.py');
202
- expect(String(gate?.agent)).toContain('--outline ./build/design.json');
202
+ expect(String(gate?.agent)).toContain('--outline out/webppt/${session}/build/design.json');
203
203
  expect(String(gate?.done_when)).toContain('img_route_ok');
204
204
  // 顺序:build_loop → img_route_gate → gen_images
205
205
  expect(ids.indexOf('stage_img_route_gate')).toBeGreaterThan(ids.indexOf('stage_build_loop'));
@@ -3,7 +3,7 @@
3
3
  * ------------------------------------------------------------
4
4
  * 锁定 workflows/word.yaml(素材驱动知乎深度长文 Word 工作流)的
5
5
  * 「可解析 + 关键骨架」:
6
- * - name=word,最终产物是 ./out/article.docx
6
+ * - name=word,最终产物是 out/word/${session}/article.docx
7
7
  * - inputs 支持 topic(greedy) / source / out / image_intensity / quality / debug_keep_build
8
8
  * - Markdown-first:material → outline → article.md → image_map/img_plan → docx skill → OPC
9
9
  * - 客户图必须使用;需要解释型视觉时积极文生图,提示词必须结合上下文
@@ -32,8 +32,8 @@ describe('workflows/word.yaml(素材驱动·知乎深度长文 Word)', () =>
32
32
  expect(def.description).toContain('知乎深度文章');
33
33
 
34
34
  const declared = def.artifacts ?? [];
35
- expect(declared).toContain('./out/article.docx');
36
- for (const a of declared) expect(a.startsWith('./build')).toBe(false);
35
+ expect(declared).toContain('out/word/${session}/article.docx');
36
+ for (const a of declared) expect(a.startsWith('out/word/${session}/build')).toBe(false);
37
37
 
38
38
  const inputNames = (def.inputs ?? []).map((i) => i.name);
39
39
  expect(inputNames).toEqual(
@@ -49,7 +49,7 @@ describe('workflows/word.yaml(素材驱动·知乎深度长文 Word)', () =>
49
49
  expect(inputNames[0]).toBe('topic');
50
50
  expect((def.inputs ?? []).find((i) => i.name === 'topic')?.greedy).toBe(true);
51
51
  expect((def.inputs ?? []).find((i) => i.name === 'out')?.default).toBe(
52
- './out/article.docx',
52
+ 'out/word/${session}/article.docx',
53
53
  );
54
54
  });
55
55
 
@@ -163,11 +163,11 @@ describe('workflows/word.yaml(素材驱动·知乎深度长文 Word)', () =>
163
163
  const extract = def.steps.find((s) => s.id === 'stage_extract');
164
164
  const command = String(extract?.args?.command ?? '');
165
165
 
166
- expect(command).toContain('./build/source.md');
167
- expect(command).toContain('rm -rf ./build/assets');
168
- expect(command).toContain('mkdir -p ./build/assets');
169
- expect(command).toContain('./build/word_topic_image_gate.json');
170
- expect(command).toContain('./build/word_topic_image_gate.md');
166
+ expect(command).toContain('out/word/${session}/build/source.md');
167
+ expect(command).toContain('rm -rf out/word/${session}/build/assets');
168
+ expect(command).toContain('mkdir -p out/word/${session}/build/assets');
169
+ expect(command).toContain('out/word/${session}/build/word_topic_image_gate.json');
170
+ expect(command).toContain('out/word/${session}/build/word_topic_image_gate.md');
171
171
  });
172
172
 
173
173
  it('生成配图失败时会在导出前硬失败,不会把渐变占位图交付进 Word', async () => {
@@ -20,14 +20,15 @@ from scripts.utils import parse_skill_md
20
20
 
21
21
 
22
22
  def find_project_root() -> Path:
23
- """Find the project root by walking up from cwd looking for .claude/.
23
+ """Find the project root by walking up from cwd looking for .kakadeai/
24
+ (this repo's project-config dir) or .claude/ (upstream convention).
24
25
 
25
26
  Mimics how Claude Code discovers its project root, so the command file
26
27
  we create ends up where claude -p will look for it.
27
28
  """
28
29
  current = Path.cwd()
29
30
  for parent in [current, *current.parents]:
30
- if (parent / ".claude").is_dir():
31
+ if (parent / ".kakadeai").is_dir() or (parent / ".claude").is_dir():
31
32
  return parent
32
33
  return current
33
34
 
package/src/cli/print.js CHANGED
@@ -108,8 +108,16 @@ export function extractImageOptions(args) {
108
108
  * stop_reason = end_turn | max_turns | stalled | error | interrupted | config_error
109
109
  * num_turns = assistant_message 事件计数
110
110
  * error = 失败文案(成功时 null)
111
+ * warnings = 本轮所有非致命警告(与 stderr 同源;宿主要结构化的那一份)
111
112
  * session_file(可选)= 当前对话落盘路径;拿得到才填,拿不到省略该键
112
113
  *
114
+ * completionStatus = 本层从 stop_reason + deliverables 重算:跑完了没、产物验没验
115
+ * delivery_status(可选)= 内核 turn_done 的交付判定,**回答的是另一个问题**:
116
+ * completed / delivery_incomplete / delivery_unverified / claims_unverified。
117
+ * 两者并列而非替代 —— `ok:true` 配 `delivery_status:'claims_unverified'` 是
118
+ * 完全合法的一行:Agent 确实正常收尾了,但它宣称的「测试已通过」没有证据支撑。
119
+ * 判断「任务是否真做成」的宿主应当读它,而不是只看 ok。
120
+ *
113
121
  * 导出供单测断言。
114
122
  */
115
123
  export function emitJsonResult(result, sessionFile) {
@@ -136,6 +144,7 @@ export function emitJsonResult(result, sessionFile) {
136
144
  num_turns: result.numTurns,
137
145
  error: result.error,
138
146
  completionStatus,
147
+ ...(result.deliveryStatus ? { delivery_status: result.deliveryStatus } : {}),
139
148
  deliverables,
140
149
  verification: allVerified ? 'passed' : deliverables.length > 0 ? 'not_run' : 'none',
141
150
  warnings,
@@ -399,11 +408,19 @@ export function handleEvent(event, result, verbose, outputFormat = 'text') {
399
408
  // 通用非致命警告(如 workflow 多余参数、pause 被自动跳过等)始终走 stderr,
400
409
  // 不受 verbose 控制 —— 这是用户必须看到的信号,避免静默丢弃语义被忽略。
401
410
  process.stderr.write(`${event.message}\n`);
411
+ // 同时收进结局契约:stderr 是给人看的,spawn 它的宿主要的是结构化的一条。
412
+ // (此前 PrintResult.warnings 声明了、读了、也输出了,就是没有任何地方写它 ——
413
+ // json 那行的 warnings 恒为空数组。)
414
+ (result.warnings ??= []).push(event.message);
402
415
  return {};
403
416
  case 'turn_done':
404
417
  // text 模式:非 verbose 时这里一次性把累积答案打到 stdout(旧行为,逐字节不变)。
405
418
  // json 模式:只记 stopReason='end_turn',stdout 保持静默。
406
419
  result.stopReason = 'end_turn';
420
+ // 内核的交付判定必须过进程边界:模型谎称「已验证」时,若不记这一笔,
421
+ // 宿主收到的 JSON 与一切正常逐字节相同,唯一线索只剩 stderr 上一行中文。
422
+ if (event.completionStatus)
423
+ result.deliveryStatus = event.completionStatus;
407
424
  if (!isJson && !verbose)
408
425
  console.log(result.buffer);
409
426
  return {};
package/src/cli/print.ts CHANGED
@@ -98,6 +98,15 @@ export interface PrintResult {
98
98
  deliverables?: DeliverableManifest['files'];
99
99
  warnings?: string[];
100
100
  harnessDiagnostics?: HarnessDiagnostics;
101
+ /**
102
+ * 内核 `turn_done` 给出的交付判定(loop.ts 的四态词表)。
103
+ *
104
+ * 与下面 json 里那个从 stopReason+deliverables 重算的 `completionStatus` **不是**
105
+ * 一回事:那个只回答「跑完了没、产物验没验」,这个回答「模型宣称的事到底做没做」。
106
+ * 尤其 `claims_unverified` —— 模型说「测试已全部通过」但本轮证据台账里没有一条
107
+ * 成功的验证记录 —— 是重不重要全看它能不能过进程边界。
108
+ */
109
+ deliveryStatus?: string;
101
110
  }
102
111
 
103
112
  /** handleEvent 返回的结构 */
@@ -191,8 +200,16 @@ export function extractImageOptions(args: string[]): {
191
200
  * stop_reason = end_turn | max_turns | stalled | error | interrupted | config_error
192
201
  * num_turns = assistant_message 事件计数
193
202
  * error = 失败文案(成功时 null)
203
+ * warnings = 本轮所有非致命警告(与 stderr 同源;宿主要结构化的那一份)
194
204
  * session_file(可选)= 当前对话落盘路径;拿得到才填,拿不到省略该键
195
205
  *
206
+ * completionStatus = 本层从 stop_reason + deliverables 重算:跑完了没、产物验没验
207
+ * delivery_status(可选)= 内核 turn_done 的交付判定,**回答的是另一个问题**:
208
+ * completed / delivery_incomplete / delivery_unverified / claims_unverified。
209
+ * 两者并列而非替代 —— `ok:true` 配 `delivery_status:'claims_unverified'` 是
210
+ * 完全合法的一行:Agent 确实正常收尾了,但它宣称的「测试已通过」没有证据支撑。
211
+ * 判断「任务是否真做成」的宿主应当读它,而不是只看 ok。
212
+ *
196
213
  * 导出供单测断言。
197
214
  */
198
215
  export function emitJsonResult(result: PrintResult, sessionFile?: string): void {
@@ -220,6 +237,7 @@ export function emitJsonResult(result: PrintResult, sessionFile?: string): void
220
237
  num_turns: result.numTurns,
221
238
  error: result.error,
222
239
  completionStatus,
240
+ ...(result.deliveryStatus ? { delivery_status: result.deliveryStatus } : {}),
223
241
  deliverables,
224
242
  verification: allVerified ? 'passed' : deliverables.length > 0 ? 'not_run' : 'none',
225
243
  warnings,
@@ -509,12 +527,19 @@ export function handleEvent(
509
527
  // 通用非致命警告(如 workflow 多余参数、pause 被自动跳过等)始终走 stderr,
510
528
  // 不受 verbose 控制 —— 这是用户必须看到的信号,避免静默丢弃语义被忽略。
511
529
  process.stderr.write(`${event.message}\n`);
530
+ // 同时收进结局契约:stderr 是给人看的,spawn 它的宿主要的是结构化的一条。
531
+ // (此前 PrintResult.warnings 声明了、读了、也输出了,就是没有任何地方写它 ——
532
+ // json 那行的 warnings 恒为空数组。)
533
+ (result.warnings ??= []).push(event.message);
512
534
  return {};
513
535
 
514
536
  case 'turn_done':
515
537
  // text 模式:非 verbose 时这里一次性把累积答案打到 stdout(旧行为,逐字节不变)。
516
538
  // json 模式:只记 stopReason='end_turn',stdout 保持静默。
517
539
  result.stopReason = 'end_turn';
540
+ // 内核的交付判定必须过进程边界:模型谎称「已验证」时,若不记这一笔,
541
+ // 宿主收到的 JSON 与一切正常逐字节相同,唯一线索只剩 stderr 上一行中文。
542
+ if (event.completionStatus) result.deliveryStatus = event.completionStatus;
518
543
  if (!isJson && !verbose) console.log(result.buffer);
519
544
  return {};
520
545
 
@@ -373,6 +373,30 @@ export class ConfigService {
373
373
  return null;
374
374
  return { ...advisor, provider: resolveProvider(entry), supportsVision: entry.supportsVision !== false };
375
375
  }
376
+ /**
377
+ * 同步版 advisor 可用性探测——仅用于 resolveAvailability 这类工具列表探测场景。
378
+ * 读当前内存快照(可能略滞后于磁盘最新值),但调用频率高、对实时性要求不高,
379
+ * 下一轮 LLM 请求前 configService.reload() 会刷新。
380
+ *
381
+ * 为什么不用 advisor() 的 fresh reload:
382
+ * resolveAvailability 是在主循环构造工具集时同步调用的,不能 await;
383
+ * 而且每次工具集构造都读盘的话开销太大(主循环每轮都可能构造一次)。
384
+ */
385
+ advisorAvailabilitySync() {
386
+ const config = this.snapshot.config;
387
+ const advisor = config?.advisor;
388
+ if (!advisor?.modelId)
389
+ return { enabled: false, mode: 'auto', modelId: null, configured: false };
390
+ const entry = config?.models?.find((m) => m.id === advisor.modelId);
391
+ if (!entry?.apiKey)
392
+ return { enabled: false, mode: 'auto', modelId: null, configured: false };
393
+ return {
394
+ enabled: advisor.enabled !== false,
395
+ mode: advisor.mode === 'manual' ? 'manual' : 'auto',
396
+ modelId: advisor.modelId,
397
+ configured: true,
398
+ };
399
+ }
376
400
  async subagents() {
377
401
  return (await this.reload()).config?.subagents ?? DEFAULT_SUBAGENTS_CONFIG;
378
402
  }
@@ -431,6 +431,29 @@ export class ConfigService {
431
431
  return { ...advisor, provider: resolveProvider(entry), supportsVision: entry.supportsVision !== false };
432
432
  }
433
433
 
434
+ /**
435
+ * 同步版 advisor 可用性探测——仅用于 resolveAvailability 这类工具列表探测场景。
436
+ * 读当前内存快照(可能略滞后于磁盘最新值),但调用频率高、对实时性要求不高,
437
+ * 下一轮 LLM 请求前 configService.reload() 会刷新。
438
+ *
439
+ * 为什么不用 advisor() 的 fresh reload:
440
+ * resolveAvailability 是在主循环构造工具集时同步调用的,不能 await;
441
+ * 而且每次工具集构造都读盘的话开销太大(主循环每轮都可能构造一次)。
442
+ */
443
+ advisorAvailabilitySync(): { enabled: boolean; mode: 'auto' | 'manual'; modelId: string | null; configured: boolean } {
444
+ const config = this.snapshot.config;
445
+ const advisor = config?.advisor;
446
+ if (!advisor?.modelId) return { enabled: false, mode: 'auto', modelId: null, configured: false };
447
+ const entry = config?.models?.find((m) => m.id === advisor.modelId);
448
+ if (!entry?.apiKey) return { enabled: false, mode: 'auto', modelId: null, configured: false };
449
+ return {
450
+ enabled: advisor.enabled !== false,
451
+ mode: advisor.mode === 'manual' ? 'manual' : 'auto',
452
+ modelId: advisor.modelId,
453
+ configured: true,
454
+ };
455
+ }
456
+
434
457
  async subagents(): Promise<SubagentsConfig> {
435
458
  return (await this.reload()).config?.subagents ?? DEFAULT_SUBAGENTS_CONFIG;
436
459
  }
@@ -16,7 +16,13 @@
16
16
  * 设计原则:
17
17
  * - 纯工具函数,无全局状态,无副作用(除了 shell 子进程)
18
18
  * - 不依赖任何插件代码,可被 src/ 和 plugins/ 同时导入
19
- * - 工作目录隔离:所有相对路径以 getWorkingDir() 为锚
19
+ * - 工作目录隔离:相对路径以调用方给的 cwd 为锚,缺省 getWorkingDir()
20
+ *
21
+ * ⚠️ cwd 参数不是可有可无的修饰:**验收必须和写入锚在同一个目录**。
22
+ * 会话进入 session worktree 后(EnterWorktree),写文件的工具跟着 worktree 走
23
+ * (loop.ts 按 sessionState.activeWorktree 重定向 cwd),验收若仍锚原 workspace,
24
+ * 就会在空目录里找产物 —— file_exists 恒 false,工作流被判失败并反复重试,
25
+ * 而产物其实好好躺在 worktree 里。调用方拿得到有效 cwd 时务必传进来。
20
26
  * ============================================================
21
27
  */
22
28
  import { existsSync, readFileSync } from 'node:fs';
@@ -92,12 +98,13 @@ function isAbsolutePath(file) {
92
98
  /^[A-Za-z]:[\\/]/.test(file) // C:\ 或 C:/
93
99
  );
94
100
  }
95
- function runShell(command, timeout) {
101
+ function runShell(command, timeout, cwd) {
96
102
  return new Promise((resolve) => {
97
103
  const isWin = process.platform === 'win32';
104
+ const opts = { timeout, cwd, env: sanitizeCredentialEnv() };
98
105
  const child = isWin
99
- ? spawn('cmd', ['/c', command], { timeout, env: sanitizeCredentialEnv() })
100
- : spawn('bash', ['-c', command], { timeout, env: sanitizeCredentialEnv() });
106
+ ? spawn('cmd', ['/c', command], opts)
107
+ : spawn('bash', ['-c', command], opts);
101
108
  let stdout = '';
102
109
  let stderr = '';
103
110
  child.stdout.on('data', (d) => {
@@ -114,8 +121,8 @@ function runShell(command, timeout) {
114
121
  });
115
122
  });
116
123
  }
117
- export async function verifyShell(command, timeout) {
118
- const r = await runShell(command, timeout);
124
+ export async function verifyShell(command, timeout, cwd = getWorkingDir()) {
125
+ const r = await runShell(command, timeout, cwd);
119
126
  if (r.errored) {
120
127
  return {
121
128
  check: { type: 'shell', command },
@@ -129,9 +136,9 @@ export async function verifyShell(command, timeout) {
129
136
  output: r.stdout.trim() || r.stderr.trim() || `exit code ${r.exitCode}`,
130
137
  };
131
138
  }
132
- export function verifyFileExists(file) {
133
- // 相对路径以 workingDir 为锚(绝对路径识别含 UNC)
134
- const resolved = isAbsolutePath(file) ? file : `${getWorkingDir()}/${file}`;
139
+ export function verifyFileExists(file, cwd = getWorkingDir()) {
140
+ // 相对路径以调用方给的 cwd 为锚(绝对路径识别含 UNC)
141
+ const resolved = isAbsolutePath(file) ? file : `${cwd}/${file}`;
135
142
  const exists = existsSync(resolved);
136
143
  return {
137
144
  check: { type: 'file_exists', file },
@@ -139,8 +146,8 @@ export function verifyFileExists(file) {
139
146
  output: exists ? '文件存在' : `文件不存在: ${resolved}`,
140
147
  };
141
148
  }
142
- export function verifyFileContains(file, pattern) {
143
- const resolved = isAbsolutePath(file) ? file : `${getWorkingDir()}/${file}`;
149
+ export function verifyFileContains(file, pattern, cwd = getWorkingDir()) {
150
+ const resolved = isAbsolutePath(file) ? file : `${cwd}/${file}`;
144
151
  try {
145
152
  const content = readFileSync(resolved, 'utf8');
146
153
  const regex = new RegExp(pattern);
@@ -159,11 +166,11 @@ export function verifyFileContains(file, pattern) {
159
166
  };
160
167
  }
161
168
  }
162
- async function verifyTestCount(minCount) {
169
+ async function verifyTestCount(minCount, cwd = getWorkingDir()) {
163
170
  // 跨运行时:开发用 bun → bun test;npm 全局安装用 Node → npm test --silent
164
171
  // 用 process.versions.bun 探测比 typeof Bun !== 'undefined' 更稳(不依赖全局类型)
165
172
  const cmd = process.versions.bun ? 'bun test' : 'npm test --silent';
166
- const r = await runShell(cmd, 60_000);
173
+ const r = await runShell(cmd, 60_000, cwd);
167
174
  const combined = `${r.stdout}\n${r.stderr}`;
168
175
  if (r.errored) {
169
176
  return {
@@ -185,7 +192,7 @@ async function verifyTestCount(minCount) {
185
192
  * 执行一组检查,返回聚合结果。
186
193
  * 全部通过 → passed=true;任一失败 → passed=false + summary 列出失败项。
187
194
  */
188
- export async function runVerification(checks) {
195
+ export async function runVerification(checks, cwd = getWorkingDir()) {
189
196
  if (checks.length === 0) {
190
197
  return { passed: true, details: [], summary: '无验证项' };
191
198
  }
@@ -194,16 +201,16 @@ export async function runVerification(checks) {
194
201
  let result;
195
202
  switch (check.type) {
196
203
  case 'shell':
197
- result = await verifyShell(check.command ?? '', check.timeout ?? 30_000);
204
+ result = await verifyShell(check.command ?? '', check.timeout ?? 30_000, cwd);
198
205
  break;
199
206
  case 'file_exists':
200
- result = verifyFileExists(check.file ?? '');
207
+ result = verifyFileExists(check.file ?? '', cwd);
201
208
  break;
202
209
  case 'file_contains':
203
- result = verifyFileContains(check.file ?? '', check.pattern ?? '');
210
+ result = verifyFileContains(check.file ?? '', check.pattern ?? '', cwd);
204
211
  break;
205
212
  case 'test_count':
206
- result = await verifyTestCount(check.minCount ?? 0);
213
+ result = await verifyTestCount(check.minCount ?? 0, cwd);
207
214
  break;
208
215
  default:
209
216
  result = {
@@ -16,7 +16,13 @@
16
16
  * 设计原则:
17
17
  * - 纯工具函数,无全局状态,无副作用(除了 shell 子进程)
18
18
  * - 不依赖任何插件代码,可被 src/ 和 plugins/ 同时导入
19
- * - 工作目录隔离:所有相对路径以 getWorkingDir() 为锚
19
+ * - 工作目录隔离:相对路径以调用方给的 cwd 为锚,缺省 getWorkingDir()
20
+ *
21
+ * ⚠️ cwd 参数不是可有可无的修饰:**验收必须和写入锚在同一个目录**。
22
+ * 会话进入 session worktree 后(EnterWorktree),写文件的工具跟着 worktree 走
23
+ * (loop.ts 按 sessionState.activeWorktree 重定向 cwd),验收若仍锚原 workspace,
24
+ * 就会在空目录里找产物 —— file_exists 恒 false,工作流被判失败并反复重试,
25
+ * 而产物其实好好躺在 worktree 里。调用方拿得到有效 cwd 时务必传进来。
20
26
  * ============================================================
21
27
  */
22
28
 
@@ -128,12 +134,17 @@ interface ShellRunResult {
128
134
  errored: boolean;
129
135
  }
130
136
 
131
- function runShell(command: string, timeout: number): Promise<ShellRunResult> {
137
+ function runShell(
138
+ command: string,
139
+ timeout: number,
140
+ cwd: string,
141
+ ): Promise<ShellRunResult> {
132
142
  return new Promise((resolve) => {
133
143
  const isWin = process.platform === 'win32';
144
+ const opts = { timeout, cwd, env: sanitizeCredentialEnv() };
134
145
  const child = isWin
135
- ? spawn('cmd', ['/c', command], { timeout, env: sanitizeCredentialEnv() })
136
- : spawn('bash', ['-c', command], { timeout, env: sanitizeCredentialEnv() });
146
+ ? spawn('cmd', ['/c', command], opts)
147
+ : spawn('bash', ['-c', command], opts);
137
148
 
138
149
  let stdout = '';
139
150
  let stderr = '';
@@ -155,8 +166,12 @@ function runShell(command: string, timeout: number): Promise<ShellRunResult> {
155
166
  });
156
167
  }
157
168
 
158
- export async function verifyShell(command: string, timeout: number): Promise<CheckResult> {
159
- const r = await runShell(command, timeout);
169
+ export async function verifyShell(
170
+ command: string,
171
+ timeout: number,
172
+ cwd: string = getWorkingDir(),
173
+ ): Promise<CheckResult> {
174
+ const r = await runShell(command, timeout, cwd);
160
175
  if (r.errored) {
161
176
  return {
162
177
  check: { type: 'shell', command },
@@ -171,9 +186,12 @@ export async function verifyShell(command: string, timeout: number): Promise<Che
171
186
  };
172
187
  }
173
188
 
174
- export function verifyFileExists(file: string): CheckResult {
175
- // 相对路径以 workingDir 为锚(绝对路径识别含 UNC)
176
- const resolved = isAbsolutePath(file) ? file : `${getWorkingDir()}/${file}`;
189
+ export function verifyFileExists(
190
+ file: string,
191
+ cwd: string = getWorkingDir(),
192
+ ): CheckResult {
193
+ // 相对路径以调用方给的 cwd 为锚(绝对路径识别含 UNC)
194
+ const resolved = isAbsolutePath(file) ? file : `${cwd}/${file}`;
177
195
  const exists = existsSync(resolved);
178
196
  return {
179
197
  check: { type: 'file_exists', file },
@@ -182,8 +200,12 @@ export function verifyFileExists(file: string): CheckResult {
182
200
  };
183
201
  }
184
202
 
185
- export function verifyFileContains(file: string, pattern: string): CheckResult {
186
- const resolved = isAbsolutePath(file) ? file : `${getWorkingDir()}/${file}`;
203
+ export function verifyFileContains(
204
+ file: string,
205
+ pattern: string,
206
+ cwd: string = getWorkingDir(),
207
+ ): CheckResult {
208
+ const resolved = isAbsolutePath(file) ? file : `${cwd}/${file}`;
187
209
  try {
188
210
  const content = readFileSync(resolved, 'utf8');
189
211
  const regex = new RegExp(pattern);
@@ -203,11 +225,14 @@ export function verifyFileContains(file: string, pattern: string): CheckResult {
203
225
  }
204
226
  }
205
227
 
206
- async function verifyTestCount(minCount: number): Promise<CheckResult> {
228
+ async function verifyTestCount(
229
+ minCount: number,
230
+ cwd: string = getWorkingDir(),
231
+ ): Promise<CheckResult> {
207
232
  // 跨运行时:开发用 bun → bun test;npm 全局安装用 Node → npm test --silent
208
233
  // 用 process.versions.bun 探测比 typeof Bun !== 'undefined' 更稳(不依赖全局类型)
209
234
  const cmd = process.versions.bun ? 'bun test' : 'npm test --silent';
210
- const r = await runShell(cmd, 60_000);
235
+ const r = await runShell(cmd, 60_000, cwd);
211
236
  const combined = `${r.stdout}\n${r.stderr}`;
212
237
  if (r.errored) {
213
238
  return {
@@ -232,7 +257,10 @@ async function verifyTestCount(minCount: number): Promise<CheckResult> {
232
257
  * 执行一组检查,返回聚合结果。
233
258
  * 全部通过 → passed=true;任一失败 → passed=false + summary 列出失败项。
234
259
  */
235
- export async function runVerification(checks: Check[]): Promise<VerifyResult> {
260
+ export async function runVerification(
261
+ checks: Check[],
262
+ cwd: string = getWorkingDir(),
263
+ ): Promise<VerifyResult> {
236
264
  if (checks.length === 0) {
237
265
  return { passed: true, details: [], summary: '无验证项' };
238
266
  }
@@ -244,16 +272,20 @@ export async function runVerification(checks: Check[]): Promise<VerifyResult> {
244
272
 
245
273
  switch (check.type) {
246
274
  case 'shell':
247
- result = await verifyShell(check.command ?? '', check.timeout ?? 30_000);
275
+ result = await verifyShell(
276
+ check.command ?? '',
277
+ check.timeout ?? 30_000,
278
+ cwd,
279
+ );
248
280
  break;
249
281
  case 'file_exists':
250
- result = verifyFileExists(check.file ?? '');
282
+ result = verifyFileExists(check.file ?? '', cwd);
251
283
  break;
252
284
  case 'file_contains':
253
- result = verifyFileContains(check.file ?? '', check.pattern ?? '');
285
+ result = verifyFileContains(check.file ?? '', check.pattern ?? '', cwd);
254
286
  break;
255
287
  case 'test_count':
256
- result = await verifyTestCount(check.minCount ?? 0);
288
+ result = await verifyTestCount(check.minCount ?? 0, cwd);
257
289
  break;
258
290
  default:
259
291
  result = {
@@ -9,12 +9,12 @@
9
9
  * 2) / 输入 → discoverPlugins → resolveCommand
10
10
  * a) 命中插件且其 plugin.ts 暴露 runCommand → 让 plugin.ts 接管
11
11
  * b) 命中插件但只有声明式 commands/*.md → 把 prompt 拼好喂给 runQuery
12
- * c) 未命中 → 静默透传 runQuery(/ 只是激活前缀,不是严格命令系统;
13
- * 未知的 /xxx 当普通用户输入处理,不报错)
12
+ * c) 未命中 -> warning 提示未知命令后透传 runQuery(/ 只是激活前缀,
13
+ * 不是严格命令系统;未知的 /xxx 当普通用户输入处理,不报错)
14
14
  * ============================================================
15
15
  */
16
16
  import { runQuery } from '../loop.js';
17
- import { buildCommandInput, discoverPlugins, resolveCommand, } from './commandRouter.js';
17
+ import { buildCommandInput, discoverPlugins, listAvailableCommands, resolveCommand, } from './commandRouter.js';
18
18
  import { loadPluginApi } from './pluginLoader.js';
19
19
  export async function* runWithPlugins(userInput, options) {
20
20
  const { provider, history, signal } = options;
@@ -56,10 +56,21 @@ export async function* runWithPlugins(userInput, options) {
56
56
  await discoverPlugins();
57
57
  const matched = resolveCommand(userInput);
58
58
  if (!matched) {
59
- // 未命中任何插件命令 → 静默透传 runQuery。
60
- // / 只是激活前缀(类似 Claude Code 的 / 模式),不是严格命令系统。
61
- // 用户可能输入 /init、/help、/任何东西 —— 不匹配就当普通对话处理,
62
- // 让 LLM 自行理解意图。不报错、不提示、不打断用户体验。
59
+ // 未命中任何插件命令 -> 提示后透传 runQuery。
60
+ // / 只是激活前缀(类似 Claude Code 的 / 模式),不是严格命令系统:
61
+ // 不匹配仍当普通对话处理,让 LLM 自行理解意图。但 -p 等无人值守场景里
62
+ // 拼错的 /reseach 会被静默当文本烧一轮 LLM(实战:用户以为在跑工作流,
63
+ // 实际什么都没发生)--先 warning 列出已注册命令,零成本纠正拼写。
64
+ const trimmedUnknown = userInput.trimStart();
65
+ const spaceUnknown = trimmedUnknown.indexOf(' ', 1);
66
+ const unknownCmd = (spaceUnknown >= 0 ? trimmedUnknown.slice(1, spaceUnknown) : trimmedUnknown.slice(1)).trim();
67
+ if (unknownCmd) {
68
+ const available = listAvailableCommands().map((c) => c.command);
69
+ yield {
70
+ type: 'warning',
71
+ message: `未知命令 /${unknownCmd}(已注册:${available.length ? available.join('、') : '无'}),已按普通对话处理。`,
72
+ };
73
+ }
63
74
  yield* runQuery(userInput, {
64
75
  provider,
65
76
  history,