Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
daaabf07fc
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,4 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.turbo/
|
||||
.kt-workspace/
|
||||
*.log
|
||||
|
||||
12
README.md
12
README.md
@ -18,7 +18,7 @@
|
||||
- 静态检查 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 plan;upstream audit 在显式 `execute=true` 且 `useCodex=true` 时可运行 Codex,NAS 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` 转储、备份库和行数校验。
|
||||
- 生成或执行部署观测:把 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 检查命令,不直接杀进程。
|
||||
- 清理历史产物:统一治理 `.kt-workspace` 下的测试/验证产物,按目录最近修改时间只保留最近 3 轮,模板目录永久保留;CLI 默认 dry-run,真实清理必须显式传 `--execute`。
|
||||
- 检查 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`,确认后再落盘。
|
||||
- 生成提交前检查清单:校验 KT commit message 约定。
|
||||
|
||||
@ -40,6 +40,7 @@ pnpm run self-test
|
||||
pnpm run obsidian-context -- --module ktWorkflow
|
||||
pnpm run obsidian-validate
|
||||
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 napcat-upstream-audit -- --artifact-root .kt-workspace/test-artifacts/napcat-upstream-sync
|
||||
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,保留人工审查边界。
|
||||
- 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`。
|
||||
- 两个模板默认使用 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 或其他真实凭据。
|
||||
|
||||
## 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 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-sync-candidate-review` | 输出 sync candidate review 的 prompt/context/只读命令骨架,不执行 Codex 或 Git 写操作。 |
|
||||
| `pnpm run napcat-runtime-release-readiness` | 输出 runtime release readiness 的 prompt/context/只读检查骨架,不构建镜像、不调用 kubectl、不发布。 |
|
||||
|
||||
212
ci/jenkins/KT-NapCatQQ-Runtime-Image-Build.Jenkinsfile
Normal file
212
ci/jenkins/KT-NapCatQQ-Runtime-Image-Build.Jenkinsfile
Normal 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.')
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,8 @@ pipeline {
|
||||
parameters {
|
||||
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_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_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.')
|
||||
@ -38,6 +40,8 @@ pipeline {
|
||||
-e KT_ARTIFACT_ROOT="$KT_ARTIFACT_ROOT" \
|
||||
-e NAPCAT_FORK_REF="$NAPCAT_FORK_REF" \
|
||||
-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_DESKTOP_PROFILE_VERSION_OVERRIDE="$QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE" \
|
||||
-e COREPACK_HOME=/vol1/docker/kt-codex/runtime/corepack \
|
||||
@ -72,6 +76,8 @@ pipeline {
|
||||
parameters: [
|
||||
string(name: 'NAPCAT_FORK_REF', value: params.NAPCAT_FORK_REF),
|
||||
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_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")
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
"description": "Reusable MCP workflow tools for the KT workspace.",
|
||||
"scripts": {
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type {
|
||||
ChangeDocSyncInput,
|
||||
DeployObservationInput,
|
||||
GlobalCodeReviewInput,
|
||||
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 {
|
||||
for (const name of names) {
|
||||
const keyValue = argv.find((item) => item.startsWith(`${name}=`));
|
||||
|
||||
219
src/selfTest.ts
219
src/selfTest.ts
@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
parseChangeDocSyncCliArgs,
|
||||
parseNasCodexBootstrapCliArgs,
|
||||
parseDeployObservationCliArgs,
|
||||
parseGlobalReviewCliArgs,
|
||||
@ -408,6 +409,9 @@ export async function runSelfTest(): Promise<void> {
|
||||
if (!packageJson.scripts?.["napcat-remote-dev-handoff"]) {
|
||||
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 fakeMcpServer = {
|
||||
/**
|
||||
@ -524,6 +528,12 @@ export async function runSelfTest(): Promise<void> {
|
||||
),
|
||||
"utf8",
|
||||
).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 [
|
||||
"RUN_CODEX_AUDIT",
|
||||
"CONFIRM_CODEX_WORKSPACE_WRITE",
|
||||
@ -626,6 +636,10 @@ export async function runSelfTest(): Promise<void> {
|
||||
runtimeReleaseJenkinsTemplate,
|
||||
"NapCat runtime",
|
||||
);
|
||||
assertNapCatJenkinsUsesNasAgent(
|
||||
runtimeImageBuildJenkinsTemplate,
|
||||
"NapCat runtime image build",
|
||||
);
|
||||
/**
|
||||
* Verifies that a NapCat Jenkins template runs ktWorkflow inside a helper container with NAS mounts.
|
||||
* @param template - Full Jenkinsfile text that Jenkins will execute.
|
||||
@ -658,6 +672,44 @@ export async function runSelfTest(): Promise<void> {
|
||||
runtimeReleaseJenkinsTemplate,
|
||||
"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"]) {
|
||||
if (upstreamSyncJenkinsTemplate.includes(forbidden)) {
|
||||
throw new Error(
|
||||
@ -666,6 +718,8 @@ export async function runSelfTest(): Promise<void> {
|
||||
}
|
||||
}
|
||||
for (const expected of [
|
||||
"UPSTREAM_RELEASE_COMMIT",
|
||||
"NAPCAT_BASE_IMAGE_DIGEST",
|
||||
"choice(name: 'RUNTIME_BUILD_JOB'",
|
||||
"choice(name: 'API_PROMOTION_JOB'",
|
||||
"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",
|
||||
);
|
||||
}
|
||||
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(
|
||||
".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");
|
||||
}
|
||||
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(
|
||||
["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) {
|
||||
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 = {
|
||||
defaultDryRun: parseCliCleanupArgs(["node", "server", "--cleanup-history"])
|
||||
@ -2101,6 +2304,22 @@ export async function runSelfTest(): Promise<void> {
|
||||
],
|
||||
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<{
|
||||
path: string;
|
||||
}>;
|
||||
|
||||
@ -3,6 +3,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
|
||||
import {
|
||||
parseChangeDocSyncCliArgs,
|
||||
parseNasCodexBootstrapCliArgs,
|
||||
parseDeployObservationCliArgs,
|
||||
parseGlobalReviewCliArgs,
|
||||
@ -17,6 +18,7 @@ import {
|
||||
} from './core/cli.js';
|
||||
import { cleanupHistoryArtifacts, parseCliCleanupArgs } from './tools/cleanup.js';
|
||||
import { buildWorkstreamCloseout } from './tools/closeout.js';
|
||||
import { buildChangeDocSync } from './tools/docSync.js';
|
||||
import { buildDeployObservation } from './tools/deployObservation.js';
|
||||
import { readObsidianContext, syncObsidianWorkflow, validateObsidianVault } from './tools/obsidian.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));
|
||||
} else if (process.argv.includes('--obsidian-sync')) {
|
||||
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')) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
|
||||
@ -34,9 +34,9 @@ const docSyncRules: DocSyncRule[] = [
|
||||
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,
|
||||
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'],
|
||||
@ -44,12 +44,25 @@ const docSyncRules: DocSyncRule[] = [
|
||||
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,
|
||||
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/,
|
||||
reason: '上下文治理或 skill 规则变更,需要同步根治理文档和 Obsidian 模块。',
|
||||
},
|
||||
@ -118,6 +131,7 @@ export async function buildChangeDocSync(input: ChangeDocSyncInput = {}): Promis
|
||||
return {
|
||||
changedFiles,
|
||||
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-validate',
|
||||
'pnpm --dir mcp/ktWorkflow run global-review',
|
||||
|
||||
@ -21,6 +21,8 @@ interface CanvasNode {
|
||||
file?: string;
|
||||
height?: number;
|
||||
id?: string;
|
||||
label?: string;
|
||||
text?: string;
|
||||
type?: string;
|
||||
width?: number;
|
||||
x?: number;
|
||||
@ -44,7 +46,9 @@ interface ObsidianSettings {
|
||||
}
|
||||
|
||||
interface BookmarkItem {
|
||||
items?: BookmarkItem[];
|
||||
path?: string;
|
||||
title?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
@ -173,11 +177,33 @@ const expectedIgnoredRoots = [
|
||||
'.git/',
|
||||
'.kt-workspace/',
|
||||
'Node/',
|
||||
'Plugins/',
|
||||
'Vue/',
|
||||
'Plugins/',
|
||||
'mcp/',
|
||||
'GitHub/',
|
||||
'Other/',
|
||||
'workspace-assets/',
|
||||
];
|
||||
|
||||
const requiredBaseViewNames = [
|
||||
'开工路由',
|
||||
'模块-仓库矩阵',
|
||||
'工作流入口',
|
||||
'验证与收尾',
|
||||
'运行态与远程排查',
|
||||
'文档归口',
|
||||
];
|
||||
|
||||
const requiredCanvasGroupLabels = [
|
||||
'00 当前上下文',
|
||||
'10 路由索引',
|
||||
'20 模块仓库',
|
||||
'30 工作流执行',
|
||||
'40 验证与固化',
|
||||
];
|
||||
|
||||
const canvasTextNodeMaxChars = 180;
|
||||
|
||||
const defaultContextPaths = [
|
||||
'docs/obsidian/KT 知识图谱.md',
|
||||
'docs/obsidian/modules/KT 模块索引.md',
|
||||
@ -297,6 +323,12 @@ function readTextFile(relativePath: string): string {
|
||||
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 {
|
||||
return (
|
||||
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[] {
|
||||
const frontmatter = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!frontmatter) return [];
|
||||
@ -316,6 +353,59 @@ function getMarkdownTags(content: string): string[] {
|
||||
.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[] {
|
||||
return [...content.matchAll(/\[\[([^\]\n]+)\]\]/g)]
|
||||
.map((match) => match[1]?.split('|')[0]?.split('#')[0]?.trim())
|
||||
@ -380,6 +470,27 @@ function buildContextTerms(input: ObsidianContextInput): string[] {
|
||||
.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 {
|
||||
if (includeCanvas === false) return null;
|
||||
|
||||
@ -398,6 +509,7 @@ function readCanvasSummary(includeCanvas: boolean | undefined): Record<string, u
|
||||
return {
|
||||
edges: Array.isArray(canvas.edges) ? canvas.edges.length : 0,
|
||||
fileNodes,
|
||||
indexLinks: extractCanvasIndexLinks(nodes),
|
||||
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 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 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 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) {
|
||||
if (!node.id) {
|
||||
@ -708,6 +832,26 @@ function validateCanvas(findings: ObsidianFinding[], checkLayout: boolean): { ed
|
||||
}
|
||||
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))) {
|
||||
findings.push({
|
||||
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) {
|
||||
const visibleNodes = nodes.filter((node) => node.type !== 'group');
|
||||
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> {
|
||||
const basePath = 'docs/obsidian/KT 文档矩阵.base';
|
||||
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 {
|
||||
hasCardsView: content.includes('type: cards'),
|
||||
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 {
|
||||
const scanFiles = listFiles(workspaceRoot, new Set(['.md', '.ps1', '.sh', '.ts', '.yaml', '.yml']));
|
||||
let hitCount = 0;
|
||||
@ -855,6 +1140,8 @@ export function validateObsidianVault(input: ObsidianValidateInput = {}): Record
|
||||
|
||||
const base = validateBaseFile(findings);
|
||||
const canvas = validateCanvas(findings, options.checkLayout);
|
||||
const moduleFrontmatterCount = validateModuleFrontmatter(findings);
|
||||
const nestedDocsCount = validateNoNestedObsidianDocs(findings);
|
||||
const wikiLinkCount = validateWikiLinks(findings);
|
||||
const markdownLinkCount = options.includeMarkdownLinks ? validateMarkdownLinks(findings) : 0;
|
||||
const staleReferenceCount = options.includeStaleReferences ? validateStaleReferences(findings) : 0;
|
||||
@ -867,6 +1154,8 @@ export function validateObsidianVault(input: ObsidianValidateInput = {}): Record
|
||||
canvas,
|
||||
configFiles: obsidianConfigFiles.length,
|
||||
markdownLinks: markdownLinkCount,
|
||||
moduleFrontmatter: moduleFrontmatterCount,
|
||||
nestedDocs: nestedDocsCount,
|
||||
staleReferences: staleReferenceCount,
|
||||
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[] {
|
||||
const findings: ObsidianFinding[] = [];
|
||||
const missingFiles = expectedObsidianFiles.filter((file) => !existsSync(resolveInsideRoot(file)));
|
||||
@ -968,7 +1269,7 @@ function buildSyncFindings(): ObsidianFinding[] {
|
||||
});
|
||||
|
||||
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
|
||||
.filter((file) => !bookmarkPaths.has(file))
|
||||
.forEach((file) => {
|
||||
|
||||
@ -68,6 +68,16 @@ const napcatImageAssignmentPattern =
|
||||
/^\s*QQBOT_NAPCAT_IMAGE\s*=\s*([^#\s]+)\s*(?:#.*)?$/;
|
||||
const taskRecordAllowedFields = new Set(["范围", "关键词", "验证"]);
|
||||
const taskRecordRequiredFields = ["范围", "关键词", "验证"] as const;
|
||||
const taskRequiredSecondLevelSections = [
|
||||
"当前上下文",
|
||||
"维护规则",
|
||||
"最近记录",
|
||||
"历史索引",
|
||||
] as const;
|
||||
const taskFileMaxBytes = 14 * 1024;
|
||||
const taskHistoryItemMaxChars = 180;
|
||||
const taskRecordFieldMaxChars = 360;
|
||||
const taskRecentRecordLimit = 3;
|
||||
|
||||
interface ReviewFinding {
|
||||
category: string;
|
||||
@ -92,11 +102,22 @@ interface ReviewProjectResult {
|
||||
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(
|
||||
lines: string[],
|
||||
): ReviewFinding[] {
|
||||
const findings: ReviewFinding[] = [];
|
||||
findings.push(...findTaskFileShapeFindings(lines));
|
||||
|
||||
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;
|
||||
|
||||
const nextSectionOffset = lines
|
||||
@ -104,6 +125,22 @@ export function findTaskRecordGovernanceFindings(
|
||||
.findIndex((line) => /^##\s+/.test(line.trim()));
|
||||
const recentEnd =
|
||||
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: {
|
||||
fields: Set<string>;
|
||||
heading: string;
|
||||
@ -142,13 +179,38 @@ export function findTaskRecordGovernanceFindings(
|
||||
}
|
||||
|
||||
if (!currentRecord) continue;
|
||||
if (!line) continue;
|
||||
|
||||
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();
|
||||
if (taskRecordAllowedFields.has(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;
|
||||
}
|
||||
|
||||
@ -168,6 +230,97 @@ export function findTaskRecordGovernanceFindings(
|
||||
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:
|
||||
"删除流水账、长验证、截图清单和完整命令;必要细节放入 docs,TASKS 只保留索引。",
|
||||
});
|
||||
}
|
||||
|
||||
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(
|
||||
lines: string[],
|
||||
context: {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user