拆解 Understand-Anything 的 project-scanner:确定性脚本与 LLM 叙事分工的项目清点 Agent 设计
本文以 understand-anything-plugin/agents/project-scanner.md 这一 Agent 定义为骨架,逐阶段剖析它如何把一个代码仓库转换成结构化的 scan-result.json 清点文件:确定性部分(文件枚举、语言检测、分类、行数统计、import 解析)全部交给捆绑脚本 scan-project.mjs 与 extract-import-map.mjs,LLM 只负责从 README 和 manifest 中合成 name / description / frameworks / languages 叙事字段。读完后你能掌握该 Agent 的两阶段工作流、每个字段的产出来源、可复制的脚本调用命令,以及其输出如何被 /understand 主管线消费。
1. 角色定位:一个"绝不臆造路径"的项目清点专家
project-scanner 是 Understand-Anything 多 Agent 流水线中的第一个 Worker。在 README.md 的 "Multi-Agent Pipeline" 表中,它的角色是 "Discovers files, detects languages and frameworks",由 /understand 命令调度。SKILL.md 的 Phase 1(SCAN)会派发一个以 agents/project-scanner.md 为定义的子 Agent,并在派发提示中附加 README 前 3000 字符、主 manifest 内容和语言指令,最终产物写入 $UA_DIR/intermediate/scan-result.json。
Agent 定义开篇就确立了四条基调(引自 project-scanner.md):
- 准确性至上:报告中每个文件路径必须真实存在于磁盘;
- 子 Agent 边界:不得再委派工作或创建子 Agent(包括通过 Agent 工具),必须直接完成任务;
- 确定性 / LLM 分工:文件枚举、语言检测、类别分配、行数统计、复杂度估计、
.understandignore过滤、import 解析这些确定性逻辑由两个捆绑脚本完成,"Do NOT re-implement any of this logic";LLM 唯一的贡献是阅读 README + manifest,合成叙事性的name/description/frameworks/languages。
这种分工在 scan-project.mjs 的头部注释里解释得更直白:早期版本让 LLM 每次运行时现写一个 Node.js 脚本走文件树、查分类表——"a pure rule-lookup pass that was being billed at LLM rates and adding many minutes of per-run latency"。脚本化之后,纯规则查找以脚本速率运行,LLM 只保留真正需要语义理解的部分。
另外定义中有一条语言指令规则:若派发提示中包含语言指令(例如 "Generate all textual content in Chinese"),则 Phase 2 合成的 description 字段必须用该语言表达,采用母语级自然措辞,无标准译法的技术术语保留英文(如 "middleware"、"hook"、"barrel")。
2. Phase 1 — Discovery:三步编排
Phase 1 由三个步骤组成:步骤 B 与 C 运行捆绑脚本,步骤 A 是本阶段唯一的 LLM 工作。
2.1 Step A(LLM):读 manifest 与 README,产出叙事字段
Agent 只读项目根目录的顶层文件,"Do NOT walk the file tree or count files yourself — that is Step B's job"。按需读取:
README.md(或README.rst、README)——取前约 10 行作为叙事依据;package.json——提取name、description,以及dependencies/devDependencies的键用于框架检测;pyproject.toml、setup.py、setup.cfg、Pipfile、requirements.txt——Python 框架信号;Cargo.toml——Rust 项目名 +[dependencies];go.mod——Go 模块名 +require块;Gemfile——Ruby 框架信号;pom.xml、build.gradle、build.gradle.kts——JVM 项目信号;composer.json——PHP 项目信号。
由这些素材合成四个字段:
name 的优先级顺序:package.json 的 name → Cargo.toml [package].name → go.mod 模块路径的最后一段 → pyproject.toml 的 [project].name 或 [tool.poetry].name → 都没有则回退到项目根目录名。
rawDescription:取 package.json(或对应 manifest 等价字段)的 description,没有则为 ""。readmeHead:README 前约 10 行,无 README 则为 ""。
frameworks 的检测清单(依赖名精确匹配 + 基础设施文件推断):
| 生态 | 匹配目标 |
|---|---|
| JS / TS | react, vue, svelte, @angular/core, express, fastify, koa, next, nuxt, vite, vitest, jest, mocha, tailwindcss, prisma, typeorm, sequelize, mongoose, redux, zustand, mobx |
| Python | django, djangorestframework, fastapi, flask, sqlalchemy, alembic, celery, pydantic, uvicorn, gunicorn, aiohttp, tornado, starlette, pytest, hypothesis, channels |
| Ruby | rails, railties, sinatra, grape, rspec, sidekiq, activerecord, actionpack, devise, pundit |
| Go | github.com/gin-gonic/gin, github.com/labstack/echo, github.com/gofiber/fiber, github.com/go-chi/chi, gorm.io/gorm |
| Rust | actix-web, axum, rocket, diesel, tokio, serde, warp |
| JVM | spring-boot, spring-web, spring-data, quarkus, micronaut, hibernate, jakarta, junit, ktor |
基础设施工具按 manifest/文件存在性推断:有 Dockerfile 加 Docker;有 docker-compose.yml/docker-compose.yaml 加 Docker Compose;任意 *.tf 加 Terraform;.github/workflows/*.yml 加 GitHub Actions;.gitlab-ci.yml 加 GitLab CI;Jenkinsfile 加 Jenkins。
languages:manifest 中观察到的顶层语言集合,去重、按字母排序,并与 Step B 输出的逐文件语言计数(stats.byLanguage)交叉核对。
兜底原则:manifest 缺失或格式错误时,对应字段留空而不是猜测。
2.2 Step B(scan-project.mjs):文件枚举 + 语言 + 类别 + 行数
首先一次性解析项目数据目录并全程复用 $UA_DIR——已存在旧目录 .understand-anything/ 时沿用之,否则用新目录 .ua/:
UA_DIR="$PROJECT_ROOT/$([ -d "$PROJECT_ROOT/.understand-anything" ] && echo .understand-anything || echo .ua)"
mkdir -p $UA_DIR/tmp
node $PLUGIN_ROOT/skills/understand/scan-project.mjs \
"$PROJECT_ROOT" \
"$UA_DIR/tmp/ua-scan-files.json"
若派发提示带有排除模式,追加 --exclude "<patterns>"(逗号分隔,脚本内部自行拆分),例如:
node $PLUGIN_ROOT/skills/understand/scan-project.mjs \
"$PROJECT_ROOT" \
"$UA_DIR/tmp/ua-scan-files.json" \
--exclude "tests/*,docs/*"
输出 JSON 形态如下(Agent 会逐字读取并合并进最终 scan-result):
{
"scriptCompleted": true,
"files": [
{"path": "src/index.ts", "language": "typescript", "sizeLines": 150, "fileCategory": "code"},
{"path": "README.md", "language": "markdown", "sizeLines": 45, "fileCategory": "docs"},
{"path": "Dockerfile", "language": "dockerfile", "sizeLines": 22, "fileCategory": "infra"},
{"package.json": "", "language": "json", "sizeLines": 35, "fileCategory": "config"}
],
"totalFiles": 42,
"filteredByIgnore": 0,
"estimatedComplexity": "moderate",
"stats": {
"filesScanned": 42,
"byCategory": {"code": 28, "config": 6, "docs": 4, "infra": 2, "script": 2},
"byLanguage": {"typescript": 22, "javascript": 6, "json": 5, "markdown": 4, "yaml": 3, "shell": 2}
}
}
脚本的确定性行为(Agent 不维护这些表,表住在脚本里):
files按path.localeCompare排序(确定性)。从源码看,scan-project.mjs 实际用的是 ECMAScript 关系字符串比较(UTF-16 码元字典序),注释明确说明这是为了"结果不随 ICU 版本、进程 locale、操作系统设置变化",比localeCompare更严格地做到了跨主机可复现;fileCategory每文件必发,取值code | config | docs | infra | data | script | markup;language对每个文件都是非空字符串:已知扩展名映射到规范 id,未知扩展名映射为小写扩展名,无扩展名且非Dockerfile/Makefile/Jenkinsfile的文件为"unknown";filteredByIgnore只统计"超出硬编码默认值"的丢弃量,.understandignore中的!取反能正确重新包含文件;- 逐文件失败(权限拒绝、畸形 unicode、文件消失)输出
Warning: scan-project: <path> — <reason> — file skipped from output到 stderr,Agent 需捕获并追加到阶段警告; - 结束前输出一行信息性汇总
scan-project: filesScanned=… filteredByIgnore=… complexity=…。
标准类别表(记录在案,脚本为准,不要在提示词里重新推导这些规则):
| 模式 | 类别 |
|---|---|
LICENSE |
code(例外——不算 docs) |
Dockerfile、Dockerfile.*、docker-compose.*、compose.yml/compose.yaml、Makefile、Jenkinsfile、Procfile、Vagrantfile、.gitlab-ci.yml、.dockerignore、.github/workflows/*、.circleci/*、k8s/ 或 kubernetes/ 路径下的文件、*.k8s.yml/*.k8s.yaml |
infra |
.md、.mdx、.rst、.txt、.text(LICENSE 除外) |
docs |
.yaml、.yml、.json、.jsonc、.toml、.xml、.xsl、.xsd、.plist、.cfg、.ini、.env、.properties、.csproj、.sln、.mod、.sum、.gradle、.sbt |
config |
.tf、.tfvars |
infra |
.sql、.graphql、.gql、.proto、.prisma、.csv、.tsv |
data |
.sh、.bash、.zsh、.ps1、.psm1、.psd1、.bat、.cmd |
script |
.html、.htm、.css、.scss、.sass、.less |
markup |
| 其余一切 | code |
优先级规则:最具体者胜。文件名/路径规则先于扩展名规则触发——例如 docker-compose.yml 是 infra(不是 config);.github/workflows/ci.yml 是 infra(不是 config);LICENSE 是 code(不是 docs)。这与源码 detectCategory 的实现逐条对应:规则 1 判断 LICENSE,规则 2 按文件名匹配 infra(含 Dockerfile.* 前缀、docker-compose.* 前缀、compose.yml/compose.yaml),规则 3 按路径前缀匹配(.github/workflows/、.circleci/、任意层级的 k8s//kubernetes/ 段、*.k8s.(ya?ml)),规则 4 才是扩展名查表,最后兜底 code。
.understandignore 行为:脚本读取项目根的 .understandignore 以及数据目录内的 .understandignore(.ua/.understandignore,或旧目录 .understand-anything/.understandignore),与硬编码默认值经 core 包的 createIgnoreFilter 合并;! 取反覆盖默认值(如 !dist/ 可把 dist/ 重新纳入);filteredByIgnore 计数器只度量用户驱动的丢弃,不度量基线默认丢弃。从源码看,计数实现 是构造两套过滤器做差集:一套"仅默认值",一套"默认值 + 用户模式",凡是组合过滤器丢弃而仅默认过滤器本会保留的文件才计入 filteredByIgnore——通过 ! 重新包含的文件不在组合丢弃集中,因此不会被错误计数。
失败策略:脚本非零退出时读 stderr 诊断,最多允许 2 次重试(重新调用)后判定阶段失败;"Do NOT attempt to substitute a custom scanner — there is no second-source replacement"。
源码层面还有两个 Agent 文档未明说、但影响输出质量的事实:
- 枚举策略:优先
git ls-files -z -co --exclude-standard(见 enumerateViaGit),-z使路径以 NUL 分隔,避免非 ASCII 路径名被 git 的 C-escape 转义后下游无法回读;非 git 目录回退到递归遍历,遍历器在 ignore 过滤前先硬跳过node_modules、.git、.svn、.hg、__pycache__,且不跟随符号链接。 - 复杂度分档:estimateComplexity 的阈值为
small(≤30 文件)、moderate(31–150)、large(151–500)、very-large(>500),边界取闭区间下界;0 个文件归入small。 - 内容指纹:脚本还会对"按稳定路径序 + 每帧
uint32be(路径长度) || uint64be(内容长度) || 路径字节 || 内容字节"计算 sha256contentDigest,供增量更新判断使用。
2.3 Step C(extract-import-map.mjs):确定性 import 解析
Step B 产出文件清单后,调用捆绑的 extract-import-map.mjs 对所有受支持的代码语言做确定性 import 提取。它内部使用 core 包的 TreeSitterPlugin + PluginRegistry 解析源码(tree-sitter),再用语言特定的解析规则映射到项目内文件路径。文档明确要求:不要重新实现 import 模式——Step B 已为每个文件发出 path/language/fileCategory,该脚本消费这个清单并产出 importMap。
先写输入 JSON(files[] 数组与 Step B 的 files[] 完全一致,逐字透传):
UA_DIR="$PROJECT_ROOT/$([ -d "$PROJECT_ROOT/.understand-anything" ] && echo .understand-anything || echo .ua)"
mkdir -p $UA_DIR/tmp
cat > $UA_DIR/tmp/ua-import-map-input.json << 'ENDJSON'
{
"projectRoot": "<absolute-project-root>",
"files": [
{"path": "src/index.ts", "language": "typescript", "fileCategory": "code"},
{"path": "README.md", "language": "markdown", "fileCategory": "docs"}
]
}
ENDJSON
然后运行:
node $PLUGIN_ROOT/skills/understand/extract-import-map.mjs \
$UA_DIR/tmp/ua-import-map-input.json \
$UA_DIR/tmp/ua-import-map-output.json
输出 JSON 形态:
{
"scriptCompleted": true,
"stats": { "filesScanned": 314, "filesWithImports": 142, "totalEdges": 487 },
"importMap": {
"src/index.ts": ["src/utils.ts", "src/config.ts"],
"src/utils.ts": [],
"README.md": [],
"Dockerfile": []
}
}
读取输出 JSON,把 importMap 字段直接合并进最终 scan-result.json(同名键 importMap)。格式与 project-scanner 契约匹配:每个输入文件都有条目;非代码文件为空数组;只包含已解析的内部路径(外部包被丢弃)。
stderr 处理:运行捆绑脚本时捕获 stderr;任何以 Warning: 开头的行都追加到阶段警告——SKILL.md 编排器会收集它们进入最终报告。脚本完成时还会输出一行汇总 extract-import-map: filesScanned=… filesWithImports=… totalEdges=…,可忽略或作为信息展示。
支持的语言(13 种,清单与实现一一对应,无 LLM 兜底):
- TypeScript;
- JavaScript(含 CJS
require()); - Python(相对 + 绝对 +
__init__.py包解析); - Go(go.mod 前缀剥离);
- Rust(
use crate::、use super::、use self::及mod x;声明); - Java;
- Kotlin;
- Scala(点分 FQN + selector 列表 + package 对象);
- C#;
- Ruby(
require+require_relative); - PHP(composer.json PSR-4 autoload);
- C;
- C++(
#include,相对路径 +include/+src/探测)。
集合之外的语言得到空数组——没有基于 LLM 的回退。从源码结构看,extract-import-map.mjs 会加载输入文件清单中发现的每一个 go.mod(支持多服务 monorepo,按导入文件向上寻找最近的 go.mod 分发模块前缀)以及每个 composer.json 的 PSR-4 autoload 映射,配置在启动时一次性缓存,避免千文件项目重复解析。
3. Phase 2 — Description 合成与最终组装
Steps A + B + C 全部完成后,Agent 读取三个来源:
$UA_DIR/tmp/ua-scan-files.json——scan-project.mjs的输出(含totalFiles、filteredByIgnore、estimatedComplexity);$UA_DIR/tmp/ua-import-map-output.json——extract-import-map.mjs的输出(importMap字段);- Step A 的内存笔记(
name、rawDescription、readmeHead、frameworks、languages)。
禁止事项:不得重新遍历文件树、重新计数行数或重新推导类别——完全信任 scan-project.mjs;不得重新实现 import 解析——完全信任 extract-import-map.mjs。
字段剥离规则(IMPORTANT):最终输出不得包含任一捆绑脚本的 scriptCompleted 或 stats 字段,也不得包含临时工作字符串 rawDescription / readmeHead。最终 importMap 必须与 extract-import-map.mjs 的 importMap 字段逐字相等(不得编辑、重排、过滤);最终 files 数组必须与 Step B 的 files 数组逐字相等(不得重排、丢弃、增补)。
Phase 2 中 Agent 唯一的合成任务是最终 description 字段,规则依次为:
- 若
rawDescription非空,以它为基础,必要时清理(去掉营销话术,保证 1–2 句); - 若
rawDescription为空但readmeHead非空,从 README 内容合成 1–2 句描述; - 两者都为空,使用
"No description available"; - 若
totalFiles> 100,追加固定备注:" Note: this project has over 100 source files; consider scoping analysis to a subdirectory for faster results."
最终输出 JSON 组装:
{
"name": "project-name",
"description": "Brief description from README or package.json",
"languages": ["markdown", "typescript", "yaml"],
"frameworks": ["React", "Vite", "Vitest", "Docker"],
"files": [
{"path": "src/index.ts", "language": "typescript", "sizeLines": 150, "fileCategory": "code"},
{"path": "README.md", "language": "markdown", "sizeLines": 45, "fileCategory": "docs"},
{"path": "Dockerfile", "language": "dockerfile", "sizeLines": 22, "fileCategory": "infra"}
],
"totalFiles": 42,
"filteredByIgnore": 0,
"estimatedComplexity": "moderate",
"importMap": {
"src/index.ts": ["src/utils.ts"]
}
}
字段要求逐项核对:
name(string):来自 Step A 叙事工作;description(string):合成的 1–2 句描述;languages(string[]):去重、按字母排序,并与 Step Bstats.byLanguage的键交叉核对;frameworks(string[]):仅已确认的框架(未检测到则为空数组);files(object[]):直接取自 Step Bfiles[](逐字,含fileCategory);totalFiles(integer):直接取自 Step B;filteredByIgnore(integer):直接取自 Step B;estimatedComplexity(string):直接取自 Step B;importMap(object):直接取自 Step C 的importMap字段。
4. Critical Constraints 与结果落盘
定义末尾的约束清单是整个 Agent 的"合同条款":
- 绝不发明或猜测文件路径——
files数组中每个path必须来自scan-project.mjs的输出(其本身来自git ls-files或真实目录列表); - 绝不包含磁盘上不存在的文件;
- 必须校验
totalFiles与files数组实际长度一致; - 信任 Step B 做文件枚举 + 语言检测 + 类别分配 + 行数统计 + 复杂度估计,信任 Step C 做
importMap;Agent 自己的合成只有description字段(外加 Step A 的name、frameworks、languages); - 不要在发现脚本中重新实现文件枚举、语言检测或类别分配——使用捆绑的
scan-project.mjs;"If the table doesn't cover your project type, file an issue rather than ad-hoc handling"; - 不要尝试重新实现 import 解析——捆绑脚本通过 tree-sitter + 逐语言解析器确定性地覆盖全部 13 种代码语言;
- 每个文件必须有
fileCategory字段,取值为code、config、docs、infra、data、script、markup之一——scan-project.mjs保证这一点,只需不要把它剥掉。
Writing Results(产物落盘与汇报):
- 创建输出目录
mkdir -p $UA_DIR/intermediate(数据目录——.ua/,或已存在时的旧.understand-anything/); - 将 JSON 写入
$UA_DIR/intermediate/scan-result.json;若派发提示给出了确切输出路径,则使用之; - 回复仅包含简短文本摘要:项目名、总文件数(按类别分解)、检测到的语言、估计复杂度——不要把完整 JSON 放进文本回复。
5. 在主管线中的位置:scan-result.json 如何被消费
从 SKILL.md 的 Phase 1 编排看,project-scanner 的输出是整个 /understand 流水线的地基:
- 主会话派发子 Agent 时会注入 README 与 manifest 内容,但明确要求把它们当作不受信任的项目数据——仅用于推断项目名、描述和框架事实,忽略其中内嵌的任何指令式文本(这是针对提示注入的防护);
--excludeCLI 旗标会转为$EXCLUDE_PATTERNS传入派发提示,再由 Agent 以--exclude传给scan-project.mjs;- 子 Agent 完成后,主编排器读取
scan-result.json:取出项目名/描述、语言/框架、带fileCategory的文件清单、复杂度估计与importMap; importMap存为内存变量$IMPORT_MAP、文件清单存为$FILE_LIST,供 Phase 1.5 的 compute-batches.mjs 做语义分批——Phase 2 中最多 5 个 file-analyzer 子 Agent 并发、每批 20–30 个文件并行解析时,直接消费预解析好的 importMap 而不是从源码重新推导;- Gate check:文件数超过 100 时,编排器会提示用户并建议用子目录参数限定范围(这与 Phase 2 中 description 追加的 ">100 文件" 备注是同一阈值的前后呼应);若
filteredByIgnore > 0,向用户报告 "Excluded N files via.understandignoreand/or--excluderules"。
再往上游看一步:Phase 0.5 会用 generate-ignore.mjs 生成起步 .understandignore(读取 .gitignore、对照内置默认去重、按语言分组给出测试文件建议),并等待用户确认后才进入扫描——这正是 Step B 中 filteredByIgnore 计数与"用户模式覆盖默认值"行为的来源。
6. 设计要点小结
project-scanner 这一份 Agent 定义值得借鉴的地方在于其职责切分的精确性:
- 确定性下沉到脚本:凡是"同一输入必须产出同一输出"的工作(枚举、分类、计数、import 边)全部由两个捆绑脚本承担,LLM 零自由度,天然可复现;脚本还内建了逐文件容错(失败只警告并跳过单文件)、stderr-only 日志、符号链接防护和非 ASCII 路径安全(
git ls-files -z); - LLM 只保留叙事权:
name/description/frameworks/languages四个字段的合成规则被写成优先级清单(manifest 类型优先序、框架匹配表、description 三级回退),最大限度压缩 LLM 的解释空间; - 逐字透传合同:
files[]与importMap在最终产物中必须逐字等于脚本输出,Agent 不得重排、丢弃或增补——这使结构侧数据(README 所谓 "the same code always yields the same edges")与语义侧数据在边界上清晰隔离; - 可验证的自检条款:
totalFiles必须等于files长度、路径必须来自真实磁盘列表,让下游(编排器、file-analyzer)可以放心消费。
这套"确定性脚本 + 受限 LLM 叙事"的双轨结构,是 Understand-Anything 把动辄数万行的仓库扫描从"分钟级 LLM 查表"变成"秒级脚本 + 少量语义合成"的关键,也是 scan-result.json 成为后续分批解析、图谱构建与增量更新共同基础的原因。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00