From b5486a61ab0aa6c227366a95d1afa68281584359 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 21 May 2026 07:52:26 +0000 Subject: [PATCH] fix: support rolling commander brief profile --- docs/reference/cli.md | 4 +- docs/reference/code-queue-supervision.md | 6 +- scripts/gh-cli-issue-guard-contract-test.ts | 110 +++++++++++++++++++- scripts/src/gh.ts | 108 +++++++++++++++---- 4 files changed, 202 insertions(+), 26 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index d5790afc..3c4dbf19 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -32,8 +32,8 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI - `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`、`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 应优先用该字段分流。 - `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]` 通过 GitHub REST 读取 issue title/body/state/url 和 comments,默认输出 JSON;`view` 只保留为兼容别名。兼容旧脚本的 `--json body` 和 `--json body,title,state,comments` 字段选择,且正文仍稳定暴露在 `.data.issue.body`,避免调用方因为 JSON 路径变化把空值当成正文。字段白名单是 `body,title,state,comments,number,url,author,createdAt,updatedAt`,未知字段必须结构化失败并带 `runnerDisposition=business-failed`。`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 总看板和 #24 指挥简报是长期 body-only issue,`--body-profile auto` 会按 issue number 自动启用结构 guard:#20 必须包含 `## 看板(OPEN)`,#24 必须包含 `## 常驻观察与长期建议`;也可显式使用 `--body-profile code-queue-board|commander-brief`。`--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,防止旧缓存覆盖新正文。 -- `gh issue edit 24 --body-file <file> --notify-claudeqq-brief-diff [--dry-run]` 是指挥简报 #24 的通知入口。正式执行会先读取 GitHub 上 #24 旧正文并通过 #24 body profile guard,再从 `--body-file` 读取新正文;随后先 PATCH issue 主体,再把本次新增的 `## 更新 YYYY-MM-DD HH:MM 北京时间` 段落发送给 ClaudeQQ,ClaudeQQ 失败不会回滚 issue 正文,失败只体现在返回 JSON 的 `claudeqq.ok=false` 和结构化 `degradedReason`。带通知 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 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,防止旧缓存覆盖新正文。 +- `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]` 是 #20 长期总看板只读覆盖审计入口,默认 repo 为 `pikasTech/unidesk`、board issue 为 `20`、输出 JSON 且不 PATCH/POST/DELETE GitHub。它读取 board body、GitHub open issue 列表和 closed issue 列表,对比 OPEN/CLOSED Markdown 表格并输出 `missingOpenIssues`、`closedInOpenRows`、`missingClosedRows`、`openInClosedRows`、`rowValidationWarnings`、`ignoredIssues` 和 `recommendedActions`。当表格里存在 Issue 列时,row.issueNumber 优先取 Issue cell 中第一个指向 `/issues/<N>` 的 Markdown link,找不到时取开头的 `#N`;同一 Issue cell 里主引用后面的标题说明引用(例如 `#20 总看板`、`基于 #4`)不触发 `multiple-issue-references`。没有 Issue 列的旧表格仍回退到整行 issue 提取,并保留多 issue 引用告警。`相关 Code Queue 任务`/`relatedTask` 列允许 `—`、`-`、`n/a`、`无任务` 等无关联任务占位表示 closed 历史/治理项没有 Code Queue task;这个放宽不适用于 branch、acceptance 或 progress。默认把 #20 和 #24 作为 `known-meta` 治理/简报 issue 忽略;需要扩展治理项用 `--known-meta-issue`,临时排除业务 issue 用 `--ignore-issue`。指挥官发现总看板可能漏行时,应先跑 board audit 获取结构化结果,再决定是否人工编辑 #20,而不是只靠 grep。 - `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 move|delete` 第一阶段结构化返回 `unsupported-command`,因为 OPEN/CLOSED 表间移动和删行还需要明确插入位置、重复行和归档策略。 - `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|read|view [--json ...]` 提供 REST 列表和详情,PR 字段白名单是 `body,title,state,number,url,author,head,base,draft,createdAt,updatedAt`。`gh pr create --title <title> --body-file <file>|--body <text> --base <branch> --head <branch> [--draft] [--dry-run]`、`gh pr update <number> --mode replace|append --body-file <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 update --mode append` 会先读取当前 PR body 再追加正文。`gh pr delete <number>` 和 `gh pr merge` 本阶段不开放,始终结构化返回 `unsupported-command`;PR 生命周期删除语义请使用 `close`。 diff --git a/docs/reference/code-queue-supervision.md b/docs/reference/code-queue-supervision.md index 78c77902..3a119b39 100644 --- a/docs/reference/code-queue-supervision.md +++ b/docs/reference/code-queue-supervision.md @@ -81,7 +81,7 @@ MiniMax 风险控制必须固定包含:禁止 prod/重启/密钥/DB 写入; ## GitHub Issue 和 PR 使用 -GitHub issue 是长期需求、缺陷、阻塞和决策的 source of record。聊天上下文、Code Queue final response 和指挥简报可以帮助恢复现场,但不能替代 issue 中的可检索记录。指挥简报自身也按 issue 主体维护,当前入口是 GitHub issue #24;后续只更新 #24 正文,不在评论区追加,且所有时间统一使用北京时间。 +GitHub issue 是长期需求、缺陷、阻塞和决策的 source of record。聊天上下文、Code Queue final response 和指挥简报可以帮助恢复现场,但不能替代 issue 中的可检索记录。指挥简报自身也按 issue 主体维护:#24 是兼容的长期入口,每日滚动简报可以使用标题形如 `YYYY-MM-DD 指挥简报(北京时间)` 的独立 issue。简报更新只改 issue 正文,不在评论区追加,且所有时间统一使用北京时间。 指挥官应在以下情况创建或更新 GitHub issue: @@ -95,11 +95,11 @@ issue 内容必须自包含,至少写清楚背景、外部收益、当前观 如果某个 worker 任务需要依赖 GitHub issue 内容,但 runner 的 issue 可达性尚未被单独验证,指挥官不能默认 worker 已能读取该 issue。此时 worker prompt 必须直接内嵌完整需求、约束和验收点,issue URL 只能作为辅助引用。若要把 issue 作为任务输入源,先单独做可达性探测,再决定是否把 issue 作为常规前置条件。 -GitHub issue/PR 操作应优先使用 UniDesk CLI 的安全入口:`bun scripts/cli.ts gh auth status`、`gh issue list/read/view/create/update/comment create/comment delete/close/reopen/scan-escape/cleanup-plan/board-audit/board-row list/board-row get/board-row update`、`gh pr list/read/view/create/update/comment create/comment delete/close/reopen`。该入口默认 repo 是 `pikasTech/unidesk`,支持 `--repo owner/name`,输出稳定 JSON,并把 `missing-binary`、`missing-token`、`auth-failed`、`network-proxy-failed`、`permission-denied`、`repo-not-found`、`repo-forbidden`、`issue-not-found`、`pr-not-found`、`scope-insufficient`、`validation-failed`、`invalid-response`、`unsupported-command` 等失败原因结构化。失败对象必须包含 `runnerDisposition=infra-blocked|business-failed`,runner 应用它区分基础设施阻塞和业务/参数失败。`gh issue list --state open --limit N --json number,title,state,url` 是有界 issue 发现入口,`--state` 只接受 `open|closed|all`,list 字段白名单是 `number,title,state,url,updatedAt,createdAt,author,labels`;未知 state 或未知字段必须失败,不能静默返回空数组。`gh issue read <number> --json body` 是 canonical 入口,正文仍应从 `.data.issue.body` 读取;`view` 只保留为兼容别名。未知 `--json` 字段必须失败,不得让调用方把空正文误判为读取成功。`gh issue scan-escape --limit N [--dry-run]` 与 `gh issue cleanup-plan` 只读扫描 issue body/comments 的字面量 `\n`、shell escape、短 body、blank/null body,输出 `classification=suspected-pollution|explanatory-mention|risk`、body/comment id、预览和清理建议;说明性提到 `\n` 不应被当成污染,cleanup-plan 永远不真实清理历史评论。`gh issue board-audit --board-issue 20 --limit N --dry-run` 只读对比 GitHub open/closed issue 列表与 #20 的 OPEN/CLOSED Markdown 表格,输出 `missingOpenIssues`、`closedInOpenRows`、`missingClosedRows`、`rowValidationWarnings`、`ignoredIssues` 和 `recommendedActions`。存在 Issue 列时,row key 优先来自 Issue cell 的主 Markdown issue link `/issues/<N>`,其次是开头 `#N`;主引用后的标题说明引用不触发多 issue 告警。没有 Issue 列的旧表格仍使用整行 fallback 并保留 `multiple-issue-references`。`相关 Code Queue 任务` 可用 `—`、`-` 或等价无任务占位表达无关联 task,但 branch、acceptance 和 progress 仍必须填写真实值;默认把 #20/#24 作为 known-meta 忽略。指挥官发现 #20 可能漏跟 open issue 或分表错误时,应先跑 board audit 取得结构化缺口,再人工审阅和编辑 #20,不要只靠 grep。`gh issue board-row list --board-issue 20 --state open|closed|all` 和 `gh issue board-row get <issueNumber> --board-issue 20` 复用同一个 parser 读取表格行;`gh issue board-row update <issueNumber> --board-issue 20 --field progress|status|validation|branch|tasks|focus --value <text>` 只替换一行一个单元格,输出 old/new row、body SHA、body guard 和 request plan,且默认 dry-run,正式写入必须带 `--expect-body-sha` 或 `--expect-updated-at`。字段映射中 `status`/`validation` 都指向 `验收状态`,`tasks` 指向 `相关 Code Queue 任务`,`focus` 指向 `当前关注点`;单元格管道会转义、真实换行会折叠为空格,避免新增字面量 `\n`。`board-row move/delete` 暂时结构化 unsupported。`gh pr list|read|view --json ...` 支持 `body,title,state,number,url,author,head,base,draft,createdAt,updatedAt` 字段白名单。issue/PR 创建、更新、评论、评论删除、关闭和重开使用 GitHub REST API;只要有 `GH_TOKEN` 或 `GITHUB_TOKEN`,就不依赖系统 `gh` binary。`gh` binary 只作为状态探测和 `gh auth token` fallback,不是写操作的主路径。GitHub 不支持 issue/PR 硬删除,`gh issue delete` 和 `gh pr delete` 必须结构化返回 `unsupported-command`;生命周期删除语义使用 `close`。`gh pr merge` 仍然不开放。 +GitHub issue/PR 操作应优先使用 UniDesk CLI 的安全入口:`bun scripts/cli.ts gh auth status`、`gh issue list/read/view/create/update/comment create/comment delete/close/reopen/scan-escape/cleanup-plan/board-audit/board-row list/board-row get/board-row update`、`gh pr list/read/view/create/update/comment create/comment delete/close/reopen`。该入口默认 repo 是 `pikasTech/unidesk`,支持 `--repo owner/name`,输出稳定 JSON,并把 `missing-binary`、`missing-token`、`auth-failed`、`network-proxy-failed`、`permission-denied`、`repo-not-found`、`repo-forbidden`、`issue-not-found`、`pr-not-found`、`scope-insufficient`、`validation-failed`、`invalid-response`、`unsupported-command` 等失败原因结构化。失败对象必须包含 `runnerDisposition=infra-blocked|business-failed`,runner 应用它区分基础设施阻塞和业务/参数失败。`gh issue list --state open --limit N --json number,title,state,url` 是有界 issue 发现入口,`--state` 只接受 `open|closed|all`,list 字段白名单是 `number,title,state,url,updatedAt,createdAt,author,labels`;未知 state 或未知字段必须失败,不能静默返回空数组。`gh issue read <number> --json body` 是 canonical 入口,正文仍应从 `.data.issue.body` 读取;`view` 只保留为兼容别名。未知 `--json` 字段必须失败,不得让调用方把空正文误判为读取成功。`gh issue scan-escape --limit N [--dry-run]` 与 `gh issue cleanup-plan` 只读扫描 issue body/comments 的字面量 `\n`、shell escape、短 body、blank/null body,输出 `classification=suspected-pollution|explanatory-mention|risk`、body/comment id、预览和清理建议;说明性提到 `\n` 不应被当成污染,cleanup-plan 永远不真实清理历史评论。`gh issue board-audit --board-issue 20 --limit N --dry-run` 只读对比 GitHub open/closed issue 列表与 #20 的 OPEN/CLOSED Markdown 表格,输出 `missingOpenIssues`、`closedInOpenRows`、`missingClosedRows`、`rowValidationWarnings`、`ignoredIssues` 和 `recommendedActions`。存在 Issue 列时,row key 优先来自 Issue cell 的主 Markdown issue link `/issues/<N>`,其次是开头 `#N`;主引用后的标题说明引用不触发多 issue 告警。没有 Issue 列的旧表格仍使用整行 fallback 并保留 `multiple-issue-references`。`相关 Code Queue 任务` 可用 `—`、`-` 或等价无任务占位表达无关联 task,但 branch、acceptance 和 progress 仍必须填写真实值;默认把 #20/#24 作为 known-meta 忽略。指挥官发现 #20 可能漏跟 open issue 或分表错误时,应先跑 board audit 取得结构化缺口,再人工审阅和编辑 #20,不要只靠 grep。显式 `gh issue update <number> --body-profile commander-brief` 可用于 #24 legacy 简报和每日滚动简报 issue;每日简报 issue 应用标题 `YYYY-MM-DD 指挥简报(北京时间)` 或在既有正文首行/关键 heading 中标明简报身份,且新正文必须包含 `## 常驻观察与长期建议`。对非简报 issue 使用该 profile 应失败为 `profile-issue-mismatch`。`gh issue board-row list --board-issue 20 --state open|closed|all` 和 `gh issue board-row get <issueNumber> --board-issue 20` 复用同一个 parser 读取表格行;`gh issue board-row update <issueNumber> --board-issue 20 --field progress|status|validation|branch|tasks|focus --value <text>` 只替换一行一个单元格,输出 old/new row、body SHA、body guard 和 request plan,且默认 dry-run,正式写入必须带 `--expect-body-sha` 或 `--expect-updated-at`。字段映射中 `status`/`validation` 都指向 `验收状态`,`tasks` 指向 `相关 Code Queue 任务`,`focus` 指向 `当前关注点`;单元格管道会转义、真实换行会折叠为空格,避免新增字面量 `\n`。`board-row move/delete` 暂时结构化 unsupported。`gh pr list|read|view --json ...` 支持 `body,title,state,number,url,author,head,base,draft,createdAt,updatedAt` 字段白名单。issue/PR 创建、更新、评论、评论删除、关闭和重开使用 GitHub REST API;只要有 `GH_TOKEN` 或 `GITHUB_TOKEN`,就不依赖系统 `gh` binary。`gh` binary 只作为状态探测和 `gh auth token` fallback,不是写操作的主路径。GitHub 不支持 issue/PR 硬删除,`gh issue delete` 和 `gh pr delete` 必须结构化返回 `unsupported-command`;生命周期删除语义使用 `close`。`gh pr merge` 仍然不开放。 CLI 是短 shout 的需求原语,不是长驻服务器进程。CLI 功能不好用、兼容性不足、安全 guard 不够或输出不利于 runner/指挥官使用时,应默认创建 GitHub issue 并用 Code Queue 推进;这类 CLI 问题走 `master`、remote commit、轻量 contract test 和文档更新,不套用 backend-core、Code Queue runtime 这类运行态服务的重部署门禁。若 CLI 缺陷已经阻塞当前指挥,可以先做最小安全绕行,同时把长期修复写入 issue 并派单。 -所有 GitHub Markdown 正文写入必须来自 `--body-file <file>`。不要使用 `gh issue comment --body`、`gh api -f body=...` 或把多行正文直接拼进 shell 参数;这些路径容易把真实换行、反引号和 Markdown 表格污染成字面量 `\n` 或 shell escape。从 shell 生成正文文件时使用 quoted heredoc,例如 `cat <<'EOF' > /tmp/body.md`,保证反引号和反斜杠不被展开;JSON 请求体场景优先使用对应 CLI 的 `--body-file` 或 `--body-stdin`,不要把长 JSON 塞进命令行参数。`gh issue` 写命令不接受 stdin 正文;需要更新 #20 总看板或创建新 issue/comment 时,先把正文写入 Markdown 文件,再运行 `bun scripts/cli.ts gh issue update|comment create|create ... --body-file <file>`。`gh issue update --mode replace|append --body-file` 是主更新入口,`edit` 只是兼容别名;`append` 会先读取当前正文再追加文件字节,保留真实换行、反引号和 Markdown 表格,不走 shell 拼接。`gh issue update --body-file` 默认拒绝 `null`、空白和过短正文;#20 自动要求 `## 看板(OPEN)`,#24 自动要求 `## 常驻观察与长期建议`。更新 body-only issue 前优先跑 `--dry-run`,查看旧/新正文长度、body SHA、关键标题、字面量 `\n` 和 shell 污染信号;正式写入长期正文时优先带上 `--expect-updated-at` 或 `--expect-body-sha`,避免旧缓存覆盖新正文。#24 指挥简报是长期记录入口,更新正文时默认只写 GitHub issue,不自动向 ClaudeQQ 推送;如确需提醒用户,按本文的 ClaudeQQ 通知门槛单独发送。提交前或巡检时可用 `gh issue scan-escape --limit N --dry-run` 或 `gh issue cleanup-plan --limit N` 只读扫描污染并生成建议,不自动修复。 +所有 GitHub Markdown 正文写入必须来自 `--body-file <file>`。不要使用 `gh issue comment --body`、`gh api -f body=...` 或把多行正文直接拼进 shell 参数;这些路径容易把真实换行、反引号和 Markdown 表格污染成字面量 `\n` 或 shell escape。从 shell 生成正文文件时使用 quoted heredoc,例如 `cat <<'EOF' > /tmp/body.md`,保证反引号和反斜杠不被展开;JSON 请求体场景优先使用对应 CLI 的 `--body-file` 或 `--body-stdin`,不要把长 JSON 塞进命令行参数。`gh issue` 写命令不接受 stdin 正文;需要更新 #20 总看板或创建新 issue/comment 时,先把正文写入 Markdown 文件,再运行 `bun scripts/cli.ts gh issue update|comment create|create ... --body-file <file>`。`gh issue update --mode replace|append --body-file` 是主更新入口,`edit` 只是兼容别名;`append` 会先读取当前正文再追加文件字节,保留真实换行、反引号和 Markdown 表格,不走 shell 拼接。`gh issue update --body-file` 默认拒绝 `null`、空白和过短正文;#20 自动要求 `## 看板(OPEN)`,指挥简报 profile 自动要求 `## 常驻观察与长期建议`,并允许 #24 legacy 或每日滚动简报 issue。更新 body-only issue 前优先跑 `--dry-run`,查看旧/新正文长度、body SHA、关键标题、字面量 `\n` 和 shell 污染信号;正式写入长期正文时优先带上 `--expect-updated-at` 或 `--expect-body-sha`,避免旧缓存覆盖新正文。指挥简报更新正文时默认只写 GitHub issue,不自动向 ClaudeQQ 推送;#24 legacy 可用 `--notify-claudeqq-brief-diff` 通知 helper,如确需提醒用户,按本文的 ClaudeQQ 通知门槛单独发送。提交前或巡检时可用 `gh issue scan-escape --limit N --dry-run` 或 `gh issue cleanup-plan --limit N` 只读扫描污染并生成建议,不自动修复。 PR 是审查型交付入口,不是所有 Code Queue 任务的默认出口。默认 master-only 交付仍按项目 Git 规则执行;当变更风险高、跨模块、需要人工审查、或任务目标明确要求 PR 交付时,worker 可以创建 PR。PR 型任务必须报告源分支、目标分支、PR URL、关联 issue、测试证据和未完成风险。禁止把 PR 当成隐藏分支仓库;PR 分支必须来自最新目标线,保持小而可审查,并在合并后确认目标分支远端 commit 可 fetch。 diff --git a/scripts/gh-cli-issue-guard-contract-test.ts b/scripts/gh-cli-issue-guard-contract-test.ts index c335b428..ae6995cf 100644 --- a/scripts/gh-cli-issue-guard-contract-test.ts +++ b/scripts/gh-cli-issue-guard-contract-test.ts @@ -127,6 +127,52 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque ].join("\n"), html_url: "https://github.com/pikasTech/unidesk/issues/60", }; + const legacyCommanderBriefIssue = { + ...issue, + id: 2024, + number: 24, + title: "指挥简报", + body: [ + "# 指挥简报", + "", + "## 常驻观察与长期建议", + "", + "- 维持 Code Queue 指挥态势。", + "", + "## 更新 2026-05-20 17:28 北京时间", + "", + "- 已完成历史简报入口维护。", + "", + ].join("\n"), + html_url: "https://github.com/pikasTech/unidesk/issues/24", + }; + const dailyCommanderBriefIssue = { + ...issue, + id: 2046, + number: 46, + title: "2026-05-21 指挥简报(北京时间)", + body: [ + "# 2026-05-21 指挥简报(北京时间)", + "", + "## 常驻观察与长期建议", + "", + "- 今日滚动简报使用每日 issue 主体维护。", + "", + "## 更新 2026-05-21 09:00 北京时间", + "", + "- 启动当日队列监督。", + "", + ].join("\n"), + html_url: "https://github.com/pikasTech/unidesk/issues/46", + }; + const nonBriefIssue = { + ...issue, + id: 2047, + number: 47, + title: "普通任务 issue", + body: "# 普通任务\n\n## 背景\n\n- 不是指挥简报。\n", + html_url: "https://github.com/pikasTech/unidesk/issues/47", + }; const issueList = [ { id: 2001, @@ -351,6 +397,18 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque sendJson(res, 200, boardIssue); return; } + if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/24") { + sendJson(res, 200, legacyCommanderBriefIssue); + return; + } + if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/46") { + sendJson(res, 200, dailyCommanderBriefIssue); + return; + } + if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/47") { + sendJson(res, 200, nonBriefIssue); + return; + } if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/60") { sendJson(res, 200, legacyBoardIssue); return; @@ -474,7 +532,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> { const mock = await startMockGitHub(); const tmp = mkdtempSync(join(tmpdir(), "unidesk-gh-issue-guard-")); const env = { - GH_TOKEN: "contract-token", + GH_TOKEN: "contract-token-should-not-print", UNIDESK_GITHUB_API_URL: mock.baseUrl, }; try { @@ -707,9 +765,56 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> { const briefWrongData = failedDataOf(briefWrongProfile.json ?? {}); const briefWrongGuard = briefWrongData.guard as JsonRecord; assertCondition(Array.isArray(briefWrongGuard.failures) && briefWrongGuard.failures.includes("profile-issue-mismatch"), "explicit profile should check issue number", briefWrongGuard); + assertCondition(!briefWrongProfile.stdout.includes(env.GH_TOKEN) && !briefWrongProfile.stderr.includes(env.GH_TOKEN), "failed profile output must not print GH_TOKEN", { + stdout: briefWrongProfile.stdout, + stderr: briefWrongProfile.stderr, + }); const safeFile = join(tmp, "safe.md"); writeFileSync(safeFile, "# Code Queue\n\n## 看板(OPEN)\n\n- multiline Markdown keeps `code` intact.\n- real newline follows.\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n", "utf8"); + const validBriefFile = join(tmp, "valid-commander-brief.md"); + writeFileSync(validBriefFile, [ + "# 2026-05-21 指挥简报(北京时间)", + "", + "## 常驻观察与长期建议", + "", + "- 保持滚动简报正文只通过 body-file 更新。", + "", + "## 更新 2026-05-21 15:18 北京时间", + "", + "- 今日新增进展包含 `code` 和表格。", + "", + "| 项 | 状态 |", + "| --- | --- |", + "| CLI | guarded |", + "", + ].join("\n"), "utf8"); + + const legacyBriefDryRun = await runCli(["gh", "issue", "update", "24", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", validBriefFile, "--body-profile", "commander-brief", "--dry-run"], env); + assertCondition(legacyBriefDryRun.status === 0, "#24 explicit commander-brief profile should remain compatible", legacyBriefDryRun.json ?? { stdout: legacyBriefDryRun.stdout }); + const legacyBriefData = dataOf(legacyBriefDryRun.json ?? {}); + const legacyBriefGuard = legacyBriefData.guard as JsonRecord; + const legacyBriefProfile = legacyBriefGuard.profile as JsonRecord; + assertCondition(legacyBriefGuard.ok === true && legacyBriefProfile.issueMatchesProfile === true && legacyBriefProfile.issueMatchReason === "legacy-issue-number", "#24 commander-brief profile should pass by legacy issue number", legacyBriefGuard); + + const dailyBriefDryRun = await runCli(["gh", "issue", "update", "46", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", validBriefFile, "--body-profile", "commander-brief", "--dry-run"], env); + assertCondition(dailyBriefDryRun.status === 0, "daily commander brief issue should pass commander-brief dry-run guard", dailyBriefDryRun.json ?? { stdout: dailyBriefDryRun.stdout }); + const dailyBriefData = dataOf(dailyBriefDryRun.json ?? {}); + const dailyBriefGuard = dailyBriefData.guard as JsonRecord; + const dailyBriefProfile = dailyBriefGuard.profile as JsonRecord; + assertCondition(dailyBriefGuard.ok === true && dailyBriefProfile.issueMatchesProfile === true && dailyBriefProfile.issueMatchReason === "daily-title", "daily commander brief profile should match by title", dailyBriefGuard); + assertCondition(dailyBriefData.containsLiteralBackslashN === false && dailyBriefData.containsMarkdownTable === true, "daily brief dry-run should preserve markdown safety signals", dailyBriefData); + + const nonBriefDryRun = await runCli(["gh", "issue", "update", "47", "--repo", "pikasTech/unidesk", "--mode", "replace", "--body-file", validBriefFile, "--body-profile", "commander-brief", "--dry-run"], env); + assertCondition(nonBriefDryRun.status !== 0, "non-brief issue should fail commander-brief profile", nonBriefDryRun.json ?? { stdout: nonBriefDryRun.stdout }); + const nonBriefData = failedDataOf(nonBriefDryRun.json ?? {}); + const nonBriefGuard = nonBriefData.guard as JsonRecord; + assertCondition(Array.isArray(nonBriefGuard.failures) && nonBriefGuard.failures.includes("profile-issue-mismatch"), "non-brief issue should report profile-issue-mismatch", nonBriefGuard); + assertCondition(!nonBriefDryRun.stdout.includes(env.GH_TOKEN) && !nonBriefDryRun.stderr.includes(env.GH_TOKEN), "non-brief failure must not print GH_TOKEN", { + stdout: nonBriefDryRun.stdout, + stderr: nonBriefDryRun.stderr, + }); + const requestCountBeforeDryRun = mock.requests.length; const safeDryRun = await runCli(["gh", "issue", "edit", "20", "--repo", "pikasTech/unidesk", "--body-file", safeFile, "--dry-run"], env); assertCondition(safeDryRun.status === 0, "safe issue edit dry-run should succeed", safeDryRun.json ?? { stdout: safeDryRun.stdout }); @@ -825,6 +930,9 @@ 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", + "#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", "dry-run reports old/new body safety and does not PATCH", "multiline Markdown and backticks are not polluted", "expect-updated-at stale write protection blocks PATCH", diff --git a/scripts/src/gh.ts b/scripts/src/gh.ts index d56b8aea..31a500dd 100644 --- a/scripts/src/gh.ts +++ b/scripts/src/gh.ts @@ -30,11 +30,14 @@ const ISSUE_BODY_PROFILES = { requiredHeadings: ["## 看板(OPEN)"], }, "commander-brief": { - label: "Commander brief body-only issue #24", - issueNumber: COMMANDER_BRIEF_TARGET_ISSUE, + label: "Commander brief body-only issue (#24 legacy or daily rolling brief)", + legacyIssueNumber: COMMANDER_BRIEF_TARGET_ISSUE, requiredHeadings: ["## 常驻观察与长期建议"], }, } as const; +const DAILY_COMMANDER_BRIEF_TITLE_PATTERN = /^\d{4}-\d{2}-\d{2}\s+指挥简报(北京时间)$/u; +const DAILY_COMMANDER_BRIEF_BODY_HEADING_PATTERN = /^#\s+\d{4}-\d{2}-\d{2}\s+指挥简报(北京时间)\s*$/u; +const LEGACY_COMMANDER_BRIEF_BODY_HEADING_PATTERN = /^#\s+指挥简报\s*$/u; type IssueViewJsonField = typeof ISSUE_VIEW_JSON_FIELDS[number]; type IssueListJsonField = typeof ISSUE_LIST_JSON_FIELDS[number]; @@ -289,6 +292,12 @@ interface GitHubOptions { boardRowValue?: string; } +interface IssueProfileValidationContext { + issueTitle?: string | null; + issueBody?: string | null; + issueMetadataFetched?: boolean; +} + interface GitHubErrorPayload { ok: false; degradedReason: GitHubDegradedReason; @@ -1890,21 +1899,67 @@ function issueProfileFor(issueNumber: number, requested: IssueBodyProfileOption) return null; } -function issueProfileValidation(issueNumber: number, body: string, requested: IssueBodyProfileOption): Record<string, unknown> { +function firstMarkdownLine(body: string | null | undefined): string { + return normalizeNewlines(body ?? "").split("\n").find((line) => line.trim().length > 0)?.trim() ?? ""; +} + +function isDailyCommanderBriefTitle(title: string | null | undefined): boolean { + return DAILY_COMMANDER_BRIEF_TITLE_PATTERN.test((title ?? "").trim()); +} + +function isDailyCommanderBriefBodyHeading(line: string): boolean { + return DAILY_COMMANDER_BRIEF_BODY_HEADING_PATTERN.test(line.trim()); +} + +function isLegacyCommanderBriefBodyHeading(line: string): boolean { + return LEGACY_COMMANDER_BRIEF_BODY_HEADING_PATTERN.test(line.trim()); +} + +function currentBodyLooksLikeCommanderBrief(body: string | null | undefined): boolean { + const normalized = normalizeNewlines(body ?? ""); + const firstLine = firstMarkdownLine(normalized); + if (isDailyCommanderBriefBodyHeading(firstLine)) return true; + return isLegacyCommanderBriefBodyHeading(firstLine) + && normalized.includes("## 常驻观察与长期建议") + && extractTimelineSections(normalized).length > 0; +} + +function commanderBriefIssueProfileMatch(issueNumber: number, context: IssueProfileValidationContext): { ok: boolean; reason: string } { + if (issueNumber === COMMANDER_BRIEF_TARGET_ISSUE) return { ok: true, reason: "legacy-issue-number" }; + if (isDailyCommanderBriefTitle(context.issueTitle)) return { ok: true, reason: "daily-title" }; + if (currentBodyLooksLikeCommanderBrief(context.issueBody)) return { ok: true, reason: "current-body-heading" }; + return { + ok: false, + reason: context.issueMetadataFetched === true ? "not-commander-brief" : "issue-metadata-unavailable", + }; +} + +function issueProfileNeedsMetadata(issueNumber: number, requested: IssueBodyProfileOption): boolean { + return requested === "commander-brief" && issueNumber !== COMMANDER_BRIEF_TARGET_ISSUE; +} + +function issueProfileValidation(issueNumber: number, body: string, requested: IssueBodyProfileOption, context: IssueProfileValidationContext = {}): Record<string, unknown> { const profileName = issueProfileFor(issueNumber, requested); if (profileName === null) return { profile: null, applied: false, requiredHeadings: [], missingHeadings: [], ok: true }; const profile = ISSUE_BODY_PROFILES[profileName]; - const issueMatchesProfile = issueNumber === profile.issueNumber; + const match = profileName === "commander-brief" + ? commanderBriefIssueProfileMatch(issueNumber, context) + : { ok: issueNumber === ISSUE_BODY_PROFILES["code-queue-board"].issueNumber, reason: issueNumber === ISSUE_BODY_PROFILES["code-queue-board"].issueNumber ? "issue-number" : "wrong-issue-number" }; const missingHeadings = profile.requiredHeadings.filter((heading) => !body.includes(heading)); return { profile: profileName, label: profile.label, applied: true, - expectedIssueNumber: profile.issueNumber, - issueMatchesProfile, + ...(profileName === "code-queue-board" + ? { expectedIssueNumber: ISSUE_BODY_PROFILES["code-queue-board"].issueNumber } + : { legacyIssueNumber: COMMANDER_BRIEF_TARGET_ISSUE }), + issueMatchesProfile: match.ok, + issueMatchReason: match.reason, + issueMetadataFetched: context.issueMetadataFetched === true, + issueTitle: context.issueTitle ?? null, requiredHeadings: profile.requiredHeadings, missingHeadings, - ok: issueMatchesProfile && missingHeadings.length === 0, + ok: match.ok && missingHeadings.length === 0, }; } @@ -1917,12 +1972,12 @@ function validationStringArray(record: Record<string, unknown>, key: string): st return Array.isArray(value) ? value.map((item) => String(item)) : []; } -function validateIssueBodyGuard(repo: string, issueNumber: number, body: string, options: GitHubOptions): GitHubCommandResult | null { +function validateIssueBodyGuard(repo: string, issueNumber: number, body: string, options: GitHubOptions, profileContext: IssueProfileValidationContext = {}): GitHubCommandResult | null { const trimmed = body.trim(); const isLiteralNull = trimmed.toLowerCase() === "null"; const isBlank = trimmed.length === 0; const isShort = trimmed.length > 0 && trimmed.length < MIN_SAFE_ISSUE_BODY_CHARS; - const profileValidation = issueProfileValidation(issueNumber, body, options.bodyProfile); + const profileValidation = issueProfileValidation(issueNumber, body, options.bodyProfile, profileContext); const profileOk = validationBoolean(profileValidation, "ok"); const failures: string[] = []; if (isLiteralNull) failures.push("literal-null-body"); @@ -1958,9 +2013,9 @@ function validateIssueBodyGuard(repo: string, issueNumber: number, body: string, }); } -function issueEditGuardSummary(issueNumber: number, body: string, options: GitHubOptions): Record<string, unknown> { +function issueEditGuardSummary(issueNumber: number, body: string, options: GitHubOptions, profileContext: IssueProfileValidationContext = {}): Record<string, unknown> { const trimmed = body.trim(); - const profile = issueProfileValidation(issueNumber, body, options.bodyProfile); + const profile = issueProfileValidation(issueNumber, body, options.bodyProfile, profileContext); const warnings: string[] = []; if (trimmed.length > 0 && trimmed.length < MIN_SAFE_ISSUE_BODY_CHARS) { warnings.push(options.allowShortBody ? "short-body-allowed" : "short-body-would-fail"); @@ -2642,7 +2697,8 @@ async function issueCreate(repo: string, token: string, options: GitHubOptions): async function issueEdit(repo: string, token: string, issueNumber: number, options: GitHubOptions, commandName = "issue edit"): Promise<GitHubCommandResult> { const body = readBodyFile(options.bodyFile, commandName); - if (options.mode === "replace") { + const needsProfileMetadata = issueProfileNeedsMetadata(issueNumber, options.bodyProfile); + if (options.mode === "replace" && !needsProfileMetadata) { const bodyGuard = validateIssueBodyGuard(repo, issueNumber, body, options); if (bodyGuard !== null) return bodyGuard; } @@ -2650,27 +2706,31 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio if (concurrencyOptionError !== null) return concurrencyOptionError; let oldIssue: GitHubIssue | null = null; let briefDiff: CommanderBriefDiff | null = null; + let profileContext: IssueProfileValidationContext = {}; const claudeQqConfig = commanderBriefClaudeQqConfig(); if (options.notifyClaudeQqBriefDiff && issueNumber !== COMMANDER_BRIEF_TARGET_ISSUE) { return validationError(commandName, repo, "--notify-claudeqq-brief-diff is only supported for commander brief issue #24", { issueNumber }); } - const needsReadBeforeEdit = options.mode === "append" || !options.dryRun && (options.notifyClaudeQqBriefDiff || options.expectUpdatedAt !== undefined || options.expectBodySha !== undefined); + const needsReadBeforeEdit = options.mode === "append" + || needsProfileMetadata && token.length > 0 + || !options.dryRun && (options.notifyClaudeQqBriefDiff || options.expectUpdatedAt !== undefined || options.expectBodySha !== undefined); if (needsReadBeforeEdit) { const issue = await getIssue(token, repo, issueNumber); if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber, phase: "read-before-update" }); oldIssue = issue; + profileContext = { issueTitle: issue.title, issueBody: issue.body, issueMetadataFetched: true }; const concurrencyError = validateIssueConcurrency(repo, issueNumber, issue, options); if (concurrencyError !== null) return concurrencyError; if (options.notifyClaudeQqBriefDiff) briefDiff = commanderBriefDiff(issue.body ?? "", options.mode === "append" ? `${issue.body ?? ""}${body}` : body); } const finalBody = options.mode === "append" ? `${oldIssue?.body ?? ""}${body}` : body; - if (options.mode === "append") { - const bodyGuard = validateIssueBodyGuard(repo, issueNumber, finalBody, options); + if (options.mode === "append" || needsProfileMetadata) { + const bodyGuard = validateIssueBodyGuard(repo, issueNumber, finalBody, options, profileContext); if (bodyGuard !== null) return bodyGuard; } if (options.dryRun) { const dryRunDiff = options.notifyClaudeQqBriefDiff ? commanderBriefDiff("", finalBody) : null; - const guard = issueEditGuardSummary(issueNumber, finalBody, options); + const guard = issueEditGuardSummary(issueNumber, finalBody, options, profileContext); let dryRunOldBody: Record<string, unknown> = { fetched: false, bodyChars: null, @@ -2678,7 +2738,15 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio updatedAt: null, reason: token.length > 0 ? "not-requested" : "no token supplied to dry-run", }; - if (token.length > 0) { + if (oldIssue !== null) { + const oldBody = oldIssue.body ?? ""; + dryRunOldBody = { + fetched: true, + bodyChars: oldBody.length, + bodySha: bodySha(oldBody), + updatedAt: oldIssue.updated_at ?? null, + }; + } else if (token.length > 0) { const issue = await getIssue(token, repo, issueNumber); if (isGitHubError(issue)) { dryRunOldBody = { @@ -2743,7 +2811,7 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio if (options.title !== undefined) payload.title = options.title; const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, payload); if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber }); - const guard = issueEditGuardSummary(issueNumber, finalBody, options); + const guard = issueEditGuardSummary(issueNumber, finalBody, options, profileContext); const concurrency = oldIssue === null ? { checked: false, expectUpdatedAt: options.expectUpdatedAt ?? null, expectBodySha: options.expectBodySha ?? null } : { checked: true, oldIssueUpdatedAt: oldIssue.updated_at ?? null, oldBodySha: bodySha(oldIssue.body ?? ""), expectUpdatedAt: options.expectUpdatedAt ?? null, expectBodySha: options.expectBodySha ?? null }; @@ -3351,7 +3419,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 and #24 body-only profiles require their stable headings.", + "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 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.", @@ -3360,7 +3428,7 @@ export function ghHelp(): unknown { "issue board-audit is read-only and defaults to repo pikasTech/unidesk plus board issue #20. It compares GitHub open/closed issue lists with the board OPEN/CLOSED tables and reports missingOpenIssues, closedInOpenRows, missingClosedRows, rowValidationWarnings, ignoredIssues, and recommendedActions. When an Issue column exists, row.issueNumber is taken from that column; #20 and #24 are known meta issues by default.", "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 move/delete are structured unsupported in this phase; open/closed relocation and row removal need explicit table placement and duplicate-row semantics before they write.", - "issue edit 24 --notify-claudeqq-brief-diff reads the old issue body, PATCHes the new body, and sends only newly added '## 更新 ... 北京时间' sections to ClaudeQQ; ClaudeQQ failure does not roll back GitHub.", + "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.", "PR read is the canonical read path; view remains a compatibility alias. PR create/update/comment are safe-write operations with dry-run planning; merge is intentionally unsupported in this phase.",