@fx-data/ai-diagnostics 0.1.20261026070913 → 0.1.20261027064707

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/AGENT_SETUP.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # FX 数据诊断:Agent 安装与管理
2
2
 
3
- 本文供执行安装与管理的 Agent 读取,不作为日常诊断的前置上下文。包版本为 `0.1.20261026070913`,需要 Node.js `>=22` 及 npm。用户只需表达安装、使用、更新或卸载意图,环境准备和客户端登记由 Agent 完成。
3
+ 本文供执行安装与管理的 Agent 读取,不作为日常诊断的前置上下文。包版本为 `0.1.20261027064707`,需要 Node.js `>=22` 及 npm。用户只需表达安装、使用、更新或卸载意图,环境准备和客户端登记由 Agent 完成。
4
4
 
5
5
  用户明确要求安装、更新、回退、卸载或检查 FX 数据诊断时,也会触发薄 Skill,并直接执行对应管理命令,不先运行 `context` 或读取诊断规则;只有开始诊断时才按薄 Skill 加载内容。普通聊天不主动加载。
6
6
 
@@ -54,15 +54,17 @@
54
54
 
55
55
  ## 按需使用
56
56
 
57
- 用户明确要求用 FX 数据诊断分析问题后,按薄 Skill 执行 `context`,读取返回的 `agent_rules`,只加载当前问题需要的 Skill 和引用。固定本次调查的版本与 `pinned_cli`;普通聊天和管理请求无需预加载诊断内容。
57
+ 用户明确要求用 FX 数据诊断分析问题后,按薄 Skill 调用一次稳定入口的 `context`,读取返回的 `agent_rules`,只加载当前问题需要的 Skill 和引用。CLI 同时在后台检查更新,固定本次调查返回的版本与 `pinned_cli`;同一调查继续复用,不重复调用稳定入口的 `context`。普通聊天和管理请求无需预加载诊断内容或检查更新。
58
58
 
59
59
  已有 SigNoZ MCP 直接复用;实时查询受阻时才按包内配置 Skill 处理,凭据只保存在本机。Python 解析按需准备;独立解释器可通过 `FX_DATA_DIAGNOSTICS_PYTHON` 指定绝对路径。
60
60
 
61
61
  ## 更新与回退
62
62
 
63
- 用户要求更新时,调用稳定入口的 `update`;要求回退到上次更新前时调用 `rollback`,明确指定版本时调用 `update --version <版本号>`。新调查使用切换后的版本,进行中的调查保持原版本。新增客户端只登记,不隐式更新。
63
+ 用户要求更新时,调用稳定入口的 `update`;要求回退到上次更新前时调用 `rollback`,明确指定版本时调用 `update --version <版本号>`。新调查使用切换后的版本,进行中的调查保持原版本。回退不锁定后续版本,下一次新诊断仍检查最新版;需要持续使用旧版时按配置变量关闭后台更新。新增客户端只登记,不隐式更新。
64
64
 
65
- 用户要求自动更新时,通过当前客户端或系统调度同一稳定入口的 `update`,告知是否设置成功;普通诊断不自动检查或更新。版本切换后按客户端要求刷新技能或新开聊天。
65
+ 使用时的后台更新由 CLI 完成,无需 Agent 设置定时任务或另跑 `update`。通知读取、去重和提示按 [安装副本的助手规则](content/AGENTS.md#安装副本的助手规则)执行,不轮询等待。
66
+
67
+ 旧版用户需先手动升级一次才能获得后台更新能力。版本切换后按客户端要求刷新技能或新开聊天。
66
68
 
67
69
  若旧试用版提示需要迁移,先用当前 npm 包替换全局程序,直接调用新包 CLI 的 `update`,再登记与检查。迁移保留客户端登记、诊断材料和独立 MCP 配置;旧试用版不作为回退目标。
68
70
 
@@ -83,5 +85,6 @@ CLI 使用首次安装记录的 Node/npm 和 prefix,在无网络模式下清
83
85
  | `FX_DATA_DIAGNOSTICS_HOME` | 自定义共享安装目录 |
84
86
  | `FX_DATA_DIAGNOSTICS_PYTHON` | 自定义 Python 可执行文件 |
85
87
  | `FX_DATA_DIAGNOSTICS_AGENT_HOME` | 自定义客户端用户目录,通常无需设置 |
88
+ | `FX_DATA_DIAGNOSTICS_AUTO_UPDATE` | 默认开启后台更新;设为 `0` 可关闭,适用于受限环境或需要持续使用旧版时 |
86
89
 
87
90
  显式 CLI 参数优先,其次是环境变量,未指定时使用默认目录或自动发现的运行环境。
package/README.md CHANGED
@@ -22,13 +22,13 @@
22
22
 
23
23
  ## 更新
24
24
 
25
- > 更新 FX 数据诊断。
25
+ 每次开始新诊断时,工具会在后台检查更新,不等待更新即可排查。更新成功后,各 Agent 的下一次新诊断使用新版;当前调查保持原版本,更新失败也不影响排查。Agent 会随后续使用提示更新状态,没有新版时不打扰你。
26
26
 
27
- 已启用的 Agent 共用更新后的版本。需要自动更新时,可以说:
27
+ 需要立即更新并等待完成时说:
28
28
 
29
- > 帮我设置每天上午 9 点(北京时间)检查并更新 FX 数据诊断的任务,失败时通知我。
29
+ > 更新 FX 数据诊断。
30
30
 
31
- 能否设置定时任务取决于当前Agent。若新版有问题,可以说“把 FX 数据诊断回退到这次更新前的版本”。
31
+ 旧版用户先手动更新一次,即可获得后台更新能力。若新版有问题,可以说“把 FX 数据诊断回退到这次更新前的版本”;需要持续使用旧版时,同时让 Agent 关闭后台更新。
32
32
 
33
33
  ## 卸载
34
34
 
@@ -38,4 +38,4 @@
38
38
 
39
39
  ---
40
40
 
41
- **给 Agent:** 按 [Agent 执行说明](https://unpkg.com/@fx-data/ai-diagnostics@0.1.20261026070913/AGENT_SETUP.md) 完成安装与管理。已有本地包时,优先读取包内 `AGENT_SETUP.md`;本地 `.tgz` 只需读取 `package/AGENT_SETUP.md`。按命令结果简短反馈,成功即结束;只有失败或结果不完整时才排错。
41
+ **给 Agent:** 按 [Agent 执行说明](https://unpkg.com/@fx-data/ai-diagnostics@0.1.20261027064707/AGENT_SETUP.md) 完成安装与管理。已有本地包时,优先读取包内 `AGENT_SETUP.md`;本地 `.tgz` 只需读取 `package/AGENT_SETUP.md`。按命令结果简短反馈,成功即结束;只有失败或结果不完整时才排错。
package/bin/core-ai.mjs CHANGED
@@ -4,7 +4,7 @@ import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { createHash, randomUUID } from 'node:crypto';
7
- import { spawnSync } from 'node:child_process';
7
+ import { spawn, spawnSync } from 'node:child_process';
8
8
  import { fileURLToPath, pathToFileURL } from 'node:url';
9
9
 
10
10
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -29,7 +29,7 @@ function argumentsFor(argv) {
29
29
  if (args[i] === '--activate-package') {
30
30
  if (command !== 'update') throw new Error('--activate-package is reserved for the update subprocess');
31
31
  options.activatePackage = true;
32
- } else if (['--home', '--agent-home', '--agent', '--skills-dir', '--registry', '--version'].includes(args[i])) {
32
+ } else if (['--home', '--agent-home', '--agent', '--skills-dir', '--registry', '--version', '--background-job'].includes(args[i])) {
33
33
  if (!args[i + 1] || args[i + 1].startsWith('--')) throw new Error(`Missing value for ${args[i]}`);
34
34
  options[args[i].slice(2)] = args[++i];
35
35
  } else rest.push(args[i]);
@@ -183,8 +183,12 @@ ${powershell} context
183
183
 
184
184
  后续管理命令使用同一组 Node 和稳定脚本路径,将 context 替换为对应子命令,不依赖 PATH 中的全局 CLI 版本。
185
185
 
186
+ 每次新诊断只调用一次稳定入口 context:立即取得并固定本次版本,同时后台检查更新,不等待网络。同一调查继续复用首次结果,不重复调用稳定入口 context;需要重新读取固定路径时使用 pinned_cli context,不触发新检查。
187
+
186
188
  context 返回当前版本、内容路径和 agent_rules。读取 agent_rules,按问题只读取所需 Skill 和引用,不预加载全部知识,也不切换用户工作区。包内仓库相对路径以 content_root 为基准,文档链接相对引用文件解析。
187
189
 
190
+ context、status、session 的 auto_update.notification,以及 python/metric 标准错误中 fx_data_diagnostics.auto_update.notification,是更新提示。有提示时按 message 简短告知用户;无新版不打扰。不为获取状态额外调用 CLI 或轮询;本轮没有后续 CLI 调用时,下次使用再补报。更新成功只影响下次新诊断,失败继续使用本次版本。
191
+
188
192
  当前调查固定使用首次 context 返回的 node、pinned_cli 和内容版本。脚本通过 <node> <pinned_cli> python <脚本绝对路径> ... 执行;Windows PowerShell 同样在带引号的 Node 路径前加 &。Metric 调用先读取 analyze-engine-metric 的输入表:纯文本用 metric --metric;SigNoZ JSON 用 metric --metric-log-json <文件> --task-name <选定 task>。通过 session 获取临时目录,日志和产物不写入安装内容。只有解析脚本需要 Python;doctor 的 python.available=false 表示按需补齐,不代表安装失败。已有 MCP 直接复用;实时查询受阻时才读取 setup-signoz-mcp。
189
193
 
190
194
  用户要求更新时执行稳定入口 update,默认使用 latest;要求回退到上次更新前时执行 rollback,也可用 --version 指定版本。已保留版本校验通过后直接复用。各客户端的新调查共用切换后的版本,进行中调查继续使用原 pinned_cli。更新后按客户端要求刷新技能或新开聊天;安装副本不做 Git 更新。
@@ -362,6 +366,7 @@ function install(home, options, { sourceRoot = packageRoot, action = 'install' }
362
366
  const { pkg, manifest } = verifyBundle(sourceRoot, { allowMacMetadata: isInstalledBundle(sourceRoot, home) });
363
367
  noSymlinkAncestors(home);
364
368
  const prior = ownedState(home, false);
369
+ if (options['background-job']) assertBackgroundJob(home, options['background-job'], pkg.version);
365
370
  if (!prior && fs.existsSync(home) && fs.readdirSync(home).length) throw new Error('Refusing to take over a nonempty installation directory');
366
371
  const ownership = prior?.ownership || { schema_version: 1, home, install_id: randomUUID() };
367
372
  const registrationOnly = Boolean(action === 'install' && prior?.state?.lifecycle_protocol === lifecycleProtocol);
@@ -387,9 +392,13 @@ function install(home, options, { sourceRoot = packageRoot, action = 'install' }
387
392
  if (!prior) atomicWrite(path.join(home, homeMarker), JSON.stringify(ownership));
388
393
  const releaseLock = acquireInstallLock(home);
389
394
  let backup = [];
395
+ const launcherBackup = [];
390
396
  const release = path.join(home, 'releases', pkg.version);
391
397
  const stage = `${release}.${randomUUID()}.tmp`;
392
398
  try {
399
+ // A download may outlive its worker or a manual version change. Recheck at
400
+ // activation, under the installation lock, before touching shared entries.
401
+ if (options['background-job']) assertBackgroundJob(home, options['background-job'], pkg.version);
393
402
  backup = writeDirectories.map(target => ({ ...target, existed: fs.existsSync(target.directory), text: fs.existsSync(path.join(target.directory, 'SKILL.md')) ? fs.readFileSync(path.join(target.directory, 'SKILL.md')) : null, owner: fs.existsSync(path.join(target.directory, ownerFile)) ? fs.readFileSync(path.join(target.directory, ownerFile)) : null }));
394
403
  if (fs.existsSync(release)) {
395
404
  verifyBundle(release, { allowMacMetadata: Boolean(prior?.state) });
@@ -412,7 +421,12 @@ function install(home, options, { sourceRoot = packageRoot, action = 'install' }
412
421
  [path.join(home, 'bin', commandName), `#!/bin/sh\nexec ${quote(process.execPath)} ${quote(launchers(home).script)} "$@"\n`],
413
422
  [path.join(home, 'bin', `${commandName}.cmd`), `@echo off\r\n"${process.execPath}" "%~dp0core-ai.mjs" %*\r\n`],
414
423
  ]) {
415
- if (!registrationOnly || !fs.existsSync(file)) atomicWrite(file, text, 0o755);
424
+ if (!registrationOnly || !fs.existsSync(file)) {
425
+ const existed = fs.existsSync(file);
426
+ launcherBackup.push({ file, existed, text: existed ? fs.readFileSync(file) : null,
427
+ mode: existed ? fs.statSync(file).mode & 0o777 : 0o755 });
428
+ atomicWrite(file, text, 0o755);
429
+ }
416
430
  }
417
431
  const registrationReady = performance.now();
418
432
  const checks = { integrity: { status: 'ok' }, skills: skillChecks(targets, home, ownership.install_id), context: { status: 'ok' } };
@@ -430,6 +444,9 @@ function install(home, options, { sourceRoot = packageRoot, action = 'install' }
430
444
  const npmInstallation = prior?.state ? prior.state.npm_installation : detectNpmInstallation(sourceRoot);
431
445
  const state = { schema_version: 1, lifecycle_protocol: lifecycleProtocol, release_protocols: releaseProtocols, install_id: ownership.install_id, package: pkg.name, version: pkg.version, previous_version: prior?.state?.version === pkg.version ? prior.state.previous_version : prior?.state?.version || null, registry, skills: targets, source_commit: manifest.source_commit,
432
446
  ...(npmInstallation ? { npm_installation: npmInstallation } : {}) };
447
+ // Copying and validation can outlast the background deadline. The last
448
+ // check belongs immediately before committing the active version too.
449
+ if (options['background-job']) assertBackgroundJob(home, options['background-job'], pkg.version);
433
450
  if (JSON.stringify(state) !== JSON.stringify(prior?.state)) atomicWrite(path.join(home, 'state.json'), JSON.stringify(state, null, 2));
434
451
  const finished = performance.now();
435
452
  const elapsed = (from, to) => Math.round((to - from) * 100) / 100;
@@ -440,6 +457,10 @@ function install(home, options, { sourceRoot = packageRoot, action = 'install' }
440
457
  agent_hint: '安装登记与文件检查已完成。按返回结果用 2–3 句话反馈版本、首次安装或复用、需要的刷新及使用提示后结束;不再运行 status/ls/doctor/context 或读取源码、客户端缓存。本工具不要求安装清单或长期记忆,仍遵循客户端强制规则。客户端发现未验证时只提示刷新或新开聊天。' } : {}),
441
458
  refresh: 'Open a new Agent conversation if the skill has not refreshed.' });
442
459
  } catch (error) {
460
+ for (const item of launcherBackup) {
461
+ if (item.existed) atomicWrite(item.file, item.text, item.mode);
462
+ else fs.rmSync(item.file, { force: true });
463
+ }
443
464
  for (const item of backup) {
444
465
  if (item.existed) { atomicWrite(path.join(item.directory, 'SKILL.md'), item.text, 0o644); atomicWrite(path.join(item.directory, ownerFile), item.owner); }
445
466
  else if (fs.existsSync(item.directory)) fs.rmSync(item.directory, { recursive: true });
@@ -565,11 +586,187 @@ function update(home, options, action = 'update') {
565
586
  for (const key of Object.keys(env)) if (key.toLowerCase() === 'path') delete env[key];
566
587
  env.PATH = [path.dirname(process.execPath), inheritedPath].filter(Boolean).join(path.delimiter);
567
588
  Object.assign(env, { FX_DATA_DIAGNOSTICS_HOME: home, NPM_CONFIG_USERCONFIG: path.join(npmHome, 'user.npmrc'), NPM_CONFIG_GLOBALCONFIG: path.join(npmHome, 'global.npmrc'), NPM_CONFIG_CACHE: path.join(npmHome, 'cache'), NPM_CONFIG_REGISTRY: registry, NPM_CONFIG_UPDATE_NOTIFIER: 'false' });
568
- const result = spawnSync(process.execPath, [npmExecutable(), 'exec', '--yes', '--registry', registry, '--package', `${state.package}@${version}`, '--', commandName, 'update', '--activate-package', '--home', home, '--registry', registry], { cwd: npmHome, env, stdio: 'inherit' });
589
+ const background = options['background-job'];
590
+ if (background) assertBackgroundJob(home, background, version);
591
+ const result = spawnSync(process.execPath, [npmExecutable(), 'exec', '--yes', '--registry', registry,
592
+ ...(background ? ['--fetch-retries=0', '--fetch-timeout=15000', '--ignore-scripts'] : []),
593
+ '--package', `${state.package}@${version}`, '--', commandName, 'update', '--activate-package', '--home', home, '--registry', registry,
594
+ ...(background ? ['--background-job', background] : [])], { cwd: npmHome, env, stdio: 'inherit' });
569
595
  if (result.error) throw result.error;
570
596
  process.exitCode = result.status ?? 1;
571
597
  }
572
598
 
599
+ // Automatic updates belong to the shared installation, not to an Agent. Only
600
+ // a new diagnostic entry starts a worker; pinned scripts merely carry notices.
601
+ const autoUpdateFile = 'auto-update.json';
602
+ const autoUpdateLock = '.auto-update.lock';
603
+ const autoUpdateNotice = 'auto-update-notice.json';
604
+ const autoUpdateTimeout = 60_000;
605
+ const autoUpdateEnabled = () => process.env.FX_DATA_DIAGNOSTICS_AUTO_UPDATE !== '0';
606
+ const readOptional = file => { try { return json(file); } catch { return null; } };
607
+
608
+ function deadProcess(pid) {
609
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
610
+ try { process.kill(pid, 0); } catch (error) { return error.code === 'ESRCH'; }
611
+ return false;
612
+ }
613
+
614
+ function newerVersion(candidate, current) {
615
+ if (!versionPattern.test(candidate) || !versionPattern.test(current)) return false;
616
+ const split = value => { const [core, ...pre] = value.split('-'); return { core: core.split('.').map(BigInt), pre: pre.join('-') }; };
617
+ const next = split(candidate), old = split(current);
618
+ for (let i = 0; i < 3; i++) if (next.core[i] !== old.core[i]) return next.core[i] > old.core[i];
619
+ if (!next.pre || !old.pre) return Boolean(old.pre && !next.pre);
620
+ const left = next.pre.split('.'), right = old.pre.split('.');
621
+ for (let i = 0; i < Math.max(left.length, right.length); i++) {
622
+ if (left[i] === undefined || right[i] === undefined) return right[i] === undefined;
623
+ if (left[i] === right[i]) continue;
624
+ const numericLeft = /^\d+$/.test(left[i]), numericRight = /^\d+$/.test(right[i]);
625
+ if (numericLeft && numericRight) return BigInt(left[i]) > BigInt(right[i]);
626
+ return numericLeft !== numericRight ? !numericLeft : left[i] > right[i];
627
+ }
628
+ return false;
629
+ }
630
+
631
+ function updateJobOwned(home, id) {
632
+ const lock = readOptional(path.join(home, autoUpdateLock));
633
+ return lock?.id === id && lock.hostname === os.hostname();
634
+ }
635
+
636
+ function releaseUpdateJob(home, id) {
637
+ try {
638
+ if (updateJobOwned(home, id)) fs.rmSync(path.join(home, autoUpdateLock), { force: true });
639
+ } catch { /* Optional background bookkeeping cannot fail a diagnostic call. */ }
640
+ }
641
+
642
+ function saveUpdateJob(home, job, status) {
643
+ if (!updateJobOwned(home, job.id) || ownedState(home, false)?.state?.install_id !== job.install_id) return;
644
+ job.status = status;
645
+ atomicWrite(path.join(home, autoUpdateFile), JSON.stringify(job));
646
+ const message = status === 'updating'
647
+ ? `发现 FX 数据诊断新版 ${job.target_version},正在后台更新。本次分析继续使用已固定版本,更新成功后下次诊断生效。`
648
+ : status === 'updated'
649
+ ? `FX 数据诊断已后台更新至 ${job.target_version},供下次新诊断使用;进行中的分析仍使用各自已固定的版本。`
650
+ : status === 'failed'
651
+ ? 'FX 数据诊断后台检查或更新未完成,继续使用当前可用版本,不影响本次排查。'
652
+ : null;
653
+ if (message) atomicWrite(path.join(home, autoUpdateNotice), JSON.stringify({ id: `${job.id}:${status}`, install_id: job.install_id, status, message }));
654
+ }
655
+
656
+ function autoUpdateDetails(home) {
657
+ if (!autoUpdateEnabled()) return null;
658
+ try {
659
+ const state = ownedState(home, false)?.state;
660
+ if (!state) return null;
661
+ const job = readOptional(path.join(home, autoUpdateFile));
662
+ const details = job?.install_id === state.install_id
663
+ ? { status: job.status, ...(job.target_version ? { target_version: job.target_version } : {}) } : null;
664
+ // Claim one notice without deleting a newer notice written concurrently.
665
+ // Other clients may receive the next state; the worker never writes to a
666
+ // parent's terminal and diagnostic/script stdout keeps its original shape.
667
+ const claimed = path.join(home, `${autoUpdateNotice}.${randomUUID()}.read`);
668
+ try {
669
+ fs.renameSync(path.join(home, autoUpdateNotice), claimed);
670
+ const notice = readOptional(claimed);
671
+ if (notice?.install_id === state.install_id) return { ...details, notification: { id: notice.id, status: notice.status, message: notice.message } };
672
+ } catch { /* No notice, or another CLI already delivered it. */ }
673
+ finally { fs.rmSync(claimed, { force: true }); }
674
+ return details;
675
+ } catch { return null; } // Update bookkeeping must never block diagnosis.
676
+ }
677
+
678
+ function assertBackgroundJob(home, id, version) {
679
+ const job = readOptional(path.join(home, autoUpdateFile));
680
+ const state = ownedState(home, false)?.state;
681
+ if (!updateJobOwned(home, id) || job?.id !== id || job.status !== 'updating'
682
+ || Date.now() >= job.expires_at || deadProcess(job.pid)
683
+ || !state || state.install_id !== job.install_id || state.version !== job.base_version
684
+ || version !== job.target_version) throw new Error('Background update is no longer applicable; keeping the current installation');
685
+ }
686
+
687
+ function startAutoUpdate(home) {
688
+ if (!autoUpdateEnabled()) return;
689
+ let job;
690
+ try {
691
+ const { state } = ownedState(home);
692
+ if (!state) return;
693
+ const lockPath = path.join(home, autoUpdateLock);
694
+ const existing = readOptional(lockPath);
695
+ if (fs.existsSync(lockPath)) {
696
+ // Age alone does not permit stealing a live worker's lock. Failed jobs
697
+ // have no cooldown; after a dead worker the next diagnosis can try again.
698
+ if (existing?.hostname !== os.hostname() || !deadProcess(existing.pid)) return;
699
+ if (readOptional(lockPath)?.id !== existing.id) return;
700
+ fs.unlinkSync(lockPath);
701
+ }
702
+ job = { id: randomUUID(), install_id: state.install_id, base_version: state.version,
703
+ pid: process.pid, hostname: os.hostname(), status: 'checking', expires_at: Date.now() + autoUpdateTimeout };
704
+ const fd = fs.openSync(lockPath, 'wx', 0o600);
705
+ try { fs.writeFileSync(fd, JSON.stringify(job)); } finally { fs.closeSync(fd); }
706
+ saveUpdateJob(home, job, 'checking');
707
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(NPM_TOKEN|NODE_AUTH_TOKEN)$/i.test(key)));
708
+ const child = spawn(process.execPath, [path.join(home, 'releases', state.version, 'bin/core-ai.mjs'), '__background-update', job.id, '--home', home],
709
+ { cwd: home, env, detached: true, stdio: 'ignore', windowsHide: true });
710
+ child.on('error', () => {
711
+ try { saveUpdateJob(home, job, 'failed'); } catch { /* Diagnosis still works. */ }
712
+ finally { releaseUpdateJob(home, job.id); }
713
+ });
714
+ if (child.pid) {
715
+ // Hand the live lock to the worker before the launching CLI exits.
716
+ job.pid = child.pid;
717
+ atomicWrite(lockPath, JSON.stringify(job));
718
+ }
719
+ child.unref();
720
+ } catch (error) {
721
+ if (job && updateJobOwned(home, job.id)) {
722
+ try { saveUpdateJob(home, job, 'failed'); } catch { /* Best effort only. */ }
723
+ finally { releaseUpdateJob(home, job.id); }
724
+ }
725
+ }
726
+ }
727
+
728
+ async function backgroundUpdate(home, id) {
729
+ const job = readOptional(path.join(home, autoUpdateFile));
730
+ if (job?.id !== id || !updateJobOwned(home, id)) return;
731
+ job.pid = process.pid;
732
+ try {
733
+ const state = ownedState(home).state;
734
+ if (!state || state.install_id !== job.install_id || state.version !== job.base_version) return;
735
+ saveUpdateJob(home, job, 'checking');
736
+ const registry = registryOrigin(state.registry);
737
+ const response = await fetch(`${registry}/${encodeURIComponent(state.package)}/latest`,
738
+ { signal: AbortSignal.timeout(3_000), redirect: 'error', headers: { Accept: 'application/json', 'Cache-Control': 'no-cache' } });
739
+ if (!response.ok) throw new Error('Version check failed');
740
+ const latest = await response.json();
741
+ if (latest.name !== state.package || typeof latest.version !== 'string' || !versionPattern.test(latest.version)) throw new Error('Invalid latest version');
742
+ if (!newerVersion(latest.version, state.version)) { saveUpdateJob(home, job, 'current'); return; }
743
+ job.target_version = latest.version;
744
+ saveUpdateJob(home, job, 'updating');
745
+ assertBackgroundJob(home, id, latest.version);
746
+ // Reuse the explicit-version updater in an isolated child. Its output must
747
+ // not keep the initiating Agent tool open or mix with diagnostic results.
748
+ await new Promise((resolve, reject) => {
749
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), 'update', '--version', latest.version,
750
+ '--home', home, '--background-job', id], { cwd: home, env: process.env, stdio: 'ignore', windowsHide: true });
751
+ const timer = setTimeout(() => {
752
+ // Invalidate activation before stopping the child: npm may have its own
753
+ // subprocess, which must not activate a release after this deadline.
754
+ try { saveUpdateJob(home, job, 'failed'); } catch { /* Best effort. */ }
755
+ child.kill('SIGKILL');
756
+ reject(new Error('Background update timed out'));
757
+ }, Math.max(1, job.expires_at - Date.now()));
758
+ child.once('error', error => { clearTimeout(timer); reject(error); });
759
+ child.once('exit', code => { clearTimeout(timer); code === 0 ? resolve() : reject(new Error('Background update failed')); });
760
+ });
761
+ if (ownedState(home, false)?.state?.version !== latest.version) throw new Error('Version activation was not confirmed');
762
+ saveUpdateJob(home, job, 'updated');
763
+ } catch {
764
+ try { if (job.status !== 'failed') saveUpdateJob(home, job, 'failed'); } catch { /* Never disturb the active release. */ }
765
+ } finally {
766
+ releaseUpdateJob(home, id);
767
+ }
768
+ }
769
+
573
770
  function python() {
574
771
  const configured = process.env.FX_DATA_DIAGNOSTICS_PYTHON;
575
772
  const candidates = configured ? [[configured]] : process.platform === 'win32' ? [['py', '-3'], ['python'], ['python3']] : [['python3'], ['python']];
@@ -595,7 +792,7 @@ function uninstall(home) {
595
792
  // Remove only owned program files. Configuration, npm cache and evidence are retained.
596
793
  // Keep the stable command and state until version cleanup completes, so a
597
794
  // client-denied recursive deletion can be confirmed and retried normally.
598
- for (const name of ['releases', 'bin', 'state.json']) fs.rmSync(path.join(home, name), { recursive: true, force: true });
795
+ for (const name of [autoUpdateLock, autoUpdateFile, autoUpdateNotice, 'releases', 'bin', 'state.json']) fs.rmSync(path.join(home, name), { recursive: true, force: true });
599
796
  const finished = performance.now();
600
797
  const elapsed = (from, to) => Math.round((to - from) * 100) / 100;
601
798
  emit({ ok: true, action: 'uninstall', complete: npmPackage.status !== 'unconfirmed', home,
@@ -637,6 +834,8 @@ async function main() {
637
834
  if (help) return emit(help);
638
835
  const { command, options, rest } = argumentsFor(process.argv.slice(2));
639
836
  const home = normalizedPath(options.home || process.env.FX_DATA_DIAGNOSTICS_HOME || installedBundleHome(packageRoot) || defaultHome());
837
+ if (command === '__background-update') return backgroundUpdate(home, rest[0]);
838
+ if (options['background-job'] && command !== 'update') throw new Error('--background-job is reserved for background updates');
640
839
  let current;
641
840
  try { current = ownedState(home, false); }
642
841
  catch (error) { if (command === 'doctor') return doctor(home); throw error; }
@@ -676,14 +875,31 @@ async function main() {
676
875
  if (command === 'uninstall') return uninstall(home);
677
876
  const { pkg, manifest, content } = verifyBundle(packageRoot, { allowMacMetadata: isInstalledBundle(packageRoot, home) });
678
877
  if (command === '--version' || command === 'version') return emit({ version: pkg.version, package: pkg.name });
679
- if (command === 'status') return emit({ ok: true, installed: Boolean(current?.state), home, ...current?.state, lifecycle_protocol: current?.state?.lifecycle_protocol ?? null, migration_required: migrationRequired, launcher: current?.state ? launchers(home).executable : null, launcher_script: current?.state ? launchers(home).script : null, node: process.execPath });
680
- if (command === 'context') return emit(contextFor(packageRoot, home, pkg, manifest));
681
- if (command === 'session') return emit({ directory: fs.mkdtempSync(path.join(os.tmpdir(), 'core-ai-')) });
878
+ if (command === 'status') {
879
+ const auto = autoUpdateDetails(home);
880
+ return emit({ ok: true, installed: Boolean(current?.state), home, ...current?.state, lifecycle_protocol: current?.state?.lifecycle_protocol ?? null, migration_required: migrationRequired, launcher: current?.state ? launchers(home).executable : null, launcher_script: current?.state ? launchers(home).script : null, node: process.execPath, ...(auto ? { auto_update: auto } : {}) });
881
+ }
882
+ if (command === 'context') {
883
+ // Capture paths before starting the worker. Direct calls to a pinned release
884
+ // are continuations, even if that release is still the current one.
885
+ const context = contextFor(packageRoot, home, pkg, manifest);
886
+ const auto = autoUpdateDetails(home);
887
+ const directPinned = isInstalledBundle(packageRoot, home)
888
+ && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
889
+ if (current?.state && !directPinned) startAutoUpdate(home);
890
+ return emit({ ...context, ...(auto ? { auto_update: auto } : {}) });
891
+ }
892
+ if (command === 'session') {
893
+ const auto = autoUpdateDetails(home);
894
+ return emit({ directory: fs.mkdtempSync(path.join(os.tmpdir(), 'core-ai-')), ...(auto ? { auto_update: auto } : {}) });
895
+ }
682
896
  if (command === 'metric' || command === 'python') {
683
897
  const runtime = python();
684
898
  if (!runtime.available) throw new Error(runtime.requirement);
685
899
  const args = command === 'metric' ? [path.join(content, '.agents/skills/analyze-engine-metric/scripts/analyze_engine_metric.py'), ...rest] : rest;
686
900
  if (!args.length) throw new Error('A script or Python option is required');
901
+ const auto = autoUpdateDetails(home);
902
+ if (auto?.notification) process.stderr.write(`${JSON.stringify({ fx_data_diagnostics: { auto_update: auto } })}\n`);
687
903
  const result = spawnSync(runtime.executable, ['-I', '-B', ...args], { stdio: 'inherit' });
688
904
  if (result.error) throw result.error;
689
905
  process.exitCode = result.status ?? 1;
package/content/AGENTS.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  只有本项目根目录存在 `.git` 文件或目录时,才按源码仓库方式更新:每次新对话首次使用时,在该根目录运行 `git pull --ff-only`;同一对话不重复更新。保留本地改动,不切换分支。无法安全更新或联网失败时,简短说明并继续原任务。
8
8
 
9
- 通过 CLI 安装的目录没有 `.git`,不得在它或使用者的其他项目执行 Git 更新。全局 Skill 通过 `context` 返回内容目录时,遵循 [npm 安装副本规则](#安装副本的助手规则)。首次使用时读取对应说明,本轮已读则复用。升级由使用者明确发起,不在诊断中途更换版本。
9
+ 通过 CLI 安装的目录没有 `.git`,不得在它或使用者的其他项目执行 Git 更新。全局 Skill 通过 `context` 返回内容目录时,遵循 [npm 安装副本规则](#安装副本的助手规则)。首次使用时读取对应说明,本轮已读则复用。新诊断由 CLI 后台检查更新,当前调查始终使用首次返回的版本。
10
10
 
11
11
  ### 安装副本的助手规则
12
12
 
@@ -14,6 +14,8 @@
14
14
 
15
15
  - 安装副本没有 `.git`,跳过 Git 更新检查,不更新用户的其他项目。只读取当前问题需要的 Skill 及引用,不将交付方案全文作为诊断前置知识。
16
16
  - 本次调查固定使用首次 `context` 返回的版本、`content_root`、`agent_rules`、`skills_root`、`node` 和 `pinned_cli`;脚本通过 `<node> <pinned_cli> python <脚本路径> ...` 执行。仓库相对路径以 `content_root` 为基准,文档链接相对引用文件解析。
17
+ - 每次新诊断只调用一次稳定入口的 `context`;CLI 在后台检查更新,不等待网络或下载,同一调查继续复用已返回的版本。
18
+ - 更新通知由 CLI 返回:`context`、`status`、`session` 的 JSON 中查看可选的 `auto_update.notification`;`python`、`metric` 的 stderr 可带独立 JSON,其中为 `fx_data_diagnostics.auto_update.notification`,stdout 仍是原脚本结果。通知包含 `id`、`status`、`message`,按 `message` 简短告知,按 `id` 避免重复提示;当前调查保持固定版本,新版供下次新诊断使用。没有通知不提示,不为获取更新结果额外轮询;本轮没有后续 CLI 调用时,下次使用再补报。
17
19
  - 用 `session` 获取临时目录。Metric 纯正文使用 `metric --metric`;SigNoZ JSON 响应使用 `metric --metric-log-json ... --task-name ...`,不可混用。
18
20
  - 已有可用 MCP 直接复用;实时查询受阻时读取 [MCP 配置 Skill](.agents/skills/setup-signoz-mcp/SKILL.md),适配当前客户端。凭据保存在本机配置,不写入能力包或诊断报告。
19
21
  - 用户要求更新、回退、卸载或检查时使用稳定入口的 `update`、`rollback`、`uninstall`、`doctor`。版本切换后按客户端要求刷新技能或新开聊天;当前调查不切换版本。
package/content/README.md CHANGED
@@ -18,6 +18,8 @@
18
18
 
19
19
  交付方式见 [FX 数据诊断交付方案](docs/npm-cli.md):用户说“帮我安装并启用 FX 数据诊断,npm 包是 `@fx-data/ai-diagnostics`”,Agent 从包内 README 的 Agent 入口读取安装说明,优先复用已有共享安装,再登记当前客户端的薄 Skill。CLI 使用 Node.js/npm,Python 解析环境按需准备。
20
20
 
21
+ npm 安装副本在每次新诊断时后台检查更新,当前排查不等待;更新成功后下次新诊断生效,失败继续使用旧版,见[版本管理](docs/npm-cli.md#34-版本管理)。
22
+
21
23
  1. 使用源码目录时,用支持 `AGENTS.md` 的 Agent 客户端(如 Codex、Claude Code)打开本仓库,更新规则见 [助手执行契约](AGENTS.md#使用前更新代码);npm 安装副本按交付方案管理版本。
22
24
  2. 诊断需要查询 SigNoZ 的 trace、日志和引擎统计。当前客户端还没有 SigNoZ 连接时,助手会自动安装并配置,只需提供对应环境的 API Key。正式环境和测试环境的 Key 分别维护。
23
25
  3. 只有离线材料(日志、trace 导出、Metric 文件)时,无需配置连接,直接交给助手分析。
@@ -66,6 +66,7 @@
66
66
  | 非性能案例保留什么、反馈后怎样改进 | [非性能案例](diagnostic-feedback.md#非性能案例怎样保留)、[收到改进请求后](diagnostic-feedback.md#收到改进请求后怎样使用) |
67
67
  | Skill 与说明的写法 | [Skill 编写建议](skill-writing-guide.md) |
68
68
  | 交付方案选型、用户安装使用、版本更新与发布 | [FX 数据诊断交付方案](npm-cli.md) |
69
+ | 新诊断后台检查更新、失败保留旧版与更新状态提示 | [版本管理](npm-cli.md#34-版本管理)、[安装副本的助手规则](../AGENTS.md#安装副本的助手规则) |
69
70
  | npm 交付模块目录、构建与发布职责 | [构建打包](npm-cli.md#32-构建打包)、[发布分发](npm-cli.md#33-发布分发) |
70
71
  | npm 包内的用户使用说明 | 包内 README,由 `packages/ai-diagnostics/templates/README.md` 模板生成 |
71
72
  | npm 包的 Agent 安装与管理步骤、配置变量 | 包内 `AGENT_SETUP.md`,由 `packages/ai-diagnostics/templates/AGENT_SETUP.md` 模板生成 |
@@ -106,9 +106,13 @@ flowchart TD
106
106
 
107
107
  同一系统账户下,各 Agent 共用一份安装和当前版本。CLI 管理各版本副本,通过固定入口执行更新与回退。新版下载并校验成功后切换,失败保留原可用版本,旧版保留供回退。更新或回退后,各 Agent 的新调查统一使用切换后的版本,进行中的调查保持原版本,避免中途改变诊断规则或脚本。
108
108
 
109
- CLI 记录更新前的版本,用户要求回退时,Agent 根据记录确定目标版本,无需用户记忆版本号;也支持用户明确指定版本。
109
+ CLI 记录更新前的版本,用户要求回退时,Agent 根据记录确定目标版本,无需用户记忆版本号;也支持用户明确指定版本。回退只切换当前版本,不锁定后续版本;下一次新诊断仍检查最新版。需要持续使用旧版或在受限环境中禁止后台联网时,可设置 `FX_DATA_DIAGNOSTICS_AUTO_UPDATE=0` 关闭后台更新,默认开启。
110
110
 
111
- 默认由用户发起更新。用户开启自动更新后,Agent 负责设置定时任务,客户端或系统负责按时调度,CLI 执行更新;手动与自动更新使用同一个入口。取消任务后仍可手动更新。诊断材料独立保存,更新不影响材料,也不替换独立安装的 MCP 及其配置。
111
+ **每次开始新诊断时后台检查更新,不设固定检查周期。** 稳定入口的 `context` 立即返回并固定本次调查版本,由独立后台任务检查 npm 最新版本;有新版才下载并校验,成功后切换共享版本。当前调查不等待网络或下载,始终使用已返回的内容和脚本;网络、下载或校验失败时继续使用原可用版本。同一账户的各 Agent 共用后台任务,已有任务时复用,同一调查不重复发起检查;普通聊天和管理命令不触发检查。
112
+
113
+ 更新状态随后续 CLI 调用返回,由 Agent 简短提示“发现新版,后台更新,下次诊断生效”“更新完成”或“更新失败,继续使用当前版本”。没有新版不提示,不为等待更新结果增加调用或轮询;本轮没有后续 CLI 调用时,下次使用再补报。后台进程不直接向客户端聊天窗口发送消息。助手执行约定见 [npm 安装副本规则](../AGENTS.md#安装副本的助手规则)。
114
+
115
+ 用户仍可明确要求立即更新,由 Agent 调用 `update` 并等待结果。旧版安装需要先手动升级一次,才能获得使用时后台更新的能力。诊断材料独立保存,更新不影响材料,也不替换独立安装的 MCP 及其配置。
112
116
 
113
117
  ### 3.5 安装与加载
114
118
 
@@ -134,7 +138,7 @@ npm 安装取得 CLI 和完整诊断内容;随后 Agent 只需调用一次 `in
134
138
 
135
139
  ```text
136
140
  读取薄 Skill
137
- → 调用 CLI context 获取版本与内容路径
141
+ → 调用 CLI context 固定本次版本与内容路径,同时后台检查更新
138
142
  → 读取 AGENTS.md 和当前问题所需的 Skill、知识
139
143
  → 调用 SigNoZ MCP 查询,或调用包内脚本解析材料
140
144
  → 分析证据并回答
@@ -175,25 +179,19 @@ Agent 自动判断首次安装或复用已有安装,用户无需指定安装
175
179
 
176
180
  ### 4.3 版本管理
177
181
 
178
- 需要新版时说:
179
-
180
- > 更新 FX 数据诊断。
182
+ 开始新诊断时自动在后台检查更新,不耽误本次排查;Agent 会提示发现新版、更新结果和何时生效,没有新版不打扰用户。
181
183
 
182
- 希望每天自动更新时说:
184
+ 需要立即更新并等待完成时说:
183
185
 
184
- > 帮我设置一个每天上午 9 点(北京时间)检查并更新 FX 数据诊断到最新版的任务,更新失败时通知我。
185
-
186
- 停止自动更新时说:
187
-
188
- > 取消 FX 数据诊断的自动更新任务。
186
+ > 更新 FX 数据诊断。
189
187
 
190
188
  需要回退时说:
191
189
 
192
190
  > 把 FX 数据诊断回退到这次更新前的版本。
193
191
 
194
- 也可以明确指定要回退的版本号。
192
+ 也可以明确指定要回退的版本号。若需持续使用旧版,请同时让 Agent 关闭后台更新。
195
193
 
196
- 更新、回退和定时调度机制见[版本管理](#34-版本管理)。
194
+ 更新、回退和后台检查机制见[版本管理](#34-版本管理)。旧版用户先手动更新一次,即可获得后台更新能力。
197
195
 
198
196
  ### 4.4 卸载
199
197
 
@@ -16,7 +16,7 @@
16
16
 
17
17
  每条规则只有一个归属文件,统一登记在[文档目录](index.md)。各 Skill 保留触发条件和引用,经验用于提出业务假设,不另设取证门槛。
18
18
 
19
- 正式方向见 [FX 数据诊断交付方案](npm-cli.md):统一 npm 包与薄 Skill,优先采用公共 npm 分发,默认手动更新、自动更新由用户选择。当前实现已通过隔离安装生命周期测试及 Docker 构建演练;新版在真实客户端(包括 WorkBuddy)中的安装与使用、Windows 真机、空白环境初始化和公开发布仍待验收。早期 beta 的 macOS WorkBuddy 验证不代表当前实现已验收;CI 尚未接入。
19
+ 正式方向见 [FX 数据诊断交付方案](npm-cli.md):统一 npm 包与薄 Skill,优先采用公共 npm 分发;新诊断后台检查更新,当前调查保持版本,更新成功后下次生效,也保留手动更新。当前实现已通过隔离安装生命周期测试及 Docker 构建演练;新版在真实客户端(包括 WorkBuddy)中的安装与使用、Windows 真机、空白环境初始化和公开发布仍待验收。早期 beta 的 macOS WorkBuddy 验证不代表当前实现已验收;CI 尚未接入。
20
20
 
21
21
  ## 优先补齐什么
22
22
 
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "version": "0.1.20261026070913",
4
- "source_commit": "8910a4c7f834258ea712157af524f43a7f8a78e0",
3
+ "version": "0.1.20261027064707",
4
+ "source_commit": "1a2b56dabd80096d81ae5dde02034fd85adc33a2",
5
5
  "source_dirty": false,
6
- "cli_sha256": "f444bb65876bdada3c854295d2c476356b9fdfc9107800112275d0a81e6d7f58",
6
+ "cli_sha256": "06cd1dfa37a105db668d3a9228ec016fcce22f17bdce6c2611c8d8c4e1e44dc6",
7
7
  "files": {
8
8
  ".agents/skills/analyze-analysis-table-update-runtime/SKILL.md": "6f8b31592d5c4396aaa928c171f24b28ecd675f792c760e2d4aa5749e61a9750",
9
9
  ".agents/skills/analyze-analysis-table-update-runtime/agents/openai.yaml": "b4802f9423cdc96e911c91a6edbc45e268a5c98bb2a723b8d2776c7d2ee385d2",
@@ -35,14 +35,14 @@
35
35
  ".agents/skills/setup-signoz-mcp/references/windows-artifact.md": "1e05225befe619eaf2c0beccd392c626755abd3feae9c4867cec1da28b9f44ab",
36
36
  ".agents/skills/setup-signoz-mcp/scripts/install_signoz_mcp.ps1": "a50ff759df00aa45332aede4ebee55a1778d3ba4d48ed384c8ae08c68e5cd94e",
37
37
  ".agents/skills/setup-signoz-mcp/scripts/install_signoz_mcp.py": "ae54d74787ee80b55a1aaa84cf621857698ecf990dbf6f2b1a41379a9215abf7",
38
- "AGENTS.md": "6b00940c88cdf138f22741a6b26c0e211b54e5a76d4f06f07b139c885f7f1d0d",
39
- "README.md": "20c5e6506869fd7cf8331eb2cc9423eb6fc64c6650eccc3f02c3a34fc0805921",
38
+ "AGENTS.md": "b2cee2d651fd2819a24d8c23d9a3082362f8295c26e71562db0a0f47bf22c550",
39
+ "README.md": "9c5544db33389f1d610424aa6175163bcaa6b0e08887b6d68016d8cc52e8ca1a",
40
40
  "docs/architecture.md": "d4e8d4366c508b0feababff82cf323cdc9b38a11bdbfb721e96e0cc085d8325e",
41
41
  "docs/diagnostic-feedback.md": "2d117bb4d748a89d057ed0d2ba72c283cdebbd3b734e5e7b57341b9d02aeaaf0",
42
42
  "docs/diagnostic-report.md": "5ec89d3e5fdec81dfec02671875a19058e22ebd34d6d4fb9d5ab240fc459b691",
43
- "docs/index.md": "ccb2c68cacaa08940d2de5b9a59e6f49eb09f3f4e44423f6c84ed7e1ac23be76",
44
- "docs/npm-cli.md": "6dcf794ff1500e2bc931f83e3e300d73047035809a7ab391f1120147b3af70b4",
45
- "docs/roadmap.md": "11ef0894fc3ebc076fd5266364416abbe5a1c37213ffa6336126725209e727ce",
43
+ "docs/index.md": "7f8ef0a238cb7bc817f57ceb67eca4f13baa95360fb1be355017c5aaa594122f",
44
+ "docs/npm-cli.md": "5e6e8db38d68b092065c5cff8f9bb13eed685dbb0f69c8f742c01596ebccb1a6",
45
+ "docs/roadmap.md": "ceea629a6cd037ebb3d12c46830994b4cb832f06c459b9a855832186fc373d32",
46
46
  "docs/scenario-guide.md": "db3174d7d3cb07f4a9302c59f0f238e528cdde7fa96f5ead466b7f8aa3f4418a",
47
47
  "docs/skill-writing-guide.md": "600e1b20e55423fe44a1f66db82fa0b3175e7e5d618590d75678846fc7180e9d",
48
48
  "knowledge/common/knowledge-applicability.md": "f80d8c31a94b9b92f4bc98d6c5042033d63d795c6af4c90ea72d151588a536ae",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fx-data/ai-diagnostics",
3
- "version": "0.1.20261026070913",
3
+ "version": "0.1.20261027064707",
4
4
  "description": "FX数据诊断:Agent 诊断技能、知识与本地解析工具",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",