From cb8335306f5e8748de3d9256bb5ccde6e1c28e3c Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 23 May 2026 01:34:35 +0000 Subject: [PATCH] fix: guard hwlab product routing from issue 20 --- docs/reference/cli.md | 2 +- docs/reference/code-queue-supervision.md | 2 +- docs/reference/host-codex-commander.md | 1 + docs/reference/hwlab.md | 2 + scripts/gh-cli-issue-guard-contract-test.ts | 102 +++++++++++++++++-- scripts/src/gh.ts | 106 ++++++++++++++++++-- 6 files changed, 198 insertions(+), 17 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 2d2d6400..533c57a0 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -36,7 +36,7 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI - `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。 - `gh issue read [--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 --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 输出;默认 list/read 输出仍不得扩散到无界非 JSON 文本。`gh issue create --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 payload,GitHub 返回不存在 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 只允许承担长期总看板职责;每日进展必须写入当天滚动指挥简报 issue,并由 #20 顶部“指挥简报索引”引用。`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:...` 这类简报段落,并在 `codeQueueBoardHint` 中提示改写到每日简报 issue;`gh issue board-row list|get|update|add|move|delete|upsert --board-issue 20` 也会返回同一 hint,提醒不要把每日简报混入 #20。 +- #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 北京时间` 段落发送给 ClaudeQQ,ClaudeQQ 失败不会回滚 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 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 手工替换。 diff --git a/docs/reference/code-queue-supervision.md b/docs/reference/code-queue-supervision.md index a939edcf..fd1d619a 100644 --- a/docs/reference/code-queue-supervision.md +++ b/docs/reference/code-queue-supervision.md @@ -28,7 +28,7 @@ ## HWLAB / #20 监督口径 -`pikasTech/unidesk#20` 只承载 UniDesk 指挥控制面:command/control、Code Queue、CLI、基础设施、PR 收口和治理态势。HWLAB 产品功能、用户体验、M3 验收、DEV runtime、发布收敛和用户反馈必须进入 `pikasTech/HWLAB` issue/PR,并由 `pikasTech/HWLAB#7` 或 HWLAB 侧对应看板承载;#20 只能保留指挥侧路由行、关联 task、阻塞分类和回链,不复制 HWLAB 产品验收口径。HWLAB 指挥侧固定入口、热修边界和长期 source of truth 见 `docs/reference/hwlab.md`。 +`pikasTech/unidesk#20` 只承载 UniDesk 指挥控制面:command/control、Code Queue、CLI、基础设施、PR 收口和治理态势。HWLAB 产品功能、用户体验、M3 验收、DEV runtime、发布收敛和用户反馈必须进入 `pikasTech/HWLAB` issue/PR,并由 `pikasTech/HWLAB#7` 或 HWLAB 侧对应看板承载;#20 只能保留指挥侧路由行、关联 task、阻塞分类和回链,不复制 HWLAB 产品验收口径。更新 #20 时优先使用 `bun scripts/cli.ts gh issue update|board-row ... --board-issue 20`,该 guard 会拒绝把 HWLAB 产品 issue 行写入 #20,并提示改写到 `pikasTech/HWLAB`。HWLAB 指挥侧固定入口、热修边界和长期 source of truth 见 `docs/reference/hwlab.md`。 当 HWLAB 是当前优先级最高的推进对象时,所有派单 prompt 的开头都必须显式引用 `DC-DCSN-P0-2026-003`,并说明任务究竟服务于 M3 虚拟硬件可信闭环的哪一环。如果任务只是在做发布前置、诊断、证据收口或文档治理,也必须明确写出它不抢占 M3 P0。 diff --git a/docs/reference/host-codex-commander.md b/docs/reference/host-codex-commander.md index 961a8e44..9f7e7d65 100644 --- a/docs/reference/host-codex-commander.md +++ b/docs/reference/host-codex-commander.md @@ -11,6 +11,7 @@ - 所有输出必须 redaction token/secret/URL credential。 - 不得重启或接管 Code Queue backend,不得 cancel/interrupt 运行任务,不得打印 token 明文。 - 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 看板行维护。 ## PR 能力判读 diff --git a/docs/reference/hwlab.md b/docs/reference/hwlab.md index e89fe4eb..6fa1b886 100644 --- a/docs/reference/hwlab.md +++ b/docs/reference/hwlab.md @@ -19,6 +19,8 @@ - HWLAB 用户反馈入口:`pikasTech/HWLAB#108`。 - HWLAB 手动发布复盘与自动化收敛:`pikasTech/HWLAB#61`。 +`pikasTech/unidesk#20` 只记录 UniDesk 侧 commander、Code Queue、CLI 和 infra governance。HWLAB 用户反馈、Cloud Workbench、DEV-LIVE、M3 闭环和其他产品事项必须写入 `pikasTech/HWLAB` 的 issue;如果需要在 #20 出现,只能作为 UniDesk 侧调度、CLI guard、infra blocker 或验收治理 lane 的上下文,而不能作为 HWLAB 产品 row。 + ## DEV 入口 - DEV 前端入口固定为 `http://74.48.78.17:16666/`。 diff --git a/scripts/gh-cli-issue-guard-contract-test.ts b/scripts/gh-cli-issue-guard-contract-test.ts index b6c10e20..31925337 100644 --- a/scripts/gh-cli-issue-guard-contract-test.ts +++ b/scripts/gh-cli-issue-guard-contract-test.ts @@ -737,7 +737,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> { assertCondition(Array.isArray(boardRowGetRow.cells) && boardRowGetRow.cells[1] === "OPEN", "board-row get should expose the GitHub status column", boardRowGetRow); assertCondition(boardRowGetFields.branch === "master" && boardRowGetFields.status === "pass" && boardRowGetFields.validation === "pass" && boardRowGetFields.tasks === "cq-35" && String(boardRowGetFields.focus ?? "").includes("当前关注点"), "board-row get should expose canonical field aliases", boardRowGetFields); const boardRowGetHint = boardRowGetData.codeQueueBoardHint as JsonRecord; - assertCondition(boardRowGetHint.detected === false && String(boardRowGetHint.warning ?? "").includes("#20 is the long-term board only"), "board-row get should remind callers not to put daily briefs into #20", boardRowGetHint); + assertCondition(boardRowGetHint.detected === false && String(boardRowGetHint.warning ?? "").includes("governance board only"), "board-row get should remind callers that #20 is governance-only", boardRowGetHint); const boardRowUpsertUpdateRequestCountBefore = mock.requests.length; const boardRowUpsertUpdate = await runCli([ @@ -968,7 +968,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> { const boardRowAddNoGuardData = dataOf(boardRowAddNoGuard.json ?? {}); assertCondition(boardRowAddNoGuardData.dryRun === true && boardRowAddNoGuardData.planned === true, "board-row add without guard should not PATCH GitHub", boardRowAddNoGuardData); const boardRowAddNoGuardPlan = boardRowAddNoGuardData.add as JsonRecord; - assertCondition(boardRowAddNoGuardPlan.section === "open" && boardRowAddNoGuardPlan.insertAfterLine > 0, "board-row add without guard should still return an insertion plan", boardRowAddNoGuardPlan); + assertCondition(boardRowAddNoGuardPlan.section === "open" && Number(boardRowAddNoGuardPlan.insertAfterLine ?? 0) > 0, "board-row add without guard should still return an insertion plan", boardRowAddNoGuardPlan); const boardRowAddNoGuardWriteCount = mock.requests.slice(boardRowAddNoGuardRequestCountBefore).filter((request) => request.method === "PATCH").length; assertCondition(boardRowAddNoGuardWriteCount === 0, "board-row add without guard must not PATCH GitHub", { requests: mock.requests.slice(boardRowAddNoGuardRequestCountBefore) }); @@ -978,7 +978,8 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> { const boardRowAddDryRunData = dataOf(boardRowAddDryRun.json ?? {}); assertCondition(boardRowAddDryRunData.command === "issue board-row add" && boardRowAddDryRunData.dryRun === true && boardRowAddDryRunData.planned === true, "board-row add should default to dry-run", boardRowAddDryRunData); const boardRowAddDryRunPlan = boardRowAddDryRunData.add as JsonRecord; - assertCondition(boardRowAddDryRunPlan.section === "open" && boardRowAddDryRunPlan.validation.actualStatus === "OPEN", "board-row add dry-run should validate the target section and GitHub status", boardRowAddDryRunPlan); + const boardRowAddDryRunValidation = boardRowAddDryRunPlan.validation as JsonRecord; + assertCondition(boardRowAddDryRunPlan.section === "open" && boardRowAddDryRunValidation.actualStatus === "OPEN", "board-row add dry-run should validate the target section and GitHub status", boardRowAddDryRunPlan); const boardRowAddDryRunPatchCount = mock.requests.slice(boardRowAddDryRunRequestCountBefore).filter((request) => request.method === "PATCH").length; assertCondition(boardRowAddDryRunPatchCount === 0, "board-row add dry-run must not PATCH GitHub", { requests: mock.requests.slice(boardRowAddDryRunRequestCountBefore) }); @@ -992,7 +993,8 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> { const boardRowAddData = dataOf(boardRowAdd.json ?? {}); assertCondition(boardRowAddData.dryRun === false && boardRowAddData.rest === true, "board-row add should report a real REST update", boardRowAddData); const boardRowAddPlan = boardRowAddData.add as JsonRecord; - assertCondition(boardRowAddPlan.section === "open" && boardRowAddPlan.validation.expectedStatus === "OPEN" && boardRowAddPlan.validation.actualStatus === "OPEN", "board-row add should validate section/status alignment", boardRowAddPlan); + const boardRowAddValidation = boardRowAddPlan.validation as JsonRecord; + assertCondition(boardRowAddPlan.section === "open" && boardRowAddValidation.expectedStatus === "OPEN" && boardRowAddValidation.actualStatus === "OPEN", "board-row add should validate section/status alignment", boardRowAddPlan); const boardRowAddRequests = mock.requests.slice(boardRowAddRequestCountBefore).filter((request) => request.method === "PATCH" && request.url === "/repos/pikasTech/unidesk/issues/20"); assertCondition(boardRowAddRequests.length === 1, "board-row add should send exactly one PATCH", { requests: mock.requests.slice(boardRowAddRequestCountBefore) }); const boardRowAddPayload = JSON.parse(boardRowAddRequests[0]?.body ?? "{}") as JsonRecord; @@ -1066,7 +1068,8 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> { const boardRowMoveDryRunData = dataOf(boardRowMoveDryRun.json ?? {}); assertCondition(boardRowMoveDryRunData.command === "issue board-row move" && boardRowMoveDryRunData.dryRun === true && boardRowMoveDryRunData.planned === true, "board-row move should default to dry-run", boardRowMoveDryRunData); const boardRowMoveDryRunPlan = boardRowMoveDryRunData.move as JsonRecord; - assertCondition(boardRowMoveDryRunPlan.from === "open" && boardRowMoveDryRunPlan.to === "closed" && boardRowMoveDryRunPlan.status.requested === "CLOSED", "board-row move dry-run should plan the cross-section migration", boardRowMoveDryRunPlan); + const boardRowMoveDryRunStatus = boardRowMoveDryRunPlan.status as JsonRecord; + assertCondition(boardRowMoveDryRunPlan.from === "open" && boardRowMoveDryRunPlan.to === "closed" && boardRowMoveDryRunStatus.requested === "CLOSED", "board-row move dry-run should plan the cross-section migration", boardRowMoveDryRunPlan); const boardRowMoveConflict = await runCli(["gh", "issue", "board-row", "move", "35", "--repo", "pikasTech/unidesk", "--board-issue", "20", "--to", "closed", "--status", "OPEN", "--dry-run"], env); assertCondition(boardRowMoveConflict.status !== 0, "board-row move status conflict should fail structurally", boardRowMoveConflict.json ?? { stdout: boardRowMoveConflict.stdout, stderr: boardRowMoveConflict.stderr }); @@ -1082,7 +1085,8 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> { const boardRowMoveData = dataOf(boardRowMove.json ?? {}); assertCondition(boardRowMoveData.dryRun === false && boardRowMoveData.rest === true, "board-row move should report a real REST update", boardRowMoveData); const boardRowMovePlan = boardRowMoveData.move as JsonRecord; - assertCondition(boardRowMovePlan.from === "open" && boardRowMovePlan.to === "closed" && boardRowMovePlan.status.new === "CLOSED", "board-row move should update the GitHub status column", boardRowMovePlan); + const boardRowMoveStatus = boardRowMovePlan.status as JsonRecord; + assertCondition(boardRowMovePlan.from === "open" && boardRowMovePlan.to === "closed" && boardRowMoveStatus.new === "CLOSED", "board-row move should update the GitHub status column", boardRowMovePlan); const boardRowMoveLinePlan = boardRowMovePlan.linePlan as JsonRecord; const boardRowMoveSectionPlan = boardRowMovePlan.sectionPlan as JsonRecord; assertCondition(boardRowMoveLinePlan.action === "move" && Number(boardRowMoveLinePlan.sourceLineNumber ?? 0) > 0 && Number(boardRowMoveLinePlan.newLineNumber ?? 0) > 0, "board-row move should expose a line plan", boardRowMoveLinePlan); @@ -1123,7 +1127,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> { const readIssue = readBodyData.issue as JsonRecord; assertCondition(typeof readIssue.body === "string" && readIssue.body.includes("## 看板(OPEN)"), ".data.issue.body should remain readable", readBodyData); const readBodyHint = readBodyData.codeQueueBoardHint as JsonRecord; - assertCondition(readBodyHint.detected === false && String(readBodyHint.warning ?? "").includes("#20 is the long-term board only"), "issue read #20 should remind callers not to put daily briefs into #20", readBodyHint); + assertCondition(readBodyHint.detected === false && String(readBodyHint.warning ?? "").includes("governance board only"), "issue read #20 should remind callers that #20 is governance-only", readBodyHint); const selectedJson = readBodyData.json as JsonRecord; assertCondition(typeof selectedJson.body === "string" && selectedJson.body === readIssue.body, "selected json body should match issue body", readBodyData); assertCondition(!("comments" in selectedJson), "--json body should not imply comments field", selectedJson); @@ -1216,6 +1220,88 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> { const pollutedBoardHint = pollutedBoardGuard.codeQueueBoardHint as JsonRecord; assertCondition(pollutedBoardHint.detected === true && String(pollutedBoardHint.route ?? "").includes("daily rolling commander brief"), "#20 guard should hint to move updates to the daily brief issue", pollutedBoardHint); + const hwlabProductBoardFile = join(tmp, "hwlab-product-board.md"); + writeFileSync(hwlabProductBoardFile, [ + "# Code Queue", + "", + "## 看板(OPEN)", + "", + "| Issue | GitHub 状态 | Branch | 验收状态 | 相关 Code Queue 任务 | 当前关注点 | 进度 |", + "| --- | --- | --- | --- | --- | --- | --- |", + "| [pikasTech/HWLAB#108](https://github.com/pikasTech/HWLAB/issues/108) HWLAB user feedback | OPEN | main | product | cq-hwlab | Cloud Workbench 用户反馈 | doing |", + "", + ].join("\n"), "utf8"); + const hwlabProductBoard = await runCli(["gh", "issue", "update", "20", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", hwlabProductBoardFile, "--dry-run"], env); + assertCondition(hwlabProductBoard.status !== 0, "#20 body guard should reject HWLAB product issue rows", hwlabProductBoard.json ?? { stdout: hwlabProductBoard.stdout }); + const hwlabProductBoardData = failedDataOf(hwlabProductBoard.json ?? {}); + const hwlabProductBoardGuard = hwlabProductBoardData.guard as JsonRecord; + assertCondition(Array.isArray(hwlabProductBoardGuard.failures) && hwlabProductBoardGuard.failures.includes("code-queue-board-contains-hwlab-product-work"), "#20 guard should report HWLAB product routing pollution", hwlabProductBoardGuard); + const hwlabProductHint = hwlabProductBoardGuard.codeQueueBoardHint as JsonRecord; + const hwlabProductRouting = hwlabProductHint.hwlabProductRouting as JsonRecord; + assertCondition(hwlabProductRouting.detected === true && String(hwlabProductRouting.route ?? "").includes("pikasTech/HWLAB"), "#20 guard should route HWLAB product issues to the HWLAB repo", hwlabProductRouting); + + const hwlabProductUpsert = await runCli([ + "gh", + "issue", + "board-row", + "upsert", + "108", + "--repo", + "pikasTech/unidesk", + "--board-issue", + "20", + "--section", + "open", + "--branch", + "main", + "--tasks", + "cq-hwlab", + "--summary", + "pikasTech/HWLAB#108 HWLAB user feedback", + "--focus", + "Cloud Workbench 用户反馈", + "--validation", + "product", + "--progress", + "doing", + "--dry-run", + ], env); + assertCondition(hwlabProductUpsert.status !== 0, "#20 board-row upsert should reject HWLAB product issue rows", hwlabProductUpsert.json ?? { stdout: hwlabProductUpsert.stdout }); + const hwlabProductUpsertData = failedDataOf(hwlabProductUpsert.json ?? {}); + const hwlabProductUpsertGuard = hwlabProductUpsertData.guard as JsonRecord; + assertCondition(Array.isArray(hwlabProductUpsertGuard.warnings) && hwlabProductUpsertGuard.warnings.includes("code-queue-board-contains-hwlab-product-work"), "board-row upsert guard should report HWLAB product routing pollution", hwlabProductUpsertGuard); + + const hwlabGovernanceGuard = await runCli([ + "gh", + "issue", + "board-row", + "upsert", + "109", + "--repo", + "pikasTech/unidesk", + "--board-issue", + "20", + "--section", + "open", + "--branch", + "master", + "--tasks", + "cq-guard", + "--summary", + "UniDesk CLI guard for HWLAB#108 routing", + "--focus", + "commander governance guard prevents HWLAB product misfile", + "--validation", + "dry-run guard", + "--progress", + "ready", + "--dry-run", + ], env); + assertCondition(hwlabGovernanceGuard.status === 0, "#20 board-row upsert should allow UniDesk governance rows that mention HWLAB as routing context", hwlabGovernanceGuard.json ?? { stdout: hwlabGovernanceGuard.stdout }); + const hwlabGovernanceGuardData = dataOf(hwlabGovernanceGuard.json ?? {}); + const hwlabGovernanceGuardSummary = hwlabGovernanceGuardData.guard as JsonRecord; + assertCondition(hwlabGovernanceGuardSummary.ok === true && !(hwlabGovernanceGuardSummary.warnings as unknown[]).includes("code-queue-board-contains-hwlab-product-work"), "governance rows mentioning HWLAB should not be classified as HWLAB product work", hwlabGovernanceGuardSummary); + const commanderBriefBlocked = await runCli(["gh", "issue", "edit", "24", "--repo", "pikasTech/unidesk", "--body-file", missingHeadingFile, "--dry-run"], env); assertCondition(commanderBriefBlocked.status !== 0, "#24 missing heading should fail", commanderBriefBlocked.json ?? { stdout: commanderBriefBlocked.stdout }); const commanderBriefData = failedDataOf(commanderBriefBlocked.json ?? {}); @@ -1397,6 +1483,8 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> { "unsupported --json fields fail structurally", "issue edit --body-file rejects literal null", "#20/#24 body profile guards reject missing headings or wrong profile", + "#20 body and board-row guards reject HWLAB product issue routing and point to pikasTech/HWLAB", + "#20 board-row guard allows UniDesk governance rows that mention HWLAB only as routing context", "#24 commander-brief profile remains compatible", "daily commander brief issues match commander-brief profile by title", "non-brief issues fail commander-brief profile without printing token", diff --git a/scripts/src/gh.ts b/scripts/src/gh.ts index 16c71f83..67988f88 100644 --- a/scripts/src/gh.ts +++ b/scripts/src/gh.ts @@ -993,16 +993,102 @@ function commanderBriefUpdateHeadings(body: string): string[] { .filter((line) => isCommanderBriefUpdateHeading(line)); } +function codeQueueBoardHwlabProductRoutingFindings(body: string | undefined): Array<Record<string, unknown>> { + if (body === undefined) return []; + const findings: Array<Record<string, unknown>> = []; + const directIssuePatterns = [ + { kind: "hwlab-repo-issue-url", pattern: /(?:https?:\/\/github\.com\/)?pikasTech\/HWLAB\/issues\/\d+/iu }, + { kind: "hwlab-repo-issue-short-ref", pattern: /\bpikasTech\/HWLAB#\d+\b/iu }, + { kind: "hwlab-short-issue-ref", pattern: /\bHWLAB#\d+\b/iu }, + ]; + const hwlabContextPattern = /\bHWLAB\b|pikasTech\/HWLAB|hwlab-/iu; + const strongProductPattern = /hwlab-cloud-web|Cloud Workbench|DEV-LIVE|res_boxsimu|hwlab-patch-panel|patch panel|M3\s*(?:虚拟硬件|可信闭环)/iu; + const genericProductPattern = /用户反馈|user feedback/iu; + const governanceContextPattern = /\b(?:commander|Code Queue|CLI|infra|governance|guard|guardrail|routing|misfile|board)\b|治理|调度|基础设施|守卫|边界|分流|看板/iu; + const lines = normalizeNewlines(body).split("\n"); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + const isTableRow = line.trim().startsWith("|") && !isMarkdownTableSeparator(line); + const strongProductSignal = strongProductPattern.test(line); + const genericProductSignal = genericProductPattern.test(line); + const governanceContext = governanceContextPattern.test(line); + for (const definition of directIssuePatterns) { + const match = definition.pattern.exec(line); + if (match !== null && !governanceContext && (isTableRow || strongProductSignal || (genericProductSignal && hwlabContextPattern.test(line)))) { + findings.push({ + kind: definition.kind, + lineNumber: index + 1, + match: match[0], + snippet: preview(line.trim()), + }); + } + } + if (isTableRow && !governanceContext && (strongProductSignal || (genericProductSignal && hwlabContextPattern.test(line)))) { + const match = strongProductSignal ? strongProductPattern.exec(line) : genericProductPattern.exec(line); + findings.push({ + kind: "hwlab-product-signal", + lineNumber: index + 1, + match: match?.[0] ?? "HWLAB product signal", + snippet: preview(line.trim()), + }); + } + if (findings.length >= 12) break; + } + return findings; +} + +function codeQueueBoardHwlabProductRoutingHint(boardIssueNumber: number, body?: string): Record<string, unknown> | null { + if (boardIssueNumber !== CODE_QUEUE_BOARD_TARGET_ISSUE) return null; + const findings = codeQueueBoardHwlabProductRoutingFindings(body); + return { + warning: "#20 is only for UniDesk commander/Code Queue/CLI/infra governance; HWLAB user/product issues belong in pikasTech/HWLAB.", + route: "Create or update the corresponding issue in pikasTech/HWLAB; keep #20 rows limited to UniDesk governance or infrastructure support work.", + forbiddenPatterns: [ + "pikasTech/HWLAB#<N>", + "github.com/pikasTech/HWLAB/issues/<N>", + "HWLAB#<N>", + "HWLAB product/live validation rows", + ], + allowedScope: ["commander governance", "Code Queue supervision", "UniDesk CLI guardrails", "UniDesk infrastructure"], + detected: findings.length > 0, + findings, + }; +} + +function codeQueueBoardHintHasHwlabProductRouting(hint: Record<string, unknown> | null): boolean { + if (hint === null) return false; + const routing = hint.hwlabProductRouting; + return typeof routing === "object" && routing !== null && (routing as { detected?: unknown }).detected === true; +} + +function codeQueueBoardHintHasCommanderBriefUpdates(hint: Record<string, unknown> | null): boolean { + if (hint === null) return false; + const commanderBrief = hint.commanderBrief; + return typeof commanderBrief === "object" && commanderBrief !== null && (commanderBrief as { detected?: unknown }).detected === true; +} + function codeQueueBoardCommanderBriefHint(boardIssueNumber: number, body?: string): Record<string, unknown> | null { if (boardIssueNumber !== CODE_QUEUE_BOARD_TARGET_ISSUE) return null; const headings = body === undefined ? [] : commanderBriefUpdateHeadings(body); + const hwlabProductRouting = codeQueueBoardHwlabProductRoutingHint(boardIssueNumber, body); + const commanderBriefDetected = headings.length > 0; + const hwlabProductDetected = codeQueueBoardHintHasHwlabProductRouting({ hwlabProductRouting }); return { - warning: "#20 is the long-term board only; do not write daily commander brief update sections into #20.", - route: "Move daily progress notes to the daily rolling commander brief issue referenced in #20's 指挥简报索引, using --body-profile commander-brief.", + warning: "#20 is the long-term UniDesk commander/Code Queue/CLI/infra governance board only; do not write daily commander brief updates or HWLAB product/user issue tracking into #20.", + route: commanderBriefDetected + ? "Move daily progress notes to the daily rolling commander brief issue referenced in #20's 指挥简报索引, using --body-profile commander-brief." + : hwlabProductDetected + ? "Move HWLAB product/user issue tracking to pikasTech/HWLAB; keep #20 limited to UniDesk commander/Code Queue/CLI/infra governance." + : "Use #20 only for UniDesk commander/Code Queue/CLI/infra governance rows.", forbiddenHeadings: ["## 更新 YYYY-MM-DD HH:mm 北京时间", "## YYYY-MM-DD HH:mm 北京时间指挥更新", "### YYYY-MM-DD HH:mm CST:..."], suggestedCommand: "bun scripts/cli.ts gh issue update <daily-brief-issue> --mode replace --body-file <file> --body-profile commander-brief --expect-body-sha <sha>", - detected: headings.length > 0, + detected: commanderBriefDetected || hwlabProductDetected, detectedHeadings: headings, + commanderBrief: { + detected: commanderBriefDetected, + detectedHeadings: headings, + }, + hwlabProductRouting, }; } @@ -3561,13 +3647,15 @@ function validateIssueBodyGuard(repo: string, issueNumber: number, body: string, const isShort = trimmed.length > 0 && trimmed.length < MIN_SAFE_ISSUE_BODY_CHARS; const profileValidation = issueProfileValidation(issueNumber, body, options.bodyProfile, profileContext); const boardBriefHint = codeQueueBoardCommanderBriefHint(issueNumber, body); - const boardContainsCommanderBriefUpdates = boardBriefHint?.detected === true; + const boardContainsCommanderBriefUpdates = codeQueueBoardHintHasCommanderBriefUpdates(boardBriefHint); + const boardContainsHwlabProductWork = codeQueueBoardHintHasHwlabProductRouting(boardBriefHint); const profileOk = validationBoolean(profileValidation, "ok"); const failures: string[] = []; if (isLiteralNull) failures.push("literal-null-body"); if (isBlank) failures.push("blank-body"); if (isShort && !options.allowShortBody) failures.push("short-body"); if (boardContainsCommanderBriefUpdates) failures.push("code-queue-board-contains-commander-brief-updates"); + if (boardContainsHwlabProductWork) failures.push("code-queue-board-contains-hwlab-product-work"); if (!profileOk) { if (profileValidation.issueMatchesProfile === false) failures.push("profile-issue-mismatch"); const missingHeadings = validationStringArray(profileValidation, "missingHeadings"); @@ -3609,7 +3697,8 @@ function issueEditGuardSummary(issueNumber: number, body: string, options: GitHu } if (options.allowShortBody) warnings.push("allow-short-body enabled; caller accepted short-body corruption risk"); if (profile.ok === false) warnings.push("profile guard would fail"); - if (boardBriefHint?.detected === true) warnings.push("code-queue-board-contains-commander-brief-updates"); + if (codeQueueBoardHintHasCommanderBriefUpdates(boardBriefHint)) warnings.push("code-queue-board-contains-commander-brief-updates"); + if (codeQueueBoardHintHasHwlabProductRouting(boardBriefHint)) warnings.push("code-queue-board-contains-hwlab-product-work"); const signals = bodySafetySignals(body); const shellPollution = signals.shellPollution as Record<string, unknown>; if (shellPollution.suspected === true) warnings.push("shell-pollution-suspected"); @@ -3618,7 +3707,8 @@ function issueEditGuardSummary(issueNumber: number, body: string, options: GitHu && trimmed.toLowerCase() !== "null" && (trimmed.length >= MIN_SAFE_ISSUE_BODY_CHARS || options.allowShortBody) && profile.ok === true - && boardBriefHint?.detected !== true, + && !codeQueueBoardHintHasCommanderBriefUpdates(boardBriefHint) + && !codeQueueBoardHintHasHwlabProductRouting(boardBriefHint), minSafeBodyChars: MIN_SAFE_ISSUE_BODY_CHARS, allowShortBody: options.allowShortBody, warnings, @@ -5173,7 +5263,7 @@ export function ghHelp(): unknown { "--body-file is the recommended source for Markdown bodies so real newlines, backticks, and tables are read as file bytes instead of shell arguments.", "update defaults to --mode replace; --mode append reads the current body and appends file bytes so real newlines, backticks, and Markdown tables are preserved.", "issue edit is a compatibility alias for issue update --mode replace.", - "issue update --body-file refuses literal null, blank, and too-short bodies by default. Use --allow-short-body only for intentional short writes; #20 requires its board heading, and commander-brief requires its stable heading on legacy #24 plus daily rolling brief issues titled YYYY-MM-DD 指挥简报(北京时间).", + "issue update --body-file refuses literal null, blank, and too-short bodies by default. Use --allow-short-body only for intentional short writes; #20 requires its board heading and rejects HWLAB product/user issue rows in favor of pikasTech/HWLAB, while commander-brief requires its stable heading on legacy #24 plus daily rolling brief issues titled YYYY-MM-DD 指挥简报(北京时间).", "issue update dry-run reports old/new body length slots, body SHA, required heading checks, literal \\n detection, and shell-pollution signals. Non-dry-run can use --expect-updated-at or --expect-body-sha for stale-cache protection.", "Issue body stdin is intentionally unsupported in this CLI; write generated Markdown to a file and pass --body-file.", "When staging a body file from a shell, use a quoted heredoc such as cat <<'EOF' > /tmp/body.md so backticks and backslashes are not expanded before --body-file reads the file.", @@ -5182,7 +5272,7 @@ export function ghHelp(): unknown { "issue board-audit is read-only and defaults to repo pikasTech/unidesk plus board issue #20. It reads only the board issue body, returns body size/SHA and parsed Markdown board sections, and no longer validates GitHub open/closed issue coverage against OPEN/CLOSED tables. The legacy coverage fields remain present as empty arrays/zero counts for compatibility.", "issue board-row list/get reuse the board-audit table parser and are read-only. board-row update changes one table cell by issue number, returns old/new row, body SHA, body guard and request plan, and defaults to dry-run unless --expect-updated-at or --expect-body-sha is supplied for the guarded PATCH. Field aliases map status and validation to the 验收状态 column, tasks to 相关 Code Queue 任务, and focus to 当前关注点.", "issue board-row upsert updates an existing row when the issue is already present, or generates a complete row in --section open|closed when missing. It returns operation=update or operation=add, defaults to dry-run, requires --expect-body-sha or --expect-updated-at before PATCH, and refuses section migration; use board-row move for OPEN/CLOSED migration.", - "issue board-row add/move/delete are row-scoped #20 table mutations. add validates a one-line --row-file against the target table column count, Issue column, and GitHub 状态 column; move refuses duplicate/ambiguous rows and can update GitHub 状态 via --status; delete removes only the matched row. All three default to dry-run and require --expect-body-sha or --expect-updated-at before PATCH. add/move/delete return old/new row, body SHA, and line/section plan details for the parsed table mutation.", + "issue board-row add/move/delete are row-scoped #20 table mutations. add validates a one-line --row-file against the target table column count, Issue column, and GitHub 状态 column; move refuses duplicate/ambiguous rows and can update GitHub 状态 via --status; delete removes only the matched row. All three default to dry-run and require --expect-body-sha or --expect-updated-at before PATCH. add/move/delete return old/new row, body SHA, and line/section plan details for the parsed table mutation, and the shared #20 guard rejects HWLAB product/user issue rows in favor of pikasTech/HWLAB.", "issue edit 24 --notify-claudeqq-brief-diff remains the legacy #24 notification helper: it reads the old issue body, PATCHes the new body, and sends only newly added '## 更新 ... 北京时间' sections to ClaudeQQ; ClaudeQQ failure does not roll back GitHub.", "Commander brief ClaudeQQ defaults to private target 645275593 through backend-core /api/microservices/claudeqq/proxy; UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_* env vars can override target, base URL, timeout, and enabled state.", "comment delete is supported because GitHub supports deleting issue comments; issue/pr hard delete is unsupported and close is the lifecycle alternative.",