@zhushanwen/pi-subagent-workflow 8.1.1 → 8.3.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.
@@ -11,9 +11,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
11
11
  // 隔离真实用户全局目录:resource-discovery 用 homedir() 推导 user-agents 源
12
12
  // (~/.agents/agents/),测试环境可能存在真实 agent 文件(如 tech-design-review.md),
13
13
  // 不 mock 会导致「期望空列表/精确列表」用例被环境污染(2026-08 实测 4 个失败)。
14
+ // 用真实 tmpdir 下的子目录作 mock homedir(macOS SIP 禁止 mkdir /nonexistent-*)。
15
+ const mockHomeDir = vi.hoisted(() => {
16
+ const fs = require("node:fs");
17
+ const os = require("node:os");
18
+ const path = require("node:path");
19
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "res-disc-home-"));
20
+ return dir;
21
+ });
22
+
14
23
  vi.mock("node:os", async (importOriginal) => {
15
24
  const actual = await importOriginal<typeof import("node:os")>();
16
- return { ...actual, homedir: () => "/nonexistent-home-for-tests" };
25
+ return { ...actual, homedir: () => mockHomeDir };
17
26
  });
18
27
 
19
28
  import {
@@ -23,6 +32,11 @@ import {
23
32
  processPackageSync,
24
33
  getCachedFile,
25
34
  getCachedFileContent,
35
+ __testResetShadowDedup,
36
+ __testInjectShadowDedupKey,
37
+ isMachineSource,
38
+ getCachedParsed,
39
+ clearFileCache,
26
40
  } from "../resource-discovery.ts";
27
41
  import { getLogger } from "@zhushanwen/pi-extension-logger";
28
42
 
@@ -216,6 +230,7 @@ describe("discoverResources (async)", () => {
216
230
  });
217
231
  afterEach(() => {
218
232
  fs.rmSync(ws, { recursive: true, force: true });
233
+ __testResetShadowDedup();
219
234
  });
220
235
 
221
236
  it("discovers agents from project .pi/agents/ (async)", async () => {
@@ -260,6 +275,7 @@ describe("user-extension-paths (XYZ_EXTENSION_PATHS)", () => {
260
275
  if (savedEnv === undefined) delete process.env.XYZ_EXTENSION_PATHS;
261
276
  else process.env.XYZ_EXTENSION_PATHS = savedEnv;
262
277
  fs.rmSync(ws, { recursive: true, force: true });
278
+ __testResetShadowDedup();
263
279
  });
264
280
 
265
281
  it("discovers agents from XYZ_EXTENSION_PATHS via pi.agents manifest", () => {
@@ -390,26 +406,204 @@ describe("user-extension-paths (XYZ_EXTENSION_PATHS)", () => {
390
406
  expect(asyncResult).toEqual(discoverResourcesSync(config));
391
407
  });
392
408
 
393
- it("async: 同名遮蔽时输出 warn(D8d 有检测必有报告)", async () => {
409
+ it("async: 同名遮蔽时机器源×机器源降 debug 不产生 warn(D8d 分级)", async () => {
394
410
  const npmPkg = path.join(agentDir, "npm", "node_modules", "test-pkg");
395
411
  writePackageJson(npmPkg, { agents: ["./agents"] });
396
412
  const npmFile = writeFile(path.join(npmPkg, "agents"), "dup.md", "npm-body");
397
413
  const projFile = writeFile(path.join(ws, ".agents", "agents"), "dup.md", "project-body");
398
414
  const warnSpy = vi.spyOn(getLogger("subagents"), "warn");
415
+ const debugSpy = vi.spyOn(getLogger("subagents"), "debug");
399
416
 
400
417
  try {
401
418
  const result = await discoverResources({ kind: "agents", workspaceRoot: ws, agentDir });
402
419
 
403
420
  // 遮蔽仍生效(last-writer-wins 语义不变)
404
421
  expect(result.find((r) => path.basename(r.path) === "dup.md")?.source).toBe("project-agents");
405
- // 但不再静默:warn 报告被遮蔽方与保留方路径(D8d「有检测无报告」修复)
406
- expect(warnSpy).toHaveBeenCalledTimes(1);
407
- const [msg, data] = warnSpy.mock.calls[0];
422
+ // npm 与 project-agents 均为机器源 → 降级 debug,不产生 warn
423
+ expect(warnSpy).not.toHaveBeenCalled();
424
+ expect(debugSpy).toHaveBeenCalledTimes(1);
425
+ const [msg, data] = debugSpy.mock.calls[0];
408
426
  expect(String(msg)).toContain('duplicate agents "dup"');
409
427
  expect(String(msg)).toContain("project-agents shadows npm");
410
428
  expect(data).toMatchObject({ shadowed: npmFile, kept: projFile });
411
429
  } finally {
412
430
  warnSpy.mockRestore();
431
+ debugSpy.mockRestore();
432
+ }
433
+ });
434
+
435
+ it("(a) 机器源×用户源降 debug:npm vs user-pi 不产生 warn", async () => {
436
+ // npm 源(机器源)与 user-pi 源(用户源)——任一侧为机器源即降 debug
437
+ const npmPkg = path.join(agentDir, "npm", "node_modules", "test-pkg");
438
+ writePackageJson(npmPkg, { agents: ["./agents"] });
439
+ writeFile(path.join(npmPkg, "agents"), "dup.md", "npm-body");
440
+ // user-pi 源 = agentDir/<kind>/(agentDir 是独立于 homedir mock 的入参,
441
+ // mockHomeDir 由 vi.hoisted mkdtempSync 创建真实 tmpdir)
442
+ writeFile(path.join(agentDir, "agents"), "dup.md", "user-pi-body");
443
+ const warnSpy = vi.spyOn(getLogger("subagents"), "warn");
444
+ const debugSpy = vi.spyOn(getLogger("subagents"), "debug");
445
+
446
+ try {
447
+ await discoverResources({ kind: "agents", workspaceRoot: ws, agentDir });
448
+
449
+ expect(warnSpy).not.toHaveBeenCalled();
450
+ expect(debugSpy).toHaveBeenCalled();
451
+ const [msg] = debugSpy.mock.calls[0];
452
+ expect(String(msg)).toContain('duplicate agents "dup"');
453
+ } finally {
454
+ warnSpy.mockRestore();
455
+ debugSpy.mockRestore();
456
+ }
457
+ });
458
+
459
+ it("(a) npm vs user-extension-paths 机器源×机器源降 debug", async () => {
460
+ // 两个机器源同名(npm 包 vs XYZ_EXTENSION_PATHS 注入的 dev 包,后者 source 标签为 user-extension-paths)
461
+ const npmPkg = path.join(agentDir, "npm", "node_modules", "test-pkg");
462
+ writePackageJson(npmPkg, { agents: ["./agents"] });
463
+ writeFile(path.join(npmPkg, "agents"), "shared.md", "npm-body");
464
+ const devPkg = path.join(ws, "dev-ext");
465
+ writePackageJson(devPkg, { agents: ["./agents"] });
466
+ writeFile(path.join(devPkg, "agents"), "shared.md", "dev-body");
467
+ process.env.XYZ_EXTENSION_PATHS = devPkg;
468
+ const warnSpy = vi.spyOn(getLogger("subagents"), "warn");
469
+ const debugSpy = vi.spyOn(getLogger("subagents"), "debug");
470
+
471
+ try {
472
+ await discoverResources({ kind: "agents", workspaceRoot: ws, agentDir });
473
+
474
+ expect(warnSpy).not.toHaveBeenCalled();
475
+ expect(debugSpy).toHaveBeenCalled();
476
+ } finally {
477
+ warnSpy.mockRestore();
478
+ debugSpy.mockRestore();
479
+ }
480
+ });
481
+
482
+ it("(b) 双用户源重复产生 warn 且同进程第二次 discoverResources 不重复(去重生效)", async () => {
483
+ // user-pi 与 user-agents 都是用户源——需要构造两个源都有同名文件
484
+ // user-pi = agentDir/agents/(buildScanTargets 第一个 target)
485
+ // user-agents = mockHomeDir/.agents/agents/(vi.hoisted 创建的真实 tmpdir)
486
+ const userAgentsDir = path.join(mockHomeDir, ".agents", "agents");
487
+ fs.mkdirSync(userAgentsDir, { recursive: true });
488
+ try {
489
+ // user-pi: agentDir/agents/dup.md
490
+ writeFile(path.join(agentDir, "agents"), "dup.md", "user-pi-body");
491
+ // user-agents: mockHomeDir/.agents/agents/dup.md
492
+ fs.writeFileSync(path.join(userAgentsDir, "dup.md"), "user-agents-body", "utf-8");
493
+
494
+ const warnSpy = vi.spyOn(getLogger("subagents"), "warn");
495
+
496
+ try {
497
+ // 第一次调用——应产生 warn(双用户源)
498
+ await discoverResources({ kind: "agents", workspaceRoot: ws, agentDir });
499
+ expect(warnSpy).toHaveBeenCalledTimes(1);
500
+ const [msg] = warnSpy.mock.calls[0];
501
+ expect(String(msg)).toContain('duplicate agents "dup"');
502
+
503
+ // 第二次调用——同进程去重,不再报
504
+ warnSpy.mockClear();
505
+ await discoverResources({ kind: "agents", workspaceRoot: ws, agentDir });
506
+ expect(warnSpy).not.toHaveBeenCalled();
507
+ } finally {
508
+ warnSpy.mockRestore();
509
+ }
510
+ } finally {
511
+ fs.rmSync(userAgentsDir, { recursive: true, force: true });
512
+ }
513
+ });
514
+
515
+ it("(c) path 变化(新 key)重新报 warn", async () => {
516
+ const userAgentsDir = path.join(mockHomeDir, ".agents", "agents");
517
+ fs.mkdirSync(userAgentsDir, { recursive: true });
518
+ try {
519
+ writeFile(path.join(agentDir, "agents"), "dup.md", "user-pi-body");
520
+ fs.writeFileSync(path.join(userAgentsDir, "dup.md"), "user-agents-body", "utf-8");
521
+
522
+ const warnSpy = vi.spyOn(getLogger("subagents"), "warn");
523
+
524
+ try {
525
+ // 第一次调用——报 warn
526
+ await discoverResources({ kind: "agents", workspaceRoot: ws, agentDir });
527
+ expect(warnSpy).toHaveBeenCalledTimes(1);
528
+
529
+ // 第二次调用——同 key 去重,不报
530
+ warnSpy.mockClear();
531
+ await discoverResources({ kind: "agents", workspaceRoot: ws, agentDir });
532
+ expect(warnSpy).not.toHaveBeenCalled();
533
+
534
+ // 真实 path 变化产生新 key:双用户目录各加一个不同 stem(dup2.md)
535
+ // → 新遮蔽对(新 stem 新 path)→ 新 key → 重新报
536
+ writeFile(path.join(agentDir, "agents"), "dup2.md", "user-pi-body-2");
537
+ fs.writeFileSync(path.join(userAgentsDir, "dup2.md"), "user-agents-body-2", "utf-8");
538
+ warnSpy.mockClear();
539
+ await discoverResources({ kind: "agents", workspaceRoot: ws, agentDir });
540
+ expect(warnSpy).toHaveBeenCalledTimes(1);
541
+ const [newMsg] = warnSpy.mock.calls[0];
542
+ expect(String(newMsg)).toContain('duplicate agents "dup2"');
543
+ } finally {
544
+ warnSpy.mockRestore();
545
+ }
546
+ } finally {
547
+ fs.rmSync(userAgentsDir, { recursive: true, force: true });
548
+ }
549
+ });
550
+
551
+ it("分级穷举:全部 8 个 ResourceSource 的机器/用户归属与 D3 一致", () => {
552
+ // 封闭枚举逐值断言,防止未来新增/修改枚举值时分级边界漂移
553
+ const machine: ResourceSource[] = ["npm", "npm-dev", "user-extension-paths", "project-pi", "project-pi-tmp", "project-agents"];
554
+ const user: ResourceSource[] = ["user-pi", "user-agents"];
555
+ for (const s of machine) expect(isMachineSource(s), `${s} 应为机器源`).toBe(true);
556
+ for (const s of user) expect(isMachineSource(s), `${s} 应为用户源`).toBe(false);
557
+ });
558
+
559
+ it("(d) cap 清空行为:超限后 clear 再 add,之前报过的 key 可重新报", async () => {
560
+ const userAgentsDir = path.join(mockHomeDir, ".agents", "agents");
561
+ fs.mkdirSync(userAgentsDir, { recursive: true });
562
+ try {
563
+ writeFile(path.join(agentDir, "agents"), "dup.md", "user-pi-body");
564
+ fs.writeFileSync(path.join(userAgentsDir, "dup.md"), "user-agents-body", "utf-8");
565
+
566
+ const warnSpy = vi.spyOn(getLogger("subagents"), "warn");
567
+
568
+ try {
569
+ // 步骤 1:首次调用——报 warn,dedup key 加入 set
570
+ await discoverResources({ kind: "agents", workspaceRoot: ws, agentDir });
571
+ expect(warnSpy).toHaveBeenCalledTimes(1);
572
+ warnSpy.mockClear();
573
+
574
+ // 步骤 2:重置 set,注入 1024 个虚拟 key(不含 dedup key)
575
+ // 使 set.size = MAX,dedup key 不在 set 中
576
+ __testResetShadowDedup();
577
+ for (let i = 0; i < 1024; i++) {
578
+ __testInjectShadowDedupKey(`fake|key${i}|/a|/b`);
579
+ }
580
+
581
+ // 步骤 3:第二次调用——dedup key 不在 set → 进 else 分支 →
582
+ // size(1024) >= MAX(1024) → clear() → set 空 → add dedup key → warn
583
+ await discoverResources({ kind: "agents", workspaceRoot: ws, agentDir });
584
+ expect(warnSpy).toHaveBeenCalledTimes(1);
585
+ warnSpy.mockClear();
586
+
587
+ // 步骤 4:第三次调用——dedup key 在 set 中 → 去重跳过 warn
588
+ await discoverResources({ kind: "agents", workspaceRoot: ws, agentDir });
589
+ expect(warnSpy).not.toHaveBeenCalled();
590
+
591
+ // 步骤 5:再次填充到 cap——重置 + 注入 1024 个虚拟 key
592
+ // dedup key(步骤 3 add 的)已被 reset 清除
593
+ __testResetShadowDedup();
594
+ for (let i = 0; i < 1024; i++) {
595
+ __testInjectShadowDedupKey(`fake2|key${i}|/a|/b`);
596
+ }
597
+
598
+ // 步骤 6:第四次调用——cap 再次触发 clear → dedup key 重新报
599
+ await discoverResources({ kind: "agents", workspaceRoot: ws, agentDir });
600
+ expect(warnSpy).toHaveBeenCalledTimes(1);
601
+ } finally {
602
+ warnSpy.mockRestore();
603
+ __testResetShadowDedup();
604
+ }
605
+ } finally {
606
+ fs.rmSync(userAgentsDir, { recursive: true, force: true });
413
607
  }
414
608
  });
415
609
  });
@@ -463,3 +657,80 @@ describe("m5: 统一 mtime 缓存层", () => {
463
657
  }
464
658
  });
465
659
  });
660
+
661
+ // ── KV-cache 稳定性改造:解析结果缓存 getCachedParsed ──
662
+
663
+ describe("getCachedParsed(mtime 级解析缓存)", () => {
664
+ beforeEach(() => {
665
+ clearFileCache();
666
+ });
667
+
668
+ it("mtime 未变时 parse 只跑一次(缓存解析结果)", () => {
669
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "parsed-cache-"));
670
+ const f = path.join(dir, "a.md");
671
+ fs.writeFileSync(f, "---\nname: x\ndescription: y\n---", "utf-8");
672
+ try {
673
+ const parse = vi.fn((content: string) => (content.includes("name: x") ? "OK" : "BAD"));
674
+ const first = getCachedParsed(f, parse);
675
+ const second = getCachedParsed(f, parse);
676
+ expect(first).toBe("OK");
677
+ expect(second).toBe("OK");
678
+ expect(parse).toHaveBeenCalledTimes(1); // 第二次命中缓存,不重 parse
679
+ } finally {
680
+ fs.rmSync(dir, { recursive: true, force: true });
681
+ }
682
+ });
683
+
684
+ it("mtime 变后重新 parse;文件删除后返回 null 并驱逐", () => {
685
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "parsed-cache2-"));
686
+ const f = path.join(dir, "a.md");
687
+ fs.writeFileSync(f, "v1", "utf-8");
688
+ try {
689
+ const parse = (content: string) => content;
690
+ expect(getCachedParsed(f, parse)).toBe("v1");
691
+ fs.writeFileSync(f, "v2", "utf-8");
692
+ expect(getCachedParsed(f, parse)).toBe("v2"); // mtime 变 → 重新 parse
693
+ fs.rmSync(f);
694
+ expect(getCachedParsed(f, parse)).toBeNull(); // 删除 → null
695
+ } finally {
696
+ fs.rmSync(dir, { recursive: true, force: true });
697
+ }
698
+ });
699
+
700
+ it("clearFileCache 同时清空解析缓存", () => {
701
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "parsed-cache3-"));
702
+ const f = path.join(dir, "a.md");
703
+ fs.writeFileSync(f, "content", "utf-8");
704
+ try {
705
+ const parse = vi.fn(() => "OK");
706
+ getCachedParsed(f, parse);
707
+ clearFileCache();
708
+ getCachedParsed(f, parse);
709
+ expect(parse).toHaveBeenCalledTimes(2); // 缓存被清 → 重新 parse
710
+ } finally {
711
+ fs.rmSync(dir, { recursive: true, force: true });
712
+ }
713
+ });
714
+
715
+ it("同一 path 的不同 parse 各自独立缓存(缓存键含 parse 身份,防跨 parse 污染)", () => {
716
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "parsed-cache4-"));
717
+ const f = path.join(dir, "a.md");
718
+ fs.writeFileSync(f, "shared-content", "utf-8");
719
+ try {
720
+ // 模拟真实双 parse 场景:parseAgentFrontmatter vs parseWorkflowMeta 对同一
721
+ // path(agent 与 workflow 发现源理论上可命中同一路径)各自解析
722
+ const parseA = (content: string) => ({ kind: "agent" as const, content });
723
+ const parseW = (content: string) => ({ kind: "workflow" as const, len: content.length });
724
+ const a1 = getCachedParsed(f, parseA);
725
+ // 修复前:缓存键只有 path,这里会命中 parseA 的缓存条目并 as T 断言返回
726
+ // {kind:"agent"}——w1 被污染成错误类型
727
+ const w1 = getCachedParsed(f, parseW);
728
+ const a2 = getCachedParsed(f, parseA);
729
+ expect(a1).toEqual({ kind: "agent", content: "shared-content" });
730
+ expect(w1).toEqual({ kind: "workflow", len: 14 });
731
+ expect(a2).toEqual({ kind: "agent", content: "shared-content" });
732
+ } finally {
733
+ fs.rmSync(dir, { recursive: true, force: true });
734
+ }
735
+ });
736
+ });
@@ -21,6 +21,22 @@ import { getLogger } from "@zhushanwen/pi-extension-logger";
21
21
  // 模块级 logger(setPiHandle 注入后自动走 appendEntry,未注入时 console 兜底)
22
22
  const logger = getLogger("subagents");
23
23
 
24
+ // [D8d] warn 路径进程内去重集合:key=(kind, stem, shadowedPath, keptPath),
25
+ // cap 1024 超限先清空再 add(对齐 ui-request-observability 的 MAX_WARNED_SESSIONS 范式)。
26
+ // debug 路径不去重(默认 no-op,无成本)。
27
+ const shadowWarnDedup = new Set<string>();
28
+ const MAX_SHADOW_WARN_DEDUP = 1024;
29
+
30
+ /** @internal 测试辅助:重置 warn 去重集合(cap 测试用,生产代码不调用)。 */
31
+ export function __testResetShadowDedup(): void {
32
+ shadowWarnDedup.clear();
33
+ }
34
+
35
+ /** @internal 测试辅助:向 warn 去重集合注入 key(cap 测试用)。 */
36
+ export function __testInjectShadowDedupKey(key: string): void {
37
+ shadowWarnDedup.add(key);
38
+ }
39
+
24
40
  // ── 类型 ─────────────────────────────────────────────────────
25
41
 
26
42
  /** 资源种类:agent 或 workflow */
@@ -53,6 +69,23 @@ export interface ScanConfig {
53
69
 
54
70
  // ── 常量 ─────────────────────────────────────────────────────
55
71
 
72
+ /** 机器源集合:包管理/工程配置产物,其同名重复是安装拓扑常态(非用户配置错误)。
73
+ * 用户个人源(user-pi / user-agents)不在此列——双个人源同名重复保留 warn。 */
74
+ const MACHINE_SOURCES: ReadonlySet<ResourceSource> = new Set<ResourceSource>([
75
+ "npm",
76
+ "npm-dev",
77
+ "user-extension-paths",
78
+ "project-pi",
79
+ "project-pi-tmp",
80
+ "project-agents",
81
+ ]);
82
+
83
+ /** 判断 source 是否属于机器源(安装拓扑常态,同名重复降 debug)。
84
+ * 导出仅为测试穷举断言用(封闭 8 值枚举 × 分级边界)。 */
85
+ export function isMachineSource(source: ResourceSource): boolean {
86
+ return MACHINE_SOURCES.has(source);
87
+ }
88
+
56
89
  /** workspace root 向上查找的最大深度 */
57
90
  const WORKSPACE_ROOT_MAX_DEPTH = 20;
58
91
 
@@ -174,11 +207,49 @@ export function getCachedFileContent(filePath: string): string | null {
174
207
  return getCachedFile(filePath)?.content ?? null;
175
208
  }
176
209
 
210
+ // [perf] 解析结果缓存(KV-cache 稳定性改造):外层 key = parse 函数身份,内层 key =
211
+ // path,value = { mtimeMs, parsed }。key 含 parse 身份是正确性要求——同一 path 可能被
212
+ // 不同 parse(agent frontmatter vs workflow meta)解析,单层 path key 会跨 parse 类型
213
+ // 互相污染缓存(先 parse 的结果被 as T 断言返回)。用普通 Map 而非 WeakMap:
214
+ // clearFileCache 需全量清空(测试隔离),WeakMap 不可遍历;parse 函数均为模块级
215
+ // 常量,强引用无泄漏。复用 getCachedFile 的 mtime 判变——mtime 未变时跳过 parse
216
+ // (frontmatter YAML 解析是重建发现时最大的可省 CPU 项)。parse 的确定性结果(含
217
+ // null,如 frontmatter 非法)均可缓存:同一 content 必然解析出同一结果。失效与
218
+ // mtimeCache 同步(clearFileCache)。
219
+ const parsedCache = new Map<
220
+ (content: string) => unknown,
221
+ Map<string, { mtimeMs: number; parsed: unknown }>
222
+ >();
223
+
224
+ /**
225
+ * mtime 级解析结果缓存:mtime 未变返回缓存 parsed,变则经 getCachedFile 取 content
226
+ * 重新 parse 并缓存。文件不存在/不可读 → null(并驱逐条目)。缓存按 parse 函数隔离
227
+ * ——同一 path 的不同 parse 互不污染。
228
+ */
229
+ export function getCachedParsed<T>(filePath: string, parse: (content: string) => T): T | null {
230
+ const file = getCachedFile(filePath);
231
+ let perParse = parsedCache.get(parse);
232
+ if (!file) {
233
+ perParse?.delete(filePath);
234
+ return null;
235
+ }
236
+ if (!perParse) {
237
+ perParse = new Map();
238
+ parsedCache.set(parse, perParse);
239
+ }
240
+ const entry = perParse.get(filePath);
241
+ if (entry && entry.mtimeMs === file.mtimeMs) return entry.parsed as T;
242
+ const parsed = parse(file.content);
243
+ perParse.set(filePath, { mtimeMs: file.mtimeMs, parsed });
244
+ return parsed;
245
+ }
246
+
177
247
  /** 清空(invalidateCache 语义——测试隔离 + mtime 漏判场景手动刷新兜底)。 */
178
248
  export function clearFileCache(): void {
179
249
  mtimeCache.clear();
180
250
  workspaceRootCache.clear();
181
251
  manifestCache.clear();
252
+ for (const perParse of parsedCache.values()) perParse.clear();
182
253
  }
183
254
 
184
255
  export function findWorkspaceRoot(cwd?: string): string {
@@ -541,13 +612,29 @@ export async function discoverResources(config: ScanConfig): Promise<DiscoveredR
541
612
  if (!r.available && existing) {
542
613
  continue;
543
614
  }
544
- // [D8d] 同名遮蔽可观测:高优先级源覆盖低优先级同名资源时 warn——此前
545
- // 「有检测无报告」,用户自定义 agent/workflow 被静默遮蔽后排查无从下手。
615
+ // [D8d] 同名遮蔽可观测:高优先级源覆盖低优先级同名资源时分级报告——
616
+ // 机器源重复是安装拓扑常态(npm 包与用户目录结构性同名),降 debug 默认静默
617
+ // (XYZ_AGENT_DEBUG=1 文件日志可查);双用户源重复是配置错误,保留 warn 首报。
618
+ // warn 路径进程内去重(Set cap 1024,对齐 ui-request-observability 范式):
619
+ // 每 session 独立进程(process-manager.ts L142-143),进程级去重 ≈ session 级首报。
546
620
  if (existing && existing.path !== r.path) {
547
- logger.warn(
548
- `[resource-discovery] duplicate ${config.kind} "${key}" from ${r.source} shadows ${existing.source}`,
549
- { shadowed: existing.path, kept: r.path },
550
- );
621
+ const msg =
622
+ `[resource-discovery] duplicate ${config.kind} "${key}" from ${r.source} shadows ${existing.source}`;
623
+ const data = { shadowed: existing.path, kept: r.path };
624
+ if (isMachineSource(existing.source) || isMachineSource(r.source)) {
625
+ // 任一侧为机器源 → 降级 debug(安装拓扑常态,排查走 XYZ_AGENT_DEBUG=1)
626
+ logger.debug(msg, data);
627
+ } else {
628
+ // 双侧均为用户源 → 保持 warn,进程内去重(同 key 只报首次)
629
+ const dedupKey = `${config.kind}|${key}|${existing.path}|${r.path}`;
630
+ if (!shadowWarnDedup.has(dedupKey)) {
631
+ if (shadowWarnDedup.size >= MAX_SHADOW_WARN_DEDUP) {
632
+ shadowWarnDedup.clear();
633
+ }
634
+ shadowWarnDedup.add(dedupKey);
635
+ logger.warn(msg, data);
636
+ }
637
+ }
551
638
  }
552
639
  merged.set(key, r);
553
640
  }
@@ -0,0 +1,35 @@
1
+ // src/shared/xml-injection.ts
2
+ //
3
+ // XML 注入段渲染共享原语——subagent / workflow / model 三个 injector 的 format
4
+ // 函数曾是三份手写同构(escapeXml 逐字重复 + 同一段落骨架),提取此模块消除重复。
5
+ // 调用方保留各自的排序契约与条目渲染(字段差异大,不强行归一)。
6
+
7
+ /**
8
+ * 转义 XML 特殊字符(注入段进每 turn system prompt,内容含 < > & 等会破坏
9
+ * XML 结构——全部字段过一遍转义防注入段破碎)。
10
+ */
11
+ export function escapeXml(str: string): string {
12
+ return str
13
+ .replace(/&/g, "&amp;")
14
+ .replace(/</g, "&lt;")
15
+ .replace(/>/g, "&gt;")
16
+ .replace(/"/g, "&quot;")
17
+ .replace(/'/g, "&apos;");
18
+ }
19
+
20
+ /**
21
+ * XML 注入段渲染骨架:`"\n\n<tag>"` 前导(衔接宿主 prompt 末尾)+ 引导语 +
22
+ * 条目行 + 闭合标签,以 "\n" join。空条目返回空串(不注入)。
23
+ *
24
+ * 三个 injector 共用此骨架保证段落结构逐字节同构;KV-cache 契约(顺序稳定 =
25
+ * 注入段字节稳定)由调用方排序保证,本函数不重排。
26
+ */
27
+ export function renderXmlSection(section: {
28
+ tag: string;
29
+ guide: string;
30
+ items: string[];
31
+ }): string {
32
+ if (section.items.length === 0) return "";
33
+ const lines = [`\n\n<${section.tag}>`, section.guide, ...section.items, `</${section.tag}>`];
34
+ return lines.join("\n");
35
+ }