@migor/cli 0.10.3 → 0.10.5

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.
package/dist/cli.js CHANGED
@@ -1,7 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
  /** agentia CLI 入口:手写参数解析 + 命令分发(零依赖) */
3
- import { readFileSync } from 'node:fs';
4
- import { fileURLToPath } from 'node:url';
5
3
  import { createProject } from './create.js';
6
4
  import { generateCapability } from './generate.js';
7
5
  import { isCapabilityType, isValidName, CAPABILITY_TYPES } from './templates.js';
@@ -13,22 +11,35 @@ import { reportCommand, USAGE as REPORT_USAGE } from './report.js';
13
11
  import { harvestCommand, USAGE as HARVEST_USAGE } from './harvest.js';
14
12
  import { diffCommand, USAGE as DIFF_USAGE } from './diff.js';
15
13
  import { exportCommand, USAGE as EXPORT_USAGE } from './export.js';
14
+ import { viewCommand, USAGE as VIEW_USAGE } from './view.js';
15
+ import { manifestCommand } from './manifest.js';
16
+ import { readCliVersion } from './version.js';
16
17
  const USAGE = `agentia —— Agentia 框架命令行工具
17
18
 
18
- 用法:
19
- agentia create <name> [--dir <parent>] 创建项目脚手架(目录 <parent|当前目录>/<name>/)
19
+ 核心工作流:
20
+ agentia create <name> [--dir <parent>] [--minimal]
21
+ 创建项目(默认完整服务;--minimal = 单文件 + 裸工具)
20
22
  agentia g <type> <name> 在当前目录生成能力(别名:generate)
21
23
  type: ${CAPABILITY_TYPES.join(' | ')}
22
24
  agentia dev [-- "问题"] 启动开发模式(常驻 runner + 本地 inspector 面板:
23
25
  在面板里选能力/工作目录、输入 prompt 驱动一次 run,
24
26
  并看它的调用树;改代码或 .md 自动重启)
25
27
  agentia doctor [--json] 装配体检(未登记/悬空能力/命名规范/重复条目)
26
- agentia report <trace.jsonl> [--json] 从 trace 落盘文件生成调优报告(能力耗时/成本/错误率排行)
28
+ agentia manifest 真实装配 app → 能力契约 JSON(schema/owner/风险/审批/hash)
29
+
30
+ 观测与调优:
31
+ agentia view <trace.jsonl> 落盘 trace → 单文件自包含 HTML(离线可开、可转发)
32
+ ([-o <out.html>] [--open] [--raw] [--limit N] [--trace-id <id>])
33
+ agentia report <trace.jsonl> [--json] 生成调优报告(能力耗时/成本/错误率排行)
34
+ agentia diff <a.jsonl> <b.jsonl> [--json] 两条 trace 的调用树 A/B 比对(有差异时退出码 1)
35
+
36
+ 评测与数据:
27
37
  agentia harvest <trace.jsonl> 把线上 trace 翻成 eval 用例骨架
28
38
  ([--out <file.ts>] [--force] [--failed] [--limit N])
29
- agentia diff <a.jsonl> <b.jsonl> [--json] 两条 trace 的调用树 A/B 比对(有差异时退出码 1)
30
39
  agentia export <trace.jsonl> trace 落盘文件 → 训练数据集(JSONL,一行一份 messages)
31
40
  ([--out <file>] [--ok-only] [--min-score n])
41
+
42
+ 能力包与其他:
32
43
  agentia add <pkg> 安装第三方能力包并登记到 src/registry.ts
33
44
  agentia --help 显示本帮助
34
45
  agentia --version 显示版本(等价 -v)
@@ -40,7 +51,7 @@ name 规则:小写字母开头的小写 kebab-case(如 hello、doc-reviewer
40
51
  `;
41
52
  // 各子命令自己的用法串 —— 同时供 fail() 与「子命令级 --help」使用(单源,不写两遍)。
42
53
  // report / harvest / diff 的用法串在各自模块里(那里本来就有),此处 import 复用。
43
- const CREATE_USAGE = '用法:agentia create <name> [--dir <parent>]';
54
+ const CREATE_USAGE = '用法:agentia create <name> [--dir <parent>] [--minimal]';
44
55
  const G_USAGE = `用法:agentia g <type> <name>(type: ${CAPABILITY_TYPES.join(' | ')})`;
45
56
  const ADD_USAGE = '用法:agentia add <pkg>(npm 包名或本地包路径)';
46
57
  /** 子命令 → 自己的用法串。`--help` 落在子命令上时打它。 */
@@ -50,10 +61,12 @@ const SUB_USAGE = {
50
61
  generate: G_USAGE,
51
62
  dev: '用法:agentia dev [-- "首次 run 的 prompt"]',
52
63
  doctor: '用法:agentia doctor [--json]',
64
+ manifest: '用法:agentia manifest(真实装配 src/app.ts,不调用模型)',
53
65
  report: REPORT_USAGE,
54
66
  harvest: HARVEST_USAGE,
55
67
  diff: DIFF_USAGE,
56
68
  export: EXPORT_USAGE,
69
+ view: VIEW_USAGE,
57
70
  add: ADD_USAGE,
58
71
  };
59
72
  function fail(message) {
@@ -61,20 +74,6 @@ function fail(message) {
61
74
  process.exitCode = 1;
62
75
  return 1;
63
76
  }
64
- /**
65
- * CLI 版本 = 本包 package.json 的 `version`(**单源**:`scripts/check-release.mjs` 已保证它
66
- * 与根包一致,所以这里不另存一份常量 —— 常量会漂,读到的文件不会)。
67
- * 源码直跑(`src/cli.ts`)与发布物(`dist/cli.js`)的 `../package.json` 都是包根那一份。
68
- */
69
- function readCliVersion() {
70
- try {
71
- const pkg = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'));
72
- return pkg.version ?? 'unknown';
73
- }
74
- catch {
75
- return 'unknown';
76
- }
77
- }
78
77
  function checkName(name) {
79
78
  if (name === undefined)
80
79
  return false;
@@ -103,6 +102,7 @@ function main(argv) {
103
102
  if (command === 'create') {
104
103
  const name = rest[0];
105
104
  let parent;
105
+ let minimal = false;
106
106
  for (let i = 1; i < rest.length; i += 1) {
107
107
  if (rest[i] === '--dir') {
108
108
  parent = rest[i + 1];
@@ -110,6 +110,9 @@ function main(argv) {
110
110
  return fail('--dir 需要一个目录参数');
111
111
  i += 1;
112
112
  }
113
+ else if (rest[i] === '--minimal') {
114
+ minimal = true;
115
+ }
113
116
  else {
114
117
  return fail(`未知参数:${rest[i]}`);
115
118
  }
@@ -119,7 +122,7 @@ function main(argv) {
119
122
  if (!checkName(name)) {
120
123
  return fail(`非法项目名「${name}」:需匹配小写 kebab-case(如 my-app)`);
121
124
  }
122
- return createProject(name, parent);
125
+ return createProject(name, parent, { minimal });
123
126
  }
124
127
  if (command === 'g' || command === 'generate') {
125
128
  const [type, name, ...extra] = rest;
@@ -152,6 +155,11 @@ function main(argv) {
152
155
  return fail(`未知参数:${extra[0]}`);
153
156
  return doctor({ json: rest.includes('--json') });
154
157
  }
158
+ if (command === 'manifest') {
159
+ if (rest.length > 0)
160
+ return fail(`未知参数:${rest[0]}`);
161
+ return manifestCommand();
162
+ }
155
163
  if (command === 'report') {
156
164
  // 异步命令(要 dynamic import 构建期拷进来的聚合实现):自己设 exitCode,
157
165
  // 返回值仅表示「已受理」—— 挂着的 Promise 会让 Node 等到它 settle 再退出。
@@ -185,6 +193,14 @@ function main(argv) {
185
193
  });
186
194
  return 0;
187
195
  }
196
+ if (command === 'view') {
197
+ // 异步命令(读文件/读构建期资源/写文件):同 report 的受理模式
198
+ void viewCommand(rest).catch((e) => {
199
+ console.error(`错误:${e.message}`);
200
+ process.exitCode = 1;
201
+ });
202
+ return 0;
203
+ }
188
204
  if (command === 'add') {
189
205
  const [pkg, ...extra] = rest;
190
206
  if (pkg === undefined || extra.length > 0) {
@@ -0,0 +1,46 @@
1
+ /** create --minimal:单文件 + 裸工具的项目生成(与完整服务模板分文件,避免 create.ts 继续膨胀)。 */
2
+ import { mkdirSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { minimalMainTs, minimalReadme, projectDotEnv, projectDotEnvExample, projectGitignore, projectPackageJson, projectTsconfig, } from './templates.js';
5
+ import { GUIDE_VERSION_MARKER, readCliVersion } from './version.js';
6
+ /** 与完整模板共享依赖和版本 pin,只收敛 scripts。 */
7
+ export function minimalPackageJson(name) {
8
+ const pkg = JSON.parse(projectPackageJson(name));
9
+ pkg.scripts = {
10
+ ...pkg.scripts,
11
+ dev: 'tsx src/main.ts',
12
+ build: 'tsc -p tsconfig.json',
13
+ start: 'node dist/main.js',
14
+ };
15
+ delete pkg.scripts['start:prod'];
16
+ delete pkg.scripts['start:batch'];
17
+ return `${JSON.stringify(pkg, null, 2)}\n`;
18
+ }
19
+ function write(dir, rel, content) {
20
+ const file = join(dir, rel);
21
+ mkdirSync(dirname(file), { recursive: true });
22
+ writeFileSync(file, content, 'utf8');
23
+ }
24
+ export function createMinimalProject(dir, name, guide) {
25
+ write(dir, 'package.json', minimalPackageJson(name));
26
+ write(dir, 'tsconfig.json', projectTsconfig());
27
+ write(dir, 'src/main.ts', minimalMainTs(name));
28
+ write(dir, 'README.md', minimalReadme(name));
29
+ write(dir, '.gitignore', projectGitignore());
30
+ write(dir, '.env', projectDotEnv());
31
+ write(dir, '.env.example', projectDotEnvExample());
32
+ write(dir, 'AGENTS.md', `${GUIDE_VERSION_MARKER(readCliVersion())}\n${guide}`);
33
+ console.log(`已创建最小项目 ${dir}
34
+
35
+ 后续步骤:
36
+ cd ${dir}
37
+ npm install
38
+ 把 API key 填进 .env(已生成,且已被 .gitignore 忽略)
39
+ npm run dev -- "你的问题" # 一个入口文件 + 一个裸工具,跑一次并退出
40
+
41
+ 构建后运行:npm run typecheck && npm run build && npm start -- "你的问题"
42
+
43
+ 需要 inspector、能力目录/registry 或 HTTP 服务时,用默认完整模板另建项目:
44
+ npx @migor/cli create my-service`);
45
+ return 0;
46
+ }
package/dist/create.js CHANGED
@@ -4,6 +4,8 @@ import { dirname, join, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { appTs, batchTs, CAPABILITY_DIR_LIST, cleanMjs, copyAssetsMjs, devConfigTs, emptyRegistryTemplate, mainTs, projectDotEnv, projectDotEnvExample, projectGitignore, projectPackageJson, projectReadme, projectTsconfig, readFileToolIndexTs, REGISTRY_PATH, sessionStoreTs, toolIndexTs, } from './templates.js';
6
6
  import { registerCapability } from './registry.js';
7
+ import { GUIDE_VERSION_MARKER, readCliVersion } from './version.js';
8
+ import { createMinimalProject } from './create-minimal.js';
7
9
  function write(dir, rel, content) {
8
10
  const file = join(dir, rel);
9
11
  mkdirSync(dirname(file), { recursive: true });
@@ -27,7 +29,7 @@ function readUsageGuide() {
27
29
  }
28
30
  throw new Error(`找不到 AI 使用说明(docs/usage-guide.md)。查找过:\n ${candidates.join('\n ')}`);
29
31
  }
30
- export function createProject(name, parent) {
32
+ export function createProject(name, parent, options = {}) {
31
33
  const dir = resolve(parent ?? process.cwd(), name);
32
34
  let guide;
33
35
  try {
@@ -52,6 +54,8 @@ export function createProject(name, parent) {
52
54
  return 1;
53
55
  }
54
56
  mkdirSync(dir, { recursive: true });
57
+ if (options.minimal === true)
58
+ return createMinimalProject(dir, name, guide);
55
59
  write(dir, 'package.json', projectPackageJson(name));
56
60
  write(dir, 'tsconfig.json', projectTsconfig());
57
61
  // 装配(app.ts)与启动分离:dev 环要复用 app.ts 的工厂,
@@ -88,12 +92,18 @@ export function createProject(name, parent) {
88
92
  // 生成 .env 却不忽略它,等于把 key 直接送进用户的第一个 commit。
89
93
  write(dir, '.env', projectDotEnv());
90
94
  write(dir, '.env.example', projectDotEnvExample());
91
- // AI 使用说明:让 Claude Code / Cursor / Copilot 等一进项目就拿到权威 API 速查
92
- write(dir, 'AGENTS.md', guide);
95
+ // AI 使用说明:让 Claude Code / Cursor / Copilot 等一进项目就拿到权威 API 速查。
96
+ // ⚠️ 头部带一行**版本标记**(2026-10-16 ref-scaffold-observability-gap):这份说明是
97
+ // `docs/usage-guide.md` 的**快照**,而 CLI **没有任何命令会更新它** —— 升级框架后它会
98
+ // 静默过期,偏偏 AI 就是照它写代码的。有了标记,`agentia doctor` 才能把
99
+ // 「说明是 vX 写的、当前 CLI 是 vY」说出口(此前只能靠人记得对拍)。
100
+ write(dir, 'AGENTS.md', `${GUIDE_VERSION_MARKER(readCliVersion())}\n${guide}`);
93
101
  registerCapability(dir, 'hello', 'tool');
94
- // read-file 的构造器要一个工作目录(DI 注入),而 discover 自动注册的 provider
95
- // **没有 deps** ⇒ 它必须走显式注册(registry.ts 的 providers 里那个 WORKDIR 是它的依赖)。
96
- // 不登记的话 `agentia doctor` 会报「存在但未登记」—— 新生成的工程不该自带一条警告。
102
+ // read-file 的构造器要一个工作目录(DI 注入)。它的依赖声明**写在能力类上**
103
+ //(`static readonly deps = ['WORKDIR']`,discover 会带上),所以默认路由(discover)
104
+ // 下 `app.ts` 不必为它再注册一遍 —— 这里登记是给**显式装配路线**(registry.ts)
105
+ // 用的:那份 providers 里要带上 deps,顺带也让 `agentia doctor` 不报「存在但未登记」
106
+ //(新生成的工程不该自带一条警告)。
97
107
  registerCapability(dir, 'read-file', 'tool', ['WORKDIR']);
98
108
  console.log(`已创建项目 ${dir}
99
109
 
package/dist/doctor.js CHANGED
@@ -3,6 +3,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { CAPABILITY_DIRS, CAPABILITY_DIR_LIST, REGISTRY_PATH, isValidName } from './templates.js';
5
5
  import { legacyLayout, legacyMigrationHint } from './layout.js';
6
+ import { GUIDE_VERSION_RE, readCliVersion } from './version.js';
6
7
  /** 能力入口候选(须与框架 src/toolkit/discover.ts 的 ENTRY_CANDIDATES 一致) */
7
8
  const ENTRY_CANDIDATES = ['index.ts', 'index.mts', 'index.js', 'index.mjs'];
8
9
  /**
@@ -153,6 +154,45 @@ export function doctor(opts = {}) {
153
154
  warnings.push(`${REGISTRY_PATH} 条目 { provide: '${e.token}' } 指向 ${source},但该目录已不存在(悬空条目,请删除条目或恢复目录)`);
154
155
  }
155
156
  }
157
+ // trace 出口未接 → 警告(2026-10-16 ref-scaffold-observability-gap)。
158
+ // 框架**刻意不内建**任何出口(spec §9.3,出口只有 `sinks` 这条缝),所以「缝在那儿、
159
+ // 没人接」不会有任何报错:同步 `/run` 的 trace 随响应回去了,而异步 `/tasks` 的 trace
160
+ // 落进任务库里**没人导出** —— 等你要查「这个任务为什么慢」时它正好不在。
161
+ // 判据只认**代码里的** `sinks:`:整行注释掉的不算(那种情况下出口确实没接上)。
162
+ const appPath = join(cwd, 'src', 'app.ts');
163
+ if (existsSync(appPath)) {
164
+ const code = readFileSync(appPath, 'utf8')
165
+ .split('\n')
166
+ .filter((l) => !l.trimStart().startsWith('//'))
167
+ .join('\n');
168
+ if (/sinks\s*:/.test(code)) {
169
+ oks.push('src/app.ts:已配 trace 出口(sinks)');
170
+ }
171
+ else {
172
+ warnings.push('src/app.ts 未配 sinks ⇒ trace 没有任何出口(异步 /tasks 的 trace 只会躺在任务库里、没人导出);' +
173
+ '接一行 jsonlTraceSink 即可,配方见框架 docs/observability.md');
174
+ }
175
+ }
176
+ // 项目内 AGENTS.md 的版本漂移(2026-10-16 ref-scaffold-observability-gap):它是 `create` 时
177
+ // 拷进来的一份**快照**,之后没有任何命令更新它,而 AI 正是照它写代码 ⇒ 框架升级后
178
+ // 「权威入口」静默过期。有版本标记才对得出来;没标记(旧版脚手架 / 手写)也要说一声。
179
+ const agentsFile = join(cwd, 'AGENTS.md');
180
+ if (existsSync(agentsFile)) {
181
+ const agentsSrc = readFileSync(agentsFile, 'utf8');
182
+ const marker = agentsSrc.match(GUIDE_VERSION_RE);
183
+ const current = readCliVersion();
184
+ if (marker === null) {
185
+ warnings.push(`AGENTS.md 没有版本标记(旧版脚手架生成或手写)⇒ 无法判断它与当前 CLI(v${current})` +
186
+ '是否同一版;升级后它可能与框架 API 已经不一致');
187
+ }
188
+ else if (marker[1] !== current) {
189
+ warnings.push(`AGENTS.md 是 v${marker[1]} 写的,当前 CLI 是 v${current} ⇒ 说明可能已过期` +
190
+ '(AI 照它写代码):重新 create 一份,或对照 docs/usage-guide.md 手工更新');
191
+ }
192
+ else {
193
+ oks.push(`AGENTS.md:版本标记与 CLI 一致(v${current})`);
194
+ }
195
+ }
156
196
  if (json) {
157
197
  const payload = {
158
198
  ok: oks,
package/dist/harvest.js CHANGED
@@ -13,8 +13,13 @@
13
13
  import { existsSync } from 'node:fs';
14
14
  import { readFile, writeFile } from 'node:fs/promises';
15
15
  import { extractTrace } from './report.js';
16
- /** trace 不记 assistant 文本 —— 脚本 text 块的统一占位(与框架侧同一个词) */
16
+ /** trace 没开 `traceContent`(或该回合确实没说话)时,脚本 text 块的统一占位(与框架侧同一个词) */
17
17
  const ASSISTANT_TEXT_PLACEHOLDER = '[harvest] assistant 文本未入 trace';
18
+ /** 取本回合的真实 assistant 文本 —— 只认 opt-in 的 `output.text`(与框架侧逐字同形) */
19
+ function assistantTextOf(turn) {
20
+ const text = turn.attributes?.['output.text'];
21
+ return typeof text === 'string' && text !== '' ? text : null;
22
+ }
18
23
  /** 任意值 → 字符串(core/json.ts stringifySafe 的移植) */
19
24
  function stringifySafe(x) {
20
25
  if (typeof x === 'string')
@@ -96,9 +101,15 @@ export function harvestEvalCase(input) {
96
101
  let tuSeq = 0;
97
102
  const steps = mainTurns.map((turn, i) => {
98
103
  const inputs = toolInputsOf(turn);
99
- const content = [
100
- { type: 'text', text: ASSISTANT_TEXT_PLACEHOLDER },
101
- ];
104
+ const text = assistantTextOf(turn);
105
+ const content = [];
106
+ if (text !== null) {
107
+ content.push({ type: 'text', text });
108
+ }
109
+ else if (inputs.length === 0) {
110
+ // 只有「既没文本又没工具」的终端回合才放占位(与框架侧逐字同步)
111
+ content.push({ type: 'text', text: ASSISTANT_TEXT_PLACEHOLDER });
112
+ }
102
113
  for (const e of inputs) {
103
114
  content.push({
104
115
  type: 'tool_use',
@@ -117,11 +128,20 @@ export function harvestEvalCase(input) {
117
128
  });
118
129
  const toolSeq = mainTurns.flatMap((t) => toolInputsOf(t).map((e) => e.tool));
119
130
  const userText = lastUserText(input.messages);
131
+ const textTurns = mainTurns.filter((t) => assistantTextOf(t) !== null).length;
120
132
  const head = [
121
133
  `// ┄┄ harvest 用例骨架:${name}(trace ${oneLine(trace.traceId ?? '')}${input.source ? `,来源 ${oneLine(input.source)}` : ''})┄┄`,
122
134
  '// ⚠️ 脚手架,不是成品 —— 人工核对后再进 CI:',
123
- '// · trace 不记 assistant 文本(llm.turn 只记 usage/事件),脚本里的 text 块是占位;',
124
135
  ];
136
+ if (textTurns > 0) {
137
+ head.push(`// · assistant 文本取自 trace 的 output.text(该 run 开了 traceContent: 'full')` +
138
+ (textTurns < mainTurns.length
139
+ ? `;${mainTurns.length - textTurns} 个回合没有文本(纯 tool_use 回合本来就没文本);`
140
+ : ';'));
141
+ }
142
+ else {
143
+ head.push("// · trace 里没有 assistant 文本(该 run 未开 traceContent: 'full')—— 脚本只含工具调用,", '// 终端回合的 text 是占位;要真文本请在 app 里开 traceContent 后重跑再 harvest;');
144
+ }
125
145
  if (nestedCount > 0) {
126
146
  head.push(`// · 只含主循环 ${mainTurns.length} 个回合;${nestedCount} 个子 agent 嵌套回合已略去`, '// (嵌套回合不走主循环脚本,scriptedClient 驱动不了它们;要覆盖子 agent 请单独写 eval);');
127
147
  }
@@ -175,8 +195,9 @@ function renderEvalFile(opts) {
175
195
  (opts.badLines > 0 ? `,跳过无法解析 ${opts.badLines} 行` : '');
176
196
  const cases = opts.cases.map((c) => c.replace(/\n$/, '').replace(/^/gm, ' ')).join(',\n');
177
197
  return `// 由 \`agentia harvest\` 生成的 eval 用例脚手架(${note})
178
- // ⚠️ 这是脚手架:assistant 文本为占位(trace 不记文本);每个用例里的 expect 是
179
- // 从原 trace 抄录的「实际轨迹」—— 发生过 ≠ 应该发生,人工核对后再进 CI。
198
+ // ⚠️ 这是脚手架:assistant 文本视 trace 而定(run 开了 traceContent: 'full' 则取自
199
+ // output.text,否则只含工具调用);每个用例里的 expect 是
200
+ // 从原 trace 抄录的「实际轨迹」—— 发生过 ≠ 应该发生,人工核对后再进 CI。
180
201
  import assert from 'node:assert/strict';
181
202
  import { defineEval, scriptedClient } from '@migor/agentia';
182
203
 
@@ -93,7 +93,15 @@ function eventFull(ev) {
93
93
  */
94
94
  export function playTrace(view, trace) {
95
95
  const spans = Array.isArray(trace?.spans) ? trace.spans : [];
96
- if (!spans.length) return false;
96
+ // ⚠️ 空 spans 也**必须 reset**:playTrace 的契约是「把这个 view 变成这棵树」——
97
+ // 直接 return 会把**上一条 trace 的树**留在页面上,而下拉里选中的是这一条
98
+ // (切换后看到别人的数据,比看到空白更坏;2026-10-21 在 `agentia view` 的多 trace
99
+ // 下拉里实测到:先选一个正常 trace,再选一个 0 spans 的,树原样不动)。
100
+ if (!spans.length) {
101
+ const label = trace?.traceId ? String(trace.traceId) : '空 trace';
102
+ view.reset(`run · ${label}(无 spans)`);
103
+ return false;
104
+ }
97
105
 
98
106
  const byId = new Map(spans.map((s) => [s.spanId, s]));
99
107
  const root = spans.find((s) => s.kind === 'run') || spans[0];