@zhushanwen/pi-subagent-workflow 5.0.2 → 7.0.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 (77) hide show
  1. package/agents/{reviewer.md → code-reviewer.md} +20 -3
  2. package/agents/context-builder.md +5 -0
  3. package/agents/doc-reviewer.md +9 -2
  4. package/agents/explorer.md +5 -0
  5. package/agents/general-purpose.md +5 -0
  6. package/agents/oracle.md +20 -4
  7. package/agents/orchestrator.md +6 -1
  8. package/agents/planner.md +5 -0
  9. package/agents/researcher.md +5 -0
  10. package/agents/worker.md +5 -0
  11. package/package.json +6 -4
  12. package/src/execution/__tests__/agent-registry.test.ts +189 -119
  13. package/src/execution/__tests__/crash-recovery.test.ts +0 -1
  14. package/src/execution/__tests__/execute-options-mapper.test.ts +4 -4
  15. package/src/execution/__tests__/index-session-start.test.ts +0 -1
  16. package/src/execution/__tests__/model-resolver.test.ts +20 -0
  17. package/src/execution/__tests__/session-start-reaper.test.ts +0 -2
  18. package/src/execution/__tests__/subprocess-agent-runner.test.ts +1 -1
  19. package/src/execution/agent-registry.ts +92 -169
  20. package/src/execution/execute-options-mapper.ts +2 -2
  21. package/src/execution/model-config-service.ts +13 -34
  22. package/src/execution/model-resolver.ts +5 -3
  23. package/src/execution/subagent-service.ts +9 -6
  24. package/src/execution/subprocess-agent-runner.ts +3 -2
  25. package/src/index.ts +4 -25
  26. package/src/injectors/__tests__/subagent-list-injector.test.ts +266 -14
  27. package/src/injectors/__tests__/workflow-list-injector.test.ts +236 -32
  28. package/src/injectors/subagent-list-injector.ts +99 -48
  29. package/src/injectors/workflow-list-injector.ts +65 -50
  30. package/src/interface/__tests__/detectors.test.ts +100 -43
  31. package/src/interface/__tests__/subagent-tool-prompt.test.ts +8 -12
  32. package/src/interface/__tests__/tool-workflow-script-generate.test.ts +163 -0
  33. package/src/interface/__tests__/workflow-tool-prompt.test.ts +55 -9
  34. package/src/interface/subagent-tool.ts +7 -4
  35. package/src/interface/tool-workflow-script.ts +27 -10
  36. package/src/interface/tool-workflow.ts +174 -81
  37. package/src/orchestration/__tests__/args-validator.test.ts +143 -0
  38. package/src/orchestration/__tests__/config-loader.test.ts +124 -40
  39. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +33 -2
  40. package/src/orchestration/__tests__/lifecycle.test.ts +59 -2
  41. package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +116 -51
  42. package/src/orchestration/__tests__/script-lint.test.ts +167 -1
  43. package/src/orchestration/__tests__/worker-host.test.ts +120 -0
  44. package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +69 -0
  45. package/src/orchestration/__tests__/worker-script-builder.test.ts +51 -1
  46. package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +2 -2
  47. package/src/orchestration/__tests__/workflows-e2e.test.ts +177 -24
  48. package/src/orchestration/agent-opts-resolver.ts +51 -101
  49. package/src/orchestration/args-validator.ts +127 -0
  50. package/src/orchestration/config-loader.ts +63 -94
  51. package/src/orchestration/error-recovery.ts +6 -9
  52. package/src/orchestration/launcher.ts +77 -41
  53. package/src/orchestration/lifecycle.ts +7 -0
  54. package/src/orchestration/models/ports.ts +0 -14
  55. package/src/orchestration/models/run-spec.ts +24 -1
  56. package/src/orchestration/models/types.ts +15 -8
  57. package/src/orchestration/models/workflow-script-registry.ts +3 -0
  58. package/src/orchestration/models/workflow-script.ts +10 -14
  59. package/src/orchestration/script-lint.ts +159 -0
  60. package/src/orchestration/worker-host.ts +5 -0
  61. package/src/orchestration/worker-script-builder.ts +15 -4
  62. package/src/orchestration/workflow-script-registry-impl.ts +34 -29
  63. package/src/shared/__tests__/meta-parser.test.ts +304 -0
  64. package/src/shared/__tests__/resource-discovery.test.ts +167 -7
  65. package/src/shared/__tests__/resource-meta.test.ts +51 -0
  66. package/src/shared/agent-ref.ts +36 -0
  67. package/src/shared/meta-parser.ts +257 -0
  68. package/src/shared/resource-discovery.ts +88 -2
  69. package/src/shared/resource-meta.ts +60 -0
  70. package/workflows/README.md +2 -2
  71. package/workflows/_shared/agent-refs.cjs +40 -0
  72. package/workflows/chain.js +30 -6
  73. package/workflows/map-reduce.js +33 -5
  74. package/workflows/parallel.js +34 -7
  75. package/workflows/review-fix-loop-utils.cjs +23 -103
  76. package/workflows/review-fix-loop.js +121 -58
  77. package/workflows/scatter-gather.js +30 -6
@@ -0,0 +1,304 @@
1
+ /**
2
+ * meta-parser 测试(v5 §11.1 / TC1-TC10 + 边界 + exec-review 回归)
3
+ *
4
+ * [P-yaml] 探针由 TC5/TC10 承载:验证 eemeli/yaml 真实抛错对象有 linePos 字段且结构为
5
+ * {line,col}(取自 linePos[0])。不 mock parseYaml——必须用真实库验证此运行时断言。
6
+ *
7
+ * exec-review 回归:major-1(星斜杠截断)+ minor-2(kind 串类)+ minor-3(description 必填)
8
+ * + minor-4(detail 非字符串)+ minor-5(parameters 非对象)+ minor-6(空块 vs 缺块)+ minor-7(CRLF)。
9
+ *
10
+ * 框架:vitest(项目规范:禁 node:test,import from "vitest")。
11
+ */
12
+ import { describe, expect, it } from "vitest";
13
+
14
+ import {
15
+ parseResourceMeta,
16
+ parseResourceMetaDetailed,
17
+ } from "../meta-parser.ts";
18
+ import type { AgentMeta, WorkflowMeta } from "../resource-meta.ts";
19
+
20
+ // ── 固定 content fixtures ────────────────────────────────────
21
+
22
+ const VALID_WORKFLOW = `// 顶部可有任意代码注释
23
+ /* @pi-meta
24
+ name: chain
25
+ description: 通用编排三步链
26
+ when: 用户要顺序编排
27
+ phases: [analyze, transform, synthesize]
28
+ parameters:
29
+ type: object
30
+ properties:
31
+ task: { type: string }
32
+ required: [task]
33
+ usage: |
34
+ ## 使用说明
35
+ - task 必填
36
+ */
37
+ const agent = require("./agent");
38
+ `;
39
+
40
+ const VALID_AGENT = `---
41
+ name: code-review
42
+ description: 审查代码变更
43
+ when: 用户要求 review
44
+ notFor: 非 xyz-agent 项目
45
+ examples:
46
+ - match: "帮我 review 一下"
47
+ action: 调用 code-review
48
+ positive: true
49
+ - match: 今天天气
50
+ action: 不调用
51
+ positive: false
52
+ tools: [read, bash, grep]
53
+ model: claude-3.5
54
+ ---
55
+ # code-review agent 正文
56
+ `;
57
+
58
+ const MALFORMED_INDENT = `/* @pi-meta
59
+ name: x
60
+ description: bad
61
+ description: bad indent
62
+ phases: [a]
63
+ */`;
64
+
65
+ const REGEX_SINGLE_BACKSLASH = `/* @pi-meta
66
+ name: rfl
67
+ description: 测试正则转义
68
+ phases: [Review, Fix]
69
+ parameters:
70
+ type: object
71
+ patternProperties:
72
+ "^batch\\d+$": { type: string }
73
+ */`;
74
+
75
+ const LEGACY_CONST_META = `const meta = {
76
+ name: chain,
77
+ description: 旧格式,
78
+ phases: ["a"]
79
+ };
80
+ const agent = require("./agent");
81
+ `;
82
+
83
+ const NO_META_BLOCK = `const agent = require("./agent");
84
+ // 纯代码,无 @pi-meta
85
+ `;
86
+
87
+ const EMPTY_PI_META = `/* @pi-meta
88
+
89
+ */
90
+ const x = 1;
91
+ `;
92
+
93
+ const EMPTY_PHASES = `/* @pi-meta
94
+ name: x
95
+ description: 空 phases
96
+ phases: []
97
+ */`;
98
+
99
+ // ── parseResourceMeta(IF1,discovery)──────────────────────
100
+
101
+ describe("parseResourceMeta (discovery, fail-safe null)", () => {
102
+ it("TC1: 合法 workflow @pi-meta 返 WorkflowMeta", () => {
103
+ const meta = parseResourceMeta(VALID_WORKFLOW, "workflow");
104
+ expect(meta).not.toBeNull();
105
+ const wf = meta as WorkflowMeta;
106
+ expect(wf.kind).toBe("workflow");
107
+ expect(wf.name).toBe("chain");
108
+ expect(wf.phases).toEqual(["analyze", "transform", "synthesize"]);
109
+ expect(wf.when).toBe("用户要顺序编排");
110
+ expect(wf.parameters).toBeDefined();
111
+ expect(wf.usage).toContain("task 必填");
112
+ });
113
+
114
+ it("TC2: 合法 agent frontmatter 返 AgentMeta", () => {
115
+ const meta = parseResourceMeta(VALID_AGENT, "agent");
116
+ expect(meta).not.toBeNull();
117
+ const a = meta as AgentMeta;
118
+ expect(a.kind).toBe("agent");
119
+ expect(a.name).toBe("code-review");
120
+ expect(a.notFor).toBe("非 xyz-agent 项目");
121
+ expect(a.examples).toHaveLength(2);
122
+ expect(a.examples![0]).toEqual({ match: "帮我 review 一下", action: "调用 code-review", positive: true });
123
+ expect(a.examples![1]!.positive).toBe(false);
124
+ expect(a.tools).toEqual(["read", "bash", "grep"]);
125
+ expect(a.model).toBe("claude-3.5");
126
+ });
127
+
128
+ it("TC3: malformed YAML(缩进错)返 null,不抛 [P-yaml]", () => {
129
+ expect(() => parseResourceMeta(MALFORMED_INDENT, "workflow")).not.toThrow();
130
+ expect(parseResourceMeta(MALFORMED_INDENT, "workflow")).toBeNull();
131
+ });
132
+
133
+ it("TC4: 缺 meta 块返 null", () => {
134
+ expect(parseResourceMeta(NO_META_BLOCK, "workflow")).toBeNull();
135
+ });
136
+
137
+ it("TC8(修正): workflow 字段(phases)当 agent kind 解析,typecheckMeta 串类 reject [minor-2]", () => {
138
+ // 用 frontmatter 包裹 workflow 字段,确保 FRONTMATTER_RE 匹配、typecheckMeta 真正被触达
139
+ const content = `---
140
+ name: chainlike
141
+ description: has phases but parsed as agent
142
+ phases: [analyze, transform]
143
+ parameters:
144
+ type: object
145
+ ---
146
+ # agent body`;
147
+ expect(parseResourceMeta(content, "agent")).toBeNull();
148
+ });
149
+
150
+ it("TC8b: agent 字段(examples)当 workflow kind 解析,串类 reject [minor-2]", () => {
151
+ const content = `/* @pi-meta
152
+ name: x
153
+ description: has examples but workflow
154
+ phases: [A]
155
+ examples:
156
+ - match: m
157
+ action: a
158
+ positive: true
159
+ */`;
160
+ expect(parseResourceMeta(content, "workflow")).toBeNull();
161
+ });
162
+
163
+ it("TC9: legacy const meta(无 @pi-meta 块)返 null(无 adapter)[D1]", () => {
164
+ expect(parseResourceMeta(LEGACY_CONST_META, "workflow")).toBeNull();
165
+ });
166
+
167
+ it("TC10: patternProperties 单反斜杠正则致 YAML 解析失败返 null [P-yaml]", () => {
168
+ expect(parseResourceMeta(REGEX_SINGLE_BACKSLASH, "workflow")).toBeNull();
169
+ });
170
+
171
+ it("边界:空 @pi-meta 块(内容为空)返 null", () => {
172
+ expect(parseResourceMeta(EMPTY_PI_META, "workflow")).toBeNull();
173
+ });
174
+
175
+ it("边界:phases: [] 空数组合法(workflow 可无 phase)", () => {
176
+ const meta = parseResourceMeta(EMPTY_PHASES, "workflow");
177
+ expect(meta).not.toBeNull();
178
+ expect((meta as WorkflowMeta).phases).toEqual([]);
179
+ });
180
+
181
+ // ── exec-review 回归 ──────────────────────────────────────
182
+
183
+ it("major-1: usage 块标量含 */ 不再截断,parameters 不丢失", () => {
184
+ const content = `/* @pi-meta
185
+ name: x
186
+ description: d
187
+ phases: [A]
188
+ when: present
189
+ usage: |
190
+ see */ for details
191
+ parameters:
192
+ type: object
193
+ properties:
194
+ task: { type: string }
195
+ */`;
196
+ const meta = parseResourceMeta(content, "workflow");
197
+ expect(meta).not.toBeNull();
198
+ const wf = meta as WorkflowMeta;
199
+ expect(wf.parameters).toBeDefined(); // major-1:此前被 */ 截断静默丢失
200
+ expect(wf.when).toBe("present"); // 此前同样丢失
201
+ expect(wf.usage).toContain("see */ for details");
202
+ });
203
+
204
+ it("major-1b: patternProperties 正则含 * 后接 / 不再误截断", () => {
205
+ const content = `/* @pi-meta
206
+ name: re
207
+ description: regex
208
+ phases: [A]
209
+ parameters:
210
+ type: object
211
+ patternProperties:
212
+ "^\\\\d*/\\\\w+$": { type: string }
213
+ */`;
214
+ const meta = parseResourceMeta(content, "workflow");
215
+ expect(meta).not.toBeNull();
216
+ expect((meta as WorkflowMeta).parameters).toBeDefined();
217
+ });
218
+
219
+ it("minor-3: 缺 description 返 null(必填,不再静默空串)", () => {
220
+ const content = `/* @pi-meta
221
+ name: nodesc
222
+ phases: [A]
223
+ */`;
224
+ expect(parseResourceMeta(content, "workflow")).toBeNull();
225
+ });
226
+
227
+ it("minor-4: phase detail 非字符串返 null(不再静默丢弃)", () => {
228
+ const content = `/* @pi-meta
229
+ name: x
230
+ description: d
231
+ phases:
232
+ - title: A
233
+ detail: 123
234
+ */`;
235
+ expect(parseResourceMeta(content, "workflow")).toBeNull();
236
+ });
237
+
238
+ it("minor-5: parameters 是数组返 null(不再静默当 undefined)", () => {
239
+ const content = `/* @pi-meta
240
+ name: x
241
+ description: d
242
+ phases: [A]
243
+ parameters: [a, b]
244
+ */`;
245
+ expect(parseResourceMeta(content, "workflow")).toBeNull();
246
+ });
247
+
248
+ it("minor-7: CRLF 行尾 frontmatter 正确提取", () => {
249
+ const content = "---\r\nname: a\r\ndescription: d\r\n---\r\n# body";
250
+ const meta = parseResourceMeta(content, "agent");
251
+ expect(meta).not.toBeNull();
252
+ expect((meta as AgentMeta).name).toBe("a");
253
+ });
254
+ });
255
+
256
+ // ── parseResourceMetaDetailed(IF2,generate 闭环)──────────
257
+
258
+ describe("parseResourceMetaDetailed (generate, linePos)", () => {
259
+ it("TC6: 合法返 {ok:true, meta}", () => {
260
+ const r = parseResourceMetaDetailed(VALID_WORKFLOW, "workflow");
261
+ expect(r.ok).toBe(true);
262
+ if (r.ok) expect(r.meta.name).toBe("chain");
263
+ });
264
+
265
+ it("TC5: malformed YAML 返 {ok:false, error, linePos:{line,col}} [P-yaml]", () => {
266
+ const r = parseResourceMetaDetailed(MALFORMED_INDENT, "workflow");
267
+ expect(r.ok).toBe(false);
268
+ if (!r.ok) {
269
+ expect(r.error).toBeTruthy();
270
+ expect(r.linePos).toBeDefined();
271
+ expect(typeof r.linePos!.line).toBe("number");
272
+ expect(typeof r.linePos!.col).toBe("number");
273
+ expect(r.linePos!.line).toBeGreaterThan(0);
274
+ }
275
+ });
276
+
277
+ it("TC7: 缺块返 {ok:false, error} 无 linePos", () => {
278
+ const r = parseResourceMetaDetailed(NO_META_BLOCK, "workflow");
279
+ expect(r.ok).toBe(false);
280
+ if (!r.ok) expect(r.linePos).toBeUndefined();
281
+ });
282
+
283
+ it("TC10: 单反斜杠正则返 {ok:false, error 含 escape, linePos} [P-yaml]", () => {
284
+ const r = parseResourceMetaDetailed(REGEX_SINGLE_BACKSLASH, "workflow");
285
+ expect(r.ok).toBe(false);
286
+ if (!r.ok) {
287
+ expect(r.error).toContain("escape");
288
+ expect(r.linePos).toBeDefined();
289
+ expect(r.linePos!.line).toBeGreaterThan(0);
290
+ }
291
+ });
292
+
293
+ it("minor-6: 空块与缺块错误消息区分(空块不报「未找到」)", () => {
294
+ const empty = parseResourceMetaDetailed(EMPTY_PI_META, "workflow");
295
+ const missing = parseResourceMetaDetailed(NO_META_BLOCK, "workflow");
296
+ expect(empty.ok).toBe(false);
297
+ expect(missing.ok).toBe(false);
298
+ if (!empty.ok && !missing.ok) {
299
+ // 缺块报「未找到 meta 块」;空块报类型校验失败(块在但内容空/非法)
300
+ expect(missing.error).toContain("未找到");
301
+ expect(empty.error).not.toContain("未找到");
302
+ }
303
+ });
304
+ });
@@ -6,13 +6,23 @@ import * as fs from "node:fs";
6
6
  import * as os from "node:os";
7
7
  import * as path from "node:path";
8
8
 
9
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
+
11
+ // 隔离真实用户全局目录:resource-discovery 用 homedir() 推导 user-agents 源
12
+ // (~/.agents/agents/),测试环境可能存在真实 agent 文件(如 tech-design-review.md),
13
+ // 不 mock 会导致「期望空列表/精确列表」用例被环境污染(2026-08 实测 4 个失败)。
14
+ vi.mock("node:os", async (importOriginal) => {
15
+ const actual = await importOriginal<typeof import("node:os")>();
16
+ return { ...actual, homedir: () => "/nonexistent-home-for-tests" };
17
+ });
10
18
 
11
19
  import {
12
20
  discoverResources,
13
21
  discoverResourcesSync,
14
22
  findWorkspaceRoot,
15
23
  processPackageSync,
24
+ getCachedFile,
25
+ getCachedFileContent,
16
26
  } from "../resource-discovery.ts";
17
27
 
18
28
  // ============================================================
@@ -79,8 +89,11 @@ describe("discoverResourcesSync", () => {
79
89
  it("discovers agents from project .pi/agents/", () => {
80
90
  writeFile(path.join(ws, ".pi", "agents"), "worker.md", "body");
81
91
  const result = discoverResourcesSync({ kind: "agents", workspaceRoot: ws, agentDir });
82
- expect(result.map((r) => path.basename(r.path))).toEqual(["worker.md"]);
83
- expect(result[0]?.available).toBe(true);
92
+ // source 过滤断言——用户全局目录(~/.agents/agents/)可能有真实 agent 文件,
93
+ // 测试不假设环境为空(2026-08:环境新增 tech-design-review.md 暴露此脆弱性)
94
+ const project = result.filter((r) => r.source === "project-pi");
95
+ expect(project.map((r) => path.basename(r.path))).toEqual(["worker.md"]);
96
+ expect(project[0]?.available).toBe(true);
84
97
  });
85
98
 
86
99
  it("discovers workflows from project .pi/workflows/", () => {
@@ -93,8 +106,9 @@ describe("discoverResourcesSync", () => {
93
106
  writeFile(path.join(ws, ".pi", "agents"), "worker.md", "pi-body");
94
107
  writeFile(path.join(ws, ".agents", "agents"), "worker.md", "agents-body");
95
108
  const result = discoverResourcesSync({ kind: "agents", workspaceRoot: ws, agentDir });
96
- expect(result).toHaveLength(1);
97
- expect(result[0]?.source).toBe("project-agents");
109
+ const project = result.filter((r) => r.source === "project-agents");
110
+ expect(project).toHaveLength(1);
111
+ expect(project[0]?.source).toBe("project-agents");
98
112
  });
99
113
 
100
114
  it("includes tmp source for workflows when includeTmp=true", () => {
@@ -121,12 +135,14 @@ describe("discoverResourcesSync", () => {
121
135
  writeFile(dir, "_skip.md", "ignored");
122
136
  writeFile(dir, "trace.chain.md", "ignored");
123
137
  const result = discoverResourcesSync({ kind: "agents", workspaceRoot: ws, agentDir });
124
- expect(result.map((r) => path.basename(r.path))).toEqual(["real.md"]);
138
+ const project = result.filter((r) => r.source === "project-pi");
139
+ expect(project.map((r) => path.basename(r.path))).toEqual(["real.md"]);
125
140
  });
126
141
 
127
142
  it("nonexistent directories are silently skipped", () => {
128
143
  const result = discoverResourcesSync({ kind: "agents", workspaceRoot: ws, agentDir });
129
- expect(result).toEqual([]);
144
+ // 用户全局目录可能有真实文件——只断言 project 源为空
145
+ expect(result.filter((r) => r.source === "project-pi" || r.source === "project-agents")).toEqual([]);
130
146
  });
131
147
  });
132
148
 
@@ -224,3 +240,147 @@ describe("discoverResources (async)", () => {
224
240
  expect(result).toHaveLength(2);
225
241
  });
226
242
  });
243
+
244
+ // ============================================================
245
+ // user-extension-paths (XYZ_EXTENSION_PATHS) — dev-link 扩展发现
246
+ // ============================================================
247
+
248
+ describe("user-extension-paths (XYZ_EXTENSION_PATHS)", () => {
249
+ let ws: string;
250
+ let agentDir: string;
251
+ let savedEnv: string | undefined;
252
+
253
+ beforeEach(() => {
254
+ ws = tmpWorkspace();
255
+ agentDir = path.join(ws, ".fake-agent");
256
+ savedEnv = process.env.XYZ_EXTENSION_PATHS;
257
+ });
258
+ afterEach(() => {
259
+ if (savedEnv === undefined) delete process.env.XYZ_EXTENSION_PATHS;
260
+ else process.env.XYZ_EXTENSION_PATHS = savedEnv;
261
+ fs.rmSync(ws, { recursive: true, force: true });
262
+ });
263
+
264
+ it("discovers agents from XYZ_EXTENSION_PATHS via pi.agents manifest", () => {
265
+ const pkgDir = path.join(ws, "my-ext");
266
+ writePackageJson(pkgDir, { agents: ["./agents"] });
267
+ writeFile(path.join(pkgDir, "agents"), "custom.md", "body");
268
+ process.env.XYZ_EXTENSION_PATHS = pkgDir;
269
+ const result = discoverResourcesSync({ kind: "agents", workspaceRoot: ws, agentDir });
270
+ const ext = result.filter((r) => r.source === "user-extension-paths");
271
+ expect(ext.map((r) => path.basename(r.path))).toEqual(["custom.md"]);
272
+ expect(ext[0]?.source).toBe("user-extension-paths");
273
+ });
274
+
275
+ it("discovers agents via convention dir (no manifest)", () => {
276
+ const pkgDir = path.join(ws, "my-ext");
277
+ writeFile(path.join(pkgDir, "agents"), "conv.md", "body");
278
+ process.env.XYZ_EXTENSION_PATHS = pkgDir;
279
+ const result = discoverResourcesSync({ kind: "agents", workspaceRoot: ws, agentDir });
280
+ const ext = result.filter((r) => r.source === "user-extension-paths");
281
+ expect(ext.map((r) => path.basename(r.path))).toEqual(["conv.md"]);
282
+ expect(ext[0]?.source).toBe("user-extension-paths");
283
+ });
284
+
285
+ it("multiple paths separated by delimiter", () => {
286
+ const pkg1 = path.join(ws, "ext1");
287
+ const pkg2 = path.join(ws, "ext2");
288
+ writeFile(path.join(pkg1, "agents"), "a1.md", "body");
289
+ writeFile(path.join(pkg2, "agents"), "a2.md", "body");
290
+ process.env.XYZ_EXTENSION_PATHS = `${pkg1}${path.delimiter}${pkg2}`;
291
+ const result = discoverResourcesSync({ kind: "agents", workspaceRoot: ws, agentDir });
292
+ const ext = result.filter((r) => r.source === "user-extension-paths");
293
+ expect(ext.map((r) => path.basename(r.path)).sort()).toEqual(["a1.md", "a2.md"]);
294
+ });
295
+
296
+ it("overrides npm on name clash (priority: user-extension-paths > npm)", () => {
297
+ const npmPkg = path.join(agentDir, "npm", "node_modules", "test-pkg");
298
+ writePackageJson(npmPkg, { agents: ["./agents"] });
299
+ writeFile(path.join(npmPkg, "agents"), "shared.md", "npm-body");
300
+ const devPkg = path.join(ws, "dev-ext");
301
+ writePackageJson(devPkg, { agents: ["./agents"] });
302
+ writeFile(path.join(devPkg, "agents"), "shared.md", "dev-body");
303
+ process.env.XYZ_EXTENSION_PATHS = devPkg;
304
+ const result = discoverResourcesSync({ kind: "agents", workspaceRoot: ws, agentDir });
305
+ const shared = result.find((r) => path.basename(r.path) === "shared.md");
306
+ expect(shared?.source).toBe("user-extension-paths");
307
+ });
308
+
309
+ it("project-agents overrides user-extension-paths (project wins)", () => {
310
+ const devPkg = path.join(ws, "dev-ext");
311
+ writePackageJson(devPkg, { agents: ["./agents"] });
312
+ writeFile(path.join(devPkg, "agents"), "x.md", "dev-body");
313
+ writeFile(path.join(ws, ".agents", "agents"), "x.md", "project-body");
314
+ process.env.XYZ_EXTENSION_PATHS = devPkg;
315
+ const result = discoverResourcesSync({ kind: "agents", workspaceRoot: ws, agentDir });
316
+ const x = result.find((r) => path.basename(r.path) === "x.md");
317
+ expect(x?.source).toBe("project-agents");
318
+ });
319
+
320
+ it("empty/unset env → no user-extension-paths source", () => {
321
+ delete process.env.XYZ_EXTENSION_PATHS;
322
+ const result = discoverResourcesSync({ kind: "agents", workspaceRoot: ws, agentDir });
323
+ expect(result.filter((r) => r.source === "user-extension-paths")).toEqual([]);
324
+ });
325
+
326
+ it("async discoverResources also scans user-extension-paths", async () => {
327
+ const pkgDir = path.join(ws, "my-ext");
328
+ writePackageJson(pkgDir, { agents: ["./agents"] });
329
+ writeFile(path.join(pkgDir, "agents"), "async.md", "body");
330
+ process.env.XYZ_EXTENSION_PATHS = pkgDir;
331
+ const result = await discoverResources({ kind: "agents", workspaceRoot: ws, agentDir });
332
+ const ext = result.filter((r) => r.source === "user-extension-paths");
333
+ expect(ext.map((r) => path.basename(r.path))).toEqual(["async.md"]);
334
+ expect(ext[0]?.source).toBe("user-extension-paths");
335
+ });
336
+ });
337
+
338
+ // ── m5 TC4/TC5: 统一 mtime 缓存层(P-cache / P-cache-invalidation) ──
339
+
340
+ describe("m5: 统一 mtime 缓存层", () => {
341
+ it("TC4a: P-cache——文件未变时二次读取命中同一缓存条目(对象引用)", () => {
342
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "m5-cache-"));
343
+ const f = path.join(dir, "a.md");
344
+ fs.writeFileSync(f, "---\nname: x\ndescription: y\n---\nbody", "utf-8");
345
+ try {
346
+ // ESM 下 node:fs 不可 spy(vitest 限制)——命中断言用对象引用:
347
+ // getCachedFile 命中时返回缓存条目对象本身(非新建)
348
+ const first = getCachedFile(f);
349
+ const second = getCachedFile(f);
350
+ expect(first?.content).toBe("---\nname: x\ndescription: y\n---\nbody");
351
+ expect(second).toBe(first); // 同引用 = 命中缓存(未重 read)
352
+ } finally {
353
+ fs.rmSync(dir, { recursive: true, force: true });
354
+ }
355
+ });
356
+
357
+ it("TC4b: P-cache——两处消费方(getCachedFile/getCachedFileContent)共享同一缓存条目", () => {
358
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "m5-cache2-"));
359
+ const f = path.join(dir, "a.md");
360
+ fs.writeFileSync(f, "---\nname: x\ndescription: y\n---\nbody", "utf-8");
361
+ try {
362
+ const a = getCachedFile(f);
363
+ const b = getCachedFileContent(f);
364
+ // 内容一致性(命中语义由 TC4a 的对象引用断言覆盖——此处验证两 API 一致)
365
+ expect(a?.content).toBe(b);
366
+ } finally {
367
+ fs.rmSync(dir, { recursive: true, force: true });
368
+ }
369
+ });
370
+
371
+ it("TC5: P-cache-invalidation——改内容(mtime 变)后重读新内容;删文件后驱逐", () => {
372
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "m5-cache3-"));
373
+ const f = path.join(dir, "a.md");
374
+ fs.writeFileSync(f, "v1", "utf-8");
375
+ try {
376
+ expect(getCachedFileContent(f)).toBe("v1");
377
+ fs.writeFileSync(f, "v2", "utf-8");
378
+ expect(getCachedFileContent(f)).toBe("v2"); // mtime 变 → 重读
379
+ // 删除 → 驱逐(不再返回旧内容)
380
+ fs.rmSync(f);
381
+ expect(getCachedFileContent(f)).toBeNull();
382
+ } finally {
383
+ fs.rmSync(dir, { recursive: true, force: true });
384
+ }
385
+ });
386
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * resource-meta 类型族测试(v5 §11.1)
3
+ *
4
+ * 编译期 + 运行期判别 ResourceMeta 联合。
5
+ */
6
+ import { describe, expect, it } from "vitest";
7
+
8
+ import type { AgentMeta, ResourceMeta, WorkflowMeta } from "../resource-meta.ts";
9
+
10
+ describe("ResourceMeta 类型族", () => {
11
+ it("WorkflowMeta 判别 kind='workflow'", () => {
12
+ const wf: WorkflowMeta = {
13
+ kind: "workflow",
14
+ name: "chain",
15
+ description: "test",
16
+ phases: ["a"],
17
+ };
18
+ expect(wf.kind).toBe("workflow");
19
+ });
20
+
21
+ it("AgentMeta 判别 kind='agent'", () => {
22
+ const ag: AgentMeta = {
23
+ kind: "agent",
24
+ name: "reviewer",
25
+ description: "review",
26
+ };
27
+ expect(ag.kind).toBe("agent");
28
+ });
29
+
30
+ it("联合类型 kind 判别 narrowing", () => {
31
+ function isWorkflow(m: ResourceMeta): m is WorkflowMeta {
32
+ return m.kind === "workflow";
33
+ }
34
+ const wf: ResourceMeta = { kind: "workflow", name: "x", description: "", phases: [] };
35
+ const ag: ResourceMeta = { kind: "agent", name: "y", description: "" };
36
+ expect(isWorkflow(wf)).toBe(true);
37
+ expect(isWorkflow(ag)).toBe(false);
38
+ });
39
+
40
+ it("可选字段缺省为 undefined", () => {
41
+ const wf: WorkflowMeta = {
42
+ kind: "workflow",
43
+ name: "x",
44
+ description: "",
45
+ phases: [],
46
+ };
47
+ expect(wf.parameters).toBeUndefined();
48
+ expect(wf.usage).toBeUndefined();
49
+ expect(wf.when).toBeUndefined();
50
+ });
51
+ });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * agentRef / workflowRef 统一路径解析(S2)。
3
+ *
4
+ * 设计(agent-ref-path-redesign D1):资源引用唯一形态 = 绝对路径。
5
+ * - agentRef → .md 文件绝对路径(注入段 <available_subagents> 的 <location>)
6
+ * - workflowRef → .js 文件绝对路径(注入段 <available_workflows> 的 <location>)
7
+ * - 支持 `~/` 前缀展开(homedir);相对路径不认(注入段给绝对路径,模型照抄零歧义)
8
+ *
9
+ * 名字不再是引用——只是注入段展示标签。执行侧拿到引用后一律 normalizeRef →
10
+ * 文件加载(AgentRegistry.loadByPath / WorkflowScriptRegistry.getPath)。
11
+ */
12
+ import { homedir } from "node:os";
13
+ import { isAbsolute, join } from "node:path";
14
+
15
+ /**
16
+ * 归一化资源引用:~ 展开 + 绝对路径校验。
17
+ *
18
+ * @param ref 原始引用(注入段 location / 工具参数值)
19
+ * @param ext 期望扩展名(如 ".md" / ".js"),不匹配返回 null
20
+ * @returns 归一化绝对路径;非法(空/相对路径/扩展名不符)返回 null
21
+ */
22
+ export function normalizeRef(ref: string, ext?: string): string | null {
23
+ const trimmed = ref.trim();
24
+ if (!trimmed) return null;
25
+
26
+ const expanded = trimmed.startsWith("~/") ? join(homedir(), trimmed.slice(2)) : trimmed;
27
+ if (!isAbsolute(expanded)) return null; // 相对路径无基准(注入段给绝对路径)
28
+
29
+ if (ext !== undefined && !expanded.endsWith(ext)) return null;
30
+ return expanded;
31
+ }
32
+
33
+ /** agentRef 扩展名。 */
34
+ export const AGENT_REF_EXT = ".md";
35
+ /** workflowRef 扩展名。 */
36
+ export const WORKFLOW_REF_EXT = ".js";