From 77a8c6b8780c4416e12c9e83e99762bcc2e68598 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 23 May 2026 13:24:15 +0000 Subject: [PATCH] feat(commander): add GPT prompt boundary lint --- AGENTS.md | 2 +- docs/reference/cli.md | 2 +- docs/reference/code-queue-supervision.md | 8 + docs/reference/host-codex-commander.md | 5 + scripts/cli.ts | 4 + ...dex-commander-prompt-lint-contract-test.ts | 149 +++++++++++ scripts/src/check.ts | 6 + scripts/src/commander-prompt-lint.ts | 245 ++++++++++++++++++ scripts/src/commander.ts | 3 + scripts/src/help.ts | 15 +- 10 files changed, 434 insertions(+), 5 deletions(-) create mode 100644 scripts/host-codex-commander-prompt-lint-contract-test.ts create mode 100644 scripts/src/commander-prompt-lint.ts diff --git a/AGENTS.md b/AGENTS.md index 5a19029a..5c13f4ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,7 +64,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 contract,runner 无 `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 计划和 backend-core `microservice proxy claudeqq` 授权后候选命令,不接 live bridge、不接管人工指挥官,不发送消息,规则见 `docs/reference/host-codex-commander.md`。 +- `bun scripts/cli.ts commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run|prompt-lint --kind gpt55-pr`:查看 host Codex 指挥官直管微服务 skeleton 的 source/contract、无 daemon smoke 验证计划、.state/commander/ 状态模型、trace summary 聚合、ClaudeQQ 高风险请示草案和 GPT-5.5 PR prompt 边界辅助 lint;当前只返回 dry-run 计划和 backend-core `microservice proxy claudeqq` 授权后候选命令,不接 live bridge、不接管人工指挥官,不发送消息,`prompt-lint` 不作为业务 PR 门禁也不改变 `codex submit` 默认行为,规则见 `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 e2e;catalog/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 `:旧 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 ]` / `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`。 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index bb0a56e3..864e5c2d 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -30,7 +30,7 @@ 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 guard,core manifest 必须包含 `backend-core-dev`/`frontend-dev` Deployment/Service,Code 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` 时额外以 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml` 执行 `kubectl apply --dry-run=client --validate=false -f `,仍不 apply 资源;默认 `docker-desktop` kubeconfig 不得作为 D601 dry-run 目标。 - `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。`approval request --dry-run` 会生成 200 字以内中文纯文本 ClaudeQQ 审批草案、`notification-path-unavailable` blocker 和授权后唯一可用的 `bun scripts/cli.ts microservice proxy claudeqq /api/push/text --method POST --body-json '' --raw` 命令;不得提示使用本机 ClaudeQQ skill、powershell 或本地 server。`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|prompt-lint --kind gpt55-pr` 是 host Codex 指挥官直管微服务 skeleton 入口。当前命令返回 `phase=source-contract`、service/API/state/bridge/prompt/trace/#20/#46/ClaudeQQ 审批边界、.state/commander/ 状态模型、dev 无 daemon smoke contract、dry-run 计划和 GPT-5.5 PR prompt 边界辅助 lint,不接 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 '' --raw` 命令;不得提示使用本机 ClaudeQQ skill、powershell 或本地 server。`prompt-lint` 支持 `--prompt-file` 与 `--stdin`,输出 `ok`、`missingClauses`、`riskLevel`、`suggestedPatchSnippet` 且不回显完整 prompt;它是 commander 辅助检查,不是业务 PR 门禁,也不改变 `codex submit` 默认行为。`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 可能混入 PR,CLI 会从 `.data.issues` 中过滤 pull request。 diff --git a/docs/reference/code-queue-supervision.md b/docs/reference/code-queue-supervision.md index ed1d9f7c..2c12ac19 100644 --- a/docs/reference/code-queue-supervision.md +++ b/docs/reference/code-queue-supervision.md @@ -208,6 +208,14 @@ Git/PR 交付要求: - final response 必须报告 head branch、PR URL、远端 head commit、修改文件、验证命令、merge/close 状态和 SHA 或未 merge 原因。 ``` +GPT-5.5 PR/收口类 prompt 在提交前可先用 host commander 辅助 lint 做非阻塞检查: + +```bash +bun scripts/cli.ts commander prompt-lint --kind gpt55-pr --prompt-file /tmp/code-queue-prompt.md +``` + +该检查只服务于指挥官补齐派单边界,不是业务 PR 门禁,也不改变 `codex submit` 默认行为。输出只包含 `ok`、`missingClauses`、`riskLevel`、`suggestedPatchSnippet` 和 prompt shape,不回显完整 prompt;`data.ok=false` 表示建议补齐 PR/自合并/rebase/update 授权、artifact build/publish 授权、host-owned DEV rollout、未显式 `ROLLOUT_OK` 时禁止 runner rollout、以及 PROD/secret/DB/破坏性回滚边界。 + Runner preflight 优先使用执行面诊断入口: ```bash diff --git a/docs/reference/host-codex-commander.md b/docs/reference/host-codex-commander.md index f527cfdd..e7ab908b 100644 --- a/docs/reference/host-codex-commander.md +++ b/docs/reference/host-codex-commander.md @@ -27,10 +27,15 @@ bun scripts/cli.ts commander contract bun scripts/cli.ts commander plan --dry-run [--session-id primary] bun scripts/cli.ts commander smoke --dry-run [--session-id primary] bun scripts/cli.ts commander approval request --action --dry-run [--reason text] [--task-id id] +bun scripts/cli.ts commander prompt-lint --kind gpt55-pr (--prompt-file |--stdin) ``` `plan`、`smoke` 与 `approval request` 必须显式使用 `--dry-run`,缺失时返回 `error=dry-run-required`。 +`commander prompt-lint --kind gpt55-pr` 是指挥官派 GPT-5.5 PR/收口类任务前的本地辅助检查。它只读取 `--prompt-file` 或 `--stdin`,返回结构化 JSON 字段 `ok`、`missingClauses`、`riskLevel`、`suggestedPatchSnippet`、`promptShape.textEchoed=false` 和 `policy.advisoryOnly=true`;不会提交 Code Queue task、不会修改 scheduler、不会访问 live service,也不会回显完整 prompt。即使 lint 发现缺失条款,CLI envelope 仍保持成功退出,调用方应把 `data.ok=false` 当作派单前修补建议,而不是业务 PR 门禁或 `codex submit` admission 规则。 + +`gpt55-pr` 当前检查的硬边界包括:普通 PR 创建/更新、自合并/关闭、rebase/update/冲突处理授权;repo-owned CI/CD、build/publish artifact、DEV image/artifact tag/digest/report 授权;DEV deploy apply、rollout 和 live health verification 默认由 host commander 统一执行;未显式包含 `ROLLOUT_OK` 时 runner 不得竞争 DEV CD lock、deploy apply、rollout 或 live verification;禁止 PROD mutation、密钥读取/打印、数据库手工写入和破坏性回滚。缺失时 `suggestedPatchSnippet` 只给可追加的边界片段,不包含原 prompt。 + ## Operator-Run Stdio Loop 当前高鲁棒指挥官循环脚本固定放在 `/root/.unidesk/commander_loop.py`,工作目录使用 `/root/unidesk/`。它通过 `codex app-server --listen stdio://` 直连 Codex app-server JSON-RPC,而不是反复启动普通交互式 CLI;外层必须有持续循环和异常保护,fatal error 后自动重启,避免单次 stdio、网络或 app-server 错误让指挥官长期停止。 diff --git a/scripts/cli.ts b/scripts/cli.ts index 828191b1..ec1f426a 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -257,6 +257,10 @@ async function main(): Promise { if (top === "commander") { const result = runCommanderCommand(args.slice(1)); + if (sub === "prompt-lint") { + emitJson(commandName, result, true); + return; + } const ok = (result as { ok?: unknown }).ok !== false; emitJson(commandName, result, ok); if (!ok) process.exitCode = 1; diff --git a/scripts/host-codex-commander-prompt-lint-contract-test.ts b/scripts/host-codex-commander-prompt-lint-contract-test.ts new file mode 100644 index 00000000..6cc16be5 --- /dev/null +++ b/scripts/host-codex-commander-prompt-lint-contract-test.ts @@ -0,0 +1,149 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { lintCommanderPrompt } from "./src/commander-prompt-lint"; + +type JsonRecord = Record; + +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 asStringArray(value: unknown, label: string): string[] { + assertCondition(Array.isArray(value) && value.every((item) => typeof item === "string"), `${label} must be string array`, value); + return value as string[]; +} + +function runCli(args: string[], stdin?: string): { status: number | null; stdout: string; stderr: string; envelope: JsonRecord } { + const result = spawnSync("bun", ["scripts/cli.ts", ...args], { + cwd: process.cwd(), + input: stdin, + encoding: "utf8", + maxBuffer: 4 * 1024 * 1024, + }); + assertCondition(String(result.stdout || "").trim().length > 0, `command produced no stdout: ${args.join(" ")}`, { + status: result.status, + stderr: String(result.stderr || ""), + }); + return { + status: result.status, + stdout: String(result.stdout || ""), + stderr: String(result.stderr || ""), + envelope: asRecord(JSON.parse(String(result.stdout || "")) as unknown, "cli envelope"), + }; +} + +function dataOf(envelope: JsonRecord): JsonRecord { + return asRecord(envelope.data, "data"); +} + +const completePrompt = ` +UniDesk#20 #118 / commander prompt boundary lint + +You are a D601 Code Queue GPT-5.5 runner. Work in UniDesk only. + +PR and Git authorization: +- You may create and update a head branch and PR, rebase/update from origin/master, resolve conflicts, and self-merge/close the ordinary PR when checks pass and the task boundary is satisfied. + +Artifact authorization: +- You may use repo-owned CI/CD, publish, or equivalent controlled build paths to build/publish DEV images or artifacts, and must report commit, image tag, digest, artifact report, and validation evidence. + +Rollout boundary: +- DEV deploy apply, rollout, and live health verification are owned by the host commander unless this prompt explicitly contains ROLLOUT_OK. +- Without explicit ROLLOUT_OK, do not acquire the DEV CD lock, run deploy apply, run rollout restart, or compete with host commander live verification. + +Forbidden: +- No PROD mutation, no reading or printing secrets/tokens/credentials, no manual database/DB writes, and no destructive rollback. +`; + +const incompletePromptWithSecret = ` +UniDesk#20 #118 +Task: implement the lint. token=ghp_prompt_lint_contract_secret +Please make code changes and tests. +`; + +const lint = lintCommanderPrompt(completePrompt); +assertCondition(lint.ok === true, "complete GPT-5.5 PR prompt should pass lint", lint); +assertCondition(lint.missingClauses.length === 0, "complete prompt should have no missing clauses", lint); +assertCondition(lint.suggestedPatchSnippet === "", "passing prompt should not include patch snippet", lint); +assertCondition(lint.policy.advisoryOnly === true, "lint must be advisory only", lint); +assertCondition(lint.policy.changesCodexSubmitDefault === false, "lint must not change codex submit default", lint); +assertCondition(JSON.stringify(lint).includes("promptShape"), "lint should expose prompt shape metadata", lint); +assertCondition(!JSON.stringify(lint).includes("self-merge/close the ordinary PR"), "direct lint result must not echo full prompt", lint); + +const failingLint = lintCommanderPrompt(incompletePromptWithSecret); +assertCondition(failingLint.ok === false, "incomplete prompt should fail lint", failingLint); +assertCondition(failingLint.riskLevel === "high", "incomplete prompt should be high risk", failingLint); +for (const expected of [ + "pr-self-merge-rebase-authorization", + "artifact-build-publish-authorization", + "host-owned-dev-rollout", + "runner-rollout-forbidden-without-rollout-ok", + "prod-secret-db-rollback-boundary", +]) { + assertCondition(failingLint.missingClauses.includes(expected), `missing expected clause id ${expected}`, failingLint); +} +assertCondition(failingLint.suggestedPatchSnippet.includes("ROLLOUT_OK"), "snippet should mention ROLLOUT_OK", failingLint); +assertCondition(!JSON.stringify(failingLint).includes("ghp_prompt_lint_contract_secret"), "lint output must not echo secret-like prompt text", failingLint); + +const tmp = mkdtempSync(join(tmpdir(), "host-codex-commander-prompt-lint-")); +try { + const promptFile = join(tmp, "prompt.md"); + writeFileSync(promptFile, incompletePromptWithSecret, "utf8"); + const fileRun = runCli(["commander", "prompt-lint", "--kind", "gpt55-pr", "--prompt-file", promptFile]); + assertCondition(fileRun.status === 0, "commander prompt-lint is advisory and should exit 0 even when lint ok=false", fileRun); + assertCondition(fileRun.envelope.ok === true, "CLI envelope should remain ok for advisory lint result", fileRun.envelope); + const fileData = dataOf(fileRun.envelope); + assertCondition(fileData.ok === false, "lint data ok should reflect missing clauses", fileData); + assertCondition(asStringArray(fileData.missingClauses, "missingClauses").includes("host-owned-dev-rollout"), "file lint should report rollout clause", fileData); + assertCondition(String(fileData.suggestedPatchSnippet || "").includes("DEV deploy apply"), "file lint should include bounded patch snippet", fileData); + assertCondition(!fileRun.stdout.includes("ghp_prompt_lint_contract_secret"), "file lint stdout must not echo prompt secret", fileRun.stdout); + + const stdinRun = runCli(["commander", "prompt-lint", "--kind", "gpt55-pr", "--stdin"], completePrompt); + assertCondition(stdinRun.status === 0 && stdinRun.envelope.ok === true, "stdin lint should exit successfully", stdinRun.envelope); + const stdinData = dataOf(stdinRun.envelope); + assertCondition(stdinData.ok === true, "stdin lint data should pass for complete prompt", stdinData); + assertCondition(asStringArray(stdinData.missingClauses, "missingClauses").length === 0, "stdin lint should have no missing clauses", stdinData); + assertCondition(!stdinRun.stdout.includes("Artifact authorization:"), "stdin lint must not echo full prompt", stdinRun.stdout); +} finally { + rmSync(tmp, { recursive: true, force: true }); +} + +const submitDryRun = runCli(["codex", "submit", "--prompt-stdin", "--queue", "prompt-lint-contract", "--dry-run"], incompletePromptWithSecret); +assertCondition(submitDryRun.status === 0 && submitDryRun.envelope.ok === true, "codex submit --dry-run should not be gated by commander prompt-lint", submitDryRun.envelope); +const submitData = dataOf(submitDryRun.envelope); +assertCondition(asRecord(submitData.request, "submit request").prompt !== undefined, "submit dry-run should keep its existing prompt review behavior", submitData); + +const helpRun = runCli(["commander", "--help"]); +assertCondition(helpRun.status === 0 && helpRun.envelope.ok === true, "commander help should succeed", helpRun.envelope); +const helpData = dataOf(helpRun.envelope); +assertCondition(asStringArray(helpData.usage, "help usage").some((line) => line.includes("commander prompt-lint")), "commander help should list prompt-lint", helpData); +assertCondition(asRecord(helpData.promptLint, "promptLint").gate === "advisory-only; not a business PR gate and not a Code Queue submit admission change", "help should document advisory-only gate", helpData); + +const doc = readFileSync("docs/reference/host-codex-commander.md", "utf8"); +for (const snippet of [ + "commander prompt-lint --kind gpt55-pr", + "missingClauses", + "suggestedPatchSnippet", + "不是业务 PR 门禁", +]) { + assertCondition(doc.includes(snippet), `reference doc missing snippet: ${snippet}`); +} + +process.stdout.write(`${JSON.stringify({ + ok: true, + checks: [ + "complete GPT-5.5 PR prompt passes commander prompt-lint", + "missing PR/artifact/DEV rollout/ROLLOUT_OK/PROD-secret-DB-rollback clauses are reported as high risk", + "prompt-lint supports --prompt-file and --stdin", + "prompt-lint output does not echo full prompt or secret-like prompt text", + "commander prompt-lint remains advisory and does not gate codex submit --dry-run", + "commander help and host commander reference document the advisory lint entry", + ], +}, null, 2)}\n`); diff --git a/scripts/src/check.ts b/scripts/src/check.ts index ae11ec83..f37cd20f 100644 --- a/scripts/src/check.ts +++ b/scripts/src/check.ts @@ -28,11 +28,13 @@ const syntaxFiles = [ "scripts/src/e2e.ts", "scripts/src/help.ts", "scripts/src/commander.ts", + "scripts/src/commander-prompt-lint.ts", "scripts/src/recovery-guardrails.ts", "scripts/src/server-cleanup.ts", "scripts/src/remote.ts", "scripts/host-codex-commander-contract-test.ts", "scripts/host-codex-commander-no-daemon-smoke-contract-test.ts", + "scripts/host-codex-commander-prompt-lint-contract-test.ts", "scripts/host-codex-commander-skeleton-contract-test.ts", "scripts/auth-broker-contract-test.ts", "scripts/code-queue-cli-disclosure-contract-test.ts", @@ -335,6 +337,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default fileItem("src/components/microservices/host-codex-commander/src/redaction.ts"), fileItem("src/components/microservices/host-codex-commander/src/state.ts"), fileItem("src/components/microservices/code-queue-mgr/src/prompt-observation.ts"), + fileItem("scripts/src/commander-prompt-lint.ts"), fileItem("scripts/src/deploy.ts"), fileItem("scripts/code-queue-issue3-regression-test.ts"), fileItem("scripts/code-queue-liveness-diagnostics-test.ts"), @@ -355,6 +358,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default fileItem("scripts/code-queue-commander-view-contract-test.ts"), fileItem("scripts/host-codex-commander-skeleton-contract-test.ts"), fileItem("scripts/host-codex-commander-no-daemon-smoke-contract-test.ts"), + fileItem("scripts/host-codex-commander-prompt-lint-contract-test.ts"), fileItem("scripts/provider-runner-triage-contract-test.ts"), fileItem("scripts/ssh-argv-guidance-contract-test.ts"), fileItem("scripts/src/provider-triage.ts"), @@ -408,6 +412,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default items.push(commandItem("code-queue:commander-view-contract", ["bun", "scripts/code-queue-commander-view-contract-test.ts"], 30_000)); items.push(commandItem("host-codex-commander:skeleton-contract", ["bun", "scripts/host-codex-commander-skeleton-contract-test.ts"], 30_000)); items.push(commandItem("host-codex-commander:no-daemon-smoke-contract", ["bun", "scripts/host-codex-commander-no-daemon-smoke-contract-test.ts"], 30_000)); + items.push(commandItem("host-codex-commander:prompt-lint-contract", ["bun", "scripts/host-codex-commander-prompt-lint-contract-test.ts"], 30_000)); items.push(commandItem("provider:runner-triage-contract", ["bun", "scripts/provider-runner-triage-contract-test.ts"], 30_000)); items.push(commandItem("ssh:argv-guidance-contract", ["bun", "scripts/ssh-argv-guidance-contract-test.ts"], 30_000)); items.push(commandItem("deploy:artifact-matrix-contract", ["bun", "scripts/deploy-artifact-matrix-contract-test.ts"], 90_000)); @@ -449,6 +454,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default items.push(skippedItem("code-queue:commander-view-contract", "Code Queue commander view contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("host-codex-commander:skeleton-contract", "host Codex commander skeleton contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("host-codex-commander:no-daemon-smoke-contract", "host Codex commander no-daemon smoke contract is opt-in with script checks", "--scripts-typecheck or --full")); + items.push(skippedItem("host-codex-commander:prompt-lint-contract", "host Codex commander prompt boundary lint contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("provider:runner-triage-contract", "Provider runner triage contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("ssh:argv-guidance-contract", "SSH argv guidance and failure hint contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("deploy:artifact-matrix-contract", "deploy artifact matrix contract is opt-in with script checks", "--scripts-typecheck or --full")); diff --git a/scripts/src/commander-prompt-lint.ts b/scripts/src/commander-prompt-lint.ts new file mode 100644 index 00000000..c2bad55b --- /dev/null +++ b/scripts/src/commander-prompt-lint.ts @@ -0,0 +1,245 @@ +import { readFileSync } from "node:fs"; + +export type CommanderPromptLintKind = "gpt55-pr"; +export type CommanderPromptLintRiskLevel = "low" | "medium" | "high"; + +interface ClauseCheck { + id: string; + label: string; + required: boolean; + matched: boolean; + severity: "medium" | "high"; + suggestion: string; +} + +export interface CommanderPromptLintResult { + ok: boolean; + kind: CommanderPromptLintKind; + missingClauses: string[]; + riskLevel: CommanderPromptLintRiskLevel; + suggestedPatchSnippet: string; + promptShape: { + chars: number; + lines: number; + textEchoed: false; + }; + policy: { + advisoryOnly: true; + mutatesScheduler: false; + changesCodexSubmitDefault: false; + printsPromptText: false; + supportedInputs: Array<"--stdin" | "--prompt-file">; + reference: string; + }; +} + +interface ParsedPromptLintArgs { + kind: CommanderPromptLintKind; + prompt: string; +} + +const gpt55PrSnippet = [ + "GPT-5.5 runner boundary:", + "- You may create/update the branch and PR, resolve conflicts, rebase/update from the target branch, and self-merge/close an ordinary PR when checks pass and the task boundary is satisfied.", + "- You may use repo-owned CI/CD, publish, or equivalent controlled build paths to build/publish DEV images or artifacts, and must report commit, image tag, digest, artifact report, and validation evidence.", + "- DEV deploy apply, rollout, and live health verification are owned by the host commander unless this prompt explicitly contains ROLLOUT_OK.", + "- Without explicit ROLLOUT_OK, do not acquire the DEV CD lock, run deploy apply, perform rollout/restart, or compete with host commander live verification.", + "- Forbidden: PROD mutation, reading or printing secrets, manual database writes, destructive rollback, Code Queue backend/scheduler restart, or interrupt/cancel running tasks unless explicitly authorized.", +].join("\n"); + +function hasFlag(args: string[], flag: string): boolean { + return args.includes(flag); +} + +function optionValue(args: string[], names: string[]): string | undefined { + for (const name of names) { + const index = args.indexOf(name); + if (index === -1) continue; + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error(`${name} requires a value`); + return value; + } + return undefined; +} + +function assertKnownOptions(args: string[]): void { + const flags = new Set(["--stdin"]); + const valueOptions = new Set(["--kind", "--prompt-file", "--file"]); + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] ?? ""; + if (!arg.startsWith("--")) throw new Error(`commander prompt-lint does not accept positional prompt text; use --stdin or --prompt-file`); + if (flags.has(arg)) continue; + if (valueOptions.has(arg)) { + index += 1; + if (index >= args.length) throw new Error(`${arg} requires a value`); + continue; + } + throw new Error(`unknown commander prompt-lint option: ${arg}`); + } +} + +function parseKind(raw: string | undefined): CommanderPromptLintKind { + const kind = raw ?? "gpt55-pr"; + if (kind !== "gpt55-pr") throw new Error(`unsupported commander prompt-lint kind: ${kind}`); + return kind; +} + +function readPromptFromArgs(args: string[]): string { + const promptFile = optionValue(args, ["--prompt-file", "--file"]); + const promptStdin = hasFlag(args, "--stdin"); + const sources = [promptFile !== undefined, promptStdin].filter(Boolean).length; + if (sources !== 1) throw new Error("commander prompt-lint requires exactly one prompt source: --prompt-file or --stdin"); + const prompt = promptFile !== undefined + ? (promptFile === "-" ? readFileSync(0, "utf8") : readFileSync(promptFile, "utf8")) + : readFileSync(0, "utf8"); + if (prompt.trim().length === 0) throw new Error("commander prompt-lint prompt must not be empty"); + return prompt; +} + +export function parseCommanderPromptLintArgs(args: string[]): ParsedPromptLintArgs { + assertKnownOptions(args); + return { + kind: parseKind(optionValue(args, ["--kind"])), + prompt: readPromptFromArgs(args), + }; +} + +function any(patterns: RegExp[], prompt: string): boolean { + return patterns.some((pattern) => pattern.test(prompt)); +} + +function rolloutOkPresent(prompt: string): boolean { + return /\bROLLOUT_OK\b/u.test(prompt); +} + +function gpt55PrClauseChecks(prompt: string): ClauseCheck[] { + const prAuthorization = any([ + /\bPR\b[^\n。]{0,120}(?:create|update|push|branch|merge|close|rebase|conflict)/iu, + /(?:创建|更新|push|提交|合并|关闭|rebase|解决冲突)[^\n。]{0,80}\bPR\b/iu, + ], prompt) && any([ + /self[- ]?(?:merge|close)|自行(?:合并|关闭|收口)|自合并|自收口/iu, + /merge\/close|合并\/关闭/iu, + ], prompt) && any([ + /\brebase\b|\bupdate\b|解决冲突|更新(?:目标|base|master|分支)/iu, + ], prompt); + + const artifactAuthorization = any([ + /\b(?:build|publish)\b[^\n。]{0,80}\b(?:artifact|image|DEV image|镜像|制品)\b/iu, + /\b(?:artifact|image|镜像|制品)\b[^\n。]{0,80}\b(?:build|publish|tag|digest|构建|发布)\b/iu, + /(?:构建|发布)[^\n。]{0,80}(?:镜像|制品|artifact|image)/iu, + ], prompt) && any([ + /\b(?:tag|digest|artifact report|image tag)\b/iu, + /(?:镜像\s*tag|digest|制品报告|artifact report)/iu, + ], prompt); + + const hostOwnsDevRollout = any([ + /DEV[^\n。]{0,120}(?:deploy apply|rollout|live health|live verification)[^\n。]{0,120}(?:host commander|commander|host|统一执行|统一处理|统一复验)/iu, + /(?:host commander|commander|host|指挥官)[^\n。]{0,120}DEV[^\n。]{0,120}(?:deploy apply|rollout|live health|live verification|发布|上线|复验)/iu, + /DEV[^\n。]{0,80}(?:发布|上线|rollout|复验)[^\n。]{0,80}(?:host|commander|指挥官|统一)/iu, + ], prompt); + + const forbidsRunnerRolloutUnlessOk = any([ + /unless[^\n。]{0,80}\bROLLOUT_OK\b/iu, + /未显式[^\n。]{0,60}\bROLLOUT_OK\b[^\n。]{0,80}(?:不要|不得|禁止|do not|don't|must not)/iu, + /\bROLLOUT_OK\b[^\n。]{0,80}(?:才|unless|only if|explicit)/iu, + ], prompt) && any([ + /(?:do not|don't|must not|禁止|不要|不得)[^\n。]{0,100}(?:DEV CD lock|deploy apply|rollout|live health|rollout restart|竞争|抢)/iu, + /(?:DEV CD lock|deploy apply|rollout|live health|rollout restart|竞争|抢)[^\n。]{0,100}(?:do not|don't|must not|禁止|不要|不得)/iu, + ], prompt); + + const prodForbidden = any([ + /(?:禁止|不要|不得|must not|do not|don't|\bno\b|forbid|forbidden)[^\n。]{0,80}(?:PROD|prod|production|生产)/iu, + /(?:PROD|prod|production|生产)[^\n。]{0,80}(?:禁止|不要|不得|must not|do not|don't|\bno\b|forbid|forbidden|not allowed)/iu, + ], prompt); + const secretForbidden = any([ + /(?:禁止|不要|不得|must not|do not|don't|\bno\b|forbid|forbidden)[^\n。]{0,80}(?:secret|token|credential|密钥|凭证)/iu, + /(?:secret|token|credential|密钥|凭证)[^\n。]{0,80}(?:禁止|不要|不得|must not|do not|don't|\bno\b|forbid|forbidden|not allowed|读取|打印)/iu, + ], prompt); + const dbForbidden = any([ + /(?:禁止|不要|不得|must not|do not|don't|\bno\b|forbid|forbidden)[^\n。]{0,80}(?:database|DB|数据库)/iu, + /(?:database|DB|数据库)[^\n。]{0,80}(?:manual|手工|patch|write|写入|禁止|不要|不得|must not|do not|don't|\bno\b)/iu, + ], prompt); + const rollbackForbidden = any([ + /(?:禁止|不要|不得|must not|do not|don't|\bno\b|forbid|forbidden)[^\n。]{0,80}(?:destructive rollback|破坏性回滚|回滚)/iu, + /(?:destructive rollback|破坏性回滚)[^\n。]{0,80}(?:禁止|不要|不得|must not|do not|don't|\bno\b|forbid|forbidden|not allowed)/iu, + ], prompt); + + return [ + { + id: "pr-self-merge-rebase-authorization", + label: "PR/自合并/rebase/update 授权文本", + required: true, + matched: prAuthorization, + severity: "high", + suggestion: "明确授权 runner 创建/更新 PR、rebase/update/解决冲突,并在普通 PR 满足任务边界和检查通过时自合并/关闭。", + }, + { + id: "artifact-build-publish-authorization", + label: "build/publish artifact 授权文本", + required: true, + matched: artifactAuthorization, + severity: "high", + suggestion: "明确授权使用 repo-owned CI/CD 或受控构建路径发布 DEV image/artifact,并要求回报 tag、digest 和 artifact report。", + }, + { + id: "host-owned-dev-rollout", + label: "DEV deploy apply/rollout/live verification 由 host 统一执行文本", + required: true, + matched: hostOwnsDevRollout, + severity: "high", + suggestion: "写明 DEV deploy apply、rollout 和 live health verification 默认由 host commander 统一执行。", + }, + { + id: "runner-rollout-forbidden-without-rollout-ok", + label: "未显式 ROLLOUT_OK 时禁止 runner rollout", + required: true, + matched: forbidsRunnerRolloutUnlessOk, + severity: "high", + suggestion: "写明未显式包含 ROLLOUT_OK 时,runner 不得抢 DEV CD lock、deploy apply、rollout 或 live verification。", + }, + { + id: "prod-secret-db-rollback-boundary", + label: "PROD/secret/DB/破坏性回滚边界", + required: true, + matched: prodForbidden && secretForbidden && dbForbidden && rollbackForbidden, + severity: "high", + suggestion: "同时禁止 PROD mutation、密钥读取/打印、数据库手工写入和破坏性回滚。", + }, + ]; +} + +export function lintCommanderPrompt(prompt: string, kind: CommanderPromptLintKind = "gpt55-pr"): CommanderPromptLintResult { + const clauses = gpt55PrClauseChecks(prompt); + const missingClauses = clauses.filter((clause) => clause.required && !clause.matched).map((clause) => clause.id); + const highMissing = clauses.some((clause) => clause.severity === "high" && !clause.matched); + const rolloutOk = rolloutOkPresent(prompt); + const riskLevel: CommanderPromptLintRiskLevel = missingClauses.length === 0 + ? rolloutOk ? "medium" : "low" + : highMissing ? "high" : "medium"; + + return { + ok: missingClauses.length === 0, + kind, + missingClauses, + riskLevel, + suggestedPatchSnippet: missingClauses.length === 0 ? "" : gpt55PrSnippet, + promptShape: { + chars: prompt.length, + lines: prompt.split(/\r\n|\r|\n/u).length, + textEchoed: false, + }, + policy: { + advisoryOnly: true, + mutatesScheduler: false, + changesCodexSubmitDefault: false, + printsPromptText: false, + supportedInputs: ["--stdin", "--prompt-file"], + reference: "docs/reference/host-codex-commander.md", + }, + }; +} + +export function runCommanderPromptLintCommand(args: string[]): CommanderPromptLintResult { + const options = parseCommanderPromptLintArgs(args); + return lintCommanderPrompt(options.prompt, options.kind); +} diff --git a/scripts/src/commander.ts b/scripts/src/commander.ts index ac7659c5..a980ab72 100644 --- a/scripts/src/commander.ts +++ b/scripts/src/commander.ts @@ -1,6 +1,7 @@ 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"; +import { runCommanderPromptLintCommand } from "./commander-prompt-lint"; const requiredDryRunMessage = "This host Codex commander skeleton only supports dry-run planning; live daemon/control operations are not implemented."; @@ -36,6 +37,7 @@ function commanderHelp(): Record { "bun scripts/cli.ts commander plan --dry-run [--session-id id]", "bun scripts/cli.ts commander smoke --dry-run [--session-id id]", "bun scripts/cli.ts commander approval request --action --dry-run [--reason text] [--task-id id]", + "bun scripts/cli.ts commander prompt-lint --kind gpt55-pr (--prompt-file |--stdin)", ], highRiskActions, reference: "docs/reference/host-codex-commander.md", @@ -494,6 +496,7 @@ export function runCommanderCommand(args: string[]): Record { if (sub === "plan") return commanderPlan(args.slice(1)); if (sub === "smoke") return commanderSmoke(args.slice(1)); if (sub === "approval" && second === "request") return commanderApprovalRequest(args.slice(2)); + if (sub === "prompt-lint") return runCommanderPromptLintCommand(args.slice(1)) as unknown as Record; return { ok: false, error: "unsupported-command", diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 63fbe990..0f6ddd37 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -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 approval preview; generates <=200 char Chinese ClaudeQQ drafts plus backend-core proxy command without live bridges or message sends." }, + { command: "commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run|prompt-lint --kind gpt55-pr", description: "Host Codex commander skeleton contract, no-daemon smoke plan, dry-run approval preview, and advisory GPT-5.5 PR prompt boundary lint without live bridges, message sends, or submit gating." }, { 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 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." }, @@ -213,22 +213,31 @@ function providerHelp(): unknown { function commanderHelp(): unknown { return { - command: "commander contract|plan|smoke|approval", + command: "commander contract|plan|smoke|approval|prompt-lint", output: "json", usage: [ "bun scripts/cli.ts commander contract", "bun scripts/cli.ts commander plan --dry-run [--session-id id]", "bun scripts/cli.ts commander smoke --dry-run [--session-id id]", "bun scripts/cli.ts commander approval request --action --dry-run [--reason text] [--task-id id]", + "bun scripts/cli.ts commander prompt-lint --kind gpt55-pr (--prompt-file |--stdin)", ], - 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.", + description: "Inspect the local host Codex commander skeleton contract, dry-run planner, no-daemon smoke validation plan, state helpers, trace summary aggregator, approval draft preview, and advisory GPT-5.5 PR prompt boundary lint.", 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 <=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", + "prompt-lint is commander advisory output and does not change codex submit default behavior", "token and secret values must never be printed", ], + promptLint: { + command: "bun scripts/cli.ts commander prompt-lint --kind gpt55-pr --prompt-file ", + stdin: "cat prompt.md | bun scripts/cli.ts commander prompt-lint --kind gpt55-pr --stdin", + outputFields: ["ok", "missingClauses", "riskLevel", "suggestedPatchSnippet"], + fullPromptEchoed: false, + gate: "advisory-only; not a business PR gate and not a Code Queue submit admission change", + }, reference: "docs/reference/host-codex-commander.md", }; }