Compare commits

..

31 Commits

Author SHA1 Message Date
490160189e fix: 兼容Jenkins模板自检换行 2026-06-25 10:30:00 +08:00
598180615c chore: 完善NapCat自动化Jenkins归档 2026-06-25 09:47:04 +08:00
b8b46a04c3 fix: 适配NAS Codex nvm运行时 2026-06-25 09:41:16 +08:00
e2a50f03b3 chore: 收敛工作区资产清理规则 2026-06-25 09:27:32 +08:00
1abfcbf59d test: 加固NapCat上游审计Jenkins门禁 2026-06-25 04:03:21 +08:00
f9ce9b6015 fix: 修正NapCat运行时发布确认门禁 2026-06-25 03:51:46 +08:00
36da962bb0 fix: 收紧NapCatQQ自动化Jenkins入口 2026-06-25 03:46:22 +08:00
4fc7c4a776 docs: 增加NapCatQQ自动化Jenkins入口 2026-06-25 03:26:01 +08:00
d53b17f6f1 feat: 增加NAS Codex环境引导工具 2026-06-25 02:20:47 +08:00
30222fd85e fix: 透传NapCat自动化命令覆盖参数 2026-06-25 02:04:45 +08:00
fc750ddcf1 fix: 对齐NapCat自动化入口默认值 2026-06-25 01:52:13 +08:00
6fc5e65986 test: 覆盖NapCat审计artifactRoot解析 2026-06-25 01:31:55 +08:00
b767732fd3 feat: 接入NapCat自动化MCP工具和CLI 2026-06-25 01:24:31 +08:00
c16a867c60 fix: 加固NapCat Codex审计执行器 2026-06-25 01:04:57 +08:00
3f7ed99aec feat: 封装NapCat Codex审计执行器 2026-06-25 00:47:17 +08:00
99408d3cec fix: 补强NapCat审计Windows路径拒绝 2026-06-25 00:30:20 +08:00
7619dd5928 fix: 强化NapCat审计只读与路径校验 2026-06-25 00:22:49 +08:00
1f60506c89 fix: 补齐NapCat上游审计干合并探测 2026-06-25 00:08:05 +08:00
4b18c1bec8 feat: 增加NapCat上游审计采集器 2026-06-25 00:00:59 +08:00
d5e9bf5b01 fix: 收紧NapCat提示词加载边界 2026-06-24 23:47:19 +08:00
e29ce3be86 feat: 增加NapCat自动化提示词包 2026-06-24 23:36:40 +08:00
9052ecbc2a fix: 收紧NapCat审计输出契约 2026-06-24 23:24:56 +08:00
efc6f50199 feat: 定义NapCat自动化审计契约 2026-06-24 23:12:21 +08:00
32d02b5a8f chore: 更新NapCat登录诊断与设备护栏 2026-06-23 20:20:31 +08:00
7075d863c2 feat: 增加NapCat设备画像检查 2026-06-18 18:45:20 +08:00
c2377f96ef fix: 增加QQBot插件生产路径审查 2026-06-18 09:14:53 +08:00
8ec21c8f46 fix: 修复部署观测Jenkins状态判定 2026-06-16 01:22:44 +08:00
81d8fc67f8 fix: 放行NapCat新设备审查规则 2026-06-15 06:15:33 +08:00
ae2231d508 fix: 固化QQBot插件测试审查 2026-06-15 05:33:27 +08:00
1c768c1e79 fix: 修复部署观测提交匹配假阴性 2026-06-14 23:08:18 +08:00
e559e0ee55 feat: 添加API部署观测工作流 2026-06-14 22:51:47 +08:00
25 changed files with 5266 additions and 38 deletions

View File

@ -15,15 +15,18 @@
- 生成页面测试用例:内置“先写用例、可视化证据、事不过三”的测试闭环。
- 生成接口测试计划:接口改动后输出真实调用命令和统一返回结构断言。
- 生成业务链路测试计划:固化 Admin 登录、博客 CRUD、QQBot 扫码/自动回复、更新登录 SSE、FFLogs 命令、系统日志可视化、Web/Playground 回跳。
- 静态检查 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、不触发发布。
- 生成卡点固化记录:把超时、卡进程、远程命令误写、重复失败整理成“问题点 / 稳定解法 / 后续入口 / 验证证据”,避免原样重试。
- 生成改动文档同步计划:按变更文件自动提示需要同步的 README、API、AGENTS、docs、Obsidian、skill 和 ktWorkflow 入口。
- 生成多仓库提交/推送计划:按仓库分组、建议提交信息、列出提交和推送前检查。
- 生成远程只读健康检查和数据库同步安全向导:覆盖飞牛 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:` 为准,日志存在但缺少最终态时不认定发布完成。
- 生成专项组件工作流KtTable、BlogArgon、AdminAuth、QQBot、FF14Plugin、NapCatLogin、SystemLog、Knife4jSwagger、FnosK8s 的防踩坑清单和验证点。
- 生成验证进程清理计划:按项目路径和端口给出 PowerShell 检查命令,不直接杀进程。
- 清理历史产物:统一治理 `.kt-workspace` 下的测试/验证产物,按目录最近修改时间只保留最近 3 轮模板目录永久保留CLI 默认 dry-run真实清理必须显式传 `--execute`
- 检查 env 策略和变更风险:区分后端真实 env 与前端客户端 `.env*`提醒锁文件、核心表格组件、API 时间序列化 KtDateTime 列/DTO 装饰器入口、部署链路和 Vue TSX 插槽写法等高风险改动。
- 全局 CodeReview 只读扫描:汇总全部 KT 子仓库的 Git 状态、敏感文件跟踪、冲突标记、运行时调试输出、疑似凭据字面量、NapCat `latest` 镜像漂移风险、QQBot 重型服务单测导入风险、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` 最近记录字段结构和当前变更风险;默认只对变更文件做内容扫描,并放过短中文显示标签和明显测试占位 token避免历史误报污染上下文任何文件改动后都要跑一遍。
- 生成或写入 `TASKS.md` 最近记录:默认 `dryRun=true`,确认后再落盘。
- 生成提交前检查清单:校验 KT commit message 约定。
@ -38,13 +41,29 @@ pnpm run obsidian-context -- --module ktWorkflow
pnpm run obsidian-validate
pnpm run obsidian-sync
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
pnpm run napcat-runtime-release-readiness
pnpm run napcat-remote-dev-handoff
pnpm run nas-codex-bootstrap
pnpm run napcat-device-profile-check
pnpm run cleanup-history -- --dry-run
pnpm run cleanup-history -- --execute
pnpm run deploy-observation -- --project api --job KT-Template/KT-Template-API/main --namespace kt-prod --deployment kt-template-online-api --container api --health-url http://127.0.0.1:48085/health/runtime --smoke "curl -fsS --max-time 8 http://127.0.0.1:48085/health/runtime"
pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login
```
如果本机 Node/npm/pnpm 版本不对,先执行 `nvm ls` 查看已安装版本,再用 `nvm use <version>` 切换到目标版本;不要先扫盘找 `node.exe` 路径。
## Jenkins 入口模板
- `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 名被传入。
- 生产 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` 下。
- 模板只保存入口和参数形状,不记录 Jenkins credential、Token、SSH key 或其他真实凭据。
## MCP 客户端配置
把下面配置加入支持 stdio MCP 的客户端配置里:
@ -91,8 +110,15 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login
| `kt_commit_plan` | 生成多仓库提交计划、建议 commit message 和检查项 |
| `kt_push_plan` | 生成多仓库推送计划和远程异常提醒 |
| `kt_business_test_plan` | 生成固化业务链路测试计划,包含 QQBot SSE、FFLogs 和系统日志 |
| `kt_napcat_upstream_audit` | 生成或执行 NapCatQQ upstream release 审计,默认 dry-run显式 `execute/useCodex` 后才运行 Codex |
| `kt_napcat_sync_candidate_review` | 生成 NapCat sync candidate review 的 prompt/context/只读命令骨架Task 5 不执行 Codex 或 Git 写操作 |
| `kt_napcat_runtime_release_readiness` | 生成 NapCat runtime release readiness 的 prompt/context/只读检查骨架,不自动构建或发布 |
| `kt_napcat_remote_dev_handoff` | 生成 NapCat 自动化远程开发 handoff 的 prompt/context 骨架,不创建线程或同步仓库 |
| `kt_nas_codex_bootstrap_plan` | 生成可信 NAS Codex bootstrap dry-run/execute command planexecute 只规划目录准备和 nvm 管理的 Node/pnpm/Codex 安装,不复制 secrets、不写 systemd |
| `kt_napcat_device_profile_check` | 静态检查 API NapCat 设备身份护栏,覆盖 hostname、MAC、`machine-info`、runtime dir 和 DB timezone |
| `kt_blocker_resolution` | 生成或写入卡点固化记录,提醒停止原样重试 |
| `kt_remote_health_check` | 生成或执行远程只读健康检查命令 |
| `kt_deploy_observation` | 生成或执行 API 发布后的部署观测,汇总 Jenkins SCM revision、K8s、Pod、`/health/runtime` 和任务 smoke 证据 |
| `kt_db_sync_plan` | 生成数据库同步安全向导 |
| `kt_component_workflow` | 输出专项组件/链路防踩坑工作流,包含 FnosK8s |
| `kt_obsidian_context` | 读取 Obsidian 索引上下文,支持按 `module` / `query` 返回模块页、文档矩阵、Canvas 摘要、代码锚点和相关规则文档 |
@ -106,7 +132,14 @@ 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 重型服务单测导入风险、OneBot/QQ 登录态边界、NapCat 密码登录验证码 wait 阶段、日志窗口锚点、验证码状态早于日志 URL 的短轮询、验证码 pending 被状态轮询误清理、API Pod SSH 日志读取超时及清理前 pending 回归,以及普通处理中状态误吃旧验证码日志回归;明显测试占位 token 不报凭据风险,确认误报时优先升级 `src/tools/review.ts`,不要把误报沉积到上下文。 |
| `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 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、不发布。 |
| `pnpm run napcat-remote-dev-handoff` | 输出远程开发 handoff 的 prompt/context 骨架,不创建线程、不同步仓库、不执行 Codex。 |
| `pnpm run nas-codex-bootstrap` | 输出可信 NAS Codex bootstrap dry-run plan`--execute` 只生成目录准备和 nvm 管理的 Node/pnpm/Codex 安装命令计划,不真实执行 SSH、不复制 secrets、不写 systemd。 |
| `pnpm run napcat-device-profile-check` | 静态检查 API NapCat 设备身份护栏,防止回退到未知设备风险配置。 |
| `pnpm run deploy-observation` | 默认 dry-run 输出只读 NAS 观测命令;传 `--execute` 时执行 Jenkins/K8s/health/smoke 观测并写入 `.kt-workspace/test-artifacts/deploy-observation`。 |
| `pnpm run obsidian-context` | 输出 Obsidian 索引上下文;可传 `--module Admin`、`--query QQBot`、`--max-documents 10`。 |
| `pnpm run obsidian-validate` | 校验 KT Obsidian vault 结构和链接;默认 warning 不让脚本失败,需要严格模式时传 `--fail-on-warnings`。 |
| `pnpm run obsidian-sync` | 审计 Obsidian 工作流入口是否连通,并联动执行 validate。 |
@ -145,12 +178,15 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login
- 要收尾时调用 `kt_finish_task`,默认只生成计划和 review需要执行验证时显式传 `runValidation=true`
- 大方向结束前调用 `kt_workstream_closeout``pnpm run workstream-closeout`;必须分别传入 `--review` 的 KT 全局审查证据和 `--superpowers-review` 的 Superpowers code review 证据;如果识别到测试流、卡点、误报、清理、部署观测或命令模板,要先升级对应 ktWorkflow 规则再报告完成。
- 文件改动完成并验证后调用 `kt_global_code_review`,或运行 `pnpm run global-review`;它只读扫描,不删除文件、不提交代码。`findings` 先判定真实风险或工具误报,真实风险修业务代码,误报修 `mcp/ktWorkflow/src/tools/review.ts` 并复跑。
- QQBot NapCat 登录 review 会检查验证码 pending、Docker 日志窗口、API Pod 读取超时,以及 `needNewDevice/jumpUrl` 是否进入 `GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin` 或兼容的 `deviceVerifyUrl` pending 状态。
- QQBot NapCat 二维码刷新卡在生成阶段时,优先计数当前容器近 5-20 分钟日志里的“重置已失效登录服务后重新生成二维码”、`Login Error,ErrType: 7 ErrCode: 4` 和“二维码已保存/二维码解码URL”如果 reset/error 数远高于二维码产出,按 `docs/qqbot-nas-runtime.md` 的 v5 稳定解法排查 NapCat native login service reset 风暴,先修 fork/镜像,再看 API SSE。
- 代码或配置改动后调用 `kt_change_doc_sync`,把需要同步的 README/API/AGENTS/docs/Obsidian/skill/ktWorkflow 入口补齐;无需同步时把原因写进收尾证据。
- 报告非平凡任务完成前调用 `kt_workflow_loop_audit`,确认测试证据、文档同步、历史清理最终 `deleted=0`、问题固化、ktWorkflow 升级、KT 全局 review 和 Superpowers code review 都过门;清理证据必须显式传入,不能依赖缺省值。
- 要提交或推送时先调用 `kt_commit_plan` / `kt_push_plan`,按仓库分组确认范围。
- 要测真实业务链路时调用 `kt_business_test_plan`,选择 `admin-login`、`qqbot-auto-reply`、`qqbot-login-sse`、`fflogs-command`、`system-log-visualization` 等 flow。
- 遇到同一命令或同一远程步骤重复卡住时调用 `kt_blocker_resolution`;第二次仍失败时先写入 `TASKS.md` 或补成脚本,再继续。
- 远程服务排查先调用 `kt_remote_health_check`,默认只生成只读命令;需要执行时显式传 `execute=true`
- API 推送触发 Jenkins/K8s 后先运行 `pnpm run deploy-observation -- --project api --job KT-Template/KT-Template-API/main --namespace kt-prod --deployment kt-template-online-api --container api --health-url http://127.0.0.1:48085/health/runtime --smoke "curl -fsS --max-time 8 http://127.0.0.1:48085/health/runtime"` 查看 dry-run 命令;确认需要线上只读观测时再加 `--execute`。Deployment/Pod 成功只算发布证据,功能完成还必须看 smoke 输出。
- 数据库同步前调用 `kt_db_sync_plan`,先确认源库、目标库、`.kt-workspace/db-sync` 转储目录、带时间戳的备份库和校验点。
- 改 KtTable、BlogArgon、AdminAuth、QQBot、FF14Plugin、NapCatLogin、SystemLog、Knife4jSwagger、FnosK8s 前调用 `kt_component_workflow`,先看专项禁区。
- 不确定任务边界时调用 `kt_guardrails`,先拿到“能做什么、不能做什么、怎么验证”。

View File

@ -0,0 +1,118 @@
pipeline {
agent any
options {
buildDiscarder(logRotator(numToKeepStr: '30', artifactNumToKeepStr: '30'))
disableConcurrentBuilds()
skipDefaultCheckout(true)
timestamps()
timeout(time: 60, unit: 'MINUTES')
}
parameters {
string(name: 'NAPCAT_FORK_REF', defaultValue: 'codex/qr-refresh-login-state', description: 'Reviewed NapCatQQ fork branch, tag, or commit.')
string(name: 'UPSTREAM_RELEASE_TAG', defaultValue: '', description: 'Reviewed upstream release tag, when applicable.')
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.')
choice(name: 'RUNTIME_BUILD_JOB', choices: ['KT-NapCatQQ/Runtime-Image-Build'], description: 'Whitelisted Jenkins job that performs the reviewed runtime image build and publish.')
booleanParam(name: 'PROMOTE_API_RUNTIME', defaultValue: false, description: 'When true, trigger API promotion with explicit image/profile overrides.')
choice(name: 'API_PROMOTION_JOB', choices: ['KT-Template/KT-Template-API/main'], description: 'Whitelisted API Jenkins job that accepts NapCat runtime override parameters.')
string(name: 'CONFIRM_API_PROMOTION_TARGET', defaultValue: '', description: 'Type PROMOTE_KT_TEMPLATE_API_MAIN before triggering the production API promotion job.')
}
environment {
KT_WORKSPACE_ROOT = '/vol1/docker/kt-codex/workspace/KT'
KT_WORKFLOW_DIR = '/vol1/docker/kt-codex/workspace/KT/mcp/ktWorkflow'
KT_ARTIFACT_ROOT = '/vol1/docker/kt-codex/artifacts'
}
stages {
stage('ktWorkflow readiness') {
steps {
sh '''
set -e
pnpm --dir "$KT_WORKFLOW_DIR" run napcat-runtime-release-readiness -- \
--artifact-root "$KT_ARTIFACT_ROOT/napcat-runtime-release" \
--fork-branch "$NAPCAT_FORK_REF" \
--upstream-release-tag "$UPSTREAM_RELEASE_TAG" \
--napcat-image-tag "$QQBOT_NAPCAT_IMAGE_OVERRIDE" \
--profile "$QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE"
'''
}
}
stage('Runtime image build and publish') {
when {
expression { return params.BUILD_RUNTIME_IMAGE }
}
steps {
script {
requireRuntimeOverrides(
params.QQBOT_NAPCAT_IMAGE_OVERRIDE,
params.QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE
)
build job: params.RUNTIME_BUILD_JOB,
wait: true,
parameters: [
string(name: 'NAPCAT_FORK_REF', value: params.NAPCAT_FORK_REF),
string(name: 'UPSTREAM_RELEASE_TAG', value: params.UPSTREAM_RELEASE_TAG),
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")
]
}
}
}
stage('API promotion') {
when {
expression { return params.PROMOTE_API_RUNTIME }
}
steps {
script {
if (params.CONFIRM_API_PROMOTION_TARGET != 'PROMOTE_KT_TEMPLATE_API_MAIN') {
error('CONFIRM_API_PROMOTION_TARGET must equal PROMOTE_KT_TEMPLATE_API_MAIN before API promotion runs.')
}
requireRuntimeOverrides(
params.QQBOT_NAPCAT_IMAGE_OVERRIDE,
params.QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE
)
build job: params.API_PROMOTION_JOB,
wait: true,
parameters: [
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)
]
}
}
}
}
post {
always {
sh '''
set +e
rm -rf "$WORKSPACE/artifacts"
mkdir -p "$WORKSPACE/artifacts/napcat-runtime-release"
cp -a "$KT_ARTIFACT_ROOT/napcat-runtime-release/." "$WORKSPACE/artifacts/napcat-runtime-release/" 2>/dev/null || true
'''
archiveArtifacts artifacts: 'artifacts/napcat-runtime-release/**/*', allowEmptyArchive: true, fingerprint: false
}
}
}
/**
* Stops runtime build or promotion unless both release override values are explicit.
* @param imageTag Reviewed NapCat runtime image tag supplied by the operator.
* @param profile Reviewed desktop profile version supplied by the operator.
* @return Nothing; aborts this pipeline through `error` when a required value is missing.
*/
void requireRuntimeOverrides(String imageTag, String profile) {
if (!imageTag?.trim()) {
error('QQBOT_NAPCAT_IMAGE_OVERRIDE is required before build or promotion.')
}
if (!profile?.trim()) {
error('QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE is required before build or promotion.')
}
}

View File

@ -0,0 +1,65 @@
pipeline {
agent any
options {
buildDiscarder(logRotator(numToKeepStr: '30', artifactNumToKeepStr: '30'))
disableConcurrentBuilds()
skipDefaultCheckout(true)
timestamps()
timeout(time: 30, unit: 'MINUTES')
}
parameters {
booleanParam(name: 'RUN_CODEX_AUDIT', defaultValue: false, description: 'When true, allow the optional Codex workspace-write audit after the default dry-run audit.')
string(name: 'CONFIRM_CODEX_WORKSPACE_WRITE', defaultValue: '', description: 'Type RUN_CODEX_WORKSPACE_WRITE_AUDIT to confirm Codex may run with a workspace-write sandbox.')
}
environment {
KT_WORKFLOW_DIR = '/vol1/docker/kt-codex/workspace/KT/mcp/ktWorkflow'
KT_ARTIFACT_ROOT = '/vol1/docker/kt-codex/artifacts'
}
stages {
stage('ktWorkflow upstream audit dry-run') {
steps {
sh '''
set -e
pnpm --dir "$KT_WORKFLOW_DIR" run napcat-upstream-audit -- \
--artifact-root "$KT_ARTIFACT_ROOT/napcat-upstream-sync/dry-run"
'''
}
}
stage('ktWorkflow upstream audit with Codex') {
when {
expression { return params.RUN_CODEX_AUDIT }
}
steps {
script {
if (params.CONFIRM_CODEX_WORKSPACE_WRITE != 'RUN_CODEX_WORKSPACE_WRITE_AUDIT') {
error('CONFIRM_CODEX_WORKSPACE_WRITE must equal RUN_CODEX_WORKSPACE_WRITE_AUDIT before Codex workspace-write audit runs.')
}
}
sh '''
set -e
pnpm --dir "$KT_WORKFLOW_DIR" run napcat-upstream-audit -- \
--execute \
--use-codex \
--artifact-root "$KT_ARTIFACT_ROOT/napcat-upstream-sync/codex"
'''
}
}
}
post {
always {
sh '''
set +e
rm -rf "$WORKSPACE/artifacts"
mkdir -p "$WORKSPACE/artifacts/napcat-upstream-sync"
cp -a "$KT_ARTIFACT_ROOT/napcat-upstream-sync/." "$WORKSPACE/artifacts/napcat-upstream-sync/" 2>/dev/null || true
'''
archiveArtifacts artifacts: 'artifacts/napcat-upstream-sync/**/*', allowEmptyArchive: true, fingerprint: false
}
}
}

View File

@ -7,7 +7,14 @@
"scripts": {
"admin-login": "node --import ./node_modules/tsx/dist/loader.mjs scripts/admin-login-smoke.ts",
"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",
"napcat-device-profile-check": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --napcat-device-profile-check",
"napcat-remote-dev-handoff": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --napcat-remote-dev-handoff",
"napcat-runtime-release-readiness": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --napcat-runtime-release-readiness",
"napcat-sync-candidate-review": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --napcat-sync-candidate-review",
"napcat-upstream-audit": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --napcat-upstream-audit",
"nas-codex-bootstrap": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --nas-codex-bootstrap",
"obsidian-context": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --obsidian-context",
"obsidian-sync": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --obsidian-sync",
"obsidian-validate": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --obsidian-validate",

View File

@ -0,0 +1,41 @@
# NapCat Remote Dev Handoff
You are summarizing a supplied remote development context packet for the next human-controlled step.
Do not edit files.
Do not run merge, commit, push, deploy, or Docker mutation commands.
Use only the supplied context packet.
Return JSON that matches the requested schema.
## Handoff Scope
Summarize only packet evidence for:
- repository branches and dirty status
- artifact directories and notable generated outputs
- Jenkins status, build numbers, commit/image pointers, and unknown deployment state
- blockers, missing evidence, or unsafe assumptions
- safe next commands that are read-only or dry-run oriented
Do not ask Codex to modify files. Do not include merge, commit, push, deploy, Docker mutation, or destructive cleanup commands as next commands.
## Requested Schema
Return only this JSON object:
```json
{
"repoStates": [
{
"repo": "Repository path or label.",
"branch": "Current branch or unknown.",
"dirtyStatus": "clean | dirty | unknown",
"head": "Commit hash or unknown."
}
],
"artifactDirectories": ["Artifact directory or missing evidence note."],
"jenkinsStatus": "Jenkins status summary or missing evidence note.",
"blockers": ["Blocker or missing evidence note."],
"safeNextCommands": ["Read-only or dry-run command."]
}
```

View File

@ -0,0 +1,34 @@
# NapCat Runtime Release Readiness
You are reviewing a supplied NapCat runtime release packet before a human promotes an image/profile into the API runtime.
Do not edit files.
Do not run merge, commit, push, deploy, or Docker mutation commands.
Use only the supplied context packet.
Return JSON that matches the requested schema.
## Readiness Checks
Base every section on packet evidence:
- source validation: branch, commit, upstream base, fork patch marker, and dirty status
- image validation: image tag, image inspect/build evidence, marker evidence, and runtime asset presence
- API promotion readiness: explicit image/profile values, configuration alignment, and Jenkins/API promotion prerequisites
- online smoke readiness: QQBot/NapCat login, WebUI, command smoke, and rollback smoke expectations
- rollback pointer: last known good branch, commit, image tag, profile, and API runtime values
If any section lacks evidence, state the missing evidence plainly.
## Requested Schema
Return only this JSON object:
```json
{
"sourceValidation": "Evidence-backed source validation status.",
"imageValidation": "Evidence-backed image validation status.",
"apiPromotionReadiness": "Evidence-backed API promotion readiness status.",
"onlineSmokeReadiness": "Evidence-backed online smoke readiness status.",
"rollbackPointer": "Specific rollback pointer or the missing evidence needed to name one."
}
```

View File

@ -0,0 +1,34 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": [
"sourceValidation",
"imageValidation",
"apiPromotionReadiness",
"onlineSmokeReadiness",
"rollbackPointer"
],
"properties": {
"sourceValidation": {
"type": "string",
"minLength": 1
},
"imageValidation": {
"type": "string",
"minLength": 1
},
"apiPromotionReadiness": {
"type": "string",
"minLength": 1
},
"onlineSmokeReadiness": {
"type": "string",
"minLength": 1
},
"rollbackPointer": {
"type": "string",
"minLength": 1
}
}
}

View File

@ -0,0 +1,33 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["classification", "reasonCodes", "recommendedAction", "summary"],
"properties": {
"classification": {
"enum": ["safe-candidate", "manual-review", "blocked"]
},
"reasonCodes": {
"type": "array",
"minItems": 1,
"items": {
"enum": [
"NO_UPSTREAM_CHANGE",
"HOT_ZONE_CHANGED",
"FORK_PATCH_OVERLAP",
"DRY_MERGE_CONFLICT",
"BUILD_GRAPH_CHANGED",
"METADATA_UNAVAILABLE",
"CODEX_SCHEMA_INVALID"
]
}
},
"recommendedAction": {
"enum": ["no-op", "create-candidate-branch", "request-human-review", "block"]
},
"summary": {
"type": "string",
"minLength": 1
}
}
}

View File

@ -0,0 +1,43 @@
# NapCat Sync Candidate Review
You are reviewing a supplied NapCat candidate branch context packet before a human decides whether to continue the sync.
Do not edit files.
Do not run merge, commit, push, deploy, or Docker mutation commands.
Use only the supplied context packet.
Return JSON that matches the requested schema.
## Review Scope
Review only evidence present in the packet. Cover these areas:
- login service reset behavior and stale native login service state
- QR refresh, stale QR prevention, QR expiry, and scan confirmation state
- WebUI login runtime state, reconciled QQ online state, and false online signals
- captcha and new-device boundaries, including pending state preservation
- package, shell, framework, webui build output, and runtime asset layout
- test gaps, missing smoke evidence, and unverified online behavior
## Finding Rules
Flag only actionable risks grounded in packet evidence. If evidence is missing, report it as a test gap or blocked review item instead of guessing.
## Requested Schema
Return only this JSON object:
```json
{
"decision": "approve | manual-review | block",
"summary": "Short review summary.",
"findings": [
{
"severity": "critical | important | minor",
"area": "login-reset | qr-refresh | webui-runtime | captcha-new-device | package-build | test-gap",
"evidence": "Packet evidence for the finding.",
"requiredAction": "Human-readable action before promotion."
}
],
"testGaps": ["Missing validation evidence."]
}
```

View File

@ -0,0 +1,53 @@
# NapCat Upstream Audit
You are reviewing a supplied NapCat upstream release context packet for KT automation. Decide whether the upstream release can become a candidate branch, needs manual review, or is blocked.
Do not edit files.
Do not run merge, commit, push, deploy, or Docker mutation commands.
Use only the supplied context packet.
Return JSON that matches the requested schema.
## Inputs
Use the packet sections for upstream release metadata, fork patch range, changed files, dry-merge result, build graph diff, and Codex parser status. Treat missing packet sections as unavailable metadata.
## Classification Rules
Choose exactly one `classification`:
- `safe-candidate`: no upstream change exists, or upstream changes are outside hot zones, do not overlap fork patches, dry merge is clean, build graph is stable, and metadata is available.
- `manual-review`: upstream changed login/runtime/build-adjacent areas or fork-adjacent code, but the packet does not prove a hard blocker.
- `blocked`: dry merge conflicts, build graph changes that require maintainer action, missing required metadata, or invalid Codex output schema.
Use one or more `reasonCodes` from this exact list:
- `NO_UPSTREAM_CHANGE`
- `HOT_ZONE_CHANGED`
- `FORK_PATCH_OVERLAP`
- `DRY_MERGE_CONFLICT`
- `BUILD_GRAPH_CHANGED`
- `METADATA_UNAVAILABLE`
- `CODEX_SCHEMA_INVALID`
## Hot Zones
Treat these as NapCat login/runtime hot zones:
- login service reset and native login service state
- QR refresh, stale QR, QR expiry, and scan confirmation state
- WebUI login runtime state and reconciled QQ online state
- captcha and new-device verification boundaries
- package, shell, framework, webui build graph or output layout
## Requested Schema
Return only this JSON object:
```json
{
"classification": "safe-candidate | manual-review | blocked",
"reasonCodes": ["NO_UPSTREAM_CHANGE"],
"recommendedAction": "no-op | create-candidate-branch | request-human-review | block",
"summary": "Short evidence-based decision."
}
```

View File

@ -1,5 +1,10 @@
import type {
DeployObservationInput,
GlobalCodeReviewInput,
NapcatRemoteDevHandoffInput,
NapcatRuntimeReleaseReadinessInput,
NapcatSyncCandidateReviewInput,
NasCodexBootstrapPlanInput,
ObsidianContextInput,
ObsidianSyncInput,
ObsidianValidateInput,
@ -7,6 +12,7 @@ import type {
WorkstreamReusablePattern,
} from '../types.js';
import { workstreamReusablePatternValues } from './constants.js';
import type { NapcatUpstreamAuditInput } from '../tools/napcatAutomation.types.js';
function parseContentScanMode(argv: string[]): 'all' | 'changed' {
if (argv.includes('--content-scan-all') || argv.includes('--contentScanMode=all')) {
@ -61,6 +67,16 @@ function readOptionList(argv: string[], names: string[]): string[] {
.filter(Boolean);
}
/**
* Checks whether any supported boolean CLI flag is present in the raw argv list.
* @param argv - Full process argv including node, script, mode flag, and user options.
* @param names - Flag names to accept in dashed or camelCase form.
* @returns True when the caller provided one of the named flags.
*/
function hasFlag(argv: string[], names: string[]): boolean {
return names.some((name) => argv.includes(name));
}
export function parseWorkstreamCloseoutCliArgs(argv: string[]): WorkstreamCloseoutInput {
const patternSet = new Set<string>(workstreamReusablePatternValues);
const patterns = readOptionList(argv, ['--pattern', '--reusable-pattern'])
@ -90,6 +106,167 @@ export function parseWorkstreamCloseoutCliArgs(argv: string[]): WorkstreamCloseo
};
}
export function parseDeployObservationCliArgs(argv: string[]): DeployObservationInput {
const sshPort = Number(readOption(argv, ['--ssh-port', '--sshPort']));
return {
artifactRoot: readOption(argv, ['--artifact-root', '--artifactRoot']),
buildNumber: readOption(argv, ['--build', '--build-number', '--buildNumber']),
container: readOption(argv, ['--container']),
deployment: readOption(argv, ['--deployment']),
execute: argv.includes('--execute'),
expectedCommit: readOption(argv, [
'--commit',
'--expected-commit',
'--expectedCommit',
]),
healthUrl: readOption(argv, ['--health-url', '--healthUrl']),
imageTag: readOption(argv, ['--image-tag', '--imageTag']),
jenkinsHome: readOption(argv, ['--jenkins-home', '--jenkinsHome']),
jobName: readOption(argv, ['--job', '--job-name', '--jobName']),
kubeconfigPath: readOption(argv, [
'--kubeconfig',
'--kubeconfig-path',
'--kubeconfigPath',
]),
namespace: readOption(argv, ['--namespace']),
project: readOption(argv, ['--project']),
selector: readOption(argv, ['--selector']),
smoke: readOption(argv, ['--smoke']),
sshPort: Number.isFinite(sshPort) ? sshPort : undefined,
sshTarget: readOption(argv, ['--ssh', '--ssh-target', '--sshTarget']),
};
}
/**
* Parses the NapCat upstream audit CLI mode into the deterministic audit builder input.
* @param argv - Full process argv from `src/server.ts`; `--execute` and `--use-codex` must both be explicit before Codex can run.
* @returns Upstream audit input with dry-run defaults for all optional metadata lists.
*/
export function parseNapcatUpstreamAuditCliArgs(
argv: string[],
): NapcatUpstreamAuditInput {
return {
artifactRoot: readOption(argv, ['--artifact-root', '--artifactRoot']),
codexHome: readOption(argv, ['--codex-home', '--codexHome']),
createCandidateBranch: hasFlag(argv, ['--create-candidate-branch', '--createCandidateBranch']),
dryMergeConflict: hasFlag(argv, ['--dry-merge-conflict', '--dryMergeConflict']),
execute: hasFlag(argv, ['--execute']),
forkBranch: readOption(argv, ['--fork-branch', '--forkBranch']),
forkPatchFiles: readOptionList(argv, [
'--fork-patch-file',
'--fork-patch-files',
'--forkPatchFile',
'--forkPatchFiles',
]),
forkRepo: readOption(argv, ['--fork-repo', '--forkRepo']),
lastAcceptedUpstreamBase: readOption(argv, [
'--last-accepted-upstream-base',
'--lastAcceptedUpstreamBase',
]),
outputSchemaPath: readOption(argv, ['--output-schema', '--outputSchemaPath']),
upstreamChangedFiles: readOptionList(argv, [
'--upstream-changed-file',
'--upstream-changed-files',
'--upstreamChangedFile',
'--upstreamChangedFiles',
]),
upstreamReleaseTag: readOption(argv, [
'--upstream-release-tag',
'--upstream-release-ref',
'--upstreamReleaseTag',
]),
upstreamRepo: readOption(argv, ['--upstream-repo', '--upstreamRepo']),
useCodex: hasFlag(argv, ['--use-codex', '--useCodex']),
workspaceRoot: readOption(argv, ['--workspace-root', '--workspaceRoot']),
};
}
/**
* Parses common dry-run NapCat prompt tool options shared by candidate review, readiness, and handoff modes.
* @param argv - Full process argv from a NapCat automation CLI script.
* @returns Prompt skeleton input with `execute` false unless explicitly requested by the caller.
*/
function parseNapcatPromptToolCliArgs(argv: string[]): NapcatSyncCandidateReviewInput {
return {
artifactRoot: readOption(argv, ['--artifact-root', '--artifactRoot']),
candidateBranch: readOption(argv, ['--candidate-branch', '--candidateBranch']),
execute: hasFlag(argv, ['--execute']),
forkBranch: readOption(argv, ['--fork-branch', '--forkBranch']),
forkRepo: readOption(argv, ['--fork-repo', '--forkRepo']),
lastAcceptedUpstreamBase: readOption(argv, [
'--last-accepted-upstream-base',
'--lastAcceptedUpstreamBase',
]),
upstreamReleaseTag: readOption(argv, [
'--upstream-release-tag',
'--upstream-release-ref',
'--upstreamReleaseTag',
]),
upstreamRepo: readOption(argv, ['--upstream-repo', '--upstreamRepo']),
useCodex: hasFlag(argv, ['--use-codex', '--useCodex']),
workspaceRoot: readOption(argv, ['--workspace-root', '--workspaceRoot']),
};
}
/**
* Parses the NapCat sync-candidate review CLI mode.
* @param argv - Full process argv from `pnpm run napcat-sync-candidate-review`.
* @returns Dry-run prompt skeleton input for human/Codex candidate review handoff.
*/
export function parseNapcatSyncCandidateReviewCliArgs(
argv: string[],
): NapcatSyncCandidateReviewInput {
return parseNapcatPromptToolCliArgs(argv);
}
/**
* Parses the NapCat runtime release readiness CLI mode.
* @param argv - Full process argv from `pnpm run napcat-runtime-release-readiness`.
* @returns Dry-run prompt skeleton input with image/profile hints and no default mutation.
*/
export function parseNapcatRuntimeReleaseReadinessCliArgs(
argv: string[],
): NapcatRuntimeReleaseReadinessInput {
return {
...parseNapcatPromptToolCliArgs(argv),
apiImageTag: readOption(argv, ['--api-image-tag', '--apiImageTag']),
napcatImageTag: readOption(argv, ['--napcat-image-tag', '--napcatImageTag']),
profile: readOption(argv, ['--profile']),
};
}
/**
* Parses the NapCat remote development handoff CLI mode.
* @param argv - Full process argv from `src/server.ts --napcat-remote-dev-handoff`.
* @returns Dry-run prompt skeleton input for a remote workspace handoff packet.
*/
export function parseNapcatRemoteDevHandoffCliArgs(
argv: string[],
): NapcatRemoteDevHandoffInput {
return {
...parseNapcatPromptToolCliArgs(argv),
targetHost: readOption(argv, ['--target-host', '--targetHost']),
};
}
/**
* Parses the NAS Codex bootstrap planning CLI mode.
* @param argv - Full process argv from `pnpm run nas-codex-bootstrap`.
* @returns Dry-run bootstrap plan input; `execute` is false unless the caller explicitly opts in.
*/
export function parseNasCodexBootstrapCliArgs(
argv: string[],
): NasCodexBootstrapPlanInput {
return {
artifactRoot: readOption(argv, ['--artifact-root', '--artifactRoot']),
codexHome: readOption(argv, ['--codex-home', '--codexHome']),
execute: hasFlag(argv, ['--execute']),
serviceName: readOption(argv, ['--service-name', '--serviceName']),
workspaceRoot: readOption(argv, ['--workspace-root', '--workspaceRoot']),
};
}
export function parseObsidianCliArgs(argv: string[]): ObsidianValidateInput {
return {
checkLayout: !argv.includes('--no-layout'),

View File

@ -69,8 +69,15 @@ export const registeredToolNames = [
'kt_commit_plan',
'kt_push_plan',
'kt_business_test_plan',
'kt_napcat_upstream_audit',
'kt_napcat_sync_candidate_review',
'kt_napcat_runtime_release_readiness',
'kt_napcat_remote_dev_handoff',
'kt_nas_codex_bootstrap_plan',
'kt_napcat_device_profile_check',
'kt_blocker_resolution',
'kt_remote_health_check',
'kt_deploy_observation',
'kt_db_sync_plan',
'kt_component_workflow',
'kt_obsidian_context',
@ -85,7 +92,9 @@ export const defaultHistoryRoots = [
'.kt-workspace/test-logs',
'.kt-workspace/verify',
'.kt-workspace/playwright-mcp',
'.kt-workspace/subagents',
'.kt-workspace/db-sync',
'.kt-workspace/exports',
'.kt-workspace/tmp',
'.kt-workspace/logs',
'.kt-workspace/deploy',

View File

@ -12,10 +12,24 @@ import { buildWorkflowLoopAudit } from './tools/loop.js';
import { readObsidianContext, syncObsidianWorkflow, validateObsidianVault } from './tools/obsidian.js';
import { buildGlobalCodeReview, defaultReviewProjects } from './tools/review.js';
import { appendTaskRecord, createCommitChecklist, prepareTask, readWorkflowContext } from './tools/task.js';
import { buildBusinessTestPlan, createApiTestPlan, createPageTestCase } from './tools/testing.js';
import {
buildBusinessTestPlan,
buildNapcatDeviceProfileCheck,
createApiTestPlan,
createPageTestCase,
} from './tools/testing.js';
import { buildVerificationPlan } from './tools/verification.js';
import { buildWorkstreamCloseout } from './tools/closeout.js';
import { buildChangeDocSync } from './tools/docSync.js';
import { buildDeployObservation } from './tools/deployObservation.js';
import {
buildNapcatRemoteDevHandoff,
buildNapcatRuntimeReleaseReadiness,
buildNapcatSyncCandidateReview,
buildNapcatUpstreamAudit,
buildNasCodexBootstrapPlan,
napcatAutomationDefaults,
} from './tools/napcatAutomation.js';
import { buildCommitPlan, buildComponentWorkflow, buildDbSyncPlan, buildFinishTask, buildPushPlan, buildRemoteHealthCheck } from './tools/workflow.js';
import { workstreamReusablePatternValues } from './core/constants.js';
export function response(data: unknown): McpTextResponse {
@ -29,6 +43,11 @@ export function response(data: unknown): McpTextResponse {
};
}
/**
* Registers every ktWorkflow MCP tool against the provided MCP server instance.
* @param server - MCP SDK server that owns tool names, schemas, descriptions, and async handlers.
* @returns Nothing; handlers are attached to `server` for later stdio transport use.
*/
export function registerTools(server: McpServer): void {
server.registerTool(
'kt_read_context',
@ -159,7 +178,7 @@ export function registerTools(server: McpServer): void {
server.registerTool(
'kt_cleanup_history',
{
description: '清理 KT 运行态历史产物。默认 dryRun=true只预览执行时按 LastWriteTime 每个 .kt-workspace 历史目录仅保留最近 3 轮template/_templates 等模板目录永久保留。',
description: '清理 KT 运行态历史产物。默认 dryRun=true只预览执行时按 LastWriteTime 每个 .kt-workspace 历史目录仅保留最近 3 轮;可复用模板和参考资产应迁入 workspace-assets。',
inputSchema: {
dryRun: z.boolean().default(true),
keep: z.number().int().min(0).max(50).default(3),
@ -364,6 +383,145 @@ export function registerTools(server: McpServer): void {
async (input) => response(buildBusinessTestPlan(input)),
);
server.registerTool(
'kt_napcat_upstream_audit',
{
description:
'生成或执行 NapCatQQ 上游 release 审计:默认 dry-run只读输出 hot zone、fork patch overlap、dry merge 和 Codex 审计入口execute/useCodex 均需显式开启才会运行 Codex。',
inputSchema: {
artifactRoot: z
.string()
.default('.kt-workspace/test-artifacts/napcat-upstream-audit'),
codexHome: z.string().optional(),
createCandidateBranch: z.boolean().default(false),
dryMergeConflict: z.boolean().default(false),
execute: z.boolean().default(false),
forkBranch: z.string().default(napcatAutomationDefaults.forkBranch),
forkPatchFiles: z.array(z.string()).default([]),
forkRepo: z.string().default(napcatAutomationDefaults.forkRepo),
lastAcceptedUpstreamBase: z
.string()
.default(napcatAutomationDefaults.lastAcceptedUpstreamBase),
outputSchemaPath: z.string().optional(),
upstreamChangedFiles: z.array(z.string()).default([]),
upstreamReleaseTag: z.string().default(napcatAutomationDefaults.upstreamReleaseRef),
upstreamRepo: z.string().default(napcatAutomationDefaults.upstreamRepo),
useCodex: z.boolean().default(false),
workspaceRoot: z.string().default(napcatAutomationDefaults.workspaceRoot),
},
title: 'KT NapCat Upstream Audit',
},
async (input) => response(buildNapcatUpstreamAudit(input)),
);
server.registerTool(
'kt_napcat_sync_candidate_review',
{
description:
'生成 NapCatQQ sync candidate 人工/Codex 复审的 dry-run prompt、上下文和只读命令骨架Task 5 不执行 Codex 或 Git 写操作。',
inputSchema: {
artifactRoot: z
.string()
.default('.kt-workspace/test-artifacts/napcat-automation/sync-candidate-review'),
candidateBranch: z.string().default(napcatAutomationDefaults.candidateBranch),
execute: z.boolean().default(false),
forkBranch: z.string().default(napcatAutomationDefaults.forkBranch),
forkRepo: z.string().default(napcatAutomationDefaults.forkRepo),
lastAcceptedUpstreamBase: z
.string()
.default(napcatAutomationDefaults.lastAcceptedUpstreamBase),
upstreamReleaseTag: z.string().default(napcatAutomationDefaults.upstreamReleaseRef),
upstreamRepo: z.string().default(napcatAutomationDefaults.upstreamRepo),
useCodex: z.boolean().default(false),
workspaceRoot: z.string().default(napcatAutomationDefaults.workspaceRoot),
},
title: 'KT NapCat Sync Candidate Review',
},
async (input) => response(buildNapcatSyncCandidateReview(input)),
);
server.registerTool(
'kt_napcat_runtime_release_readiness',
{
description:
'生成 NapCat runtime release readiness dry-run prompt、上下文和只读检查骨架不自动构建镜像、不调用 kubectl、不发布。',
inputSchema: {
apiImageTag: z.string().default(napcatAutomationDefaults.apiImageTag),
artifactRoot: z
.string()
.default('.kt-workspace/test-artifacts/napcat-automation/runtime-release-readiness'),
candidateBranch: z.string().default(napcatAutomationDefaults.candidateBranch),
execute: z.boolean().default(false),
forkBranch: z.string().default(napcatAutomationDefaults.forkBranch),
forkRepo: z.string().default(napcatAutomationDefaults.forkRepo),
napcatImageTag: z.string().default(napcatAutomationDefaults.runtimeImageTag),
profile: z.string().default(napcatAutomationDefaults.runtimeProfile),
upstreamReleaseTag: z.string().default(napcatAutomationDefaults.upstreamReleaseRef),
upstreamRepo: z.string().default(napcatAutomationDefaults.upstreamRepo),
useCodex: z.boolean().default(false),
workspaceRoot: z.string().default(napcatAutomationDefaults.workspaceRoot),
},
title: 'KT NapCat Runtime Release Readiness',
},
async (input) => response(buildNapcatRuntimeReleaseReadiness(input)),
);
server.registerTool(
'kt_napcat_remote_dev_handoff',
{
description:
'生成 NapCat 自动化远程开发 handoff 的 dry-run prompt 和上下文骨架;不创建线程、不执行 Codex、不同步仓库。',
inputSchema: {
artifactRoot: z
.string()
.default('.kt-workspace/test-artifacts/napcat-automation/remote-dev-handoff'),
candidateBranch: z.string().default(napcatAutomationDefaults.candidateBranch),
execute: z.boolean().default(false),
forkBranch: z.string().default(napcatAutomationDefaults.forkBranch),
forkRepo: z.string().default(napcatAutomationDefaults.forkRepo),
targetHost: z.string().default(napcatAutomationDefaults.targetHost),
upstreamReleaseTag: z.string().default(napcatAutomationDefaults.upstreamReleaseRef),
upstreamRepo: z.string().default(napcatAutomationDefaults.upstreamRepo),
useCodex: z.boolean().default(false),
workspaceRoot: z.string().default(napcatAutomationDefaults.workspaceRoot),
},
title: 'KT NapCat Remote Dev Handoff',
},
async (input) => response(buildNapcatRemoteDevHandoff(input)),
);
server.registerTool(
'kt_nas_codex_bootstrap_plan',
{
description:
'生成可信 NAS Codex 自动化环境 bootstrap 的 dry-run planTask 5 仅输出只读检查和占位计划,不安装、不同步、不写 systemd。',
inputSchema: {
artifactRoot: z
.string()
.default('.kt-workspace/test-artifacts/napcat-automation/nas-codex-bootstrap'),
codexHome: z.string().default(napcatAutomationDefaults.codexHome),
execute: z.boolean().default(false),
serviceName: z.string().default(napcatAutomationDefaults.bootstrapServiceName),
workspaceRoot: z.string().default(napcatAutomationDefaults.workspaceRoot),
},
title: 'KT NAS Codex Bootstrap Plan',
},
async (input) => response(buildNasCodexBootstrapPlan(input)),
);
server.registerTool(
'kt_napcat_device_profile_check',
{
description:
'静态检查 API NapCat 设备身份护栏QQNT 可见 hostname、实体 OUI MAC、machine-info、runtime dir 和 DB_TIMEZONE。',
inputSchema: {
project: z.string().default('api'),
},
title: 'KT NapCat Device Profile Check',
},
async (input) => response(buildNapcatDeviceProfileCheck(input)),
);
server.registerTool(
'kt_blocker_resolution',
{
@ -400,6 +558,37 @@ export function registerTools(server: McpServer): void {
async (input) => response(await buildRemoteHealthCheck(input)),
);
server.registerTool(
'kt_deploy_observation',
{
description:
'生成或执行 API 发布后的只读部署观测Jenkins、K8s Deployment、Pod、/health/runtime 和任务 smoke并输出运行态证据。',
inputSchema: {
artifactRoot: z.string().optional(),
buildNumber: z.string().optional(),
container: z.string().default('api'),
deployment: z.string().default('kt-template-online-api'),
execute: z.boolean().default(false),
expectedCommit: z.string().optional(),
healthUrl: z.string().default('http://127.0.0.1:48085/health/runtime'),
imageTag: z.string().optional(),
jenkinsHome: z.string().default('/vol1/docker/jenkins/jenkins_home'),
jobName: z.string().default('KT-Template/KT-Template-API/main'),
kubeconfigPath: z
.string()
.default('/vol1/docker/kt-k8s/kubeconfig/kt-nas.jenkins.yaml'),
namespace: z.string().default('kt-prod'),
project: z.string().default('api'),
selector: z.string().default('app=kt-template-online-api'),
smoke: z.string().optional(),
sshPort: z.number().int().min(1).max(65_535).optional(),
sshTarget: z.string().default('nas'),
},
title: 'KT Deploy Observation',
},
async (input) => response(await buildDeployObservation(input)),
);
server.registerTool(
'kt_db_sync_plan',
{

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,13 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
parseNasCodexBootstrapCliArgs,
parseDeployObservationCliArgs,
parseGlobalReviewCliArgs,
parseNapcatRemoteDevHandoffCliArgs,
parseNapcatRuntimeReleaseReadinessCliArgs,
parseNapcatSyncCandidateReviewCliArgs,
parseNapcatUpstreamAuditCliArgs,
parseObsidianContextCliArgs,
parseObsidianCliArgs,
parseObsidianSyncCliArgs,
@ -11,10 +17,19 @@ import {
} from './core/cli.js';
import { cleanupHistoryArtifacts, parseCliCleanupArgs } from './tools/cleanup.js';
import { buildWorkstreamCloseout } from './tools/closeout.js';
import { buildDeployObservation } from './tools/deployObservation.js';
import { readObsidianContext, syncObsidianWorkflow, validateObsidianVault } from './tools/obsidian.js';
import { buildGlobalCodeReview } from './tools/review.js';
import { registerTools } from './registerTools.js';
import { runSelfTest } from './selfTest.js';
import { buildNapcatDeviceProfileCheck } from './tools/testing.js';
import {
buildNapcatRemoteDevHandoff,
buildNapcatRuntimeReleaseReadiness,
buildNapcatSyncCandidateReview,
buildNapcatUpstreamAudit,
buildNasCodexBootstrapPlan,
} from './tools/napcatAutomation.js';
if (process.argv.includes('--cleanup-history')) {
console.log(JSON.stringify(cleanupHistoryArtifacts(parseCliCleanupArgs(process.argv)), null, 2));
@ -32,10 +47,52 @@ 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('--deploy-observation')) {
console.log(
JSON.stringify(
await buildDeployObservation(parseDeployObservationCliArgs(process.argv)),
null,
2,
),
);
} else if (process.argv.includes('--napcat-upstream-audit')) {
console.log(
JSON.stringify(buildNapcatUpstreamAudit(parseNapcatUpstreamAuditCliArgs(process.argv)), null, 2),
);
} else if (process.argv.includes('--napcat-sync-candidate-review')) {
console.log(
JSON.stringify(
buildNapcatSyncCandidateReview(parseNapcatSyncCandidateReviewCliArgs(process.argv)),
null,
2,
),
);
} else if (process.argv.includes('--napcat-runtime-release-readiness')) {
console.log(
JSON.stringify(
buildNapcatRuntimeReleaseReadiness(parseNapcatRuntimeReleaseReadinessCliArgs(process.argv)),
null,
2,
),
);
} else if (process.argv.includes('--napcat-remote-dev-handoff')) {
console.log(
JSON.stringify(
buildNapcatRemoteDevHandoff(parseNapcatRemoteDevHandoffCliArgs(process.argv)),
null,
2,
),
);
} else if (process.argv.includes('--nas-codex-bootstrap')) {
console.log(
JSON.stringify(buildNasCodexBootstrapPlan(parseNasCodexBootstrapCliArgs(process.argv)), null, 2),
);
} else if (process.argv.includes('--self-test')) {
await runSelfTest();
} else if (process.argv.includes('--workstream-closeout')) {
console.log(JSON.stringify(buildWorkstreamCloseout(parseWorkstreamCloseoutCliArgs(process.argv)), null, 2));
} else if (process.argv.includes('--napcat-device-profile-check')) {
console.log(JSON.stringify(buildNapcatDeviceProfileCheck({ project: 'api' }), null, 2));
} else {
const server = new McpServer({
name: 'kt-workflow',

View File

@ -36,32 +36,31 @@ interface HistoryEntry {
type: 'directory' | 'file';
}
const alwaysPreservedTemplateNames = new Set(['_template', '_templates', 'template', 'templates']);
const workspaceRootPreservedNames = new Set([
'backup',
'db-sync',
'deploy',
'external',
'exports',
'logs',
'playwright-mcp',
'sources',
'subagents',
'test-artifacts',
'test-logs',
'tmp',
'verify',
]);
function isTemplateEntryName(name: string): boolean {
return alwaysPreservedTemplateNames.has(name.toLowerCase());
}
function buildPreservedNames(rootRelativePath: string, childNames: string[]): Set<string> {
/**
* Builds the directory names that represent generated artifact categories under
* `.kt-workspace`; reusable templates and references are intentionally not
* preserved here because they belong in `workspace-assets`.
*
* @param rootRelativePath - Cleanup root relative to the KT workspace.
* @returns Category names that cleanup should skip when pruning `.kt-workspace`.
*/
function buildPreservedNames(rootRelativePath: string): Set<string> {
const preservedNames = new Set<string>();
for (const name of childNames) {
if (isTemplateEntryName(name)) preservedNames.add(name);
}
if (rootRelativePath === '.kt-workspace') {
for (const name of workspaceRootPreservedNames) {
preservedNames.add(name);
@ -126,8 +125,7 @@ export function cleanupHistoryArtifacts(input: CleanupHistoryInput = {}): Record
continue;
}
const childNames = readdirSync(rootPath, { withFileTypes: true }).map((item) => item.name);
const preservedNames = buildPreservedNames(rootRelativePath, childNames);
const preservedNames = buildPreservedNames(rootRelativePath);
const entries = listHistoryEntries(rootPath, preservedNames);
const preserved = entries.slice(0, keep);
const deleting = entries.slice(keep);

View File

@ -26,9 +26,9 @@ const upgradeTargets = {
prompt: '把反复使用且容易写错的命令模板封装到工作流、业务测试计划或脚本。',
},
'deploy-observation': {
files: ['src/tools/workflow.ts', 'src/tools/testing.ts', 'README.md'],
files: ['src/tools/deployObservation.ts', 'src/tools/workflow.ts', 'src/tools/testing.ts', 'README.md'],
label: '部署观测',
prompt: '把 Jenkins/K8s build、commit、镜像、Deployment、Pod、日志和 smoke 观测步骤写成固定计划。',
prompt: '把 Jenkins/K8s build、commit、镜像、Deployment、Pod、/health/runtime、日志和 smoke 观测步骤写成固定计划。',
},
none: {
files: [],

View File

@ -0,0 +1,757 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import type {
DeployObservationAssertion,
DeployObservationCommand,
DeployObservationDeploymentEvidence,
DeployObservationEvidence,
DeployObservationInput,
DeployObservationJenkinsEvidence,
DeployObservationPodEvidence,
DeployObservationResult,
DeployObservationRuntimeHealthEvidence,
DeployObservationStatus,
} from '../types.js';
import { tryPowerShell } from '../core/exec.js';
import {
formatDateInShanghai,
resolveInsideRoot,
workspaceRoot,
} from '../core/workspace.js';
const defaultArtifactRoot = '.kt-workspace/test-artifacts/deploy-observation';
const unsafeHereStringPattern = /[\r\n]|'@|"@/;
interface NormalizedInput
extends Required<
Pick<
DeployObservationInput,
| 'artifactRoot'
| 'container'
| 'deployment'
| 'healthUrl'
| 'jenkinsHome'
| 'jobName'
| 'kubeconfigPath'
| 'namespace'
| 'project'
| 'selector'
| 'sshTarget'
>
> {
buildNumber?: string;
execute: boolean;
expectedCommit?: string;
imageTag?: string;
smoke?: string;
sshPort?: number;
}
interface NormalizeEvidenceInput {
checkedAt?: Date;
input: DeployObservationInput;
sectionMap: Map<string, string>;
}
function normalizeArtifactRoot(value?: string): string {
const artifactRoot = value?.trim() || defaultArtifactRoot;
assertSafeStructuredInput('artifactRoot', artifactRoot);
const base = resolveInsideRoot(defaultArtifactRoot);
const target = resolveInsideRoot(artifactRoot);
const relative = path.relative(base, target);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(
`Deploy observation artifacts must stay under ${defaultArtifactRoot}`,
);
}
return artifactRoot;
}
function assertSafeStructuredInput(field: string, value?: string): void {
if (!value) return;
if (unsafeHereStringPattern.test(value)) {
throw new Error(
`Unsafe deploy observation input "${field}": CR/LF and PowerShell here-string terminators are not allowed.`,
);
}
}
function cleanStructuredInput(
field: string,
value: string | undefined,
): string | undefined {
const trimmed = value?.trim();
assertSafeStructuredInput(field, trimmed);
return trimmed || undefined;
}
function normalizeSshTarget(value?: string, execute = false): string {
const target = cleanStructuredInput('sshTarget', value) || 'nas';
if (target.startsWith('-')) {
throw new Error(
'Unsafe deploy observation input "sshTarget": SSH option injection is not allowed.',
);
}
if (execute && target !== 'nas') {
throw new Error(
'Unsafe deploy observation input "sshTarget": execute mode only allows the stabilized "nas" SSH target.',
);
}
return target;
}
function normalizeHttpUrl(field: string, value: string): string {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new Error(
`Unsupported deploy observation ${field}: value must be a valid http(s) URL.`,
);
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(
`Unsupported deploy observation ${field}: URL must use http or https.`,
);
}
if (parsed.username || parsed.password) {
throw new Error(
`Unsupported deploy observation ${field}: URL credentials are not allowed.`,
);
}
return parsed.toString();
}
function normalizeSmokeCommand(value?: string): string | undefined {
const smoke = value?.trim();
if (!smoke) return undefined;
assertSafeStructuredInput('smoke', smoke);
const match = smoke.match(
/^curl\s+-fsS\s+--max-time\s+([1-9]\d?)\s+(['"]?)(https?:\/\/[^\s'"`;$|&<>\\]+)\2$/,
);
if (!match) {
throw new Error(
'Unsupported deploy observation smoke command: only bounded curl -fsS --max-time <seconds> <http-url> GET smoke is allowed.',
);
}
const timeout = Number(match[1]);
const url = match[3];
if (!Number.isInteger(timeout) || timeout < 1 || timeout > 30) {
throw new Error(
'Unsupported deploy observation smoke command: --max-time must be between 1 and 30 seconds.',
);
}
const parsedUrl = normalizeHttpUrl('smoke command', url);
return `curl -fsS --max-time ${timeout} ${shellSingleQuote(parsedUrl)}`;
}
function normalizeInput(input: DeployObservationInput = {}): NormalizedInput {
const deployment =
cleanStructuredInput('deployment', input.deployment) ||
'kt-template-online-api';
const healthUrl =
cleanStructuredInput('healthUrl', input.healthUrl) ||
'http://127.0.0.1:48085/health/runtime';
const execute = input.execute === true;
return {
artifactRoot: normalizeArtifactRoot(input.artifactRoot),
buildNumber: cleanStructuredInput('buildNumber', input.buildNumber),
container: cleanStructuredInput('container', input.container) || 'api',
deployment,
execute,
expectedCommit: cleanStructuredInput(
'expectedCommit',
input.expectedCommit,
),
healthUrl: normalizeHttpUrl('healthUrl', healthUrl),
imageTag: cleanStructuredInput('imageTag', input.imageTag),
jenkinsHome:
cleanStructuredInput('jenkinsHome', input.jenkinsHome) ||
'/vol1/docker/jenkins/jenkins_home',
jobName:
cleanStructuredInput('jobName', input.jobName) ||
'KT-Template/KT-Template-API/main',
kubeconfigPath:
cleanStructuredInput('kubeconfigPath', input.kubeconfigPath) ||
'/vol1/docker/kt-k8s/kubeconfig/kt-nas.jenkins.yaml',
namespace: cleanStructuredInput('namespace', input.namespace) || 'kt-prod',
project: cleanStructuredInput('project', input.project) || 'api',
selector: cleanStructuredInput('selector', input.selector) || `app=${deployment}`,
smoke: normalizeSmokeCommand(input.smoke),
sshPort:
typeof input.sshPort === 'number' && Number.isFinite(input.sshPort)
? input.sshPort
: undefined,
sshTarget: normalizeSshTarget(input.sshTarget, execute),
};
}
function shellSingleQuote(value: string): string {
return `'${value.replaceAll("'", "'\"'\"'")}'`;
}
function powerShellArgument(value: string): string {
if (/^[A-Za-z0-9._@:/-]+$/.test(value)) return value;
return `'${value.replaceAll("'", "''")}'`;
}
function numberOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function stringOrNull(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null;
}
function parseJsonObject(value: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
} catch {
return null;
}
}
function getPath(source: unknown, keys: string[]): unknown {
return keys.reduce<unknown>((current, key) => {
if (!current || typeof current !== 'object') return undefined;
return (current as Record<string, unknown>)[key];
}, source);
}
function getArray(source: unknown, keys: string[]): unknown[] {
const value = getPath(source, keys);
return Array.isArray(value) ? value : [];
}
function objectAt(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function findNamedObject(
values: unknown[],
name: string,
): Record<string, unknown> | null {
const objects = values
.map(objectAt)
.filter((item): item is Record<string, unknown> => Boolean(item));
return objects.find((item) => stringOrNull(item.name) === name) || null;
}
function readKeyValueSection(value: string): Record<string, string> {
return Object.fromEntries(
value
.split(/\r?\n/)
.map((line) => {
const index = line.indexOf('=');
return index >= 0
? [line.slice(0, index).trim(), line.slice(index + 1).trim()]
: null;
})
.filter((item): item is [string, string] => Boolean(item?.[0])),
);
}
function buildRemoteScript(input: NormalizedInput): string {
const smokeBlock = input.smoke
? [
'section smoke_text',
'set +e',
`${input.smoke} 2>&1`,
'SMOKE_EXIT=$?',
'set -u',
'section smoke_status',
'printf "exitCode=%s\\n" "$SMOKE_EXIT"',
].join('\n')
: [
'section smoke_text',
"echo 'no task smoke command provided'",
'section smoke_status',
'printf "exitCode=\\n"',
].join('\n');
return [
'set -u',
'section() { printf "\\n__KT_SECTION:%s__\\n" "$1"; }',
`JOB_NAME=${shellSingleQuote(input.jobName)}`,
`BUILD_NUMBER=${shellSingleQuote(input.buildNumber || '')}`,
`JENKINS_HOME=${shellSingleQuote(input.jenkinsHome)}`,
`KUBECONFIG_PATH=${shellSingleQuote(input.kubeconfigPath)}`,
`NAMESPACE=${shellSingleQuote(input.namespace)}`,
`DEPLOYMENT=${shellSingleQuote(input.deployment)}`,
`SELECTOR=${shellSingleQuote(input.selector)}`,
`HEALTH_URL=${shellSingleQuote(input.healthUrl)}`,
'KUBECONFIG_ACTIVE="$KUBECONFIG_PATH"',
'KUBECONFIG_TEMP=""',
'cleanup_kubeconfig_temp() { if [ -n "$KUBECONFIG_TEMP" ]; then rm -f "$KUBECONFIG_TEMP"; fi; }',
'trap cleanup_kubeconfig_temp EXIT',
'if [ -f "$KUBECONFIG_PATH" ] && grep -q "https://k3d-kt-nas-serverlb:6443" "$KUBECONFIG_PATH"; then',
' K3D_API_PORT="$(docker port k3d-kt-nas-serverlb 6443/tcp 2>/dev/null | tail -1 | rev | cut -d: -f1 | rev || true)"',
' if [ -n "$K3D_API_PORT" ]; then',
' KUBECONFIG_TEMP="/tmp/kt-deploy-observation-kubeconfig-$$.yaml"',
' sed "s#https://k3d-kt-nas-serverlb:6443#https://127.0.0.1:${K3D_API_PORT}#g" "$KUBECONFIG_PATH" > "$KUBECONFIG_TEMP"',
' KUBECONFIG_ACTIVE="$KUBECONFIG_TEMP"',
' fi',
'fi',
'JOB_DIR="$JENKINS_HOME/jobs/$JOB_NAME/builds"',
'if [ ! -d "$JOB_DIR" ]; then',
' JOB_SLASH_COUNT="$(printf "%s" "$JOB_NAME" | awk -F/ \'{print NF-1}\')"',
' if [ "$JOB_SLASH_COUNT" = "2" ]; then',
' JOB_FOLDER="${JOB_NAME%%/*}"',
' JOB_BRANCH="${JOB_NAME##*/}"',
' JOB_PIPELINE="${JOB_NAME#*/}"',
' JOB_PIPELINE="${JOB_PIPELINE%/*}"',
' MULTIBRANCH_JOB_DIR="$JENKINS_HOME/jobs/$JOB_FOLDER/jobs/$JOB_PIPELINE/branches/$JOB_BRANCH/builds"',
' if [ -d "$MULTIBRANCH_JOB_DIR" ]; then',
' JOB_DIR="$MULTIBRANCH_JOB_DIR"',
' else',
' FOUND_JOB_DIR="$(find "$JENKINS_HOME/jobs" -path "*/jobs/$JOB_PIPELINE/branches/$JOB_BRANCH/builds" -type d -print -quit 2>/dev/null || true)"',
' if [ -z "$FOUND_JOB_DIR" ]; then FOUND_JOB_DIR="$(find "$JENKINS_HOME/jobs" -path "*/jobs/$JOB_PIPELINE/branches/main/builds" -type d -print -quit 2>/dev/null || true)"; fi',
' if [ -n "$FOUND_JOB_DIR" ]; then JOB_DIR="$FOUND_JOB_DIR"; fi',
' fi',
' fi',
'fi',
'if [ -z "$BUILD_NUMBER" ] && [ -d "$JOB_DIR" ]; then BUILD_NUMBER="$(find "$JOB_DIR" -maxdepth 1 -type d -printf "%f\\n" 2>/dev/null | grep -E "^[0-9]+$" | sort -n | tail -1 || true)"; fi',
'BUILD_DIR="$JOB_DIR/$BUILD_NUMBER"',
'section jenkins_meta',
'printf "jobName=%s\\n" "$JOB_NAME"',
'printf "buildNumber=%s\\n" "$BUILD_NUMBER"',
'if [ -f "$BUILD_DIR/build.xml" ]; then RESULT="$(grep -Eo "<result>[^<]*</result>" "$BUILD_DIR/build.xml" 2>/dev/null | sed -E "s#</?result>##g" | tail -1 || true)"; else RESULT=""; fi',
'printf "result=%s\\n" "$RESULT"',
'section jenkins_build_xml',
'if [ -f "$BUILD_DIR/build.xml" ]; then grep -E "<(hash|sha1)>[0-9a-fA-F]{7,40}</(hash|sha1)>" "$BUILD_DIR/build.xml" 2>/dev/null | head -n 20 || true; else echo "jenkins build.xml not found: $BUILD_DIR/build.xml"; fi',
'section jenkins_log_tail',
'if [ -f "$BUILD_DIR/log" ]; then tail -n 160 "$BUILD_DIR/log"; else echo "jenkins log not found: $BUILD_DIR/log"; fi',
'section deployment_json',
'kubectl --kubeconfig "$KUBECONFIG_ACTIVE" --request-timeout=8s -n "$NAMESPACE" get deployment "$DEPLOYMENT" -o json 2>&1 || true',
'section pods_json',
'kubectl --kubeconfig "$KUBECONFIG_ACTIVE" --request-timeout=8s -n "$NAMESPACE" get pods -l "$SELECTOR" -o json 2>&1 || true',
'section events_tail',
'kubectl --kubeconfig "$KUBECONFIG_ACTIVE" --request-timeout=8s -n "$NAMESPACE" get events --sort-by=.lastTimestamp 2>&1 | tail -n 80 || true',
'section health_json',
'curl -fsS --max-time 8 "$HEALTH_URL" 2>&1 || true',
smokeBlock,
].join('\n');
}
export function buildDeployObservationCommands(
input: DeployObservationInput = {},
): DeployObservationCommand[] {
const normalized = normalizeInput(input);
const sshParts = [
'ssh',
...(normalized.sshPort ? ['-p', String(normalized.sshPort)] : []),
powerShellArgument(normalized.sshTarget),
'"tr -d \'\\015\' | bash -s"',
];
const command = [
"$script = @'",
buildRemoteScript(normalized),
"'@",
`$script | ${sshParts.join(' ')}`,
].join('\n');
return [
{
command,
name: 'nas-deploy-observation',
},
];
}
export function buildDeployObservationSectionMap(
stdout: string,
): Map<string, string> {
const sections = new Map<string, string>();
let current: string | null = null;
let buffer: string[] = [];
for (const line of stdout.split(/\r?\n/)) {
const marker = line.match(/^__KT_SECTION:([A-Za-z0-9_-]+)__$/);
if (marker) {
if (current) sections.set(current, buffer.join('\n').trim());
current = marker[1];
buffer = [];
} else if (current) {
buffer.push(line);
}
}
if (current) sections.set(current, buffer.join('\n').trim());
return sections;
}
function normalizeJenkinsEvidence(
input: NormalizedInput,
sectionMap: Map<string, string>,
): DeployObservationJenkinsEvidence {
const meta = readKeyValueSection(sectionMap.get('jenkins_meta') || '');
const logTail = sectionMap.get('jenkins_log_tail') || '';
const buildXmlRevision = sectionMap.get('jenkins_build_xml') || '';
const commitHaystack = `${logTail}\n${buildXmlRevision}`.toLowerCase();
const logFinishedStatuses = [...logTail.matchAll(/Finished:\s*([A-Z_]+)/gi)];
const logFinishedStatus =
logFinishedStatuses.at(-1)?.[1]?.toUpperCase() || null;
const logTailAvailable =
logTail.trim().length > 0 && !/jenkins log not found:/i.test(logTail);
const finishedStatus =
logFinishedStatus ||
(logTailAvailable ? null : meta.result?.toUpperCase() || null);
const expectedCommit = input.expectedCommit || null;
return {
buildNumber: meta.buildNumber || input.buildNumber || null,
commitMatched: expectedCommit
? commitHaystack.includes(expectedCommit.toLowerCase())
: null,
expectedCommit,
finishedStatus,
jobName: input.jobName,
};
}
function normalizeDeploymentEvidence(
input: NormalizedInput,
deploymentJson: Record<string, unknown> | null,
): DeployObservationDeploymentEvidence {
const container = findNamedObject(
getArray(deploymentJson, ['spec', 'template', 'spec', 'containers']),
input.container,
);
return {
container: input.container,
containerFound: Boolean(container),
desiredReplicas:
numberOrNull(getPath(deploymentJson, ['spec', 'replicas'])) ??
numberOrNull(getPath(deploymentJson, ['status', 'replicas'])) ??
1,
generation: numberOrNull(getPath(deploymentJson, ['metadata', 'generation'])),
image: stringOrNull(container?.image),
namespace: input.namespace,
observedGeneration: numberOrNull(
getPath(deploymentJson, ['status', 'observedGeneration']),
),
readyReplicas: numberOrNull(
getPath(deploymentJson, ['status', 'readyReplicas']),
),
updatedReplicas: numberOrNull(
getPath(deploymentJson, ['status', 'updatedReplicas']),
),
};
}
function scorePod(
pod: Record<string, unknown>,
input: NormalizedInput,
): number {
const phase = stringOrNull(getPath(pod, ['status', 'phase']));
const statuses = getArray(pod, ['status', 'containerStatuses']);
const status = findNamedObject(statuses, input.container);
const image = stringOrNull(status?.image);
return (
(phase === 'Running' ? 4 : 0) +
(image && input.imageTag && image.includes(input.imageTag) ? 2 : 0) +
(status?.ready === true ? 1 : 0)
);
}
function normalizePodEvidence(
input: NormalizedInput,
podsJson: Record<string, unknown> | null,
): DeployObservationPodEvidence {
const pods = getArray(podsJson, ['items'])
.map(objectAt)
.filter((item): item is Record<string, unknown> => Boolean(item))
.sort((left, right) => scorePod(right, input) - scorePod(left, input));
const pod = pods[0] || null;
const status = findNamedObject(
getArray(pod, ['status', 'containerStatuses']),
input.container,
);
const specContainer = findNamedObject(
getArray(pod, ['spec', 'containers']),
input.container,
);
return {
containerFound: Boolean(status || specContainer),
image: stringOrNull(status?.image) || stringOrNull(specContainer?.image),
name: stringOrNull(getPath(pod, ['metadata', 'name'])),
phase: stringOrNull(getPath(pod, ['status', 'phase'])),
ready: typeof status?.ready === 'boolean' ? status.ready : null,
restartCount: numberOrNull(status?.restartCount),
};
}
function normalizeRuntimeHealthEvidence(
healthJson: Record<string, unknown> | null,
): DeployObservationRuntimeHealthEvidence {
const checks = getArray(healthJson, ['checks']);
return {
checkCount: checks.length || null,
service: stringOrNull(healthJson?.service),
status: stringOrNull(healthJson?.status),
};
}
function parseSmokeExitCode(sectionMap: Map<string, string>): number | null {
const status = readKeyValueSection(sectionMap.get('smoke_status') || '');
if (!status.exitCode?.trim()) return null;
const exitCode = Number(status.exitCode);
return Number.isInteger(exitCode) && exitCode >= 0 ? exitCode : null;
}
function buildAssertion(
name: string,
passed: boolean,
message: string,
critical = true,
): DeployObservationAssertion {
return {
critical,
message,
name,
passed,
};
}
function aggregateStatus(
assertions: DeployObservationAssertion[],
): DeployObservationStatus {
if (assertions.every((item) => item.passed)) return 'passed';
if (assertions.some((item) => item.critical && !item.passed)) return 'failed';
return 'blocked';
}
function toFilenameSlug(value: string): string {
const slug = value
.replace(/[^A-Za-z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
return slug || 'project';
}
export function normalizeDeployObservationEvidence({
checkedAt = new Date(),
input,
sectionMap,
}: NormalizeEvidenceInput): DeployObservationEvidence {
const normalized = normalizeInput(input);
const jenkins = normalizeJenkinsEvidence(normalized, sectionMap);
const deployment = normalizeDeploymentEvidence(
normalized,
parseJsonObject(sectionMap.get('deployment_json') || ''),
);
const pod = normalizePodEvidence(
normalized,
parseJsonObject(sectionMap.get('pods_json') || ''),
);
const runtimeHealth = normalizeRuntimeHealthEvidence(
parseJsonObject(sectionMap.get('health_json') || ''),
);
const smokeOutput = sectionMap.get('smoke_text') || '';
const smokeExitCode = parseSmokeExitCode(sectionMap);
const expectedImageTag = normalized.imageTag || normalized.expectedCommit;
const deploymentObserved =
deployment.generation !== null &&
deployment.observedGeneration !== null &&
deployment.observedGeneration >= deployment.generation;
const deploymentReplicasReady =
deployment.updatedReplicas !== null &&
deployment.readyReplicas !== null &&
deployment.desiredReplicas !== null &&
deployment.updatedReplicas >= deployment.desiredReplicas &&
deployment.readyReplicas >= deployment.desiredReplicas;
const runtimeHealthStatusAllowed =
runtimeHealth.status === 'ready' ||
runtimeHealth.status === 'live' ||
runtimeHealth.status === 'degraded';
const smokeSuccess = Boolean(normalized.smoke && smokeExitCode === 0);
const assertions = [
buildAssertion(
'jenkins-build-success',
jenkins.finishedStatus === 'SUCCESS',
`Jenkins ${jenkins.jobName} #${jenkins.buildNumber || 'unknown'} result=${jenkins.finishedStatus || 'missing'}`,
),
buildAssertion(
'jenkins-commit-match',
!jenkins.expectedCommit || jenkins.commitMatched === true,
jenkins.expectedCommit
? `Expected commit ${jenkins.expectedCommit} matched=${jenkins.commitMatched}`
: 'No expected commit supplied',
Boolean(jenkins.expectedCommit),
),
buildAssertion(
'deployment-container-found',
deployment.containerFound,
`Deployment container=${deployment.container} found=${deployment.containerFound}`,
),
buildAssertion(
'deployment-image-tag',
Boolean(
deployment.image &&
(!expectedImageTag || deployment.image.includes(expectedImageTag)),
),
`Deployment image=${deployment.image || 'missing'}`,
),
buildAssertion(
'deployment-generation-observed',
deploymentObserved,
`Deployment generation=${deployment.generation} observedGeneration=${deployment.observedGeneration}`,
),
buildAssertion(
'deployment-replicas-ready',
deploymentReplicasReady,
`Deployment desired=${deployment.desiredReplicas} ready=${deployment.readyReplicas} updated=${deployment.updatedReplicas}`,
),
buildAssertion(
'pod-container-found',
pod.containerFound,
`Pod container=${normalized.container} found=${pod.containerFound}`,
),
buildAssertion(
'pod-running',
pod.phase === 'Running' && pod.ready === true,
`Pod ${pod.name || 'missing'} phase=${pod.phase || 'missing'} ready=${pod.ready}`,
),
buildAssertion(
'pod-image-tag',
Boolean(pod.image && (!expectedImageTag || pod.image.includes(expectedImageTag))),
`Pod image=${pod.image || 'missing'}`,
),
buildAssertion(
'pod-restart-count-zero',
pod.restartCount === 0,
`Pod restartCount=${pod.restartCount}`,
),
buildAssertion(
'runtime-health-available',
runtimeHealth.service === 'kt-template-online-api' &&
runtimeHealthStatusAllowed,
`Runtime health status=${runtimeHealth.status || 'missing'}`,
),
buildAssertion(
'task-smoke-success',
smokeSuccess,
normalized.smoke
? `Task smoke exitCode=${smokeExitCode ?? 'missing'}`
: 'No task smoke command supplied; rollout evidence is not functional evidence',
),
];
return {
assertions,
details: {
deployment,
eventsTail: sectionMap.get('events_tail') || '',
jenkins,
pod,
runtimeHealth,
smoke: {
command: normalized.smoke || null,
exitCode: smokeExitCode,
output: smokeOutput,
success: smokeSuccess,
},
},
endedAt: checkedAt.toISOString(),
environment: 'production',
operation: 'kt_deploy_observation',
project: normalized.project,
schemaVersion: 1,
startedAt: checkedAt.toISOString(),
status: aggregateStatus(assertions),
target: `${normalized.namespace}/${normalized.deployment}`,
taskType: 'deploy',
title: 'KT API deployment observation',
};
}
function writeEvidenceArtifact(
artifactRoot: string,
evidence: DeployObservationEvidence,
): string {
const root = resolveInsideRoot(artifactRoot);
const directory = path.join(root, formatDateInShanghai());
const safeProject = toFilenameSlug(evidence.project);
const filePath = path.resolve(
directory,
`${safeProject}-${Date.now()}-deploy-observation.json`,
);
const relative = path.relative(root, filePath);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(
`Deploy observation artifact path escaped artifact root: ${filePath}`,
);
}
mkdirSync(directory, { recursive: true });
writeFileSync(filePath, `${JSON.stringify(evidence, null, 2)}\n`, 'utf8');
return filePath;
}
export async function buildDeployObservation(
input: DeployObservationInput = {},
): Promise<DeployObservationResult> {
const normalized = normalizeInput(input);
const commands = buildDeployObservationCommands(normalized);
const requiredEvidence = [
'Jenkins job/build number and final Finished status',
'Expected commit hash match when a commit is supplied',
'K8s Deployment target container, desiredReplicas, updatedReplicas, readyReplicas, generation, observedGeneration',
'Running Pod selected by target container/current image tag with restartCount',
'GET /health/runtime response with service/status/check count',
'Task-specific smoke command output',
];
const notes = [
'Default execute=false only returns the bounded read-only command.',
'execute=true runs through ssh nas with CRLF-stripped here-string and writes local JSON evidence.',
'Jenkins/K8s rollout evidence is deployment evidence; task smoke is still required for functional completion.',
'The command reads Jenkins build files, kubectl status, events, and HTTP health only; it does not read Secrets or mutate remote state.',
];
if (!normalized.execute) {
return {
commands,
execute: false,
notes,
requiredEvidence,
};
}
const command = commands[0]?.command || '';
const result = await tryPowerShell(command, workspaceRoot, 180_000);
const sectionMap = buildDeployObservationSectionMap(result.stdout);
const evidence = normalizeDeployObservationEvidence({
input: normalized,
sectionMap,
});
const artifactPath = writeEvidenceArtifact(normalized.artifactRoot, evidence);
return {
artifactPath,
commands,
evidence,
execute: true,
notes,
requiredEvidence,
result,
};
}

View File

@ -0,0 +1,64 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
export const napcatAutomationPromptNames = [
'upstream-audit',
'sync-candidate-review',
'runtime-release-readiness',
'remote-dev-handoff',
] as const;
export type NapcatAutomationPromptName =
(typeof napcatAutomationPromptNames)[number];
const promptsRoot = path.resolve(
fileURLToPath(new URL('../..', import.meta.url)),
'prompts',
'napcat',
);
const promptsRootBoundary = promptsRoot.endsWith(path.sep)
? promptsRoot
: `${promptsRoot}${path.sep}`;
const napcatAutomationPromptNameSet = new Set<string>(
napcatAutomationPromptNames,
);
const napcatAutomationPromptFileNames = {
'upstream-audit': 'upstream-audit.md',
'sync-candidate-review': 'sync-candidate-review.md',
'runtime-release-readiness': 'runtime-release-readiness.md',
'remote-dev-handoff': 'remote-dev-handoff.md',
} satisfies Record<NapcatAutomationPromptName, string>;
/**
* Parses an external prompt name into the source-controlled NapCat prompt enum.
* @param value - Raw prompt name from CLI, JSON, MCP input, or a typed caller; only exact entries in `napcatAutomationPromptNames` are accepted.
* @returns A stable prompt name that can be mapped to a fixed file under `prompts/napcat`.
*/
export function parseNapcatAutomationPromptName(
value: string,
): NapcatAutomationPromptName {
if (napcatAutomationPromptNameSet.has(value)) {
return value as NapcatAutomationPromptName;
}
throw new Error(`Unsupported NapCat automation prompt: ${value}`);
}
/**
* Loads a source-controlled NapCat automation prompt by stable prompt name.
* @param name - Prompt identifier from `napcatAutomationPromptNames`; runtime callers may still pass untyped external strings, so the loader re-validates and resolves only fixed filenames.
* @returns UTF-8 prompt text used by Codex CLI automation.
*/
export function loadNapcatAutomationPrompt(
name: NapcatAutomationPromptName,
): string {
const promptName = parseNapcatAutomationPromptName(String(name));
const promptPath = path.resolve(
promptsRoot,
napcatAutomationPromptFileNames[promptName],
);
if (!promptPath.startsWith(promptsRootBoundary)) {
throw new Error(`NapCat automation prompt escaped prompt root: ${name}`);
}
return readFileSync(promptPath, 'utf8');
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,130 @@
import { z } from 'zod';
export const napcatAutomationClassifications = [
'safe-candidate',
'manual-review',
'blocked',
] as const;
export const napcatAutomationReasonCodes = [
'NO_UPSTREAM_CHANGE',
'HOT_ZONE_CHANGED',
'FORK_PATCH_OVERLAP',
'DRY_MERGE_CONFLICT',
'BUILD_GRAPH_CHANGED',
'METADATA_UNAVAILABLE',
'CODEX_SCHEMA_INVALID',
] as const;
export const napcatAutomationRecommendedActions = [
'no-op',
'create-candidate-branch',
'request-human-review',
'block',
] as const;
export const upstreamAuditOutputSchema = z.object({
classification: z.enum(napcatAutomationClassifications),
reasonCodes: z.array(z.enum(napcatAutomationReasonCodes)).min(1),
recommendedAction: z.enum(napcatAutomationRecommendedActions),
summary: z.string().min(1),
}).strict();
export type NapcatAutomationClassification =
(typeof napcatAutomationClassifications)[number];
export type NapcatAutomationReasonCode =
(typeof napcatAutomationReasonCodes)[number];
export type NapcatAutomationRecommendedAction =
(typeof napcatAutomationRecommendedActions)[number];
export interface NapcatAuditClassificationInput {
dryMergeConflict: boolean;
forkPatchFiles: string[];
upstreamChangedFiles: string[];
}
export interface NapcatAuditClassificationResult {
classification: NapcatAutomationClassification;
reasonCodes: NapcatAutomationReasonCode[];
recommendedAction: NapcatAutomationRecommendedAction;
summary: string;
}
export interface NapcatAuditCommand {
command: string;
description: string;
readOnly: boolean;
}
export interface NapcatCodexExecCommandInput {
artifactRoot: string;
codexHome?: string;
contextPacketPath: string;
outputSchemaPath: string;
promptName: 'upstream-audit';
workspaceRoot: string;
}
export interface NapcatCodexExecResult {
ok: boolean;
stderr: string;
stdout: string;
}
export type NapcatCodexExecutor = (
command: string,
) => NapcatCodexExecResult;
export interface NapcatUpstreamAuditInput
extends NapcatAuditClassificationInput {
artifactRoot?: string;
codexExecutor?: NapcatCodexExecutor;
codexHome?: string;
createCandidateBranch?: boolean;
execute?: boolean;
forkBranch?: string;
forkRepo?: string;
lastAcceptedUpstreamBase?: string;
outputSchemaPath?: string;
promptName?: 'upstream-audit';
upstreamReleaseTag?: string;
upstreamRepo?: string;
useCodex?: boolean;
workspaceRoot?: string;
}
export interface NapcatAutomationArtifact {
path: string;
type: 'context' | 'json' | 'jsonl' | 'log' | 'markdown' | 'script';
}
export interface NapcatAutomationResult {
artifacts: NapcatAutomationArtifact[];
classification: NapcatAutomationClassification;
execute: boolean;
recommendedAction: string;
summary: string;
}
export interface NapcatUpstreamAuditResult
extends NapcatAuditClassificationResult {
artifacts: NapcatAutomationArtifact[];
commands: NapcatAuditCommand[];
execute: boolean;
hotZonePatterns: string[];
}
/**
* Parses the upstream audit classification emitted by deterministic collectors or Codex.
* @param value - Raw classification string from deterministic audit metadata or Codex JSON output; must match the shared NapCat automation taxonomy.
* @returns A supported NapCat automation classification for downstream workflow decisions.
*/
export function parseNapcatAutomationClassification(
value: string,
): NapcatAutomationClassification {
const parsed = z.enum(napcatAutomationClassifications).safeParse(value);
if (!parsed.success) {
throw new Error(`Unsupported NapCat audit classification: ${value}`);
}
return parsed.data;
}

View File

@ -172,7 +172,6 @@ const requiredCorePlugins = [
const expectedIgnoredRoots = [
'.git/',
'.kt-workspace/',
'_exports/',
'Node/',
'Plugins/',
'Vue/',
@ -278,7 +277,6 @@ const ignoredDirectoryNames = new Set([
'.pnpm',
'.turbo',
'.vite',
'_exports',
'build',
'coverage',
'dist',

View File

@ -41,10 +41,15 @@ const rootGeneratedArtifactNames = new Set([
".codex-test-logs",
".codex-verify",
".playwright-mcp",
"_exports",
"codex-test-logs",
"test-artifacts",
]);
const workspaceAssetBlockedFilePattern =
/(^|\/)(\.env($|\..+)|.*\.(7z|dump|gz|rar|sql|storageState[^/]*|tar|tgz|zip)|id_rsa|[^/]+\.(key|pem|pfx|p12))$/i;
const workspaceAssetMaxFileBytes = 2 * 1024 * 1024;
const sensitiveTrackedFilePattern =
/(^|\/)(\.env($|\..+)|deploy\.json|secrets?\.(env|json|ya?ml)|id_rsa|[^/]+\.(pem|key))$/i;
@ -230,6 +235,126 @@ export function findQqbotCommandServiceTestImportFindings(
});
}
export function findQqbotPluginSmokeImportFindings(
lines: string[],
context: {
file: string;
project: string;
},
): ReviewFinding[] {
const normalized = context.file.replaceAll("\\", "/");
if (
context.project !== "Node/kt-template-online-api" ||
!normalized.startsWith("test/modules/qqbot/") ||
!normalized.endsWith(".ts") ||
!/(plugin-(registry|controller).*\.spec\.ts|qqbot-core-module-contract\.spec\.ts)$/.test(
normalized,
)
) {
return [];
}
const source = lines.join("\n");
const pluginImportPattern =
/from\s+['"]([^'"]*modules\/qqbot\/plugins\/(bangDream|ff14Market|fflogs|repeater)\/[^'"]*(?:\.plugin|client\.service|renderer\.facade|application\.service))['"]/g;
const findings: ReviewFinding[] = [];
const seenPlugins = new Set<string>();
let match: RegExpExecArray | null;
while ((match = pluginImportPattern.exec(source))) {
const pluginName = match[2];
if (seenPlugins.has(pluginName)) continue;
seenPlugins.add(pluginName);
const escapedPluginName = pluginName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const hasMock = new RegExp(
`jest\\.mock\\s*\\(\\s*['"][^'"]*/modules/qqbot/plugins/${escapedPluginName}/`,
"m",
).test(source);
if (hasMock) continue;
const lineIndex = lines.findIndex((line) => line.includes(match?.[1] || ""));
findings.push({
category: "qqbot-plugin-smoke-heavy-import",
file: context.file,
line: lineIndex >= 0 ? lineIndex + 1 : undefined,
level: "P2" as const,
message:
"QQBot 插件 registry/controller 契约或 HTTP smoke 导入真实插件实现但未 mock容易拖入渲染、定时器或外部请求依赖。",
project: context.project,
suggestion:
"用 jest.mock mock 具体插件 service token只测试 registry/controller 的别名解析和路由包装边界。",
});
}
return findings;
}
/**
* Finds regressions that would make descriptor-backed QQBot built-in plugins disappear from production dist images.
* @param lines - Source lines from the package path policy file being reviewed.
* @param context - Project and file identity for precise review findings.
* @returns Findings when the policy no longer supports production dist roots or compiled JavaScript entries.
*/
export function findQqbotPluginPackageProductionPathFindings(
lines: string[],
context: {
file: string;
project: string;
},
): ReviewFinding[] {
const normalized = context.file.replaceAll("\\", "/");
if (
context.project !== "Node/kt-template-online-api" ||
normalized !==
"src/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package-path-policy.service.ts"
) {
return [];
}
const content = lines.join("\n");
const findings: ReviewFinding[] = [];
const hasProductionDistRoot =
content.includes("'dist'") &&
content.includes("'modules'") &&
content.includes("'qqbot'") &&
content.includes("'plugins'");
const hasCompiledEntryFallback =
content.includes("resolveCompiledEntryFile") &&
content.includes("entryFile.endsWith('.ts')") &&
content.includes("}.js`");
if (!hasProductionDistRoot) {
findings.push({
category: "qqbot-plugin-production-dist-root-missing",
file: context.file,
line: 1,
level: "P2",
message:
"QQBot 插件包路径策略缺少生产 dist 根,生产镜像只复制 dist 时内置插件会发现为空。",
project: context.project,
suggestion:
"默认受控根应在源码根不存在时扫描 dist/modules/qqbot/plugins并保留源码态优先顺序。",
});
}
if (!hasCompiledEntryFallback) {
findings.push({
category: "qqbot-plugin-compiled-entry-fallback-missing",
file: context.file,
line: 1,
level: "P2",
message:
"QQBot 插件 manifest 的 src/index.ts entry 缺少生产编译后 .js sibling fallback。",
project: context.project,
suggestion:
"entry 仍应保持源码结构声明,但生产运行时要在同一 package root 内解析到编译后的 src/index.js。",
});
}
return findings;
}
export function findQqbotStatusBoundaryFindings(
lines: string[],
context: {
@ -505,6 +630,46 @@ export function findQqbotNapcatCaptchaFlowFindings(
});
}
const hasLegacyDeviceVerifyPending =
content.includes("keepDeviceVerifyPending") &&
content.includes("deviceVerifyUrl");
const hasNapcatNewDeviceApiFlow =
content.includes("GetNewDeviceQRCode") &&
content.includes("PollNewDeviceQR") &&
content.includes("NewDeviceLogin") &&
content.includes("deviceVerifyUrl") &&
content.includes("newDeviceQrcode") &&
content.includes("newDeviceStatus") &&
content.includes("session.captchaUrl = undefined");
const hasNapcatNewDeviceWrapperFlow =
content.includes("startNewDeviceVerification") &&
content.includes("pollNewDeviceVerification") &&
content.includes("completePasswordLoginAfterChallenge") &&
content.includes("deviceVerifyUrl") &&
content.includes("newDeviceQrcode") &&
content.includes("newDeviceStatus") &&
content.includes("session.captchaUrl = undefined");
if (
content.includes("needNewDevice") &&
!hasLegacyDeviceVerifyPending &&
!hasNapcatNewDeviceApiFlow &&
!hasNapcatNewDeviceWrapperFlow
) {
findings.push({
category: "qqbot-napcat-new-device-drops-pending",
file: context.file,
line:
lines.findIndex((line) => line.includes("needNewDevice")) + 1 ||
undefined,
level: "P2",
message:
"NapCat 验证码通过后仍可能要求新设备验证,直接提示或失败会丢掉可继续的 jumpUrl。",
project: context.project,
suggestion:
"CaptchaLogin 返回 needNewDevice/jumpUrl 时,应保持同一 scan session pending返回 deviceVerifyUrl清掉旧 captchaUrl并继续轮询同一容器。",
});
}
const logAnchorMatch = passwordReloginBody.match(
/const\s+([A-Za-z0-9_]*LogSinceMs)\s*=\s*Date\.now\(\)[\s\S]*?ensureRuntimeLoginEnv/,
);
@ -757,6 +922,14 @@ function collectProjectContentFindings(
file,
project: project.relativePath,
}),
...findQqbotPluginSmokeImportFindings(lines, {
file,
project: project.relativePath,
}),
...findQqbotPluginPackageProductionPathFindings(lines, {
file,
project: project.relativePath,
}),
...findQqbotStatusBoundaryFindings(lines, {
file,
project: project.relativePath,
@ -822,6 +995,64 @@ function findRootGeneratedArtifacts(): string[] {
.map((entry) => entry.name);
}
/**
* Finds files that would make `workspace-assets` behave like another runtime
* dump/cache directory instead of a small reusable asset store.
*
* @returns Review findings for large, archived, dump-like, or secret-like files
* found under `workspace-assets`.
*/
function findWorkspaceAssetPolicyFindings(): ReviewFinding[] {
const assetRoot = path.join(workspaceRoot, "workspace-assets");
if (!existsSync(assetRoot)) return [];
const findings: ReviewFinding[] = [];
const walk = (currentPath: string) => {
for (const entry of readdirSync(currentPath, { withFileTypes: true })) {
const entryPath = path.join(currentPath, entry.name);
const relativePath = toPosix(path.relative(workspaceRoot, entryPath));
if (entry.isDirectory()) {
if (reviewSkipDirectoryNames.has(entry.name)) continue;
walk(entryPath);
continue;
}
if (!entry.isFile()) continue;
const stats = statSync(entryPath);
if (workspaceAssetBlockedFilePattern.test(relativePath)) {
findings.push({
category: "workspace-assets-blocked-file",
file: relativePath,
level: "P1",
message: "workspace-assets 中出现禁止跟踪的归档、dump、环境或密钥类文件。",
project: "root",
suggestion:
"移回 .kt-workspace 或外部安全存储workspace-assets 只保留小型可复用资产。",
});
continue;
}
if (stats.size > workspaceAssetMaxFileBytes) {
findings.push({
category: "workspace-assets-large-file",
file: relativePath,
level: "P2",
message: "workspace-assets 中出现超过 2MB 的文件,可能是缓存或第三方源码。",
project: "root",
suggestion:
"确认是否必须 Git 管理;大体积参考材料应放到外部仓库或 .kt-workspace 缓存。",
});
}
}
};
walk(assetRoot);
return findings;
}
async function reviewProject(
projectKey: string,
input: Required<
@ -920,6 +1151,8 @@ export async function buildGlobalCodeReview(
: findWorkspaceFilesByName(new Set(["deploy.js", "deploy.json"]), 50);
const rootGeneratedArtifacts =
input.includeRootScan === false ? [] : findRootGeneratedArtifacts();
const workspaceAssetPolicyFindings =
input.includeRootScan === false ? [] : findWorkspaceAssetPolicyFindings();
const taskRecordFindings =
input.includeRootScan === false
? []
@ -947,13 +1180,14 @@ export async function buildGlobalCodeReview(
message: "根目录存在生成态临时产物。",
project: "root",
suggestion:
"统一迁入 .kt-workspace 对应子目录,并更新生成脚本或测试流程。",
"生成证据迁入 .kt-workspace可复用资产迁入 workspace-assets,并更新生成脚本或测试流程。",
}),
);
const allFindings = [
...findings,
...rootFindings,
...rootGeneratedFindings,
...workspaceAssetPolicyFindings,
...taskRecordFindings,
];

View File

@ -1,5 +1,122 @@
import type { ApiTestPlanInput, BusinessFlow, BusinessTestPlanInput, PageTestCaseInput, PageTestCaseResult } from '../types.js';
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import type {
ApiTestPlanInput,
BusinessFlow,
BusinessTestPlanInput,
NapcatDeviceProfileCheckInput,
PageTestCaseInput,
PageTestCaseResult,
} from '../types.js';
import { resolveProject } from '../core/workspace.js';
/**
* Reads a project-relative source file for static NapCat device profile checks.
* @param projectPath - Absolute project path returned by `resolveProject()`.
* @param relativePath - File path inside the API project.
* @returns UTF-8 file content, or an empty string when the file is absent.
*/
function readProjectFile(projectPath: string, relativePath: string): string {
const filePath = path.join(projectPath, relativePath);
return existsSync(filePath) ? readFileSync(filePath, 'utf8') : '';
}
/**
* Builds a boolean static check with a human-readable recovery hint.
* @param name - Stable check identifier for reports and self-test output.
* @param passed - Whether the source text still contains the expected guardrail.
* @param evidence - Short description of the expected code-level evidence.
* @returns Check result consumed by `buildNapcatDeviceProfileCheck()`.
*/
function createNapcatCheck(
name: string,
passed: boolean,
evidence: string,
): Record<string, unknown> {
return {
evidence,
name,
passed,
};
}
/**
* Checks that the API project still contains the NapCat device-profile guardrails needed to avoid unknown QQ devices.
* @param input - Project alias or path to scan; defaults to the API backend.
* @returns Static guardrail report covering hostname, physical-style MAC, machine-info, runtime dir, and DB timezone.
*/
export function buildNapcatDeviceProfileCheck(
input: NapcatDeviceProfileCheckInput = {},
): Record<string, unknown> {
const project = resolveProject(input.project || 'api');
const deviceIdentity = readProjectFile(
project.path,
'src/modules/qqbot/napcat/infrastructure/integration/device/napcat-device-identity.service.ts',
);
const dockerOptions = readProjectFile(
project.path,
'src/modules/qqbot/napcat/infrastructure/integration/container/napcat-docker-device-options.ts',
);
const containerService = readProjectFile(
project.path,
'src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts',
);
const appModule = readProjectFile(project.path, 'src/app.module.ts');
const runtimeConfig = readProjectFile(
project.path,
'src/runtime/config/runtime-config.service.ts',
);
const checks = [
createNapcatCheck(
'hostname-strategy',
deviceIdentity.includes('qqnt-visible-hostname-v1') &&
deviceIdentity.includes('pc-') &&
deviceIdentity.includes('hash.slice(0, 8)'),
'device identity uses qqnt-visible-hostname-v1 and pc-<8hex>',
),
createNapcatCheck(
'physical-oui-mac-strategy',
deviceIdentity.includes('physical-oui-mac-v1') &&
deviceIdentity.includes('hasPhysicalOuiMacPrefix') &&
deviceIdentity.includes('isRejectedVirtualMacPrefix') &&
deviceIdentity.includes('NAPCAT_PHYSICAL_OUI_PREFIXES'),
'device identity uses physical-oui-mac-v1 and rejects Docker/QEMU virtual MAC prefixes',
),
createNapcatCheck(
'machine-info-options',
dockerOptions.includes('machineInfoPath') &&
dockerOptions.includes('macAddressHyphen'),
'docker device options carry machineInfoPath and hyphenated MAC',
),
createNapcatCheck(
'machine-info-script',
containerService.includes('MACHINE_INFO_PATH') &&
containerService.includes('NAPCAT_MAC_HYPHEN') &&
containerService.includes("tr 'A-Za-z' 'N-ZA-Mn-za-m'") &&
containerService.includes("printf '\\\\000\\\\000\\\\000\\\\021'"),
'remote create script writes QQNT machine-info using ROT13 MAC format',
),
createNapcatCheck(
'runtime-dir-mount',
containerService.includes('$DATA_DIR/runtime:/tmp/runtime-napcat'),
'remote create script persists XDG_RUNTIME_DIR under the account data dir',
),
createNapcatCheck(
'db-timezone',
appModule.includes('DB_TIMEZONE') &&
appModule.includes("'+08:00'") &&
runtimeConfig.includes('DB_TIMEZONE'),
'TypeORM and runtime config expose DB_TIMEZONE with +08:00 default',
),
];
return {
checks,
ok: checks.every((item) => item.passed === true),
project,
};
}
export function createPageTestCase(input: PageTestCaseInput): PageTestCaseResult {
const project = resolveProject(input.project);
const title = input.title || `${project.label} 页面级测试`;
@ -115,13 +232,26 @@ export function buildBusinessTestPlan(input: BusinessTestPlanInput = {}): Record
},
'qqbot-account-scan': {
assertions: [
'扫码创建返回 qrcode/containerName/webuiPort。',
'扫码创建接口先返回 pending sessionId再通过 SSE/status 展示容器创建、二维码生成、等待扫码和终态。',
'新增账号首次容器创建必须在第一轮 Docker run 注入设备身份和中文桌面 runtime profile而不是登录后补 env。',
'取消/过期/失败会清理未绑定 NapCat 容器。',
'若真实接口 smoke 会创建线上 QQBot 账号或 NapCat 容器,必须先确认清理方案;不能造脏数据时用 RED/GREEN、只读线上计数复核和未执行真实造号原因替代。',
'删除账号会二次确认并删除专属容器。',
],
preflight: ['NAS SSH 可达', 'NapCat WebUI 可达', 'API QQBot env 配置完整'],
preflight: [
'NAS SSH 可达',
'NapCat WebUI 可达',
'API QQBot env 配置完整',
'确认本轮是否允许创建真实 QQBot 账号和 NapCat 容器',
],
projects: ['api', 'admin'],
steps: ['打开账号连接页', '点击扫码新增账号', '轮询二维码状态', '确认账号回填', '测试删除联动容器'],
steps: [
'打开账号连接页',
'点击扫码新增账号并确认接口不等待 Docker/WebUI 长耗时',
'订阅 scan/events 或轮询 status 确认中文进度',
'确认账号回填和 runtime/profile 归属',
'测试删除联动容器或记录未造号的替代验证证据',
],
},
'qqbot-login-sse': {
assertions: [

View File

@ -185,6 +185,44 @@ export interface BusinessTestPlanInput {
flow?: BusinessFlow | 'all';
}
export interface NapcatDeviceProfileCheckInput {
project?: string;
}
export interface NapcatPromptToolInput {
artifactRoot?: string;
candidateBranch?: string;
execute?: boolean;
forkBranch?: string;
forkRepo?: string;
upstreamReleaseTag?: string;
upstreamRepo?: string;
useCodex?: boolean;
workspaceRoot?: string;
}
export interface NapcatSyncCandidateReviewInput extends NapcatPromptToolInput {
lastAcceptedUpstreamBase?: string;
}
export interface NapcatRuntimeReleaseReadinessInput extends NapcatPromptToolInput {
apiImageTag?: string;
napcatImageTag?: string;
profile?: string;
}
export interface NapcatRemoteDevHandoffInput extends NapcatPromptToolInput {
targetHost?: string;
}
export interface NasCodexBootstrapPlanInput {
artifactRoot?: string;
codexHome?: string;
execute?: boolean;
serviceName?: string;
workspaceRoot?: string;
}
export interface BlockerResolutionInput {
attempts?: number;
command?: string;
@ -205,6 +243,116 @@ export interface RemoteHealthCheckInput {
sshTarget?: string;
}
export type DeployObservationStatus =
| 'passed'
| 'failed'
| 'blocked'
| 'skipped';
export interface DeployObservationInput {
artifactRoot?: string;
buildNumber?: string;
container?: string;
deployment?: string;
execute?: boolean;
expectedCommit?: string;
healthUrl?: string;
imageTag?: string;
jobName?: string;
jenkinsHome?: string;
kubeconfigPath?: string;
namespace?: string;
project?: string;
selector?: string;
smoke?: string;
sshPort?: number;
sshTarget?: string;
}
export interface DeployObservationAssertion {
critical: boolean;
message: string;
name: string;
passed: boolean;
}
export interface DeployObservationCommand {
command: string;
name: string;
}
export interface DeployObservationJenkinsEvidence {
buildNumber: string | null;
commitMatched: boolean | null;
expectedCommit: string | null;
finishedStatus: string | null;
jobName: string;
}
export interface DeployObservationDeploymentEvidence {
container: string;
containerFound: boolean;
desiredReplicas: number | null;
generation: number | null;
image: string | null;
namespace: string;
observedGeneration: number | null;
readyReplicas: number | null;
updatedReplicas: number | null;
}
export interface DeployObservationPodEvidence {
containerFound: boolean;
image: string | null;
name: string | null;
phase: string | null;
ready: boolean | null;
restartCount: number | null;
}
export interface DeployObservationRuntimeHealthEvidence {
checkCount: number | null;
service: string | null;
status: string | null;
}
export interface DeployObservationEvidence {
assertions: DeployObservationAssertion[];
details: {
deployment: DeployObservationDeploymentEvidence;
eventsTail: string;
jenkins: DeployObservationJenkinsEvidence;
pod: DeployObservationPodEvidence;
runtimeHealth: DeployObservationRuntimeHealthEvidence;
smoke: {
command: string | null;
exitCode: number | null;
output: string;
success: boolean;
};
};
endedAt: string;
environment: 'production';
operation: 'kt_deploy_observation';
project: string;
schemaVersion: 1;
startedAt: string;
status: DeployObservationStatus;
target: string;
taskType: 'deploy';
title: string;
}
export interface DeployObservationResult {
artifactPath?: string;
commands: DeployObservationCommand[];
evidence?: DeployObservationEvidence;
execute: boolean;
notes: string[];
requiredEvidence: string[];
result?: ExecResult;
}
export interface DbSyncPlanInput {
backupName?: string;
source?: 'local' | 'remote';