fix(commander): add ClaudeQQ approval proxy draft path (#134)

Co-authored-by: Codex <codex@noreply.local>
This commit is contained in:
Lyon
2026-05-23 19:22:07 +08:00
committed by GitHub
parent 431d6bd25b
commit 86f388722f
17 changed files with 424 additions and 80 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文
- `bun scripts/cli.ts artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service`:管理 D601 host-managed CNCF Distribution registry,并通过短生命周期 relay 或 D601 pull/import 做 commit-pinned pull-only artifact CD`deploy-backend-core` 是 deprecated 兼容名,`findjob`/`pipeline` 支持 D601 direct dev/prod`met-nonlinear``k3sctl-adapter` 只给受限计划路径,`code-queue` 只支持 dev,规则见 `docs/reference/artifact-registry.md`
- `bun scripts/cli.ts auth-broker contract|health --dry-run|credential-request --dry-run|pr-preflight --dry-run`:查看 Auth Broker P0 Rust skeleton 与 CLI adapter contractrunner 无 `GH_TOKEN`/`GITHUB_TOKEN` 时返回结构化 `auth-missing`/`broker-needed`,不读取或打印 token 值,规则见 `docs/reference/auth-broker.md`
- `bun scripts/cli.ts gh preflight|auth status|issue ...|pr list|files|diff --stat|read|view|preflight|closeout|create|edit|update|comment` / `bun scripts/code-queue-pr-preflight-example.ts`:通过 REST 执行安全 GitHub issue 读写、脱敏 auth/status 诊断、body-file Markdown 写入、当日滚动简报时间线 ClaudeQQ 通知、escape 扫描、只读 cleanup-plan 和 #20 board-audit、PR changed-file/stat summary、PR 创建/评论 dry-run、REST-only 低噪声 PR title/body 编辑、PR 收口元数据观察(含 merged/closed 区分与 merge commit)、低噪声 PR 收口 preflight 与 runner PR preflight`gh issue/pr read|view` 支持 `owner/repo#number` shorthand`--raw|--full` 是显式完整披露别名,`gh pr diff` 仅支持 `--stat` 紧凑 JSON`gh pr merge` 当前仍结构化拒绝但普通 PR 可按任务边界用 repo-owned GitHub 路径收口,规则见 `docs/reference/cli.md``docs/reference/code-queue-supervision.md`
- `bun scripts/cli.ts commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run`:查看 host Codex 指挥官直管微服务 skeleton 的 source/contract、无 daemon smoke 验证计划、.state/commander/ 状态模型、trace summary 聚合和 ClaudeQQ 高风险请示草案;当前只返回 dry-run 计划,不接 live bridge、不接管人工指挥官,不发送消息,规则见 `docs/reference/host-codex-commander.md`
- `bun scripts/cli.ts commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run`:查看 host Codex 指挥官直管微服务 skeleton 的 source/contract、无 daemon smoke 验证计划、.state/commander/ 状态模型、trace summary 聚合和 ClaudeQQ 高风险请示草案;当前只返回 dry-run 计划和 backend-core `microservice proxy claudeqq` 授权后候选命令,不接 live bridge、不接管人工指挥官,不发送消息,规则见 `docs/reference/host-codex-commander.md`
- `bun scripts/cli.ts ci install/status/run/publish-backend-core/publish-user-service/run-dev-e2e/logs`:在 D601 原生 k3s 上安装和运行 Tekton CI,支持每 commit 检查、Code Queue 只读性能门禁、`CI.json` catalog 驱动的 backend-core 与 user-service commit-pinned 镜像发布和手动触发的 `origin/master:deploy.json#environments.dev` 临时 namespace e2ecatalog/producer/consumer 分工见 `docs/reference/cicd-standardization.md``run-dev-e2e` 的 Git 控制 runner、短 launcher 和 no-CD 边界见 `docs/reference/dev-ci-runner.md`Tekton 规则见 `docs/reference/ci.md`
- `bun scripts/cli.ts codex deploy <commitId>`:旧 Code Queue 兼容部署入口已禁用,原因是它会绕过受控部署边界直连 D601 部署 Code Queue;规则见 `docs/reference/codex-deploy.md`
- `bun scripts/cli.ts codex prompt-lint [prompt|--prompt-file path|--prompt-stdin]` / `codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue <id>]` / `codex pr-preflight [--remote]``prompt-lint` 在派发/steer 前 dry-run 检查 runner prompt 的 DEV 测试授权分级(`read-only`/`live-read`/`live-mutating`)且不回显 prompt`submit --dry-run` 同时给出 MiniMax/GPT/人工路由建议、该 lint 结果和 requested/effective execution mode;真实提交成功只返回写入确认、task id、服务级 runnerPermissions 和后续查看命令,不回显 prompt;`pr-preflight` 只读检查 D601 scheduler/runner 的 GitHub token、egress 和 PR 能力,PR 型派单前必须使用,规则见 `docs/reference/cli.md``docs/reference/code-queue-supervision.md`
+2 -2
View File
@@ -30,14 +30,14 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI
- `dev-env validate [--manifest path] [--kubectl-dry-run]` 离线校验 D601 `unidesk-dev` namespace、dev PostgreSQL 底座和 dev workload manifest。默认检查 `src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-foundation.k8s.yaml`;也可显式校验 `src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-core.k8s.yaml``src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-code-queue.k8s.yaml`。所有 namespaced 对象必须只落到 `unidesk-dev`foundation manifest 必须包含 `postgres-dev` StatefulSet/Service、dev secret/config、迁移 Job 和 DB URL guardcore manifest 必须包含 `backend-core-dev`/`frontend-dev` Deployment/ServiceCode Queue dev manifest 必须包含 `code-queue-scheduler-dev``code-queue-read-dev``code-queue-write-dev`、dev provider egress proxy,以及只读挂载宿主 `/home/ubuntu/.agents/skills` 到容器 `/root/.agents/skills``skills-dir` volume。加 `--kubectl-dry-run` 时额外执行 `kubectl apply --dry-run=client --validate=false -f <manifest>`,仍不 apply 资源。
- `dev-env prewarm-images [--image image] [--provider-id D601] [--no-pull] [--proxy-url URL] [--pull-timeout-ms N] [--dry-run]` 创建异步 job,通过 UniDesk SSH 维护桥在 D601 上把开发底座依赖镜像从 Docker 缓存导入原生 k3s containerd。默认镜像是 `postgres:16-alpine``rancher/mirrored-library-busybox:1.36.1`,用于避免 `postgres-dev` 与 local-path helper pod 卡在外部 registry 拉取。该命令固定验证 `/etc/rancher/k3s/k3s.yaml` 指向的 native k3s 上下文,并输出 `dev_env_containerd_image_ready=...` 作为成功判据;它不 apply manifest、不修改生产 `unidesk` namespace。
- `artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service` 管理 D601 host-managed CNCF Distribution registry 的声明、安装、只读检查和 pull-only artifact CD。该 registry 固定为 D601 loopback `127.0.0.1:5000`,由 systemd + Docker Compose 管理,位于 native k3s 故障域外;`deploy-service` 只拉取 CI 已发布的 commit-pinned 镜像、retag/recreate 或导入 native k3s,并做 live commit 验证,不构建 runtime source。`deploy-backend-core` 是 deprecated 兼容名,标准 backend-core prod CD 入口是 `deploy apply --env prod --service backend-core`。长期规则见 `docs/reference/artifact-registry.md`
- `commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run` 是 host Codex 指挥官直管微服务 skeleton 入口。当前命令返回 `phase=source-contract`、service/API/state/bridge/prompt/trace/#20/#46/ClaudeQQ 审批边界、.state/commander/ 状态模型、dev 无 daemon smoke contract 和 dry-run 计划,服务骨架只提供本地 `/health``/api/commander/contract`、状态读写、trace summary 聚合和 approval draft preview,不接 live bridge、不注入 prompt、不发送 ClaudeQQ。`plan``smoke``approval request` 必须带 `--dry-run`;缺少时返回 `error=dry-run-required`。长期规则见 `docs/reference/host-codex-commander.md`
- `commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run` 是 host Codex 指挥官直管微服务 skeleton 入口。当前命令返回 `phase=source-contract`、service/API/state/bridge/prompt/trace/#20/#46/ClaudeQQ 审批边界、.state/commander/ 状态模型、dev 无 daemon smoke contract 和 dry-run 计划,服务骨架只提供本地 `/health``/api/commander/contract`、状态读写、trace summary 聚合和 approval draft preview,不接 live bridge、不注入 prompt、不发送 ClaudeQQ。`approval request --dry-run` 会生成 200 字以内中文纯文本 ClaudeQQ 审批草案、`notification-path-unavailable` blocker 和授权后唯一可用的 `bun scripts/cli.ts microservice proxy claudeqq /api/push/text --method POST --body-json '<payload>' --raw` 命令;不得提示使用本机 ClaudeQQ skill、powershell 或本地 server。`plan``smoke``approval request` 必须带 `--dry-run`;缺少时返回 `error=dry-run-required`。长期规则见 `docs/reference/host-codex-commander.md`
- `gh auth status [--repo owner/name]` 探测 GitHub 操作前置条件并输出脱敏 JSON:是否存在 `gh` binary、是否存在 `GH_TOKEN`/`GITHUB_TOKEN` 或可用 `gh auth token` fallback、REST API 是否可达、目标 repo 是否可见、issue 是否可读。degraded reason 必须归类为 `missing-binary``missing-token``auth-failed``github-transient``network-proxy-failed``permission-denied``repo-not-found``repo-forbidden``issue-not-found``pr-not-found``scope-insufficient``validation-failed``invalid-response``unsupported-command`,不得打印 token;失败对象必须包含 `runnerDisposition=infra-blocked|business-failed`runner 应优先用该字段分流。`github-transient` 表示 GitHub DNS/API 连接在收到 HTTP 状态前失败,输出应带 `retryable=true` 或等价 commander action;这不是缺 token、认证失败、权限不足或 PR 语义失败。
- `codex prompt-lint [prompt|--prompt-file path|--prompt-stdin]` 是派发/steer 前的本地 dry-run prompt lint。它只读取 prompt 文本,返回 `dryRun=true``mutation=false``declaredClass``effectiveClass``requiredClass``dispatchDisposition`、缺失或矛盾项和有界 evidence,不访问 live service、不提交任务、不打印完整 prompt。分级固定为 `read-only``live-read``live-mutating`;未声明时按 `read-only` 处理。`codex submit --dry-run``codex steer --dry-run` 会嵌入同一 `promptLint` 结果,帮助指挥官在 dispatch/steer 前发现缺失或矛盾的 live mutation 授权。长期规则见 `docs/reference/code-queue-supervision.md` 的 DEV 测试授权分级。
- `gh issue list [--state open|closed|all] [--limit N] [--repo owner/name] [--json number,title,state,url,updatedAt,createdAt,author,labels]` 通过 GitHub REST 列出 issue,默认 `state=open``limit=30`,输出稳定 JSON 且不依赖系统 `gh` binary。`--limit` 会映射到 GitHub `per_page` 并限制返回数量,避免一次拉爆上下文;未知 state 或未知 `--json` 字段必须结构化失败并带 `runnerDisposition=business-failed`。GitHub issues API 可能混入 PRCLI 会从 `.data.issues` 中过滤 pull request。
- `gh issue read <number|owner/repo#number> [--repo owner/name] [--json body,title,state,comments] [--raw|--full]` 通过 GitHub REST 读取 issue title/body/state/url 和 comments,默认输出 JSON`view` 只保留为兼容别名。`owner/repo#number` shorthand 会自动派生 `--repo owner/repo` 和 issue number;若同时提供冲突的显式 `--repo`CLI 必须结构化失败并给出 `gh issue read <number> --repo owner/repo --json body,title,state,comments` 与 shorthand raw 的可执行命令。兼容旧脚本的 `--json body``--json body,title,state,comments` 字段选择,且正文仍稳定暴露在 `.data.issue.body`,避免调用方因为 JSON 路径变化把空值当成正文。字段白名单是 `body,title,state,comments,number,url,author,createdAt,updatedAt`,未知字段必须结构化失败并带 `runnerDisposition=business-failed``--raw``--full` 只在 read/view 上可用,是显式完整披露别名,会选择完整支持字段集并保持结构化 JSON 输出;当最终 `gh` JSON 超过 20 KiB 时,CLI 必须把完整 JSON 写入 `/tmp/unidesk-cli-output/*.json`stdout 只返回 `outputTruncated=true`、dump path、总 bytes/lines 和 head/tail 预览。默认 list/read 输出仍不得扩散到无界非 JSON 文本。`gh issue create --title <title> --body-file <file> [--label label[,label...]]... [--dry-run]``gh issue update <number> --mode replace|append --body-file <file> [--title ...] [--dry-run]``gh issue comment create <number> --body-file <file> [--dry-run]``gh issue comment delete <commentId> [--dry-run]``gh issue close|reopen <number> [--dry-run]` 都走 REST,不依赖 `gh` binary。`--label` 仅用于 `issue create`,支持重复传入和逗号分隔;`--dry-run` 会展示解析后的 labels 与 request plan,正式创建时把 labels 放入 GitHub REST create-issue payloadGitHub 返回不存在 label 等 422 校验失败时 CLI 结构化返回 `validation-failed`,不静默成功。`gh issue delete <number>` 是结构化 `unsupported-command`,因为 GitHub REST 不支持 issue 硬删除;生命周期删除语义请使用 `close`
- `gh issue update <number> --mode replace|append --body-file <file>` 是正文更新主入口,`edit` 保留为兼容别名。`replace` 用文件正文替换现有 body`append` 先读取当前 body,再按 UTF-8 文件字节追加,保留真实换行、反引号和 Markdown 表格。更新默认拒绝字面量 `null`、空白正文和过短正文;只有真实需要写短正文时才允许显式加 `--allow-short-body`,返回 JSON 会报告该风险。#20 总看板和指挥简报类 issue 是长期 body-only issue`--body-profile auto` 会按 issue number 自动启用 #20/#24 legacy guard#20 必须包含 `## 看板(OPEN`#24 legacy 指挥简报必须包含 `## 常驻观察与长期建议`。显式 `--body-profile commander-brief` 不再固定 #24#24 仍兼容,标题为 `YYYY-MM-DD 指挥简报(北京时间)` 或既有正文首行/关键 heading 表明为每日滚动指挥简报的 issue 也合法,并仍必须包含 `## 常驻观察与长期建议`。对非简报 issue 显式使用 `commander-brief` 会结构化失败为 `profile-issue-mismatch``--dry-run` 不 PATCH GitHub,输出新正文长度、SHA、关键标题检查结果、字面量 `\n`、反引号、Markdown 表格和 shell 污染信号;若环境里有 `GH_TOKEN``GITHUB_TOKEN`,dry-run 还会只读抓取旧正文长度、SHA 和 `updatedAt` 作为更新前对照。正式写入可带 `--expect-updated-at <updated_at>``--expect-body-sha <sha256>`,CLI 会先读当前 issue,匹配后才 PATCH,防止旧缓存覆盖新正文。
- #20 只允许承担长期 UniDesk 指挥官 / Code Queue / CLI / infra 治理总看板职责;每日进展必须写入当天滚动指挥简报 issue,并由 #20 顶部“指挥简报索引”引用。HWLAB 用户反馈、Cloud Workbench、DEV-LIVE、M3 虚拟硬件可信闭环等产品 issue 必须写到 `pikasTech/HWLAB`#20 只可记录 UniDesk 侧 commander/Code Queue/CLI/infra 支撑工作。`gh issue read/view 20` 会返回 `codeQueueBoardHint``gh issue update/edit 20` 的 body guard 会拒绝 `## 更新 YYYY-MM-DD HH:mm 北京时间``## YYYY-MM-DD HH:mm 北京时间指挥更新``### YYYY-MM-DD HH:mm CST...` 这类简报段落,也会拒绝把 `pikasTech/HWLAB#N``HWLAB#N` 或 HWLAB 产品/live 验证行写入 #20,并在 `codeQueueBoardHint` 中提示改写到每日简报 issue 或 `pikasTech/HWLAB``gh issue board-row list|get|update|add|move|delete|upsert --board-issue 20` 也会返回同一 hint,提醒不要把每日简报或 HWLAB 产品看板混入 #20
- `gh issue edit 24 --body-file <file> --notify-claudeqq-brief-diff [--dry-run]` 是 legacy #24 指挥简报的通知入口。正式执行会先读取 GitHub 上 #24 旧正文并通过 #24 body profile guard,再从 `--body-file` 读取新正文;随后先 PATCH issue 主体,再把本次新增的 `## 更新 YYYY-MM-DD HH:MM 北京时间` 段落发送给 ClaudeQQClaudeQQ 失败不会回滚 issue 正文,失败只体现在返回 JSON 的 `claudeqq.ok=false` 和结构化 `degradedReason`。每日滚动简报 issue 可用普通 `gh issue update <number> --body-profile commander-brief --dry-run` 和并发 guard 更新,但此通知 helper 仍只支持 #24。带通知 flag 的 `--dry-run` 不 PATCH、不发送;它按新正文做发送预览,并在输出中标明非 dry-run 才会读取旧正文做可靠 diff。默认 ClaudeQQ 目标是私聊 `645275593`,默认 base URL 是 UniDesk 受控入口 `http://backend-core:8080/api/microservices/claudeqq/proxy`,可用 `UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_ENABLED``UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL``UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TARGET_TYPE``UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_USER_ID``UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_GROUP_ID``UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TIMEOUT_MS` 覆盖
- `gh issue edit 24 --body-file <file> --notify-claudeqq-brief-diff [--dry-run]` 是 legacy #24 指挥简报的通知入口。正式执行会先读取 GitHub 上 #24 旧正文并通过 #24 body profile guard,再从 `--body-file` 读取新正文;随后先 PATCH issue 主体,再把本次新增的 `## 更新 YYYY-MM-DD HH:MM 北京时间` 段落发送给 ClaudeQQClaudeQQ 失败不会回滚 issue 正文,失败只体现在返回 JSON 的 `claudeqq.ok=false` 和结构化 `degradedReason`。每日滚动简报 issue 可用普通 `gh issue update <number> --body-profile commander-brief --dry-run` 和并发 guard 更新,但此通知 helper 仍只支持 #24。带通知 flag 的 `--dry-run` 不 PATCH、不发送;它按新正文做发送预览,并在输出中标明非 dry-run 才会读取旧正文做可靠 diff。默认 ClaudeQQ 目标是私聊 `645275593`,默认 base URL 是 UniDesk 受控入口 `http://backend-core:8080/api/microservices/claudeqq/proxy``UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL` 只接受 backend-core `/api/microservices/claudeqq/proxy` 等价路径,非 proxy URL 会结构化为 `notification-path-unavailable`。可用 `UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_ENABLED``UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TARGET_TYPE``UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_USER_ID``UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_GROUP_ID``UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TIMEOUT_MS` 调整开关、目标和超时
- `gh issue board-audit [--repo owner/name] [--board-issue 20] [--limit N] [--known-meta-issue N[,N...]] [--ignore-issue N[,N...]] [--dry-run]` 是总看板只读结构审计入口,默认 repo 为 `pikasTech/unidesk`、board issue 为 `20`、输出 JSON 且不 PATCH/POST/DELETE GitHub。它只读取目标 board issue 正文,返回正文长度、行数、body SHA、可解析 Markdown board sections、section 行数和 parser warnings;不再拉取 GitHub open/closed issue 列表,也不再校验 OPEN/CLOSED 表是否覆盖全部 issue。兼容字段 `missingOpenIssues``closedInOpenRows``missingClosedRows``openInClosedRows``rowValidationWarnings``ignoredIssues``recommendedActions` 仍保留,但固定为空数组或 0,用于避免旧调用方因字段缺失失败。需要维护旧式 OPEN/CLOSED 明细表时,继续使用 `gh issue board-row list|get|update|add|move|delete|upsert` 的行级结构化入口。
- `gh issue board-row list --board-issue 20 [--state open|closed|all] [--dry-run]``gh issue board-row get <issueNumber> --board-issue 20``gh issue board-row update <issueNumber> --board-issue 20 --field progress|status|validation|branch|tasks|focus --value <text> [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256]`#20 看板表格单行结构化入口。list/get 复用 board-audit parser,只读返回 row、cells、fields、section、lineNumber、bodySha 和 rowValidationWarnings。update 只替换命中的一行里一个单元格,返回 old/new row、old/new body SHA、body guard、request plan 和 parser 结果;默认没有并发期望时即使不写 `--dry-run` 也只做 dry-run,正式 PATCH 必须带 `--expect-body-sha``--expect-updated-at`。字段映射固定为:`branch` -> Branch`progress` -> 进度,`status`/`validation` -> 验收状态,`tasks` -> 相关 Code Queue 任务,`focus` -> 当前关注点。单元格值中的 Markdown 表格管道会转义为 `\|`,真实换行会折叠为空格,避免新增字面量 `\n` 污染。`gh issue board-row upsert <issueNumber> --board-issue 20 --section open|closed [--category text] --branch <branch> --tasks <task> --summary <text> --focus <text> --validation <text> --progress <text> [--status OPEN|CLOSED] [--dry-run] [--expect-body-sha|--expect-updated-at]` 是行级补齐入口:若 issue 已存在则只更新传入字段并返回 `operation=update`,未传字段保留原值;若不存在则按目标 section 表头生成完整行并返回 `operation=add`。新增时 `--section` 必需,且目标表头中的 category/branch/tasks/summary/focus/validation/progress 列都必须有对应值;若表没有独立 Summary/摘要列,`--summary` 会并入 Issue 单元格。upsert 不关闭、不删除、不重开 GitHub issue,也不做 OPEN/CLOSED 迁移;已存在行的 `--section``--status` 与当前 section 冲突时会结构化失败并提示使用 `board-row move``gh issue board-row add <issueNumber> --board-issue 20 --section open|closed --row-file <file> [--dry-run] [--expect-body-sha|--expect-updated-at]``move <issueNumber> --board-issue 20 --to open|closed [--status OPEN|CLOSED] [--dry-run] [--expect-body-sha|--expect-updated-at]``delete <issueNumber> --board-issue 20 [--dry-run] [--expect-body-sha|--expect-updated-at]` 是 row-scoped #20 结构化写入口。add 校验一行 `--row-file` 的 Issue 列、列数和 GitHub 状态列与目标 section 一致;move 允许跨 OPEN/CLOSED 表迁移并在需要时同步 GitHub 状态列;delete 仅删除匹配行。四类写入口默认 dry-run,非 dry-run 必须带 `--expect-body-sha``--expect-updated-at`,并返回 old/new row、body SHA、line/section 计划和 parser 结果;duplicate/ambiguous row、列数不匹配、缺少新增必填字段、section/status 冲突或 body SHA 不匹配都会结构化失败,不会 fallback 到整篇 body 手工替换。
- `gh issue scan-escape [--repo owner/name] [--limit N] [--dry-run]` 只读扫描 issue 主体和 comments 中的字面量 `\n`、可疑 `\t`、shell newline escape、escaped backtick、ANSI escape 字符串、短 body、blank body 和 null body。输出固定 JSON`findings` 会带 `bodyKind=issue-body|comment-body``issueNumber``issueId``commentId``lineNumber``column``kind``snippet``classification=suspected-pollution|explanatory-mention|risk`,用于区分说明性提到 `\n` 和疑似污染;`cleanupSuggestions` 只给 dry-run 清理建议、body/comment 定位和 diff-like preview,不 PATCH、不 DELETE、不真实清理历史 comment。`gh issue cleanup-plan` 是同一只读能力的别名,默认 `dryRun=true``gh pr list [--state open|closed|all] [--json ...]` 提供 REST 列表,默认 `state=all` 以保持既有 UniDesk CLI 行为,字段白名单是 `body,title,state,number,url,author,head,base,draft,createdAt,updatedAt`;未知 state 或未知 `--json` 字段必须结构化失败并带 `runnerDisposition=business-failed``mergeable``mergeStateStatus``statusCheckRollup` 不属于 list 字段,请对具体 PR 使用 `gh pr view <number> --json headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup`,避免列表默认拉取 noisy/raw 状态汇总。`gh pr files <number> [--limit N]` 是 PR changed-file/stat summary 的稳定 REST 入口,返回 bounded `files``filesReturned``summary.files/additions/deletions/changes/commits``truncation``next.command`,默认不输出 raw diff 或 patch`gh pr diff <number> --stat` 是兼容别名,返回同一 JSON,未带 `--stat` 的 raw diff 请求会结构化拒绝。`gh pr read|view <number|owner/repo#number> [--json ...] [--raw|--full]` 继续稳定返回这些字段,并额外支持 `stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup``owner/repo#number` shorthand 和冲突 `--repo` 规则与 issue read/view 相同。`stateDetail` 是 UniDesk 归一化生命周期值 `open|closed|merged`,用于区分 REST `state=closed` 中的普通关闭和已合并;`closed``closedAt``merged``mergedAt``mergeCommit``headRefName``baseRefName` 都来自 REST,不需要 GraphQL。`mergeable``mergeStateStatus``statusCheckRollup` 只在 read/view 明确请求这些字段或用 `--raw|--full` 显式完整披露时通过 GitHub GraphQL 查询,GraphQL 权限不足或网络失败会结构化失败;GitHub 暂未计算完成时仍保留原始 `UNKNOWN`/null,并额外返回 `closeoutMetadata.ok=false``missingOrUnknownFields`、advice 和 `mergeBoundary.unideskCliMergeSupported=false`。此时收口人员应优先重试一次;若仍缺失、需要完整 `gh pr view --json` 等 GitHub 官方字段、或需要执行 merge/review 这类 UniDesk CLI 尚未开放的操作,回退到系统 `gh` 只读观察或人工 GitHub UI,不要把空字段当作可合并证据。`gh pr preflight <number> [--repo owner/name] [--full|--raw]` 是低噪声 PR 收口入口,`gh preflight <number>``gh pr closeout <number>` 是兼容别名;它先输出脱敏 auth capability,再读取 PR state/draft/head/base、mergeable、mergeStateStatus 和 statusCheckRollup,默认只给 status check 计数与失败/等待预览,完整 contexts 和原始读取摘要必须显式加 `--full``--raw`。该命令固定 `readOnly=true``writesRemote=false``policy.mergesPr=false``policy.unideskCliMergeSupported=false`,不会创建、评论、更新或 merge PR。`gh pr create --title <title> --body-file <file>|--body <text> --base <branch> --head <branch> [--draft] [--dry-run]``gh pr edit <number> [--title ...] [--body-file <file>|--body-file -|--body <text>] [--dry-run]``gh pr update <number> --mode replace|append [--body-file <file>|--body-file -|--body <text>] [--title ...] [--dry-run]``gh pr comment create <number> --body-file <file>|--body <text> [--dry-run]``gh pr comment delete <commentId> [--dry-run]``gh pr close|reopen <number> [--dry-run]` 是 PR CRUD/生命周期入口。`pr create --dry-run` 只输出 planned operation,不访问 GitHub;非 dry-run 创建前会校验 repo、base、head 和 compare ahead 状态,成功时返回 PR number/url。`pr edit/update` 使用 REST `PATCH /repos/{owner}/{repo}/pulls/{number}`,只发送显式提供的 `title` 和/或 `body` 字段,完全避开 GitHub Projects Classic GraphQL/projectCards;输出低噪声 JSON`ok``repo`、PR number、`changedFields``url`、body 长度/SHA/source 元数据和 request plan,不默认回显完整正文。`pr update --mode append` 会先读取当前 PR body 再追加正文。`gh pr delete <number>``gh pr merge` 本阶段不开放,始终结构化返回 `unsupported-command`PR 生命周期删除语义请使用 `close`
+2 -2
View File
@@ -303,11 +303,11 @@ stale-active 恢复和 `/api/scheduler/reconcile?staleMs=...` 诊断入口的 he
只有多个独立观察面同时失败,或同一关键路径在明确时间窗口内持续失败,才能把问题判为全局阻塞。否则应记录为 transient、`runner-local-observation-gap``external-provider-backoff``control-plane-observation-gap`,优先重试、steer 任务纠偏或拆出基础设施 follow-up;不得让业务 worker 把单次局部失败作为最终 blocker。CLI 和 runtime 必须把错误输出结构化为 `scope=runner-local|external-provider|control-plane|provider-gateway|ssh|registry|k3s|scheduler|service-proxy``observedAt``retryable``decision``blockingDisposition``healthyScopes``failedScopes` 和建议的交叉验证命令。当前 runner/local backend-core 容器缺失属于 runner-local observation gap;远程控制面也不可达属于 control-plane observation gap;两者都不能单独写成 active runner 数归零或 scheduler 停摆。
ClaudeQQ 是面向用户的主动提醒通道,不是 #24 简报更新的自动转发器。指挥官只应在三类情况下自主发送 ClaudeQQ 消息:核心服务或关键执行面宕机且需要用户知情,高风险决策需要用户请示,或出现里程碑式进展值得同步。消息必须简明扼要,一次不超过 200 个中文字符,写成一段话,不使用 Markdown 语法。普通轮询、普通 issue 更新、普通 #24 简报追加、外部 token provider 正常限流、以及无用户动作要求的中间状态,不发送 ClaudeQQ。发送失败只记录到 #24 或对应 blocker issue,不回滚已经完成的 GitHub issue 更新。
ClaudeQQ 是面向用户的主动提醒通道,不是 #24 简报更新的自动转发器。指挥官只应在三类情况下自主发送 ClaudeQQ 消息:核心服务或关键执行面宕机且需要用户知情,高风险决策需要用户请示,或出现里程碑式进展值得同步。消息必须简明扼要,一次不超过 200 个中文字符,写成一段话,不使用 Markdown 语法。普通轮询、普通 issue 更新、普通 #24 简报追加、外部 token provider 正常限流、以及无用户动作要求的中间状态,不发送 ClaudeQQ。发送失败只记录到 #24 或对应 blocker issue,不回滚已经完成的 GitHub issue 更新。高风险审批通知不得走本机 ClaudeQQ skill、powershell 或本地 serverrepo-owned 路径是先用 `bun scripts/cli.ts commander approval request --action <action> --dry-run [--task-id id] [--reason text]` 生成草案,获得授权后才使用 backend-core proxy 命令 `bun scripts/cli.ts microservice proxy claudeqq /api/push/text --method POST --body-json '<payload>' --raw`
重启 Code Queue backend、重建 Code Queue backend 容器、重启 Code Queue 执行面,或对运行中 Code Queue 任务执行 interrupt/cancel 这类会改变执行状态的操作,都属于高风险干预。即使看起来是最小恢复动作,指挥官也必须先通过 ClaudeQQ 向用户上报原因、影响范围和拟执行动作,并等待用户明确同意;未获得同意前只能做只读诊断、记录 issue、更新看板和准备恢复方案。
host Codex 指挥官正规化后仍受同一条高风险边界约束。`docs/reference/host-codex-commander.md` 中的直管微服务只能把 host Codex 保活、SSH/PTY/stdio bridge、prompt plan、trace summary、#20/#46 issue 入口和 ClaudeQQ 审批记录产品化;它不是 Code Queue runner,也不是 Code Queue 自部署通道。第一阶段 `bun scripts/cli.ts commander ...` 只允许输出 contract/dry-run 计划,不得实际重启 backend、interrupt/cancel task、读取 token 明文、打开 bridge 或发送 ClaudeQQ。
host Codex 指挥官正规化后仍受同一条高风险边界约束。`docs/reference/host-codex-commander.md` 中的直管微服务只能把 host Codex 保活、SSH/PTY/stdio bridge、prompt plan、trace summary、#20/#46 issue 入口和 ClaudeQQ 审批记录产品化;它不是 Code Queue runner,也不是 Code Queue 自部署通道。第一阶段 `bun scripts/cli.ts commander ...` 只允许输出 contract/dry-run 计划,不得实际重启 backend、interrupt/cancel task、读取 token 明文、打开 bridge 或发送 ClaudeQQ。若 dry-run 返回 `notification-path-unavailable`,这是正式发送未开放的 blocker;指挥官应记录到 issue/#24,并使用输出里的 backend-core proxy command 作为后续授权后的唯一候选命令。
当多信号裁决显示 provider 服务器、D601 执行面或关键维护桥疑似需要人工检查时,指挥官可以在更新 #24/#40 等记录之外,通过 ClaudeQQ 额外提醒用户检查 provider 服务器状态。提醒只在首次确认、状态恶化、恢复或需要用户介入时发送,不能在每轮轮询中重复轰炸。ClaudeQQ 提醒是 best-effort:若 ClaudeQQ 本身依赖同一条故障 provider/k3sctl 链路而不可达,指挥官应把通知失败的原因写入 #24 或对应 blocker issue,并继续按轮询和恢复规则推进。
+14 -4
View File
@@ -1,15 +1,16 @@
# Host Codex Commander Skeleton
本文定义 host Codex 指挥官的本地 skeleton 阶段。仓库内正式 `commander` CLI 当前只提供 `/health``/api/commander/contract``.state/commander/` 状态读写、trace summary dry-run 聚合和 approval draft preview;不接 live bridge,不发送 ClaudeQQ,不接管人工指挥官。
本文定义 host Codex 指挥官的本地 skeleton 阶段。仓库内正式 `commander` CLI 当前只提供 `/health``/api/commander/contract``.state/commander/` 状态读写、trace summary dry-run 聚合和 approval draft preview;不接 live bridge,不发送 ClaudeQQ,不接管人工指挥官。高风险 Code Queue recovery 的 ClaudeQQ 审批在本阶段只生成 200 字以内中文纯文本草案和 backend-core proxy 命令,不走本机 skill、powershell 或本地 ClaudeQQ server。
当前允许存在一个 operator-run 的高鲁棒 stdio 保活形态,用于在人工授权下让 host Codex 持续承担指挥官工作。该形态不是仓库内正式 daemon,也不替代 skeleton contract;它应被视为过渡运行方式,后续产品化仍必须回到本文件的 contract、state、approval 和安全边界。
## 边界
- `host-codex-commander` 是独立的本地 skeleton,不是运行中的 live daemon。
- 只允许本地文件状态、trace 摘要和审批草案预览。
- 只允许本地文件状态、trace 摘要和审批草案预览;审批通知草案必须是 200 字以内中文纯文本,不使用 Markdown
- 所有输出必须 redaction token/secret/URL credential。
- 不得重启或接管 Code Queue backend,不得 cancel/interrupt 运行任务,不得打印 token 明文。
- ClaudeQQ 正式发送未在 skeleton 中实现;如后续获得授权,唯一允许路径是 UniDesk backend-core microservice proxy`bun scripts/cli.ts microservice proxy claudeqq /api/push/text --method POST --body-json '<payload>' --raw`
- Host commander 观察到 `bun scripts/cli.ts codex pr-preflight --remote` 返回 `scheduler-runner-env` / `auth-missing` 时,只能把它解释为 scheduler runtime preflight surface 缺少 GitHub auth;不得据此判定当前 active runner 或 dev container 不能 push、创建 PR 或评论 PR。
- `pikasTech/unidesk#20` 只作为 UniDesk commander / Code Queue / CLI / infra governance 入口;HWLAB 用户和产品 issue 必须路由到 `pikasTech/HWLAB`,不能作为 #20 看板行维护。
@@ -77,7 +78,7 @@ host commander 不直接编辑 HWLAB 业务代码,不以本地热修绕过 HWL
- health endpoint:用 `createCommanderRequestHandler` 和临时 `RuntimeConfig` 调用 `GET /health`,期望返回 `service=host-codex-commander``stateRoot` 和日志文件路径;禁止 `Bun.serve` 和端口监听。
- state file:只在临时目录写读 `sessions/<sessionId>.json``events/<sessionId>.jsonl``approvals/draft.json`,确认 session 状态和 redaction round-trip;禁止触碰真实 `.state/commander/`
- trace summary dry-run:只喂 mock JSONL 给 `summarizeCommanderTrace`,确认 `taskId``sessionId``lastSeq``status``redactionsApplied` 和有界摘要;禁止读取 live Code Queue trace、标记已读、interrupt 或 cancel。
- approval draft preview:只运行 `commander approval request --dry-run``buildCommanderApprovalDraft`,确认 `requiresExplicitUserApproval=true``claudeqq.mutation=false``sendImplemented=false` 和敏感信息脱敏;禁止 POST ClaudeQQ。
- approval draft preview:只运行 `commander approval request --dry-run``buildCommanderApprovalDraft`,确认 `requiresExplicitUserApproval=true``claudeqq.mutation=false``sendImplemented=false``dryRunNoClaudeQqSend=true``notificationDraft.message` 为 200 字以内中文纯文本、`notificationPath.error=notification-path-unavailable`,并返回 backend-core `microservice proxy claudeqq /api/push/text` 命令;禁止 POST ClaudeQQ。
- SSH bridge boundary:只检查 `commander plan --dry-run``bridge.mutation=false``startPlan.enabled=false``safetyBoundary.phaseOneMutationAllowed=false`;禁止打开 SSH、PTY 或 stdio bridge。
轻量契约测试是:
@@ -130,6 +131,15 @@ trace summary 输入 mock Code Queue trace JSONL 和可选 task summary,输出
高风险动作只生成 approval draft JSON / Markdown preview。preview 必须显示 redaction 结果,并明确 `sendImplemented=false`
`commander approval request --dry-run` 的 ClaudeQQ 字段必须包含:
- `notificationDraft.message`:给用户看的 200 字以内中文纯文本审批草案,一段话,不含 Markdown。
- `dryRunNoClaudeQqSend=true`:确认 dry-run 不发送。
- `notificationPath.error=notification-path-unavailable`:说明当前 skeleton 未开放真实发送。
- `notificationPath.backendCoreProxyCommand`:授权后才可执行的 repo-owned 发送命令,路径固定为 `bun scripts/cli.ts microservice proxy claudeqq /api/push/text --method POST --body-json '<payload>' --raw`
不得使用 `/root/.agents/skills/claudeqq``powershell.exe`、本地 skill server 或 9082 端口作为高风险审批通知路径。若 backend-core ClaudeQQ proxy 发送失败,失败必须结构化为 `notification-proxy-failed` / `microservice-proxy-failed`,不得打印 token,不回滚已经完成的 GitHub issue 更新;指挥官应把失败原因写入 #24 或对应 blocker issue。
## 进入真实运行态前
启用 daemon、PTY/stdio bridge、SSH bridge 或 ClaudeQQ 发送路径前,必须先获得人工授权。授权必须绑定一个精确 action 和目标 session/task/service,已有 smoke/skeleton contract 通过,风险审查确认不会打印 token、不会直接 patch 数据库、不会绕过 backend 确认策略,并且已有可审计的 approval id、回滚步骤和观测步骤。
启用 daemon、PTY/stdio bridge、SSH bridge 或 ClaudeQQ 发送路径前,必须先获得人工授权。授权必须绑定一个精确 action 和目标 session/task/service,已有 smoke/skeleton contract 通过,风险审查确认不会打印 token、不会直接 patch 数据库、不会绕过 backend 确认策略,并且已有可审计的 approval id、回滚步骤和观测步骤。ClaudeQQ 发送只能通过 backend-core `/api/microservices/claudeqq/proxy`,并设置超时和结构化失败输出。
@@ -21,12 +21,12 @@ function asRecord(value: unknown, path: string): JsonRecord {
function asArray(value: unknown, path: string): unknown[] {
assertCondition(Array.isArray(value), `${path} must be an array`);
return value;
return value as unknown[];
}
function stringField(value: unknown, path: string): string {
assertCondition(typeof value === "string" && value.length > 0, `${path} must be a non-empty string`);
return value;
return value as string;
}
function serviceById(environment: JsonRecord, id: string, path: string): JsonRecord {
@@ -656,7 +656,7 @@ async function main(): Promise<void> {
}),
},
}),
}), "github transient full record");
}));
const githubTransientPreflight = asRecord(githubTransientFullRecord.preflight);
assertCondition(githubTransientPreflight.retryable === true, "GitHub transient full preflight should be retryable", githubTransientPreflight);
assertCondition(githubTransientPreflight.commanderAction === "retry-backoff-or-keep-running-if-heartbeat-fresh", "GitHub transient full preflight should expose bounded commander action", githubTransientPreflight);
@@ -0,0 +1,164 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { commanderApprovalProxyFailureSummary } from "../src/components/microservices/host-codex-commander/src/approval-notification";
type JsonRecord = Record<string, unknown>;
function assertCondition(condition: unknown, message: string, detail: unknown = {}): void {
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
}
function asRecord(value: unknown, label: string): JsonRecord {
assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, value);
return value as JsonRecord;
}
function runCli(args: string[], expectStatus: number, extraEnv: Record<string, string> = {}): JsonRecord {
const result = spawnSync(process.execPath, ["scripts/cli.ts", ...args], {
cwd: process.cwd(),
encoding: "utf8",
env: {
...process.env,
...extraEnv,
},
maxBuffer: 4 * 1024 * 1024,
});
assertCondition(result.status === expectStatus, `status mismatch for ${args.join(" ")}`, {
status: result.status,
stdout: result.stdout.slice(-2000),
stderr: result.stderr.slice(-2000),
});
assertCondition(result.stdout.trim().length > 0, `command produced no stdout: ${args.join(" ")}`);
return asRecord(JSON.parse(result.stdout) as unknown, "cli envelope");
}
function dataOf(envelope: JsonRecord): JsonRecord {
return asRecord(envelope.data, "data");
}
function charLength(value: string): number {
return Array.from(value).length;
}
function assertPlainApprovalMessage(message: string): void {
assertCondition(charLength(message) <= 200, "approval notification draft must be <=200 chars", { message, chars: charLength(message) });
assertCondition(!/[`*_#[\]|]/u.test(message), "approval notification draft must not contain Markdown syntax", { message });
assertCondition(!message.includes("\n"), "approval notification draft must be one paragraph", { message });
}
const approval = dataOf(runCli([
"commander",
"approval",
"request",
"--action",
"code-queue-task-interrupt",
"--task-id",
"stale-active-118",
"--reason",
"stale-active 恢复需要 interrupttoken=ghp_1234567890abcdef;不要使用 `local` 路径",
"--dry-run",
], 0, {
PATH: "/usr/bin:/bin",
}));
assertCondition(approval.ok === true, "approval dry-run must succeed without local powershell", approval);
assertCondition(approval.mutation === false, "approval dry-run must be non-mutating", approval);
assertCondition(!JSON.stringify(approval).includes("ghp_1234567890abcdef"), "approval dry-run must redact secret-like reason", approval);
const claudeqq = asRecord(approval.claudeqq, "claudeqq");
assertCondition(claudeqq.mutation === false, "ClaudeQQ dry-run must not mutate", claudeqq);
assertCondition(claudeqq.sendImplemented === false, "commander skeleton must not claim send implementation", claudeqq);
assertCondition(claudeqq.dryRunNoClaudeQqSend === true, "dry-run must explicitly report no ClaudeQQ send", claudeqq);
const draft = asRecord(claudeqq.notificationDraft, "claudeqq.notificationDraft");
assertCondition(draft.format === "plain-text", "approval draft must be plain text", draft);
assertCondition(draft.markdownAllowed === false, "approval draft must forbid Markdown", draft);
assertCondition(draft.containsMarkdownSyntax === false, "approval draft must report no Markdown syntax", draft);
assertPlainApprovalMessage(String(draft.message));
const path = asRecord(claudeqq.notificationPath, "claudeqq.notificationPath");
assertCondition(path.error === "notification-path-unavailable", "dry-run must expose notification-path-unavailable blocker", path);
assertCondition(path.servicePath === "/api/microservices/claudeqq/proxy/api/push/text", "service path must be backend-core ClaudeQQ proxy", path);
assertCondition(String(path.backendCoreProxyCommand).includes("bun scripts/cli.ts microservice proxy claudeqq /api/push/text --method POST"), "backend-core proxy command must be returned", path);
assertCondition(!String(path.backendCoreProxyCommand).includes("powershell"), "backend-core proxy command must not use local powershell", path);
assertCondition(!String(path.backendCoreProxyCommand).includes(".agents/skills/claudeqq"), "backend-core proxy command must not use local skill path", path);
assertCondition(path.timeoutMs === 15000, "proxy path must disclose timeout", path);
const failure = commanderApprovalProxyFailureSummary({
ok: false,
status: 503,
error: "microservice proxy task failed",
stderrTail: "token=ghp_1234567890abcdef powershell.exe failed",
});
assertCondition(failure.ok === false, "proxy failure summary must be failing", failure);
assertCondition(failure.error === "notification-proxy-failed", "proxy failure summary must be structured", failure);
assertCondition(failure.degradedReason === "microservice-proxy-failed", "proxy failure degraded reason must be microservice-proxy-failed", failure);
assertCondition(asRecord(failure.rollback, "rollback").issueUpdateRolledBack === false, "proxy failure must not imply issue rollback", failure);
assertCondition(!JSON.stringify(failure).includes("ghp_1234567890abcdef"), "proxy failure summary must redact secrets", failure);
const tmp = mkdtempSync(join(tmpdir(), "unidesk-gh-brief-notify-"));
try {
const bodyPath = join(tmp, "brief.md");
writeFileSync(bodyPath, [
"# 指挥简报",
"",
"## 常驻观察与长期建议",
"",
"- 保持监督。",
"",
"## 更新 2026-05-23 10:00 北京时间",
"",
"- 新增高风险审批路径 dry-run 预览。",
"",
].join("\n"), "utf8");
const ghDryRun = dataOf(runCli([
"gh",
"issue",
"edit",
"24",
"--body-file",
bodyPath,
"--notify-claudeqq-brief-diff",
"--dry-run",
], 0, {
UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL: "http://127.0.0.1:9082",
}));
const notification = asRecord(ghDryRun.commanderBriefNotification, "commanderBriefNotification");
const ghClaudeqq = asRecord(notification.claudeqq, "commanderBriefNotification.claudeqq");
assertCondition(ghClaudeqq.wouldSend === false, "dry-run helper must not allow non-proxy ClaudeQQ path", ghClaudeqq);
assertCondition(ghClaudeqq.blockedReason === "notification-path-unavailable", "dry-run helper must structure non-proxy blocker", ghClaudeqq);
assertCondition(String(ghClaudeqq.recommendedCommand).includes("microservice proxy claudeqq"), "dry-run helper must recommend repo-owned proxy command", ghClaudeqq);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
const hostDoc = readFileSync("docs/reference/host-codex-commander.md", "utf8");
const supervisionDoc = readFileSync("docs/reference/code-queue-supervision.md", "utf8");
for (const snippet of [
"notification-path-unavailable",
"microservice proxy claudeqq /api/push/text",
"200 字以内中文纯文本",
]) {
assertCondition(hostDoc.includes(snippet), `host commander doc missing snippet: ${snippet}`);
}
for (const snippet of [
"不超过 200 个中文字符",
"不使用 Markdown 语法",
"发送失败只记录到 #24 或对应 blocker issue",
]) {
assertCondition(supervisionDoc.includes(snippet), `code queue supervision doc missing snippet: ${snippet}`);
}
process.stdout.write(`${JSON.stringify({
ok: true,
checks: [
"commander approval dry-run survives without local powershell or ClaudeQQ skill server",
"dry-run generates a <=200 char Chinese plain-text ClaudeQQ draft and does not send",
"dry-run exposes notification-path-unavailable plus backend-core microservice proxy command",
"microservice proxy failure summary is structured, redacted, and best-effort",
"legacy gh issue edit notify dry-run rejects non-proxy ClaudeQQ base URLs",
"reference docs state the high-risk ClaudeQQ approval notification path",
],
}, null, 2)}\n`);
@@ -71,7 +71,9 @@ try {
assertCondition(session.notes[0] === "<redacted>", "session notes must be redacted", session);
assertCondition(readCommanderSession(runtime, "primary").state === "running", "session read must round-trip", readCommanderSession(runtime, "primary"));
assertCondition(listCommanderSessions(runtime).length >= 1, "session listing must include stored session", listCommanderSessions(runtime));
assertCondition(commanderSessionPreview(session).notes.includes("<redacted>"), "session preview must redact notes", commanderSessionPreview(session));
const sessionPreview = asRecord(commanderSessionPreview(session), "session preview");
const sessionPreviewNotes = Array.isArray(sessionPreview.notes) ? sessionPreview.notes.map((item) => String(item)) : [];
assertCondition(sessionPreviewNotes.includes("<redacted>"), "session preview must redact notes", sessionPreview);
const trace = summarizeCommanderTrace({
taskId: "task-123",
@@ -117,7 +119,7 @@ try {
assertCondition(Array.isArray(sessionsBody.sessions) && sessionsBody.sessions.length >= 1, "sessions route must list sessions", sessionsBody);
const traceBody = await readJson(await handler(new Request(`http://localhost/api/commander/trace-summary?taskId=task-123&traceJsonl=${encodeURIComponent(JSON.stringify({ seq: 1, status: "running", summary: "hello token=ghp_1234567890abcdef" }))}`)));
assertCondition(traceBody.ok === true && asRecord(traceBody.summary, "summary").redactionsApplied >= 1, "trace route must redact and summarize", traceBody);
assertCondition(traceBody.ok === true && Number(asRecord(traceBody.summary, "summary").redactionsApplied) >= 1, "trace route must redact and summarize", traceBody);
const approvalBody = await readJson(await handler(new Request("http://localhost/api/commander/approvals", {
method: "POST",
@@ -25,7 +25,7 @@ function asRecord(value: unknown, label: string): JsonRecord {
function asArray(value: unknown, label: string): unknown[] {
assertCondition(Array.isArray(value), `${label} must be an array`, value);
return value;
return value as unknown[];
}
interface ContractCase {
+1 -1
View File
@@ -2813,7 +2813,7 @@ function dryRunArtifactConsumerPlan(options: ArtifactRegistryOptions, spec: Arti
const drifts = deployJsonService !== null && hasDeployJsonExecutorContract(deployJsonService)
? compareDeployJsonExecutorMirrors(deployJsonService, environment, artifactRegistryDeployJsonMirrors(effectiveOptions, spec, target))
: [];
if (drifts.length > 0) return deployJsonDriftResult(deployJsonService, environment, drifts);
if (deployJsonService !== null && drifts.length > 0) return deployJsonDriftResult(deployJsonService, environment, drifts);
const verificationBlocked = spec.runtimeVerification === "blocked";
const livePolicy = artifactConsumerLivePolicy(spec, environment);
const liveReason = artifactConsumerLiveBlockReason(spec, environment);
+20 -22
View File
@@ -1,4 +1,6 @@
import { buildCommanderApprovalNotificationDraft, commanderApprovalNotificationPathUnavailable } from "../../src/components/microservices/host-codex-commander/src/approval-notification";
import { commanderContract as hostCommanderContract, commanderHighRiskActions as highRiskActions } from "../../src/components/microservices/host-codex-commander/src/contract";
import { redactText } from "../../src/components/microservices/host-codex-commander/src/redaction";
const requiredDryRunMessage = "This host Codex commander skeleton only supports dry-run planning; live daemon/control operations are not implemented.";
@@ -24,24 +26,6 @@ function isHighRiskAction(value: string): value is HighRiskAction {
return highRiskActions.some((action) => action === value);
}
function redactText(value: string): { text: string; redactionsApplied: number } {
let redactionsApplied = 0;
const patterns = [
/\b(?:sk|ghp|github_pat|xoxb|xoxp|AKIA)[A-Za-z0-9_=-]{8,}\b/g,
/\b(?:token|secret|password|passwd|authorization|cookie|api[_-]?key)\s*[:=]\s*[^,\s]+/gi,
/\bBearer\s+[A-Za-z0-9._~+/-]+=*\b/gi,
/https?:\/\/[^/\s]+:[^@\s]+@[^/\s]+/gi,
];
let text = value;
for (const pattern of patterns) {
text = text.replace(pattern, () => {
redactionsApplied += 1;
return "<redacted>";
});
}
return { text, redactionsApplied };
}
function commanderHelp(): Record<string, unknown> {
return {
command: "commander",
@@ -59,7 +43,7 @@ function commanderHelp(): Record<string, unknown> {
}
export function commanderContract(): Record<string, unknown> {
return hostCommanderContract();
return hostCommanderContract() as unknown as Record<string, unknown>;
}
function safetyBoundary(): Record<string, unknown> {
@@ -264,6 +248,9 @@ function commanderPlan(args: string[]): Record<string, unknown> {
claudeqqApproval: {
mutation: false,
commandShape: "bun scripts/cli.ts commander approval request --action <action> --dry-run",
dryRunDraft: "Chinese plain-text ClaudeQQ approval draft, <=200 chars, no Markdown",
liveSendBlockedBy: "notification-path-unavailable",
backendCoreProxyOnly: "bun scripts/cli.ts microservice proxy claudeqq /api/push/text --method POST --body-json '<payload>' --raw",
highRiskActions,
},
safetyBoundary: safetyBoundary(),
@@ -342,6 +329,9 @@ function approvalDraftValidation(): Record<string, unknown> {
"requiresExplicitUserApproval=true",
"claudeqq.mutation=false",
"claudeqq.sendImplemented=false",
"claudeqq.notificationDraft.message is <=200 Chinese chars and plain text",
"claudeqq.notificationPath.error=notification-path-unavailable",
"backend-core microservice proxy command is returned instead of a local skill/powershell command",
"reason and messageTemplate are redacted",
],
noRuntimeSideEffects: [
@@ -423,7 +413,7 @@ function commanderSmoke(args: string[]): Record<string, unknown> {
"operator explicitly names the exact live action and target session/task/service",
"current source-contract smoke and skeleton contract tests are green",
"risk review confirms no token output, no direct database patch, and no backend restart bypass",
"ClaudeQQ approval draft is reviewed, sent by an authorized future path, and matched to an explicit approval id",
"ClaudeQQ approval draft is reviewed, any authorized send uses backend-core /api/microservices/claudeqq/proxy, and the reply is matched to an explicit approval id",
"rollback and observation steps are written before enabling any daemon or bridge",
],
};
@@ -458,6 +448,8 @@ function commanderApprovalRequest(args: string[]): Record<string, unknown> {
const rawReason = optionValue(args, "--reason") ?? "operator-supplied reason required before live execution";
const reason = redactText(rawReason);
const taskId = optionValue(args, "--task-id") ?? null;
const notificationDraft = buildCommanderApprovalNotificationDraft({ action, taskId, reason: rawReason });
const notificationPath = commanderApprovalNotificationPathUnavailable(notificationDraft.message);
return {
ok: true,
phase: "source-contract",
@@ -468,12 +460,18 @@ function commanderApprovalRequest(args: string[]): Record<string, unknown> {
reason: reason.text,
redactionsApplied: reason.redactionsApplied,
requiresExplicitUserApproval: true,
notificationDraft,
claudeqq: {
mutation: false,
endpointShape: "POST /api/microservices/claudeqq/proxy/api/push/text",
endpointShape: `POST ${notificationPath.servicePath}`,
fallbackEndpointShape: `POST ${notificationPath.fallbackServicePath}`,
target: "configured primary user private chat",
messageTemplate: `Approval required for ${action}. Reason: ${reason.text}. Reply with explicit approval id before execution.`,
messageTemplate: notificationDraft.message,
sendImplemented: false,
dryRunNoClaudeQqSend: true,
notificationDraft,
notificationPath,
noLocalSkillFallback: true,
},
approvalRecordShape: {
id: "commander-approval-<stable-id>",
+22 -31
View File
@@ -1314,41 +1314,23 @@ async function sendClaudeQqEndpoint(config: ClaudeQqConfig, endpoint: string, pa
method: "POST",
body: payload,
maxResponseBytes: 240_000,
timeoutMs: config.timeoutMs,
});
if (claudeQqResponseOk(response)) return { ok: true, endpoint, status: isRecord(response) && typeof response.status === "number" ? response.status : 200, response };
return summarizeClaudeQqProxyFailure(response, endpoint);
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
try {
const response = await fetch(normalizeClaudeQqEndpoint(config.baseUrl, endpoint), {
method: "POST",
signal: controller.signal,
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(payload),
});
const parsed = await parseGitHubResponse(response);
const bodyFailed = isRecord(parsed) && (parsed.ok === false || parsed.success === false);
if (response.ok && !bodyFailed) return { ok: true, endpoint, status: response.status, response: parsed };
return {
ok: false,
endpoint,
status: response.status,
degradedReason: bodyFailed ? "upstream-rejected" : "http-failed",
message: isRecord(parsed) && typeof parsed.error === "string" ? parsed.error : response.statusText,
response: sanitizedErrorDetails(parsed),
};
} catch (error) {
return {
ok: false,
endpoint,
degradedReason: "network-proxy-failed",
message: error instanceof Error ? error.message : String(error),
};
} finally {
clearTimeout(timeout);
}
return {
ok: false,
endpoint,
degradedReason: "notification-path-unavailable",
message: "ClaudeQQ notifications must use backend-core /api/microservices/claudeqq/proxy; local skill, powershell, direct host, and ad-hoc ClaudeQQ URLs are not supported.",
response: {
baseUrl: sanitizeUrlForOutput(config.baseUrl),
requiredBaseUrl: DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL,
recommendedCommand: "bun scripts/cli.ts microservice proxy claudeqq /api/push/text --method POST --body-json '<payload>' --raw",
},
};
}
async function sendCommanderBriefClaudeQq(config: ClaudeQqConfig, message: string): Promise<ClaudeQqSendResult> {
@@ -1377,6 +1359,7 @@ async function sendCommanderBriefClaudeQq(config: ClaudeQqConfig, message: strin
}
function commanderBriefNotificationPlan(issueNumber: number, body: string, diff: CommanderBriefDiff, config: ClaudeQqConfig): Record<string, unknown> {
const proxyBasePath = proxiedClaudeQqBasePath(config.baseUrl);
return {
enabled: config.enabled,
issueNumber,
@@ -1395,10 +1378,18 @@ function commanderBriefNotificationPlan(issueNumber: number, body: string, diff:
},
claudeqq: {
dryRun: true,
wouldSend: config.enabled && diff.ok,
wouldSend: config.enabled && diff.ok && proxyBasePath !== null,
baseUrl: sanitizeUrlForOutput(config.baseUrl),
proxyBasePath,
servicePath: proxyBasePath === null ? null : normalizeClaudeQqEndpoint(proxyBasePath, "/api/push/text"),
target: maskedTarget(config),
timeoutMs: config.timeoutMs,
...(proxyBasePath === null
? {
blockedReason: "notification-path-unavailable",
recommendedCommand: "bun scripts/cli.ts microservice proxy claudeqq /api/push/text --method POST --body-json '<payload>' --raw",
}
: {}),
},
};
}
+4 -3
View File
@@ -47,7 +47,7 @@ export function rootHelp(): unknown {
{ command: "artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service", description: "Manage the D601 host-managed CNCF Distribution registry and run pull-only artifact CD for supported services, including D601 direct, k3s-managed, and code-queue dev-only consumers." },
{ command: "auth-broker contract|health --dry-run|credential-request --dry-run|pr-preflight --dry-run", description: "Inspect the P0 Rust auth broker and CLI adapter contract without reading token values, writing GitHub, or starting services." },
{ command: "gh preflight|auth|issue|pr", description: "Run safe GitHub issue and PR CRUD/lifecycle operations through REST with body-file update replace/append, comment delete, token diagnostics, PR closeout preflight, hard delete unsupported, and merge blocked." },
{ command: "commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run", description: "Host Codex commander skeleton contract, no-daemon smoke plan, and dry-run preview; exposes local health, state, trace summary, and approval draft helpers without live bridges or message sends." },
{ command: "commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run", description: "Host Codex commander skeleton contract, no-daemon smoke plan, and dry-run approval preview; generates <=200 char Chinese ClaudeQQ drafts plus backend-core proxy command without live bridges or message sends." },
{ command: "code-agent-sandbox", description: "Independent Code Agent Sandbox service skeleton for adapter, mode, and credential-boundary diagnostics." },
{ command: "schedule list|get|runs|run|retry-run|delete", description: "Manage backend-core scheduled tasks and run history; schedule run <id> supports --wait-ms N and retry-run reuses the failed run's schedule." },
{ command: "schedule upsert-pgdata-backup [--time HH:MM] [--remote-base /SERVER_DATA/UNIDESK_PG_DATA]", description: "Create or update the daily PGDATA physical backup task that uploads monthly rotated archives to Baidu Netdisk." },
@@ -219,11 +219,12 @@ function commanderHelp(): unknown {
"bun scripts/cli.ts commander smoke --dry-run [--session-id id]",
"bun scripts/cli.ts commander approval request --action <action> --dry-run [--reason text] [--task-id id]",
],
description: "Inspect the local host Codex commander skeleton contract, dry-run planner, no-daemon smoke validation plan, state helpers, trace summary aggregator, and approval draft preview.",
description: "Inspect the local host Codex commander skeleton contract, dry-run planner, no-daemon smoke validation plan, state helpers, trace summary aggregator, and approval draft preview with a backend-core ClaudeQQ proxy command.",
boundary: [
"the current skeleton is local-only and never attaches to live bridges",
"dry-run commands never open SSH, PTY, or stdio bridges",
"high-risk actions only produce a ClaudeQQ approval draft",
"high-risk actions only produce a <=200 char Chinese ClaudeQQ approval draft and notification-path-unavailable blocker",
"authorized future sends must use backend-core /api/microservices/claudeqq/proxy, not local skill or powershell paths",
"token and secret values must never be printed",
],
reference: "docs/reference/host-codex-commander.md",
+3 -2
View File
@@ -142,10 +142,10 @@ function backendCoreContainerMissing(stderr: string): boolean {
return stderr.includes("No such container: unidesk-backend-core");
}
export function coreInternalFetch(path: string, init?: { method?: string; body?: unknown; maxResponseBytes?: number }): unknown {
export function coreInternalFetch(path: string, init?: { method?: string; body?: unknown; maxResponseBytes?: number; timeoutMs?: number }): unknown {
if (!path.startsWith("/")) throw new Error("core internal path must start with /");
const command = dockerCoreFetchCommand(path, init);
const result = runCommand(command, repoRoot);
const result = runCommand(command, repoRoot, { timeoutMs: init?.timeoutMs });
if (result.exitCode !== 0) {
if (backendCoreContainerMissing(result.stderr)) {
const envPath = join(repoRoot, ".state", "docker-compose.env");
@@ -170,6 +170,7 @@ export function coreInternalFetch(path: string, init?: { method?: string; body?:
return {
ok: false,
exitCode: result.exitCode,
timedOut: result.timedOut,
stdoutTail: result.stdout.slice(-1200),
stderrTail: result.stderr.slice(-1200),
...(codeQueueStableProxy ? {
@@ -0,0 +1,163 @@
import { redactJsonValue, redactText } from "./redaction";
export const commanderApprovalNotificationMaxChars = 200;
export const commanderApprovalClaudeQqProxyBasePath = "/api/microservices/claudeqq/proxy";
export const commanderApprovalClaudeQqPrimaryEndpoint = "/api/push/text";
export const commanderApprovalClaudeQqFallbackEndpoint = "/api/send/text";
export const commanderApprovalClaudeQqDefaultUserId = "645275593";
export const commanderApprovalClaudeQqTimeoutMs = 15_000;
export interface CommanderApprovalNotificationDraft {
ok: true;
channel: "claudeqq";
language: "zh-CN";
format: "plain-text";
message: string;
chars: number;
maxChars: number;
markdownAllowed: false;
containsMarkdownSyntax: boolean;
oneParagraph: boolean;
redactionsApplied: number;
}
export interface CommanderApprovalNotificationPathUnavailable {
ok: false;
error: "notification-path-unavailable";
degradedReason: "source-contract-dry-run-only";
mutation: false;
message: string;
servicePath: string;
fallbackServicePath: string;
backendCoreProxyCommand: string;
timeoutMs: number;
forbiddenLocalPaths: string[];
}
const actionLabels: Record<string, string> = {
"code-queue-backend-restart": "重启 Code Queue backend",
"code-queue-backend-rebuild": "重建 Code Queue backend 容器",
"code-queue-execution-plane-restart": "重启 Code Queue 执行面",
"code-queue-task-interrupt": "interrupt 运行中任务",
"code-queue-task-cancel": "cancel 运行中任务",
"prod-runtime-mutation": "修改生产运行态",
};
function charLength(value: string): number {
return Array.from(value).length;
}
function clampChars(value: string, maxChars: number): string {
const chars = Array.from(value);
if (chars.length <= maxChars) return value;
if (maxChars <= 1) return "…";
return `${chars.slice(0, maxChars - 1).join("")}`;
}
function oneParagraph(value: string): string {
return value.replace(/\s+/gu, " ").trim();
}
function removeMarkdownSyntax(value: string): string {
return value
.replace(/[`*_#[\]|]/gu, "")
.replace(/^\s*[-+]\s+/gu, "")
.trim();
}
function containsMarkdownSyntax(value: string): boolean {
return /[`*_#[\]|]/u.test(value) || /\n/u.test(value);
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, "'\\''")}'`;
}
export function commanderApprovalClaudeQqPayload(message: string, userId = commanderApprovalClaudeQqDefaultUserId): Record<string, unknown> {
return {
targetType: "private",
userId,
message,
};
}
export function commanderApprovalClaudeQqProxyCommand(message: string): string {
const payload = JSON.stringify(commanderApprovalClaudeQqPayload(message));
return `bun scripts/cli.ts microservice proxy claudeqq ${commanderApprovalClaudeQqPrimaryEndpoint} --method POST --body-json ${shellQuote(payload)} --raw`;
}
export function buildCommanderApprovalNotificationDraft(input: { action: string; taskId?: string | null; reason: string }): CommanderApprovalNotificationDraft {
const redacted = redactText(input.reason);
const label = actionLabels[input.action] ?? removeMarkdownSyntax(input.action);
const target = input.taskId === null || input.taskId === undefined || input.taskId.length === 0
? ""
: `,目标 ${removeMarkdownSyntax(input.taskId)}`;
const prefix = `请审批高风险 Code Queue 恢复:拟执行「${label}${target}。原因:`;
const suffix = "。未获明确同意前只做只读诊断和 issue 记录。";
const fallbackReason = "需用户确认后才能执行";
const reason = removeMarkdownSyntax(oneParagraph(redacted.text || fallbackReason)) || fallbackReason;
const budget = Math.max(1, commanderApprovalNotificationMaxChars - charLength(prefix) - charLength(suffix));
const message = `${prefix}${clampChars(reason, budget)}${suffix}`;
const normalized = clampChars(oneParagraph(removeMarkdownSyntax(message)), commanderApprovalNotificationMaxChars);
return {
ok: true,
channel: "claudeqq",
language: "zh-CN",
format: "plain-text",
message: normalized,
chars: charLength(normalized),
maxChars: commanderApprovalNotificationMaxChars,
markdownAllowed: false,
containsMarkdownSyntax: containsMarkdownSyntax(normalized),
oneParagraph: !normalized.includes("\n"),
redactionsApplied: redacted.redactionsApplied,
};
}
export function commanderApprovalNotificationPathUnavailable(message: string): CommanderApprovalNotificationPathUnavailable {
return {
ok: false,
error: "notification-path-unavailable",
degradedReason: "source-contract-dry-run-only",
mutation: false,
message: "host-codex-commander 当前阶段只生成审批通知草案;正式发送必须在授权后走 backend-core microservice proxy,不能回退到本机 ClaudeQQ skill/powershell。",
servicePath: `${commanderApprovalClaudeQqProxyBasePath}${commanderApprovalClaudeQqPrimaryEndpoint}`,
fallbackServicePath: `${commanderApprovalClaudeQqProxyBasePath}${commanderApprovalClaudeQqFallbackEndpoint}`,
backendCoreProxyCommand: commanderApprovalClaudeQqProxyCommand(message),
timeoutMs: commanderApprovalClaudeQqTimeoutMs,
forbiddenLocalPaths: [
"/root/.agents/skills/claudeqq",
"powershell.exe",
"local ClaudeQQ skill server",
"localhost:9082",
],
};
}
export function commanderApprovalProxyFailureSummary(response: unknown, endpoint = commanderApprovalClaudeQqPrimaryEndpoint): Record<string, unknown> {
const redacted = redactJsonValue(response) as unknown;
const record = typeof redacted === "object" && redacted !== null && !Array.isArray(redacted) ? redacted as Record<string, unknown> : {};
const status = typeof record.status === "number" ? record.status : null;
const message = typeof record.error === "string"
? record.error
: typeof record.message === "string"
? record.message
: typeof record.stderrTail === "string"
? record.stderrTail.slice(-500)
: "ClaudeQQ microservice proxy request failed";
return {
ok: false,
error: "notification-proxy-failed",
degradedReason: "microservice-proxy-failed",
runnerDisposition: "infra-blocked",
endpoint,
servicePath: `${commanderApprovalClaudeQqProxyBasePath}${endpoint}`,
status,
message: redactText(message).text,
response: redacted,
rollback: {
issueUpdateRolledBack: false,
policy: "ClaudeQQ notification is best-effort; record the failure in the related issue or commander brief instead of rolling back completed GitHub issue updates.",
},
};
}
@@ -94,7 +94,7 @@ export function commanderContract(): CommanderContract {
hostCodexProcess: "Long-lived Codex process on the master server host.",
controlMicroservice: "Local skeleton for health, state, trace summary, and approval drafting with no live bridge or executor.",
codeQueue: "Execution plane remains separate and is never restarted or attached by this skeleton.",
claudeqq: "Approval draft destination only; no messages are sent from this stage.",
claudeqq: "Approval draft destination only in this stage; any future authorized send must use backend-core /api/microservices/claudeqq/proxy, never the local skill/powershell path.",
githubPrCloseout: "PR-bound GPT-5.5 runners may self-close/merge ordinary in-boundary PRs after checks; commander review remains required for high-risk, ambiguous, failed-check, production, release, security, database, or runtime scopes.",
},
requiredCapabilities: [
@@ -129,7 +129,7 @@ export function commanderContract(): CommanderContract {
},
safetyBoundary: {
phaseOneMutationAllowed: false,
forbiddenWithoutExplicitUserApproval: commanderHighRiskActions,
forbiddenWithoutExplicitUserApproval: [...commanderHighRiskActions],
alwaysForbidden: [
"print-token-values",
"read-token-files-for-display",
@@ -1,5 +1,6 @@
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { buildCommanderApprovalNotificationDraft, commanderApprovalNotificationPathUnavailable } from "./approval-notification";
import type { CommanderApprovalState, CommanderPromptState, CommanderSessionState } from "./contract";
import { redactJsonValue, redactText } from "./redaction";
@@ -180,7 +181,7 @@ export function readCommanderSession(config: CommanderStorageConfig, sessionId =
export function writeCommanderSession(config: CommanderStorageConfig, session: CommanderSessionRecord): CommanderSessionRecord {
const path = stateFilePath(config.rootDir, session.sessionId);
ensureDir(dirname(path));
const normalized = normalizeSessionRecord(session, session.sessionId);
const normalized = normalizeSessionRecord(session as unknown as Record<string, unknown>, session.sessionId);
writeFileSync(path, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
return normalized;
}
@@ -209,7 +210,7 @@ export function readCommanderApproval(config: CommanderStorageConfig, approvalId
export function writeCommanderApproval(config: CommanderStorageConfig, approval: CommanderApprovalDraftRecord): CommanderApprovalDraftRecord {
const path = approvalFilePath(config.rootDir, approval.id);
ensureDir(dirname(path));
const normalized = normalizeApprovalRecord(approval, approval.id);
const normalized = normalizeApprovalRecord(approval as unknown as Record<string, unknown>, approval.id);
writeFileSync(path, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
return normalized;
}
@@ -219,6 +220,8 @@ export function buildCommanderApprovalDraft(input: CommanderApprovalDraftInput):
const reason = redactText(input.reason);
const action = String(input.action || "unknown");
const taskId = input.taskId ?? null;
const notificationDraft = buildCommanderApprovalNotificationDraft({ action, taskId, reason: input.reason });
const notificationPath = commanderApprovalNotificationPathUnavailable(notificationDraft.message);
const previewMarkdown = [
"# Commander approval draft",
"",
@@ -233,9 +236,14 @@ export function buildCommanderApprovalDraft(input: CommanderApprovalDraftInput):
"",
reason.text || "(empty)",
"",
"## ClaudeQQ notification draft",
"",
notificationDraft.message,
"",
"## Boundary",
"",
"- ClaudeQQ send is not implemented in this skeleton.",
"- If an authorized send is required, use backend-core /api/microservices/claudeqq/proxy only.",
"- Approval is preview-only until explicit user approval is recorded.",
].join("\n");
return {
@@ -255,10 +263,16 @@ export function buildCommanderApprovalDraft(input: CommanderApprovalDraftInput):
redactionsApplied: reason.redactionsApplied,
requiresExplicitUserApproval: true,
sendImplemented: false,
notificationDraft,
claudeqq: {
mutation: false,
target: "preview-only",
messageTemplate: `Approval required for ${action}. Reason: ${reason.text}.`,
target: "configured primary user private chat",
endpointShape: `POST ${notificationPath.servicePath}`,
fallbackEndpointShape: `POST ${notificationPath.fallbackServicePath}`,
messageTemplate: notificationDraft.message,
sendImplemented: false,
dryRunNoClaudeQqSend: true,
notificationPath,
},
blockedUntilApproved: [action],
}) as Record<string, unknown>,