Merge remote-tracking branch 'origin/main'

This commit is contained in:
sunlei 2026-07-03 18:23:21 +08:00
commit daaabf07fc
11 changed files with 953 additions and 16 deletions

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
node_modules/ node_modules/
dist/ dist/
.turbo/ .turbo/
.kt-workspace/
*.log *.log

View File

@ -18,7 +18,7 @@
- 静态检查 NapCat 设备身份护栏:确认 API 仍保留 QQNT 可见 hostname、实体 OUI 风格 MAC、QQNT `machine-info` 写入、持久化 runtime dir 和 `DB_TIMEZONE` 默认值。 - 静态检查 NapCat 设备身份护栏:确认 API 仍保留 QQNT 可见 hostname、实体 OUI 风格 MAC、QQNT `machine-info` 写入、持久化 runtime dir 和 `DB_TIMEZONE` 默认值。
- 生成 NapCatQQ 上游同步与 runtime 发布自动化入口MCP/CLI 默认 dry-run覆盖 upstream audit、sync candidate review、runtime readiness、remote dev handoff 和 NAS Codex bootstrap planupstream audit 在显式 `execute=true``useCodex=true` 时可运行 CodexNAS Codex bootstrap 在显式 `execute=true` 时只返回目录准备、nvm 管理的 Node 22.14.0 / pnpm 10.28.2 / Codex CLI 安装计划,不复制 secrets、不写 systemd、不触发发布。 - 生成 NapCatQQ 上游同步与 runtime 发布自动化入口MCP/CLI 默认 dry-run覆盖 upstream audit、sync candidate review、runtime readiness、remote dev handoff 和 NAS Codex bootstrap planupstream audit 在显式 `execute=true``useCodex=true` 时可运行 CodexNAS Codex bootstrap 在显式 `execute=true` 时只返回目录准备、nvm 管理的 Node 22.14.0 / pnpm 10.28.2 / Codex CLI 安装计划,不复制 secrets、不写 systemd、不触发发布。
- 生成卡点固化记录:把超时、卡进程、远程命令误写、重复失败整理成“问题点 / 稳定解法 / 后续入口 / 验证证据”,避免原样重试。 - 生成卡点固化记录:把超时、卡进程、远程命令误写、重复失败整理成“问题点 / 稳定解法 / 后续入口 / 验证证据”,避免原样重试。
- 生成改动文档同步计划:按变更文件自动提示需要同步的 README、API、AGENTS、docs、Obsidian、skill 和 ktWorkflow 入口。 - 生成改动文档同步计划:按变更文件自动提示需要同步的 README、API、AGENTS、docs、Obsidian、skill 和 ktWorkflow 入口MCP 不可用时用 `pnpm run change-doc-sync -- --project <project>` 作为 CLI fallback
- 生成多仓库提交/推送计划:按仓库分组、建议提交信息、列出提交和推送前检查。 - 生成多仓库提交/推送计划:按仓库分组、建议提交信息、列出提交和推送前检查。
- 生成远程只读健康检查和数据库同步安全向导:覆盖飞牛 NAS 服务探测、GTID、`.kt-workspace/db-sync` 转储、备份库和行数校验。 - 生成远程只读健康检查和数据库同步安全向导:覆盖飞牛 NAS 服务探测、GTID、`.kt-workspace/db-sync` 转储、备份库和行数校验。
- 生成或执行部署观测:把 Jenkins build、`build.xml` SCM revision、日志尾部、K8s Deployment、Pod、`/health/runtime` 和任务 smoke 汇总成 `.kt-workspace/test-artifacts/deploy-observation` 下的运行态证据Jenkins 状态以日志尾部最终 `Finished:` 为准,日志存在但缺少最终态时不认定发布完成。 - 生成或执行部署观测:把 Jenkins build、`build.xml` SCM revision、日志尾部、K8s Deployment、Pod、`/health/runtime` 和任务 smoke 汇总成 `.kt-workspace/test-artifacts/deploy-observation` 下的运行态证据Jenkins 状态以日志尾部最终 `Finished:` 为准,日志存在但缺少最终态时不认定发布完成。
@ -26,7 +26,7 @@
- 生成验证进程清理计划:按项目路径和端口给出 PowerShell 检查命令,不直接杀进程。 - 生成验证进程清理计划:按项目路径和端口给出 PowerShell 检查命令,不直接杀进程。
- 清理历史产物:统一治理 `.kt-workspace` 下的测试/验证产物,按目录最近修改时间只保留最近 3 轮模板目录永久保留CLI 默认 dry-run真实清理必须显式传 `--execute` - 清理历史产物:统一治理 `.kt-workspace` 下的测试/验证产物,按目录最近修改时间只保留最近 3 轮模板目录永久保留CLI 默认 dry-run真实清理必须显式传 `--execute`
- 检查 env 策略和变更风险:区分后端真实 env 与前端客户端 `.env*`提醒锁文件、核心表格组件、API 时间序列化 KtDateTime 列/DTO 装饰器入口、部署链路和 Vue TSX 插槽写法等高风险改动。 - 检查 env 策略和变更风险:区分后端真实 env 与前端客户端 `.env*`提醒锁文件、核心表格组件、API 时间序列化 KtDateTime 列/DTO 装饰器入口、部署链路和 Vue TSX 插槽写法等高风险改动。
- 全局 CodeReview 只读扫描:汇总全部 KT 子仓库的 Git 状态、敏感文件跟踪、冲突标记、运行时调试输出、疑似凭据字面量、NapCat `latest` 镜像漂移风险、QQBot 重型服务单测导入风险、QQBot 插件 registry/controller smoke 未 mock 具体插件实现风险、QQBot OneBot/QQ 登录态边界回归、NapCat 密码登录验证码 wait 阶段、日志窗口锚点、验证码状态早于日志 URL 的短轮询、验证码 pending 被状态轮询误清理、API Pod SSH 日志读取超时和清理前 pending 回归、普通处理中状态误吃旧验证码日志回归、根目录生成产物、`TASKS.md` 最近记录字段结构和当前变更风险;默认只对变更文件做内容扫描,并放过短中文显示标签和明显测试占位 token避免历史误报污染上下文任何文件改动后都要跑一遍。 - 全局 CodeReview 只读扫描:汇总全部 KT 子仓库的 Git 状态、敏感文件跟踪、冲突标记、运行时调试输出、疑似凭据字面量、NapCat `latest` 镜像漂移风险、QQBot 重型服务单测导入风险、QQBot 插件 registry/controller smoke 未 mock 具体插件实现风险、QQBot OneBot/QQ 登录态边界回归、NapCat 密码登录验证码 wait 阶段、日志窗口锚点、验证码状态早于日志 URL 的短轮询、验证码 pending 被状态轮询误清理、API Pod SSH 日志读取超时和清理前 pending 回归、普通处理中状态误吃旧验证码日志回归、根目录生成产物、`TASKS.md` 最近记录字段结构、最近记录 3 条上限和当前变更风险;默认只对变更文件做内容扫描,并放过短中文显示标签和明显测试占位 token避免历史误报污染上下文任何文件改动后都要跑一遍。
- 生成或写入 `TASKS.md` 最近记录:默认 `dryRun=true`,确认后再落盘。 - 生成或写入 `TASKS.md` 最近记录:默认 `dryRun=true`,确认后再落盘。
- 生成提交前检查清单:校验 KT commit message 约定。 - 生成提交前检查清单:校验 KT commit message 约定。
@ -40,6 +40,7 @@ pnpm run self-test
pnpm run obsidian-context -- --module ktWorkflow pnpm run obsidian-context -- --module ktWorkflow
pnpm run obsidian-validate pnpm run obsidian-validate
pnpm run obsidian-sync pnpm run obsidian-sync
pnpm run change-doc-sync -- --project root
pnpm run workstream-closeout -- --title "发布闭环" --verification "Jenkins SUCCESS" --doc-sync "无需文档更新" --cleanup "cleanup-history dry-run deleted=0" --cleanup-final-deleted 0 --review "global-review findings=0" --superpowers-review "Superpowers reviewer completed; no Critical/Important findings" --problem "无新卡点" --solution "无新增稳定解法" pnpm run workstream-closeout -- --title "发布闭环" --verification "Jenkins SUCCESS" --doc-sync "无需文档更新" --cleanup "cleanup-history dry-run deleted=0" --cleanup-final-deleted 0 --review "global-review findings=0" --superpowers-review "Superpowers reviewer completed; no Critical/Important findings" --problem "无新卡点" --solution "无新增稳定解法"
pnpm run napcat-upstream-audit -- --artifact-root .kt-workspace/test-artifacts/napcat-upstream-sync pnpm run napcat-upstream-audit -- --artifact-root .kt-workspace/test-artifacts/napcat-upstream-sync
pnpm run napcat-sync-candidate-review pnpm run napcat-sync-candidate-review
@ -59,9 +60,10 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login
- `ci/jenkins/KT-NapCatQQ-Upstream-Sync.Jenkinsfile` 是 NapCatQQ 上游审计入口,默认只调用 ktWorkflow 的 `napcat-upstream-audit` dry-run保留人工审查边界。 - `ci/jenkins/KT-NapCatQQ-Upstream-Sync.Jenkinsfile` 是 NapCatQQ 上游审计入口,默认只调用 ktWorkflow 的 `napcat-upstream-audit` dry-run保留人工审查边界。
- Codex 上游审计必须同时设置 `RUN_CODEX_AUDIT=true` 并填写 `CONFIRM_CODEX_WORKSPACE_WRITE=RUN_CODEX_WORKSPACE_WRITE_AUDIT`;这是因为 Codex runner 使用 workspace-write sandbox不能作为定时任务默认行为。 - Codex 上游审计必须同时设置 `RUN_CODEX_AUDIT=true` 并填写 `CONFIRM_CODEX_WORKSPACE_WRITE=RUN_CODEX_WORKSPACE_WRITE_AUDIT`;这是因为 Codex runner 使用 workspace-write sandbox不能作为定时任务默认行为。
- `ci/jenkins/KT-NapCatQQ-Runtime-Release.Jenkinsfile` 是运行时发布入口,先调用 `napcat-runtime-release-readiness`,再通过显式参数触发运行时镜像构建和 API promotion下游 job 使用白名单 choice避免任意 Jenkins job 名被传入。 - `ci/jenkins/KT-NapCatQQ-Runtime-Release.Jenkinsfile` 是运行时发布入口,先调用 `napcat-runtime-release-readiness`,再通过显式参数触发运行时镜像构建和 API promotion下游 job 使用白名单 choice避免任意 Jenkins job 名被传入,并把 upstream release commit / NapCat base image digest 透传到镜像构建证据。
- `ci/jenkins/KT-NapCatQQ-Runtime-Image-Build.Jenkinsfile` 是运行时镜像构建下游,只从 NAS 本地裸仓库 clone 经过审查的 NapCatQQ fork ref构建 `NapCat.Shell`,先要求 NAS API 工作区 clean并从 Jenkins API main checkout 的只读 `.git` reference 做 `fetch + merge --ff-only`,再调用 API 仓库 `scripts/napcat-desktop-cn-stage-build.mjs` 生成 staged context执行 `docker build` 和容器内 `/ci/napcat-desktop-cn/verify.sh`;由于该下游需要访问挂载的宿主 Docker socket内层 runner 固定 `--user 0:0`,镜像验证容器按账号容器同款 `SYS_ADMIN` / seccomp / apparmor 参数和 `NAPCAT_REQUIRE_DEVICE_PROFILE=1` 启动,但它不触发 API promotion、不执行 `kubectl`、不合并或推送 Git。
- 生产 API promotion 除了 `PROMOTE_API_RUNTIME=true` 和明确 image/profile 参数外,还必须填写 `CONFIRM_API_PROMOTION_TARGET=PROMOTE_KT_TEMPLATE_API_MAIN` - 生产 API promotion 除了 `PROMOTE_API_RUNTIME=true` 和明确 image/profile 参数外,还必须填写 `CONFIRM_API_PROMOTION_TARGET=PROMOTE_KT_TEMPLATE_API_MAIN`
- 个模板默认使用 NAS 工作区 `/vol1/docker/kt-codex/workspace/KT`,运行证据落在 `/vol1/docker/kt-codex/artifacts` 下。 - 个模板默认使用 NAS 工作区 `/vol1/docker/kt-codex/workspace/KT`,运行证据落在 `/vol1/docker/kt-codex/artifacts` 下。
- 模板只保存入口和参数形状,不记录 Jenkins credential、Token、SSH key 或其他真实凭据。 - 模板只保存入口和参数形状,不记录 Jenkins credential、Token、SSH key 或其他真实凭据。
## MCP 客户端配置 ## MCP 客户端配置
@ -132,7 +134,7 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login
| 脚本 | 用途 | | 脚本 | 用途 |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pnpm run admin-login` | 使用可见 Edge 打开 Admin 登录页,填写账号密码,拖动滑块,保存登录态和截图。默认账号来自初始化数据 `admin/123456`,生产或个人账号用 `KT_ADMIN_USERNAME` / `KT_ADMIN_PASSWORD` 或 CLI 参数覆盖。 | | `pnpm run admin-login` | 使用可见 Edge 打开 Admin 登录页,填写账号密码,拖动滑块,保存登录态和截图。默认账号来自初始化数据 `admin/123456`,生产或个人账号用 `KT_ADMIN_USERNAME` / `KT_ADMIN_PASSWORD` 或 CLI 参数覆盖。 |
| `pnpm run global-review` | 对 KT 全部子仓库做只读全局 CodeReview 扫描,默认仅对变更文件做内容深扫,输出 JSON 复审报告,并校验 `TASKS.md` 最近记录只保留范围、关键词、验证字段、NapCat `latest` 漂移、QQBot 重型服务单测导入风险、QQBot 插件 registry/controller smoke 未 mock 具体插件实现风险、OneBot/QQ 登录态边界、NapCat 密码登录验证码 wait 阶段、日志窗口锚点、验证码状态早于日志 URL 的短轮询、验证码 pending 被状态轮询误清理、API Pod SSH 日志读取超时及清理前 pending 回归,以及普通处理中状态误吃旧验证码日志回归;明显测试占位 token 不报凭据风险,确认误报时优先升级 `src/tools/review.ts`,不要把误报沉积到上下文。 | | `pnpm run global-review` | 对 KT 全部子仓库做只读全局 CodeReview 扫描,默认仅对变更文件做内容深扫,输出 JSON 复审报告,并校验 `TASKS.md` 最近记录只保留最新 3 条详细记录,以及每条记录只保留范围、关键词、验证字段;同时校验 NapCat `latest` 漂移、QQBot 重型服务单测导入风险、QQBot 插件 registry/controller smoke 未 mock 具体插件实现风险、OneBot/QQ 登录态边界、NapCat 密码登录验证码 wait 阶段、日志窗口锚点、验证码状态早于日志 URL 的短轮询、验证码 pending 被状态轮询误清理、API Pod SSH 日志读取超时及清理前 pending 回归,以及普通处理中状态误吃旧验证码日志回归;明显测试占位 token 不报凭据风险,确认误报时优先升级 `src/tools/review.ts`,不要把误报沉积到上下文。 |
| `pnpm run napcat-upstream-audit` | 默认 dry-run 输出 NapCatQQ 上游 release 审计分类、只读 Git 命令和 artifact 路径;显式 `--execute --use-codex` 才运行 Codex。 | | `pnpm run napcat-upstream-audit` | 默认 dry-run 输出 NapCatQQ 上游 release 审计分类、只读 Git 命令和 artifact 路径;显式 `--execute --use-codex` 才运行 Codex。 |
| `pnpm run napcat-sync-candidate-review` | 输出 sync candidate review 的 prompt/context/只读命令骨架,不执行 Codex 或 Git 写操作。 | | `pnpm run napcat-sync-candidate-review` | 输出 sync candidate review 的 prompt/context/只读命令骨架,不执行 Codex 或 Git 写操作。 |
| `pnpm run napcat-runtime-release-readiness` | 输出 runtime release readiness 的 prompt/context/只读检查骨架,不构建镜像、不调用 kubectl、不发布。 | | `pnpm run napcat-runtime-release-readiness` | 输出 runtime release readiness 的 prompt/context/只读检查骨架,不构建镜像、不调用 kubectl、不发布。 |

View File

@ -0,0 +1,212 @@
pipeline {
agent { label 'kt-node-agent' }
options {
buildDiscarder(logRotator(numToKeepStr: '30', artifactNumToKeepStr: '30'))
disableConcurrentBuilds()
skipDefaultCheckout(true)
timestamps()
timeout(time: 90, unit: 'MINUTES')
}
parameters {
string(name: 'NAPCAT_FORK_REF', defaultValue: 'main', description: 'Reviewed NapCatQQ fork branch, tag, or commit.')
string(name: 'UPSTREAM_RELEASE_TAG', defaultValue: 'unknown', description: 'Reviewed upstream release tag for marker metadata.')
string(name: 'UPSTREAM_RELEASE_COMMIT', defaultValue: '5c18a62530d87dbadf53d267002894faa6ca7e90', description: 'Reviewed upstream release commit for marker metadata.')
string(name: 'QQBOT_NAPCAT_IMAGE_OVERRIDE', defaultValue: '', description: 'Explicit runtime image tag to build, for example kt-napcat-desktop-cn:desktop-cn-v9.')
string(name: 'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE', defaultValue: '', description: 'Explicit desktop profile version paired with the runtime image tag.')
string(name: 'NAPCAT_BASE_IMAGE_DIGEST', defaultValue: 'mlikiowa/napcat-docker@sha256:9254ec12af101576c5eeb4910847abd1d219297bc6d9a35c52511e12500f0f45', description: 'Immutable NapCat base image digest used for Docker build.')
string(name: 'KT_ARTIFACT_ROOT', defaultValue: '/vol1/docker/kt-codex/artifacts/napcat-runtime-release', description: 'NAS artifact root for runtime release evidence.')
}
environment {
KT_WORKSPACE_ROOT = '/vol1/docker/kt-codex/workspace/KT'
API_ROOT = '/vol1/docker/kt-codex/workspace/KT/Node/kt-template-online-api'
API_GIT_REFERENCE_HOST = '/vol1/docker/volumes/kt-node-agent-workdir/_data/workspace/KT-Template_KT-Template-API_main/.git'
API_GIT_REFERENCE = '/ci/kt-template-online-api-main.git'
NAPCAT_GIT_REPOSITORY = '/vol1/docker/kt-codex/git/NapCatQQ.git'
KT_WORKFLOW_RUNNER_IMAGE = 'kt-jenkins-node-agent:current'
}
stages {
stage('Validate parameters') {
steps {
script {
requireRuntimeImageParameters(
params.NAPCAT_FORK_REF,
params.UPSTREAM_RELEASE_COMMIT,
params.QQBOT_NAPCAT_IMAGE_OVERRIDE,
params.QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE,
params.NAPCAT_BASE_IMAGE_DIGEST
)
requireImageProfileMatch(
params.QQBOT_NAPCAT_IMAGE_OVERRIDE,
params.QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE
)
}
}
}
stage('Build and verify runtime image') {
steps {
sh '''
set -e
docker run --rm \
-e API_ROOT="$API_ROOT" \
-e API_GIT_REFERENCE="$API_GIT_REFERENCE" \
-e BUILD_TAG="$BUILD_TAG" \
-e BUILD_URL="$BUILD_URL" \
-e COREPACK_HOME=/vol1/docker/kt-codex/runtime/corepack \
-e KT_ARTIFACT_ROOT="$KT_ARTIFACT_ROOT" \
-e KT_WORKSPACE_ROOT="$KT_WORKSPACE_ROOT" \
-e NAPCAT_BASE_IMAGE_DIGEST="$NAPCAT_BASE_IMAGE_DIGEST" \
-e NAPCAT_FORK_REF="$NAPCAT_FORK_REF" \
-e NAPCAT_GIT_REPOSITORY="$NAPCAT_GIT_REPOSITORY" \
-e QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE="$QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE" \
-e QQBOT_NAPCAT_IMAGE_OVERRIDE="$QQBOT_NAPCAT_IMAGE_OVERRIDE" \
-e UPSTREAM_RELEASE_COMMIT="$UPSTREAM_RELEASE_COMMIT" \
-e UPSTREAM_RELEASE_TAG="$UPSTREAM_RELEASE_TAG" \
-v /vol1/docker/kt-codex:/vol1/docker/kt-codex \
-v "$API_GIT_REFERENCE_HOST":"$API_GIT_REFERENCE":ro \
-v /var/run/docker.sock:/var/run/docker.sock \
--user 0:0 \
--entrypoint bash "$KT_WORKFLOW_RUNNER_IMAGE" -lc '
set -euo pipefail
source /vol1/docker/kt-codex/runtime/nvm/nvm.sh
corepack prepare pnpm@10.28.2 --activate
ARTIFACTS_BASE_REAL=$(realpath -m /vol1/docker/kt-codex/artifacts)
ARTIFACT_ROOT_REAL=$(realpath -m "$KT_ARTIFACT_ROOT")
case "$ARTIFACT_ROOT_REAL" in
"$ARTIFACTS_BASE_REAL"/*) ;;
*)
echo "KT_ARTIFACT_ROOT must stay under /vol1/docker/kt-codex/artifacts" >&2
exit 2
;;
esac
BUILD_ID_SAFE=$(printf "%s" "${BUILD_TAG:-manual}" | tr "/ " "__")
ARTIFACT_DIR="$ARTIFACT_ROOT_REAL/runtime-image-build"
BUILD_WORKDIR="$ARTIFACT_ROOT_REAL/runtime-image-build-workspace/$BUILD_ID_SAFE"
NAPCAT_ROOT="$BUILD_WORKDIR/NapCatQQ"
STAGE_OUT=".kt-workspace/napcat-desktop-cn-build-runtime-image"
VERIFY_NAME="kt-napcat-runtime-image-verify-$BUILD_ID_SAFE"
cleanup() {
docker rm -f "$VERIFY_NAME" >/dev/null 2>&1 || true
rm -rf "$BUILD_WORKDIR"
}
trap cleanup EXIT
git config --global --add safe.directory "$NAPCAT_GIT_REPOSITORY"
git config --global --add safe.directory "$API_ROOT"
rm -rf "$ARTIFACT_DIR" "$BUILD_WORKDIR"
mkdir -p "$ARTIFACT_DIR" "$BUILD_WORKDIR"
if [ -n "$(git -C "$API_ROOT" status --short)" ]; then
echo "API_ROOT must be clean before runtime image build: $API_ROOT" >&2
git -C "$API_ROOT" status --short >&2
exit 2
fi
test -d "$API_GIT_REFERENCE"
git -C "$API_ROOT" fetch "$API_GIT_REFERENCE" refs/remotes/origin/main
git -C "$API_ROOT" checkout main
git -C "$API_ROOT" merge --ff-only FETCH_HEAD
git -C "$API_ROOT" rev-parse HEAD > "$ARTIFACT_DIR/api-root-commit.txt"
git clone "$NAPCAT_GIT_REPOSITORY" "$NAPCAT_ROOT"
cd "$NAPCAT_ROOT"
git checkout --detach "$NAPCAT_FORK_REF"
git rev-parse HEAD > "$ARTIFACT_DIR/fork-commit.txt"
git status --short > "$ARTIFACT_DIR/fork-status.txt"
pnpm install --frozen-lockfile
pnpm --filter napcat-webui-frontend run build
pnpm run build:shell
pnpm run build:framework
cd "$API_ROOT"
node scripts/napcat-desktop-cn-stage-build.mjs \
--napcat-root "$NAPCAT_ROOT" \
--out "$STAGE_OUT" \
--upstream-release-tag "$UPSTREAM_RELEASE_TAG" \
--upstream-release-commit "$UPSTREAM_RELEASE_COMMIT" \
--napcat-base-image-digest "$NAPCAT_BASE_IMAGE_DIGEST" \
--jenkins-build-url "$BUILD_URL" \
| tee "$ARTIFACT_DIR/stage-build.json"
cp "$STAGE_OUT/ci/napcat-desktop-cn/fork-artifact.json" "$ARTIFACT_DIR/fork-artifact.json"
docker build \
--build-arg NAPCAT_BASE_IMAGE="$NAPCAT_BASE_IMAGE_DIGEST" \
-t "$QQBOT_NAPCAT_IMAGE_OVERRIDE" \
-f "$STAGE_OUT/ci/napcat-desktop-cn/Dockerfile" \
"$STAGE_OUT"
docker run -d --name "$VERIFY_NAME" \
--cap-add SYS_ADMIN \
--security-opt apparmor=unconfined \
--security-opt seccomp=unconfined \
-e NAPCAT_REQUIRE_DEVICE_PROFILE=1 \
"$QQBOT_NAPCAT_IMAGE_OVERRIDE" >/dev/null
sleep 3
docker exec "$VERIFY_NAME" sh /ci/napcat-desktop-cn/verify.sh
docker image inspect "$QQBOT_NAPCAT_IMAGE_OVERRIDE" \
--format "tag={{index .RepoTags 0}} id={{.Id}} size={{.Size}} created={{.Created}} repoDigests={{json .RepoDigests}}" \
| tee "$ARTIFACT_DIR/image.txt"
'
'''
}
}
}
post {
always {
sh '''
set +e
docker run --rm \
-e KT_ARTIFACT_ROOT="$KT_ARTIFACT_ROOT" \
-e WORKSPACE="$WORKSPACE" \
-v /vol1/docker/kt-codex:/vol1/docker/kt-codex \
-v kt-node-agent-workdir:/home/jenkins/agent \
--entrypoint bash "$KT_WORKFLOW_RUNNER_IMAGE" -lc '
set +e
rm -rf "$WORKSPACE/artifacts"
mkdir -p "$WORKSPACE/artifacts/napcat-runtime-image-build"
cp -a "$KT_ARTIFACT_ROOT/runtime-image-build/." "$WORKSPACE/artifacts/napcat-runtime-image-build/" 2>/dev/null || true
'
'''
archiveArtifacts artifacts: 'artifacts/napcat-runtime-image-build/**', allowEmptyArchive: true, fingerprint: false
}
}
}
/**
* Stops runtime image builds unless all immutable release parameters are present.
* @param forkRef Reviewed fork branch, tag, or commit that is cloned into the disposable build workspace.
* @param upstreamReleaseCommit Reviewed upstream release commit stored in the image marker.
* @param imageTag Reviewed image tag that will be created on NAS Docker.
* @param profile Reviewed desktop profile version paired with the image tag.
* @param baseImageDigest Immutable upstream/base Docker image digest in `repo@sha256` form.
* @return Nothing; aborts this pipeline through `error` when required values are missing.
*/
void requireRuntimeImageParameters(String forkRef, String upstreamReleaseCommit, String imageTag, String profile, String baseImageDigest) {
if (!forkRef?.trim()) {
error('NAPCAT_FORK_REF is required before runtime image build.')
}
if (!upstreamReleaseCommit?.trim()) {
error('UPSTREAM_RELEASE_COMMIT is required before runtime image build.')
}
if (!imageTag?.trim()) {
error('QQBOT_NAPCAT_IMAGE_OVERRIDE is required before runtime image build.')
}
if (!profile?.trim()) {
error('QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE is required before runtime image build.')
}
if (!baseImageDigest?.trim()?.contains('@sha256:')) {
error('NAPCAT_BASE_IMAGE_DIGEST must be an immutable repo@sha256 digest.')
}
}
/**
* Ensures the promoted image tag and desktop profile cannot drift silently.
* @param imageTag Runtime image tag, expected to end with the profile tag suffix.
* @param profile Runtime desktop profile value passed to API promotion.
* @return Nothing; aborts this pipeline through `error` when values drift.
*/
void requireImageProfileMatch(String imageTag, String profile) {
if (!imageTag?.trim()?.endsWith(":${profile?.trim()}")) {
error('QQBOT_NAPCAT_IMAGE_OVERRIDE tag suffix must match QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE.')
}
}

View File

@ -12,6 +12,8 @@ pipeline {
parameters { parameters {
string(name: 'NAPCAT_FORK_REF', defaultValue: 'main', description: 'Reviewed NapCatQQ fork branch, tag, or commit.') string(name: 'NAPCAT_FORK_REF', defaultValue: 'main', description: 'Reviewed NapCatQQ fork branch, tag, or commit.')
string(name: 'UPSTREAM_RELEASE_TAG', defaultValue: '', description: 'Reviewed upstream release tag, when applicable.') string(name: 'UPSTREAM_RELEASE_TAG', defaultValue: '', description: 'Reviewed upstream release tag, when applicable.')
string(name: 'UPSTREAM_RELEASE_COMMIT', defaultValue: '5c18a62530d87dbadf53d267002894faa6ca7e90', description: 'Reviewed upstream release commit used for runtime marker metadata.')
string(name: 'NAPCAT_BASE_IMAGE_DIGEST', defaultValue: 'mlikiowa/napcat-docker@sha256:9254ec12af101576c5eeb4910847abd1d219297bc6d9a35c52511e12500f0f45', description: 'Immutable NapCat base image digest used by the runtime image build.')
string(name: 'QQBOT_NAPCAT_IMAGE_OVERRIDE', defaultValue: '', description: 'Explicit runtime image tag to build or promote, for example kt-napcat-desktop-cn:desktop-cn-v9.') string(name: 'QQBOT_NAPCAT_IMAGE_OVERRIDE', defaultValue: '', description: 'Explicit runtime image tag to build or promote, for example kt-napcat-desktop-cn:desktop-cn-v9.')
string(name: 'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE', defaultValue: '', description: 'Explicit API desktop profile version override.') string(name: 'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE', defaultValue: '', description: 'Explicit API desktop profile version override.')
booleanParam(name: 'BUILD_RUNTIME_IMAGE', defaultValue: false, description: 'When true, trigger the reviewed Jenkins runtime image build/publish job.') booleanParam(name: 'BUILD_RUNTIME_IMAGE', defaultValue: false, description: 'When true, trigger the reviewed Jenkins runtime image build/publish job.')
@ -38,6 +40,8 @@ pipeline {
-e KT_ARTIFACT_ROOT="$KT_ARTIFACT_ROOT" \ -e KT_ARTIFACT_ROOT="$KT_ARTIFACT_ROOT" \
-e NAPCAT_FORK_REF="$NAPCAT_FORK_REF" \ -e NAPCAT_FORK_REF="$NAPCAT_FORK_REF" \
-e UPSTREAM_RELEASE_TAG="$UPSTREAM_RELEASE_TAG" \ -e UPSTREAM_RELEASE_TAG="$UPSTREAM_RELEASE_TAG" \
-e UPSTREAM_RELEASE_COMMIT="$UPSTREAM_RELEASE_COMMIT" \
-e NAPCAT_BASE_IMAGE_DIGEST="$NAPCAT_BASE_IMAGE_DIGEST" \
-e QQBOT_NAPCAT_IMAGE_OVERRIDE="$QQBOT_NAPCAT_IMAGE_OVERRIDE" \ -e QQBOT_NAPCAT_IMAGE_OVERRIDE="$QQBOT_NAPCAT_IMAGE_OVERRIDE" \
-e QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE="$QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE" \ -e QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE="$QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE" \
-e COREPACK_HOME=/vol1/docker/kt-codex/runtime/corepack \ -e COREPACK_HOME=/vol1/docker/kt-codex/runtime/corepack \
@ -72,6 +76,8 @@ pipeline {
parameters: [ parameters: [
string(name: 'NAPCAT_FORK_REF', value: params.NAPCAT_FORK_REF), string(name: 'NAPCAT_FORK_REF', value: params.NAPCAT_FORK_REF),
string(name: 'UPSTREAM_RELEASE_TAG', value: params.UPSTREAM_RELEASE_TAG), string(name: 'UPSTREAM_RELEASE_TAG', value: params.UPSTREAM_RELEASE_TAG),
string(name: 'UPSTREAM_RELEASE_COMMIT', value: params.UPSTREAM_RELEASE_COMMIT),
string(name: 'NAPCAT_BASE_IMAGE_DIGEST', value: params.NAPCAT_BASE_IMAGE_DIGEST),
string(name: 'QQBOT_NAPCAT_IMAGE_OVERRIDE', value: params.QQBOT_NAPCAT_IMAGE_OVERRIDE), string(name: 'QQBOT_NAPCAT_IMAGE_OVERRIDE', value: params.QQBOT_NAPCAT_IMAGE_OVERRIDE),
string(name: 'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE', value: params.QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE), string(name: 'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE', value: params.QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE),
string(name: 'KT_ARTIFACT_ROOT', value: "${env.KT_ARTIFACT_ROOT}/napcat-runtime-release") string(name: 'KT_ARTIFACT_ROOT', value: "${env.KT_ARTIFACT_ROOT}/napcat-runtime-release")

View File

@ -6,6 +6,7 @@
"description": "Reusable MCP workflow tools for the KT workspace.", "description": "Reusable MCP workflow tools for the KT workspace.",
"scripts": { "scripts": {
"admin-login": "node --import ./node_modules/tsx/dist/loader.mjs scripts/admin-login-smoke.ts", "admin-login": "node --import ./node_modules/tsx/dist/loader.mjs scripts/admin-login-smoke.ts",
"change-doc-sync": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --change-doc-sync",
"cleanup-history": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --cleanup-history", "cleanup-history": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --cleanup-history",
"deploy-observation": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --deploy-observation", "deploy-observation": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --deploy-observation",
"global-review": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --global-review", "global-review": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --global-review",

View File

@ -1,4 +1,5 @@
import type { import type {
ChangeDocSyncInput,
DeployObservationInput, DeployObservationInput,
GlobalCodeReviewInput, GlobalCodeReviewInput,
NapcatRemoteDevHandoffInput, NapcatRemoteDevHandoffInput,
@ -35,6 +36,23 @@ export function parseGlobalReviewCliArgs(
}; };
} }
/**
* Parses the change documentation sync CLI mode into the same input shape as the MCP tool.
* @param argv - Full process argv including `--change-doc-sync` and optional project/file flags.
* @returns Project key plus explicitly supplied changed files; absent files make the tool infer git status.
*/
export function parseChangeDocSyncCliArgs(argv: string[]): ChangeDocSyncInput {
return {
changedFiles: readOptionList(argv, [
'--changed-file',
'--changed-files',
'--file',
'--files',
]),
project: readOption(argv, ['--project']),
};
}
function readOption(argv: string[], names: string[]): string | undefined { function readOption(argv: string[], names: string[]): string | undefined {
for (const name of names) { for (const name of names) {
const keyValue = argv.find((item) => item.startsWith(`${name}=`)); const keyValue = argv.find((item) => item.startsWith(`${name}=`));

View File

@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node
import path from "node:path"; import path from "node:path";
import { import {
parseChangeDocSyncCliArgs,
parseNasCodexBootstrapCliArgs, parseNasCodexBootstrapCliArgs,
parseDeployObservationCliArgs, parseDeployObservationCliArgs,
parseGlobalReviewCliArgs, parseGlobalReviewCliArgs,
@ -408,6 +409,9 @@ export async function runSelfTest(): Promise<void> {
if (!packageJson.scripts?.["napcat-remote-dev-handoff"]) { if (!packageJson.scripts?.["napcat-remote-dev-handoff"]) {
throw new Error("NapCat remote handoff npm script self-check failed"); throw new Error("NapCat remote handoff npm script self-check failed");
} }
if (!packageJson.scripts?.["change-doc-sync"]) {
throw new Error("change-doc-sync npm script self-check failed");
}
const registeredByFakeServer: string[] = []; const registeredByFakeServer: string[] = [];
const fakeMcpServer = { const fakeMcpServer = {
/** /**
@ -524,6 +528,12 @@ export async function runSelfTest(): Promise<void> {
), ),
"utf8", "utf8",
).replace(/\r\n/g, "\n"); ).replace(/\r\n/g, "\n");
const runtimeImageBuildJenkinsTemplate = readFileSync(
resolveInsideRoot(
"mcp/ktWorkflow/ci/jenkins/KT-NapCatQQ-Runtime-Image-Build.Jenkinsfile",
),
"utf8",
).replace(/\r\n/g, "\n");
for (const expected of [ for (const expected of [
"RUN_CODEX_AUDIT", "RUN_CODEX_AUDIT",
"CONFIRM_CODEX_WORKSPACE_WRITE", "CONFIRM_CODEX_WORKSPACE_WRITE",
@ -626,6 +636,10 @@ export async function runSelfTest(): Promise<void> {
runtimeReleaseJenkinsTemplate, runtimeReleaseJenkinsTemplate,
"NapCat runtime", "NapCat runtime",
); );
assertNapCatJenkinsUsesNasAgent(
runtimeImageBuildJenkinsTemplate,
"NapCat runtime image build",
);
/** /**
* Verifies that a NapCat Jenkins template runs ktWorkflow inside a helper container with NAS mounts. * Verifies that a NapCat Jenkins template runs ktWorkflow inside a helper container with NAS mounts.
* @param template - Full Jenkinsfile text that Jenkins will execute. * @param template - Full Jenkinsfile text that Jenkins will execute.
@ -658,6 +672,44 @@ export async function runSelfTest(): Promise<void> {
runtimeReleaseJenkinsTemplate, runtimeReleaseJenkinsTemplate,
"NapCat runtime", "NapCat runtime",
); );
assertNapCatJenkinsUsesWorkflowRunner(
runtimeImageBuildJenkinsTemplate,
"NapCat runtime image build",
);
/**
* Ensures destructive Jenkins artifact cleanup is bounded by canonical NAS paths.
* @param template - Full Jenkinsfile text whose shell guard will run before `rm -rf`.
* @param name - Human-readable template name for the self-test failure.
* @returns Nothing; throws when the guard accepts raw `..` path prefixes.
*/
const assertNapCatJenkinsCanonicalArtifactRoot = (
template: string,
name: string,
): void => {
for (const expected of [
'ARTIFACTS_BASE_REAL=$(realpath -m /vol1/docker/kt-codex/artifacts)',
'ARTIFACT_ROOT_REAL=$(realpath -m "$KT_ARTIFACT_ROOT")',
'case "$ARTIFACT_ROOT_REAL" in',
'"$ARTIFACTS_BASE_REAL"/*) ;;',
'ARTIFACT_DIR="$ARTIFACT_ROOT_REAL/runtime-image-build"',
'BUILD_WORKDIR="$ARTIFACT_ROOT_REAL/runtime-image-build-workspace/$BUILD_ID_SAFE"',
]) {
if (!template.includes(expected)) {
throw new Error(
`${name} Jenkins template must canonicalize artifact roots before cleanup: ${expected}`,
);
}
}
if (template.includes('case "$KT_ARTIFACT_ROOT" in')) {
throw new Error(
`${name} Jenkins template must not validate destructive cleanup paths with a raw prefix check`,
);
}
};
assertNapCatJenkinsCanonicalArtifactRoot(
runtimeImageBuildJenkinsTemplate,
"NapCat runtime image build",
);
for (const forbidden of ["git merge", "git push", "docker build", "kubectl"]) { for (const forbidden of ["git merge", "git push", "docker build", "kubectl"]) {
if (upstreamSyncJenkinsTemplate.includes(forbidden)) { if (upstreamSyncJenkinsTemplate.includes(forbidden)) {
throw new Error( throw new Error(
@ -666,6 +718,8 @@ export async function runSelfTest(): Promise<void> {
} }
} }
for (const expected of [ for (const expected of [
"UPSTREAM_RELEASE_COMMIT",
"NAPCAT_BASE_IMAGE_DIGEST",
"choice(name: 'RUNTIME_BUILD_JOB'", "choice(name: 'RUNTIME_BUILD_JOB'",
"choice(name: 'API_PROMOTION_JOB'", "choice(name: 'API_PROMOTION_JOB'",
"CONFIRM_API_PROMOTION_TARGET", "CONFIRM_API_PROMOTION_TARGET",
@ -748,6 +802,56 @@ export async function runSelfTest(): Promise<void> {
"NapCat runtime Jenkins API promotion confirmation must run before API build job", "NapCat runtime Jenkins API promotion confirmation must run before API build job",
); );
} }
for (const expected of [
"NAPCAT_FORK_REF",
"NAPCAT_GIT_REPOSITORY = '/vol1/docker/kt-codex/git/NapCatQQ.git'",
"QQBOT_NAPCAT_IMAGE_OVERRIDE",
"QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE",
"NAPCAT_BASE_IMAGE_DIGEST",
"requireRuntimeImageParameters(",
"requireImageProfileMatch(",
"-v /var/run/docker.sock:/var/run/docker.sock",
"--user 0:0",
"API_GIT_REFERENCE_HOST",
"API_GIT_REFERENCE",
"-v \"$API_GIT_REFERENCE_HOST\":\"$API_GIT_REFERENCE\":ro",
"--cap-add SYS_ADMIN",
"--security-opt apparmor=unconfined",
"--security-opt seccomp=unconfined",
"-e NAPCAT_REQUIRE_DEVICE_PROFILE=1",
"git -C \"$API_ROOT\" fetch \"$API_GIT_REFERENCE\" refs/remotes/origin/main",
"git -C \"$API_ROOT\" merge --ff-only FETCH_HEAD",
"\"$ARTIFACT_DIR/api-root-commit.txt\"",
"git clone \"$NAPCAT_GIT_REPOSITORY\" \"$NAPCAT_ROOT\"",
"BUILD_WORKDIR=\"$ARTIFACT_ROOT_REAL/runtime-image-build-workspace/$BUILD_ID_SAFE\"",
"KT_ARTIFACT_ROOT must stay under /vol1/docker/kt-codex/artifacts",
"scripts/napcat-desktop-cn-stage-build.mjs",
"--napcat-root \"$NAPCAT_ROOT\"",
"--napcat-base-image-digest \"$NAPCAT_BASE_IMAGE_DIGEST\"",
"docker build",
"docker exec \"$VERIFY_NAME\" sh /ci/napcat-desktop-cn/verify.sh",
"archiveArtifacts artifacts: 'artifacts/napcat-runtime-image-build/**'",
]) {
if (!runtimeImageBuildJenkinsTemplate.includes(expected)) {
throw new Error(
`NapCat runtime image build Jenkins template missing: ${expected}`,
);
}
}
for (const forbidden of [
"PROMOTE_API_RUNTIME",
"CONFIRM_API_PROMOTION_TARGET",
"kubectl",
"git push",
"git merge",
"/vol1/docker/kt-codex/workspace/KT/GitHub/NapCatQQ",
]) {
if (runtimeImageBuildJenkinsTemplate.includes(forbidden)) {
throw new Error(
`NapCat runtime image build Jenkins template forbidden text leaked: ${forbidden}`,
);
}
}
const codexExecutionArtifactRoot = resolveInsideRoot( const codexExecutionArtifactRoot = resolveInsideRoot(
".kt-workspace/test-artifacts/napcat-codex-runner-self-test", ".kt-workspace/test-artifacts/napcat-codex-runner-self-test",
); );
@ -1018,6 +1122,78 @@ export async function runSelfTest(): Promise<void> {
) { ) {
throw new Error("TASKS record governance self-check failed"); throw new Error("TASKS record governance self-check failed");
} }
const taskRecordLimitFindings = findTaskRecordGovernanceFindings([
"## 最近记录",
"### 2026-06-13记录一",
"- 范围root",
"- 关键词TASKS 治理",
"- 验证global-review",
"### 2026-06-12记录二",
"- 范围root",
"- 关键词TASKS 治理",
"- 验证global-review",
"### 2026-06-11记录三",
"- 范围root",
"- 关键词TASKS 治理",
"- 验证global-review",
"### 2026-06-10记录四",
"- 范围root",
"- 关键词TASKS 治理",
"- 验证global-review",
"## 历史索引",
]);
if (
!taskRecordLimitFindings.some(
(item) => item.category === "tasks-recent-record-limit",
)
) {
throw new Error("TASKS recent record limit self-check failed");
}
const taskHardLimitFindings = findTaskRecordGovernanceFindings([
"# TASKS.md",
"",
"## 当前上下文",
"",
"## 维护规则",
"",
"## 最近记录",
"### 2026-07-03记录一",
"- 范围root",
`- 关键词:${"TASKS 治理".repeat(60)}`,
" - 子弹列表塞长细节",
"补充验证:不允许用补充段绕过三字段结构。",
"- 验证global-review",
"## 历史索引",
"",
"### Root Governance / Workflow",
`- 2026-07-03${"历史索引也不能塞长流水。".repeat(20)}`,
...Array.from({ length: 700 }, () => "填充总体大小上限"),
]);
for (const category of [
"tasks-file-size-limit",
"tasks-record-field-too-long",
"tasks-record-freeform-line",
"tasks-history-item-too-long",
]) {
if (!taskHardLimitFindings.some((item) => item.category === category)) {
throw new Error(`TASKS hard limit self-check failed: ${category}`);
}
}
const taskSectionFindings = findTaskRecordGovernanceFindings([
"# TASKS.md",
"",
"## 当前上下文",
"## 最近记录",
"## 维护规则",
"## 历史索引",
]);
if (
!taskSectionFindings.some(
(item) => item.category === "tasks-section-order",
)
) {
throw new Error("TASKS section order self-check failed");
}
const napcatImageFindings = findNapcatImageGovernanceFindings( const napcatImageFindings = findNapcatImageGovernanceFindings(
["QQBOT_NAPCAT_IMAGE=mlikiowa/napcat-docker:latest"], ["QQBOT_NAPCAT_IMAGE=mlikiowa/napcat-docker:latest"],
@ -2052,6 +2228,33 @@ export async function runSelfTest(): Promise<void> {
if ((obsidianValidation.summary as { errors?: number }).errors !== 0) { if ((obsidianValidation.summary as { errors?: number }).errors !== 0) {
throw new Error("obsidian validation self-check failed"); throw new Error("obsidian validation self-check failed");
} }
const obsidianChecks = obsidianValidation.checks as {
base?: { requiredViews?: number; views?: number };
canvas?: { fileNodeCount?: number; groupCount?: number; indexLinkCount?: number };
moduleFrontmatter?: number;
nestedDocs?: number;
};
if (
!obsidianChecks.base ||
obsidianChecks.base.views !== obsidianChecks.base.requiredViews
) {
throw new Error("obsidian base view self-check failed");
}
if (!obsidianChecks.canvas || obsidianChecks.canvas.groupCount !== 5) {
throw new Error("obsidian canvas lane self-check failed");
}
if (obsidianChecks.canvas.fileNodeCount !== 0) {
throw new Error("obsidian canvas preview-node self-check failed");
}
if (!obsidianChecks.canvas.indexLinkCount) {
throw new Error("obsidian canvas index-link self-check failed");
}
if (!obsidianChecks.moduleFrontmatter) {
throw new Error("obsidian module frontmatter self-check failed");
}
if (obsidianChecks.nestedDocs !== 0) {
throw new Error("obsidian nested docs guard self-check failed");
}
const cleanupCliParser = { const cleanupCliParser = {
defaultDryRun: parseCliCleanupArgs(["node", "server", "--cleanup-history"]) defaultDryRun: parseCliCleanupArgs(["node", "server", "--cleanup-history"])
@ -2101,6 +2304,22 @@ export async function runSelfTest(): Promise<void> {
], ],
project: "api", project: "api",
}); });
const docSyncCli = parseChangeDocSyncCliArgs([
"node",
"server",
"--change-doc-sync",
"--project",
"api",
"--file",
"src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task.service.ts",
]);
if (
docSyncCli.project !== "api" ||
docSyncCli.changedFiles?.[0] !==
"src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task.service.ts"
) {
throw new Error("doc sync CLI parser self-check failed");
}
const docSyncRequiredDocs = docSyncBangDream.requiredDocs as Array<{ const docSyncRequiredDocs = docSyncBangDream.requiredDocs as Array<{
path: string; path: string;
}>; }>;

View File

@ -3,6 +3,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { import {
parseChangeDocSyncCliArgs,
parseNasCodexBootstrapCliArgs, parseNasCodexBootstrapCliArgs,
parseDeployObservationCliArgs, parseDeployObservationCliArgs,
parseGlobalReviewCliArgs, parseGlobalReviewCliArgs,
@ -17,6 +18,7 @@ import {
} from './core/cli.js'; } from './core/cli.js';
import { cleanupHistoryArtifacts, parseCliCleanupArgs } from './tools/cleanup.js'; import { cleanupHistoryArtifacts, parseCliCleanupArgs } from './tools/cleanup.js';
import { buildWorkstreamCloseout } from './tools/closeout.js'; import { buildWorkstreamCloseout } from './tools/closeout.js';
import { buildChangeDocSync } from './tools/docSync.js';
import { buildDeployObservation } from './tools/deployObservation.js'; import { buildDeployObservation } from './tools/deployObservation.js';
import { readObsidianContext, syncObsidianWorkflow, validateObsidianVault } from './tools/obsidian.js'; import { readObsidianContext, syncObsidianWorkflow, validateObsidianVault } from './tools/obsidian.js';
import { buildGlobalCodeReview } from './tools/review.js'; import { buildGlobalCodeReview } from './tools/review.js';
@ -63,6 +65,14 @@ if (process.argv.includes('--cleanup-history')) {
console.log(JSON.stringify(readObsidianContext(parseObsidianContextCliArgs(process.argv)), null, 2)); console.log(JSON.stringify(readObsidianContext(parseObsidianContextCliArgs(process.argv)), null, 2));
} else if (process.argv.includes('--obsidian-sync')) { } else if (process.argv.includes('--obsidian-sync')) {
console.log(JSON.stringify(syncObsidianWorkflow(parseObsidianSyncCliArgs(process.argv)), null, 2)); console.log(JSON.stringify(syncObsidianWorkflow(parseObsidianSyncCliArgs(process.argv)), null, 2));
} else if (process.argv.includes('--change-doc-sync')) {
console.log(
JSON.stringify(
await buildChangeDocSync(parseChangeDocSyncCliArgs(process.argv)),
null,
2,
),
);
} else if (process.argv.includes('--deploy-observation')) { } else if (process.argv.includes('--deploy-observation')) {
console.log( console.log(
JSON.stringify( JSON.stringify(

View File

@ -34,9 +34,9 @@ const docSyncRules: DocSyncRule[] = [
reason: 'KtTable 组件或迁移规则变更,需要同步专项模块和 skill。', reason: 'KtTable 组件或迁移规则变更,需要同步专项模块和 skill。',
}, },
{ {
docs: ['Vue/kt-blog-web/README.md', 'docs/obsidian/modules/KT 模块 - Blog Web.md', 'skills/kt-blog-argon-workflow/SKILL.md'], docs: ['Vue/kt-blog-web/README.md', 'docs/obsidian/modules/KT 模块 - Blog Web.md', 'skills/kt-component-specialist/SKILL.md'],
match: /Vue\/kt-blog-web\/|blog|argon/i, match: /Vue\/kt-blog-web\/|blog|argon/i,
reason: 'Blog/Argon 能力变更,需要同步博客 README、模块页和专项 skill。', reason: 'Blog/Argon 能力变更,需要同步博客 README、模块页和根内专项 skill;已安装 Codex skill 仅作为执行时加载入口。',
}, },
{ {
docs: ['Plugins/fnos-k8s-dashboard-fpk/README.md', 'Plugins/fnos-k8s-dashboard-fpk/AGENTS.md', 'docs/obsidian/modules/KT 模块 - Plugins.md'], docs: ['Plugins/fnos-k8s-dashboard-fpk/README.md', 'Plugins/fnos-k8s-dashboard-fpk/AGENTS.md', 'docs/obsidian/modules/KT 模块 - Plugins.md'],
@ -44,12 +44,25 @@ const docSyncRules: DocSyncRule[] = [
reason: 'fnOS K8s 插件变更,需要同步插件 README/AGENTS 和 Obsidian 模块。', reason: 'fnOS K8s 插件变更,需要同步插件 README/AGENTS 和 Obsidian 模块。',
}, },
{ {
docs: ['mcp/ktWorkflow/README.md', 'AGENTS.md', 'TASKS.md', 'docs/kt-testing-workflows.md', 'docs/obsidian/KT 工作流工程化.md'], docs: [
'mcp/ktWorkflow/README.md',
'AGENTS.md',
'TASKS.md',
'docs/kt-stabilized-runtime-rules.md',
'docs/kt-testing-workflows.md',
'docs/obsidian/KT 工作流工程化.md',
],
match: /mcp\/ktWorkflow|src\/tools\/|src\/registerTools|src\/core|workflow|cleanup|review|selfTest/i, match: /mcp\/ktWorkflow|src\/tools\/|src\/registerTools|src\/core|workflow|cleanup|review|selfTest/i,
reason: 'ktWorkflow 或自动化规则变更,需要同步 README、根规则、测试流程和当前任务记录。', reason: 'ktWorkflow 或自动化规则变更,需要同步 README、根规则、测试流程和当前任务记录。',
}, },
{ {
docs: ['AGENTS.md', 'SKILLS.md', 'docs/kt-context-semantics.md', 'docs/obsidian/modules/KT 模块 - Root Governance.md'], docs: [
'AGENTS.md',
'SKILLS.md',
'docs/kt-context-semantics.md',
'docs/kt-stabilized-runtime-rules.md',
'docs/obsidian/modules/KT 模块 - Root Governance.md',
],
match: /AGENTS\.md|SKILLS\.md|skills\/.*\/SKILL\.md|docs\/kt-context-semantics\.md/, match: /AGENTS\.md|SKILLS\.md|skills\/.*\/SKILL\.md|docs\/kt-context-semantics\.md/,
reason: '上下文治理或 skill 规则变更,需要同步根治理文档和 Obsidian 模块。', reason: '上下文治理或 skill 规则变更,需要同步根治理文档和 Obsidian 模块。',
}, },
@ -118,6 +131,7 @@ export async function buildChangeDocSync(input: ChangeDocSyncInput = {}): Promis
return { return {
changedFiles, changedFiles,
commands: [ commands: [
'pnpm --dir mcp/ktWorkflow run change-doc-sync -- --project <project>',
'pnpm --dir mcp/ktWorkflow run obsidian-context -- --query <module>', 'pnpm --dir mcp/ktWorkflow run obsidian-context -- --query <module>',
'pnpm --dir mcp/ktWorkflow run obsidian-validate', 'pnpm --dir mcp/ktWorkflow run obsidian-validate',
'pnpm --dir mcp/ktWorkflow run global-review', 'pnpm --dir mcp/ktWorkflow run global-review',

View File

@ -21,6 +21,8 @@ interface CanvasNode {
file?: string; file?: string;
height?: number; height?: number;
id?: string; id?: string;
label?: string;
text?: string;
type?: string; type?: string;
width?: number; width?: number;
x?: number; x?: number;
@ -44,7 +46,9 @@ interface ObsidianSettings {
} }
interface BookmarkItem { interface BookmarkItem {
items?: BookmarkItem[];
path?: string; path?: string;
title?: string;
type?: string; type?: string;
} }
@ -173,11 +177,33 @@ const expectedIgnoredRoots = [
'.git/', '.git/',
'.kt-workspace/', '.kt-workspace/',
'Node/', 'Node/',
'Plugins/',
'Vue/', 'Vue/',
'Plugins/',
'mcp/', 'mcp/',
'GitHub/',
'Other/',
'workspace-assets/',
]; ];
const requiredBaseViewNames = [
'开工路由',
'模块-仓库矩阵',
'工作流入口',
'验证与收尾',
'运行态与远程排查',
'文档归口',
];
const requiredCanvasGroupLabels = [
'00 当前上下文',
'10 路由索引',
'20 模块仓库',
'30 工作流执行',
'40 验证与固化',
];
const canvasTextNodeMaxChars = 180;
const defaultContextPaths = [ const defaultContextPaths = [
'docs/obsidian/KT 知识图谱.md', 'docs/obsidian/KT 知识图谱.md',
'docs/obsidian/modules/KT 模块索引.md', 'docs/obsidian/modules/KT 模块索引.md',
@ -297,6 +323,12 @@ function readTextFile(relativePath: string): string {
return readFileSync(resolveInsideRoot(relativePath), 'utf8'); return readFileSync(resolveInsideRoot(relativePath), 'utf8');
} }
/**
* Resolves the Obsidian display title from frontmatter, the first H1, or the file name.
* @param content - Markdown file content read from the KT vault.
* @param fallback - Workspace-relative file path used when title metadata is absent.
* @returns The title shown in Obsidian context output.
*/
function getMarkdownTitle(content: string, fallback: string): string { function getMarkdownTitle(content: string, fallback: string): string {
return ( return (
content.match(/^title:\s*(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, '') || content.match(/^title:\s*(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, '') ||
@ -305,6 +337,11 @@ function getMarkdownTitle(content: string, fallback: string): string {
); );
} }
/**
* Extracts tags from the YAML frontmatter subset used by KT Obsidian notes.
* @param content - Markdown content that may start with frontmatter.
* @returns Tag values without additional normalization.
*/
function getMarkdownTags(content: string): string[] { function getMarkdownTags(content: string): string[] {
const frontmatter = content.match(/^---\n([\s\S]*?)\n---/); const frontmatter = content.match(/^---\n([\s\S]*?)\n---/);
if (!frontmatter) return []; if (!frontmatter) return [];
@ -316,6 +353,59 @@ function getMarkdownTags(content: string): string[] {
.filter((tag): tag is string => Boolean(tag)); .filter((tag): tag is string => Boolean(tag));
} }
/**
* Removes matching quote wrappers from a YAML-like scalar used by KT Obsidian metadata.
* @param value - Raw scalar value from frontmatter or a Base view line.
* @returns The unquoted scalar value.
*/
function stripYamlQuotes(value: string): string {
return value.trim().replace(/^["']|["']$/g, '');
}
/**
* Parses the small frontmatter subset that KT uses for Obsidian routing fields.
* @param content - Markdown content with optional leading frontmatter.
* @returns Frontmatter fields as scalar strings or string arrays.
*/
function parseFrontmatterFields(content: string): Map<string, string | string[]> {
const frontmatter = content.match(/^---\n([\s\S]*?)\n---/);
const fields = new Map<string, string | string[]>();
if (!frontmatter?.[1]) return fields;
const lines = frontmatter[1].split(/\r?\n/);
let activeArrayKey: string | null = null;
for (const line of lines) {
const arrayItem = line.match(/^\s+-\s+(.+)$/);
if (arrayItem?.[1] && activeArrayKey) {
const current = fields.get(activeArrayKey);
const values = Array.isArray(current) ? current : [];
values.push(stripYamlQuotes(arrayItem[1]));
fields.set(activeArrayKey, values);
continue;
}
const keyValue = line.match(/^([A-Za-z0-9_-]+):(?:\s*(.*))?$/);
if (!keyValue?.[1]) {
activeArrayKey = null;
continue;
}
const key = keyValue[1];
const rawValue = keyValue[2]?.trim() || '';
if (!rawValue) {
fields.set(key, []);
activeArrayKey = key;
continue;
}
fields.set(key, stripYamlQuotes(rawValue));
activeArrayKey = null;
}
return fields;
}
function extractWikiLinks(content: string): string[] { function extractWikiLinks(content: string): string[] {
return [...content.matchAll(/\[\[([^\]\n]+)\]\]/g)] return [...content.matchAll(/\[\[([^\]\n]+)\]\]/g)]
.map((match) => match[1]?.split('|')[0]?.split('#')[0]?.trim()) .map((match) => match[1]?.split('|')[0]?.split('#')[0]?.trim())
@ -380,6 +470,27 @@ function buildContextTerms(input: ObsidianContextInput): string[] {
.filter(Boolean); .filter(Boolean);
} }
/**
* Extracts linked index targets from text nodes so Canvas can stay readable without file previews.
* @param nodes - Parsed JSON Canvas nodes from the KT relationship graph.
* @returns Text-node link summaries used by obsidian-context output.
*/
function extractCanvasIndexLinks(nodes: CanvasNode[]): Array<{ color?: string; id?: string; links: string[] }> {
return nodes
.filter((node) => node.type === 'text' && node.text)
.map((node) => ({
color: node.color,
id: node.id,
links: extractWikiLinks(node.text || ''),
}))
.filter((node) => node.links.length > 0);
}
/**
* Reads a lightweight Canvas summary for context routing without embedding full note previews.
* @param includeCanvas - Explicit false disables the Canvas summary for callers that need smaller output.
* @returns Canvas node/edge counts plus indexed wikilink targets, or null when disabled/unreadable.
*/
function readCanvasSummary(includeCanvas: boolean | undefined): Record<string, unknown> | null { function readCanvasSummary(includeCanvas: boolean | undefined): Record<string, unknown> | null {
if (includeCanvas === false) return null; if (includeCanvas === false) return null;
@ -398,6 +509,7 @@ function readCanvasSummary(includeCanvas: boolean | undefined): Record<string, u
return { return {
edges: Array.isArray(canvas.edges) ? canvas.edges.length : 0, edges: Array.isArray(canvas.edges) ? canvas.edges.length : 0,
fileNodes, fileNodes,
indexLinks: extractCanvasIndexLinks(nodes),
nodes: nodes.length, nodes: nodes.length,
}; };
} }
@ -676,15 +788,27 @@ function rectanglesOverlap(a: CanvasNode, b: CanvasNode): boolean {
); );
} }
function validateCanvas(findings: ObsidianFinding[], checkLayout: boolean): { edgeCount: number; nodeCount: number } { /**
* Validates JSON Canvas IDs, file targets, edge references, lane groups, and optional visual overlap.
* @param findings - Shared finding accumulator for the current Obsidian validation run.
* @param checkLayout - Whether non-group node overlap should be reported as a warning.
* @returns Basic Canvas counts for command output summaries.
*/
function validateCanvas(
findings: ObsidianFinding[],
checkLayout: boolean,
): { edgeCount: number; fileNodeCount: number; groupCount: number; indexLinkCount: number; nodeCount: number } {
const canvasPath = 'docs/obsidian/KT 关系图谱.canvas'; const canvasPath = 'docs/obsidian/KT 关系图谱.canvas';
const canvas = readJsonFile<CanvasDocument>(canvasPath, findings); const canvas = readJsonFile<CanvasDocument>(canvasPath, findings);
if (!canvas) return { edgeCount: 0, nodeCount: 0 }; if (!canvas) return { edgeCount: 0, fileNodeCount: 0, groupCount: 0, indexLinkCount: 0, nodeCount: 0 };
const nodes = Array.isArray(canvas.nodes) ? canvas.nodes : []; const nodes = Array.isArray(canvas.nodes) ? canvas.nodes : [];
const edges = Array.isArray(canvas.edges) ? canvas.edges : []; const edges = Array.isArray(canvas.edges) ? canvas.edges : [];
const fileNodeCount = nodes.filter((node) => node.type === 'file').length;
const indexLinkCount = extractCanvasIndexLinks(nodes).reduce((count, node) => count + node.links.length, 0);
const nodeIds = new Set<string>(); const nodeIds = new Set<string>();
const edgeIds = new Set<string>(); const edgeIds = new Set<string>();
const groupLabels = new Set(nodes.filter((node) => node.type === 'group').map((node) => node.label).filter(Boolean));
for (const node of nodes) { for (const node of nodes) {
if (!node.id) { if (!node.id) {
@ -708,6 +832,26 @@ function validateCanvas(findings: ObsidianFinding[], checkLayout: boolean): { ed
} }
nodeIds.add(node.id); nodeIds.add(node.id);
if (node.type === 'file') {
findings.push({
check: 'canvas-node',
level: 'error',
message: 'KT 关系图谱必须使用文本索引节点,不能使用会嵌入全文预览的 file 节点。',
path: canvasPath,
target: node.id,
});
}
if (node.type === 'text' && (node.text?.length || 0) > canvasTextNodeMaxChars) {
findings.push({
check: 'canvas-node',
level: 'error',
message: 'Canvas 文本节点过长,可能在 Obsidian 中出现内部滚动条。',
path: canvasPath,
target: `${node.id || 'unknown'}:${node.text?.length || 0}`,
});
}
if (node.type === 'file' && node.file && !existsSync(resolveInsideRoot(node.file))) { if (node.type === 'file' && node.file && !existsSync(resolveInsideRoot(node.file))) {
findings.push({ findings.push({
check: 'canvas-file', check: 'canvas-file',
@ -752,6 +896,18 @@ function validateCanvas(findings: ObsidianFinding[], checkLayout: boolean): { ed
} }
} }
requiredCanvasGroupLabels
.filter((label) => !groupLabels.has(label))
.forEach((label) => {
findings.push({
check: 'canvas-group',
level: 'error',
message: 'Canvas 缺少工程任务流泳道。',
path: canvasPath,
target: label,
});
});
if (checkLayout) { if (checkLayout) {
const visibleNodes = nodes.filter((node) => node.type !== 'group'); const visibleNodes = nodes.filter((node) => node.type !== 'group');
for (let index = 0; index < visibleNodes.length; index += 1) { for (let index = 0; index < visibleNodes.length; index += 1) {
@ -771,9 +927,57 @@ function validateCanvas(findings: ObsidianFinding[], checkLayout: boolean): { ed
} }
} }
return { edgeCount: edges.length, nodeCount: nodes.length }; return {
edgeCount: edges.length,
fileNodeCount,
groupCount: groupLabels.size,
indexLinkCount,
nodeCount: nodes.length,
};
} }
/**
* Extracts Obsidian Base view names from the repository-owned YAML subset without adding a parser dependency.
* @param content - Text content of `KT 文档矩阵.base`.
* @returns View names declared below the `views:` section.
*/
function extractBaseViewNames(content: string): string[] {
const names: string[] = [];
const lines = content.split(/\r?\n/);
let inViews = false;
let currentView = false;
for (const line of lines) {
if (/^views:\s*$/.test(line)) {
inViews = true;
currentView = false;
continue;
}
if (inViews && /^[A-Za-z_][A-Za-z0-9_-]*:\s*/.test(line)) {
break;
}
if (!inViews) continue;
if (/^\s*-\s+type:\s+/.test(line)) {
currentView = true;
continue;
}
const name = line.match(/^\s+name:\s+(.+)$/)?.[1];
if (currentView && name) {
names.push(stripYamlQuotes(name));
currentView = false;
}
}
return names;
}
/**
* Validates the engineering Base shape used by the KT Obsidian document matrix.
* @param findings - Shared finding accumulator for the current Obsidian validation run.
* @returns Base section and view summary for command output.
*/
function validateBaseFile(findings: ObsidianFinding[]): Record<string, unknown> { function validateBaseFile(findings: ObsidianFinding[]): Record<string, unknown> {
const basePath = 'docs/obsidian/KT 文档矩阵.base'; const basePath = 'docs/obsidian/KT 文档矩阵.base';
if (!existsSync(resolveInsideRoot(basePath))) { if (!existsSync(resolveInsideRoot(basePath))) {
@ -799,13 +1003,94 @@ function validateBaseFile(findings: ObsidianFinding[]): Record<string, unknown>
} }
} }
const viewNames = extractBaseViewNames(content);
requiredBaseViewNames
.filter((viewName) => !viewNames.includes(viewName))
.forEach((viewName) => {
findings.push({
check: 'base-view',
level: 'error',
message: 'Base 缺少工程控制视图。',
path: basePath,
target: viewName,
});
});
return { return {
hasCardsView: content.includes('type: cards'), hasCardsView: content.includes('type: cards'),
hasTableView: content.includes('type: table'), hasTableView: content.includes('type: table'),
views: [...content.matchAll(/^\s*-\s+type:\s+/gm)].length, requiredViews: requiredBaseViewNames.length,
viewNames,
views: viewNames.length,
}; };
} }
/**
* Validates that module MOC files carry routing metadata for Base views and `obsidian-context`.
* @param findings - Shared finding accumulator for the current Obsidian validation run.
* @returns Number of module notes inspected.
*/
function validateModuleFrontmatter(findings: ObsidianFinding[]): number {
const moduleFiles = listFiles(resolveInsideRoot('docs/obsidian/modules'), new Set(['.md']))
.map(relativeToWorkspace)
.filter((file) => file !== 'docs/obsidian/modules/KT 模块索引.md');
const routeFields = ['rule_anchors', 'code_anchors', 'workflow_entries', 'validation_entries'];
for (const file of moduleFiles) {
const fields = parseFrontmatterFields(readTextFile(file));
for (const requiredField of ['kt_type', 'module']) {
const value = fields.get(requiredField);
if (!value || (Array.isArray(value) && value.length === 0)) {
findings.push({
check: 'module-frontmatter',
level: 'error',
message: '模块页缺少 Obsidian 工程路由 frontmatter 字段。',
path: file,
target: requiredField,
});
}
}
const hasRouteField = routeFields.some((field) => {
const value = fields.get(field);
return Array.isArray(value) ? value.length > 0 : Boolean(value);
});
if (!hasRouteField) {
findings.push({
check: 'module-frontmatter',
level: 'error',
message: '模块页没有 rule/code/workflow/validation 任一工程锚点。',
path: file,
target: routeFields.join('|'),
});
}
}
return moduleFiles.length;
}
/**
* Rejects accidental nested Obsidian documents created by resolving wiki links from inside `docs/obsidian`.
* @param findings - Shared finding accumulator for the current Obsidian validation run.
* @returns Number of nested files found, or 1 when only the invalid directory exists.
*/
function validateNoNestedObsidianDocs(findings: ObsidianFinding[]): number {
const nestedRoot = 'docs/obsidian/docs';
const absoluteRoot = resolveInsideRoot(nestedRoot);
if (!existsSync(absoluteRoot)) return 0;
const nestedFiles = listFiles(absoluteRoot, new Set(['.base', '.canvas', '.json', '.md']))
.map(relativeToWorkspace);
findings.push({
check: 'obsidian-nested-docs',
level: 'error',
message: 'docs/obsidian 下出现误嵌套 docs 目录Obsidian 索引只能放在 docs/obsidian 根层和 modules 层。',
path: nestedRoot,
target: nestedFiles.join(', ') || nestedRoot,
});
return nestedFiles.length || 1;
}
function validateStaleReferences(findings: ObsidianFinding[]): number { function validateStaleReferences(findings: ObsidianFinding[]): number {
const scanFiles = listFiles(workspaceRoot, new Set(['.md', '.ps1', '.sh', '.ts', '.yaml', '.yml'])); const scanFiles = listFiles(workspaceRoot, new Set(['.md', '.ps1', '.sh', '.ts', '.yaml', '.yml']));
let hitCount = 0; let hitCount = 0;
@ -855,6 +1140,8 @@ export function validateObsidianVault(input: ObsidianValidateInput = {}): Record
const base = validateBaseFile(findings); const base = validateBaseFile(findings);
const canvas = validateCanvas(findings, options.checkLayout); const canvas = validateCanvas(findings, options.checkLayout);
const moduleFrontmatterCount = validateModuleFrontmatter(findings);
const nestedDocsCount = validateNoNestedObsidianDocs(findings);
const wikiLinkCount = validateWikiLinks(findings); const wikiLinkCount = validateWikiLinks(findings);
const markdownLinkCount = options.includeMarkdownLinks ? validateMarkdownLinks(findings) : 0; const markdownLinkCount = options.includeMarkdownLinks ? validateMarkdownLinks(findings) : 0;
const staleReferenceCount = options.includeStaleReferences ? validateStaleReferences(findings) : 0; const staleReferenceCount = options.includeStaleReferences ? validateStaleReferences(findings) : 0;
@ -867,6 +1154,8 @@ export function validateObsidianVault(input: ObsidianValidateInput = {}): Record
canvas, canvas,
configFiles: obsidianConfigFiles.length, configFiles: obsidianConfigFiles.length,
markdownLinks: markdownLinkCount, markdownLinks: markdownLinkCount,
moduleFrontmatter: moduleFrontmatterCount,
nestedDocs: nestedDocsCount,
staleReferences: staleReferenceCount, staleReferences: staleReferenceCount,
wikiLinks: wikiLinkCount, wikiLinks: wikiLinkCount,
}, },
@ -954,6 +1243,18 @@ function readOptionalJson<T>(relativePath: string): T | null {
} }
} }
/**
* Recursively collects file paths from Obsidian bookmarks, including grouped bookmark sections.
* @param items - Bookmark items from `.obsidian/bookmarks.json`.
* @returns File paths referenced by bookmark entries.
*/
function collectBookmarkPaths(items: BookmarkItem[]): string[] {
return items.flatMap((item) => [
...(item.path ? [item.path] : []),
...(item.items ? collectBookmarkPaths(item.items) : []),
]);
}
function buildSyncFindings(): ObsidianFinding[] { function buildSyncFindings(): ObsidianFinding[] {
const findings: ObsidianFinding[] = []; const findings: ObsidianFinding[] = [];
const missingFiles = expectedObsidianFiles.filter((file) => !existsSync(resolveInsideRoot(file))); const missingFiles = expectedObsidianFiles.filter((file) => !existsSync(resolveInsideRoot(file)));
@ -968,7 +1269,7 @@ function buildSyncFindings(): ObsidianFinding[] {
}); });
const bookmarks = readOptionalJson<BookmarkSettings>('.obsidian/bookmarks.json'); const bookmarks = readOptionalJson<BookmarkSettings>('.obsidian/bookmarks.json');
const bookmarkPaths = new Set((bookmarks?.items || []).map((item) => item.path).filter(Boolean)); const bookmarkPaths = new Set(collectBookmarkPaths(bookmarks?.items || []));
requiredBookmarkPaths requiredBookmarkPaths
.filter((file) => !bookmarkPaths.has(file)) .filter((file) => !bookmarkPaths.has(file))
.forEach((file) => { .forEach((file) => {

View File

@ -68,6 +68,16 @@ const napcatImageAssignmentPattern =
/^\s*QQBOT_NAPCAT_IMAGE\s*=\s*([^#\s]+)\s*(?:#.*)?$/; /^\s*QQBOT_NAPCAT_IMAGE\s*=\s*([^#\s]+)\s*(?:#.*)?$/;
const taskRecordAllowedFields = new Set(["范围", "关键词", "验证"]); const taskRecordAllowedFields = new Set(["范围", "关键词", "验证"]);
const taskRecordRequiredFields = ["范围", "关键词", "验证"] as const; const taskRecordRequiredFields = ["范围", "关键词", "验证"] as const;
const taskRequiredSecondLevelSections = [
"当前上下文",
"维护规则",
"最近记录",
"历史索引",
] as const;
const taskFileMaxBytes = 14 * 1024;
const taskHistoryItemMaxChars = 180;
const taskRecordFieldMaxChars = 360;
const taskRecentRecordLimit = 3;
interface ReviewFinding { interface ReviewFinding {
category: string; category: string;
@ -92,11 +102,22 @@ interface ReviewProjectResult {
trackedSensitiveFiles: string[]; trackedSensitiveFiles: string[];
} }
/**
* Checks the root task index for compact recent-record governance.
* @param lines - Current `TASKS.md` content split by line, used to inspect the `## 最近记录` section without rereading files.
* @returns Review findings for missing fields, extra fields, or too many detailed recent task records.
*/
export function findTaskRecordGovernanceFindings( export function findTaskRecordGovernanceFindings(
lines: string[], lines: string[],
): ReviewFinding[] { ): ReviewFinding[] {
const findings: ReviewFinding[] = []; const findings: ReviewFinding[] = [];
findings.push(...findTaskFileShapeFindings(lines));
const recentStart = lines.findIndex((line) => line.trim() === "## 最近记录"); const recentStart = lines.findIndex((line) => line.trim() === "## 最近记录");
const historyStart = lines.findIndex((line) => line.trim() === "## 历史索引");
if (historyStart >= 0) {
findings.push(...findTaskHistoryIndexFindings(lines, historyStart));
}
if (recentStart < 0) return findings; if (recentStart < 0) return findings;
const nextSectionOffset = lines const nextSectionOffset = lines
@ -104,6 +125,22 @@ export function findTaskRecordGovernanceFindings(
.findIndex((line) => /^##\s+/.test(line.trim())); .findIndex((line) => /^##\s+/.test(line.trim()));
const recentEnd = const recentEnd =
nextSectionOffset < 0 ? lines.length : recentStart + 1 + nextSectionOffset; nextSectionOffset < 0 ? lines.length : recentStart + 1 + nextSectionOffset;
const recordHeadings = lines
.slice(recentStart + 1, recentEnd)
.map((line, offset) => ({ line, lineNumber: recentStart + 2 + offset }))
.filter((item) => item.line.trim().startsWith("### "));
if (recordHeadings.length > taskRecentRecordLimit) {
findings.push({
category: "tasks-recent-record-limit",
file: "TASKS.md",
line: recordHeadings[taskRecentRecordLimit]?.lineNumber,
level: "P2",
message: `TASKS 最近记录只允许保留 ${taskRecentRecordLimit} 条详细记录,当前为 ${recordHeadings.length} 条。`,
project: "root",
suggestion:
"保留最新 3 条详细记录,其余迁入「历史索引」的关键词条目。",
});
}
let currentRecord: { let currentRecord: {
fields: Set<string>; fields: Set<string>;
heading: string; heading: string;
@ -142,13 +179,38 @@ export function findTaskRecordGovernanceFindings(
} }
if (!currentRecord) continue; if (!currentRecord) continue;
if (!line) continue;
const fieldMatch = line.match(/^-\s*([^:]+)[:]/); const fieldMatch = line.match(/^-\s*([^:]+)[:]/);
if (!fieldMatch) continue; if (!fieldMatch) {
findings.push({
category: "tasks-record-freeform-line",
file: "TASKS.md",
line: index + 1,
level: "P2",
message: `TASKS 最近记录「${currentRecord.heading}」包含非三字段正文。`,
project: "root",
suggestion:
"最近记录只允许三条 bullet范围、关键词、验证补充段、子列表、表格、代码块全部迁入 docs 或历史索引。",
});
continue;
}
const field = fieldMatch[1].trim(); const field = fieldMatch[1].trim();
if (taskRecordAllowedFields.has(field)) { if (taskRecordAllowedFields.has(field)) {
currentRecord.fields.add(field); currentRecord.fields.add(field);
if (line.length > taskRecordFieldMaxChars) {
findings.push({
category: "tasks-record-field-too-long",
file: "TASKS.md",
line: index + 1,
level: "P2",
message: `TASKS 最近记录字段「${field}」过长,当前 ${line.length} 字,限制 ${taskRecordFieldMaxChars} 字。`,
project: "root",
suggestion:
"把长证据、长复现和长命令移入 docs 或历史索引短关键词,最近记录只保留摘要。",
});
}
continue; continue;
} }
@ -168,6 +230,97 @@ export function findTaskRecordGovernanceFindings(
return findings; return findings;
} }
/**
* Checks the whole task index shape before section-specific recent-record checks.
* @param lines - Current `TASKS.md` content split by line.
* @returns Review findings for total size or required second-level section drift.
*/
function findTaskFileShapeFindings(lines: string[]): ReviewFinding[] {
const findings: ReviewFinding[] = [];
const byteLength = Buffer.byteLength(lines.join("\n"), "utf8");
if (byteLength > taskFileMaxBytes) {
findings.push({
category: "tasks-file-size-limit",
file: "TASKS.md",
level: "P2",
message: `TASKS.md 只能作为压缩索引,当前 ${byteLength} bytes限制 ${taskFileMaxBytes} bytes。`,
project: "root",
suggestion:
"删除流水账、长验证、截图清单和完整命令;必要细节放入 docsTASKS 只保留索引。",
});
}
const sectionHeadings = lines
.map((line, index) => ({ line, lineNumber: index + 1 }))
.filter((item) => /^##\s+/.test(item.line.trim()));
const actualSections = sectionHeadings.map((item) =>
item.line.trim().replace(/^##\s+/, ""),
);
const expectedSections = [...taskRequiredSecondLevelSections];
const hasExactSectionOrder =
actualSections.length === expectedSections.length &&
expectedSections.every((section, index) => actualSections[index] === section);
if (!hasExactSectionOrder) {
findings.push({
category: "tasks-section-order",
file: "TASKS.md",
line: sectionHeadings[0]?.lineNumber ?? 1,
level: "P2",
message: `TASKS.md 二级章节必须固定为 ${expectedSections.join(" -> ")}`,
project: "root",
suggestion:
"不要新增临时章节、补充章节或任务分组;把额外内容压缩进最近记录三字段或历史索引分类 bullet。",
});
}
return findings;
}
/**
* Checks the compact history index so old work cannot become another long-form log sink.
* @param lines - Current `TASKS.md` content split by line.
* @param historyStart - Zero-based line index of `## 历史索引`.
* @returns Review findings for long or free-form history index content.
*/
function findTaskHistoryIndexFindings(
lines: string[],
historyStart: number,
): ReviewFinding[] {
const findings: ReviewFinding[] = [];
for (let index = historyStart + 1; index < lines.length; index += 1) {
const line = lines[index].trim();
if (!line) continue;
if (line.startsWith("### ")) continue;
if (!line.startsWith("- ")) {
findings.push({
category: "tasks-history-freeform-line",
file: "TASKS.md",
line: index + 1,
level: "P2",
message: "TASKS 历史索引只能使用分类标题和单行关键词 bullet。",
project: "root",
suggestion:
"删除段落、代码块、表格和补充说明;历史索引只保留可检索关键词。",
});
continue;
}
if (line.length <= taskHistoryItemMaxChars) continue;
findings.push({
category: "tasks-history-item-too-long",
file: "TASKS.md",
line: index + 1,
level: "P2",
message: `TASKS 历史索引条目过长,当前 ${line.length} 字,限制 ${taskHistoryItemMaxChars} 字。`,
project: "root",
suggestion:
"把旧任务压缩成日期、主题和关键词;长背景移入 docs 或删除。",
});
}
return findings;
}
export function findNapcatImageGovernanceFindings( export function findNapcatImageGovernanceFindings(
lines: string[], lines: string[],
context: { context: {