From bf5dc16eb5a4bc23a2096a8e64a17159d81b15c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E7=BA=A202?= <596540@ky-tech.com.cn> Date: Fri, 18 Sep 2026 09:35:41 +0800 Subject: [PATCH] feat(issue): unified issue body contract - brief-rendered skeleton, metadata machine channel, lean plan stdout (v1.2.0) --- README.md | 22 +++- cli.mjs | 6 +- lib/commands.mjs | 4 +- lib/domains/issue.mjs | 23 +++- lib/i18n.mjs | 2 +- lib/issue-render.mjs | 44 ++++++++ package.json | 2 +- .../brief.md | 100 ++++++++++++++++++ shadow-docs/knowledge/cli-output-contract.md | 4 +- shadow-docs/knowledge/issue-body-contract.md | 38 +++++++ shadow-docs/menu.md | 1 + test/cli.test.mjs | 76 ++++++++++++- 12 files changed, 307 insertions(+), 15 deletions(-) create mode 100644 lib/issue-render.mjs create mode 100644 shadow-docs/changes/20260917-feature-unified-issue-structure/brief.md create mode 100644 shadow-docs/knowledge/issue-body-contract.md diff --git a/README.md b/README.md index e85e16f..3743ca7 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Shadow dev workflow 的确定性脚手架 CLI。所有命令走 plan → execute |------|------| | `repo inspect` | 查看仓库状态(分支、HEAD、脏文件) | | `change create\|approve\|list` | 创建/批准变更 brief;`list` 默认只列活动变更,`--all` 合并归档、`--archived` 只列归档(条目带 `archived` 布尔) | -| `issue plan\|execute` | 创建 GitHub issue | +| `issue plan\|execute` | 创建 GitHub issue(正文由 brief 确定性渲染,见「Issue 正文结构契约」) | | `branch plan\|execute` | 建功能分支 | | `sync plan\|execute` | fast-forward 同步上游 | | `conflict inspect` | 检查 active brief 文件重叠 | @@ -36,6 +36,17 @@ CLI 在 stderr 渲染一层人类提示:进场横幅(命令+参数)、收 - 缺必填参数报错时,stderr 逐行列出该命令在命令目录中的完整参数描述(`flag * 说明`,含示例值与来源位置),示例行的占位符与目录一致(如 `--name `)——提示与人用 help 共享同一事实源 `lib/commands.mjs`。 - `shadow-dev help` 概览默认只回最小面:`data.help`(命令一览字符串,约 350 字节);agent 需要结构化明细(usage/参数/必填/示例/nextStep)时用 `shadow-dev help --full`。`shadow-dev help <命令>` 查看单组详情,恒定结构化(组面小)。stderr 中文命令表不受 `--full` 影响。 +## Issue 正文结构契约 + +`issue plan/execute` 的正文由 brief **确定性渲染**(纯字符串拼接,零 AI 推导),所有 issue 共享统一骨架: + +1. 固定分节 `## 动机 / ## 引用规范 / ## 决策 / ## 任务`——从 brief 同名分节白名单搬运,缺节以 `(brief 缺少该节)` 占位;`结果/知识评估` 等内部节不进 issue。 +2. 可选 `## 补充`(来自 `--body`),随后 `完整 brief:shadow-docs/changes//brief.md` 指针行。 +3. 正文末行 `` 机器通道(name/type/scope/status/branch/baseBranch/briefPath/cliVersion/prUrl/issueNumber),插件或站点按正则单行提取。 +4. 标题自动补 `[type] ` 前缀,已带同类前缀则幂等不重复。 + +`issue plan` 的 stdout 只回摘要 `{name,title,labels,repository,bodyBytes,bodySha256,sections}`(约 0.6KB),不回显全文;「预览即提交」由 `bodySha256` 承担——plan 之后 brief 正文有任何变动都会令 execute 报 `PLAN_HASH_INVALID`,重跑 plan 即刷新。全文唯一存放处是 brief 的 `workflow.issuePlan.body`。 + ## 平台兼容 - `--files` 路径参数接受 Windows 反斜杠写法(如 `lib\a.mjs`),自动归一为正斜杠并与 git 状态、conflict 比对对齐。 @@ -65,12 +76,15 @@ CLI 在 stderr 渲染一层人类提示:进场横幅(命令+参数)、收 ```bash bash scripts/install-cli.sh install # 拉取最新 release,物化+自校验+生成托管 shim bash scripts/install-cli.sh install --json # 插件钩子用:单行机器输出(幂等,已最新秒退) -bash scripts/install-cli.sh status --json # 当前/上一版本指针 +bash scripts/install-cli.sh status --json # 当前/上一版本指针 + linked 映射目标 bash scripts/install-cli.sh rollback # 切回上一版(离线,不触网) bash scripts/install-cli.sh install --from dist/shadow-dev-cli-v1.1.0.tar.gz # 离线安装 +bash scripts/install-cli.sh link D:/works/shadow-dev-cli # 开发直通:shim 映射到仓库真实地址,代码即改即生效 +bash scripts/install-cli.sh unlink # 取消映射,回到 release 轨 ``` -- 布局:`~/.local/share/shadow-dev-cli/shadow-dev-cli-/` + `CURRENT`/`PREVIOUS` 指针文件;shim(`~/.local/bin/shadow-dev` 与 `.cmd`)运行时读指针——更新与回滚都不再改动 shim 文件。自定义位置用 `--prefix` / `--bin`。 +- **双轨并存**:shim 运行时按 `LINK → CURRENT` 两段解析——`link` 轨供 CLI 开发者/本机长期使用(落指针前同样校验 `cli.mjs`+`package.json` 并冒烟 `help --json`),release 物化轨(插件钩子契约)不受影响;`install` 不覆盖 `LINK`,`unlink` 即回退。 +- 布局:`~/.local/share/shadow-dev-cli/shadow-dev-cli-/` + `CURRENT`/`PREVIOUS`/`LINK` 指针文件;shim(`~/.local/bin/shadow-dev` 与 `.cmd`)运行时读指针——更新与回滚都不再改动 shim 文件。自定义位置用 `--prefix` / `--bin`。 - 安全边界:发布前先物化并自跑 `help --json`,失败则指针不动(旧版本照常可用);shim 路径被**非托管**同名文件占用时告警退出、绝不覆盖;并发运行有锁(陈旧 10 分钟自动接管)。 - 退出码:`0` 成功/已最新 · `1` 参数或冲突 · `2` 网络/GitHub API · `3` 产物自校验失败。信任边界为 HTTPS + GitHub 仓库,未做独立校验和。 - 通道:默认 release(可复现);`--version v*` 锁版本;`--channel main` git 浅拉 rolling,仅供插件开发。依赖 bash + node(≥20) + tar(main 通道另需 git;curl 缺失自动退 wget),Windows 在 Git Bash 下运行。 @@ -79,7 +93,7 @@ bash scripts/install-cli.sh install --from dist/shadow-dev-cli-v1.1.0.tar.gz # ## 开发 ```bash -npm test # node --test,52 项 CLI 契约 + 6 项安装器契约(离线产物全链、冲突保护、回滚、自校验) +npm test # node --test,55 项 CLI 契约 + 8 项安装器契约(离线产物全链、link 双轨、冲突保护、回滚、自校验) ``` 行为契约:命令、JSON 输出结构、错误码、planHash 机制保持稳定;`test/cli.test.mjs` 是唯一契约规格。 diff --git a/cli.mjs b/cli.mjs index dd29044..375c3c7 100755 --- a/cli.mjs +++ b/cli.mjs @@ -43,14 +43,16 @@ async function executeDomain(c, mod, r, o) { return mod.execute(r, o, e.data, b) } +// 域可导出 present(x) 声明 stdout data 的最小投影(哈希/持久化仍用完整 planData);缺省恒等 async function planDomain(c, mod, r, o) { const e = plan(c, await mod.planData(r, o)) - if (!o.name) return e + const view = mod.present ? { ...e, data: mod.present(e.data) } : e + if (!o.name) return view const b = brief(r, name(o)) b.data.workflow.planHash = e.planHash mod.persistPlan?.(b, e) write(b) - return e + return view } // 概览默认最小面(data.help 恒字符串);结构化目录经 --full opt-in;组详情恒定返回该组 commands diff --git a/lib/commands.mjs b/lib/commands.mjs index 7a5137d..e04c65f 100644 --- a/lib/commands.mjs +++ b/lib/commands.mjs @@ -15,8 +15,8 @@ export const COMMANDS = { 'change.create': c('change create', '创建变更 brief', 'create a change brief', [N, f('--type', false, 'feature|fix|build|chore|docs|refactor|style|test,默认 feat', 'one of feature|fix|build|chore|docs|refactor|style|test, default feat'), f('--scope', false, '影响范围', 'scope'), f('--base-branch', false, '基线分支,默认 main', 'base branch, default main'), FI, f('--body-file', false, 'brief 正文来源文件', 'file supplying the brief body'), f('--repository', false, 'GitHub owner/repo', 'GitHub owner/repo'), CF], 'shadow-dev change create --name --type feature --confirm', 'change approve --name {name} --confirm'), 'change.approve': c('change approve', '批准 brief(draft → proposed)', 'approve the brief (draft → proposed)', [N, CF], 'shadow-dev change approve --name --confirm', 'branch plan --name {name}'), 'change.list': c('change list', '列出变更的名称、类型、状态与分支(默认只列活动)', 'list changes with name, type, status and branch (active only by default)', [f('--all', false, '同时包含已归档变更(与 --archived 同传时按 --all 处理)', 'include archived changes as well (--all wins when both are passed)'), f('--archived', false, '只列已归档变更', 'list archived changes only')], 'shadow-dev change list'), - 'issue.plan': c('issue plan', '预览创建 GitHub issue', 'plan a GitHub issue', [N, T, B, f('--labels', false, '逗号分隔标签', 'comma-separated labels')], 'shadow-dev issue plan --name --title "标题" --labels feature', 'issue execute --name {name} --title "{title}" --body "{body}" --labels {labels} --confirm'), - 'issue.execute': c('issue execute', '创建 GitHub issue', 'create the GitHub issue', [N, T, B, f('--labels', false, '逗号分隔标签', 'comma-separated labels'), PH, CF], 'shadow-dev issue execute --name --confirm', null), + 'issue.plan': c('issue plan', '预览由 brief 确定性渲染的统一结构 issue(stdout 只回摘要)', 'plan the unified-structure issue rendered from the brief (stdout carries a lean summary)', [N, f('--title', false, '标题覆盖,缺省 = [type] 前缀 + brief H1,回落变更名', 'title override; default = [type] prefix + brief H1, falling back to the change name'), f('--body', false, '可选「补充」节内容;正文骨架由 brief 分节自动渲染', 'optional 补充 section text; the skeleton is rendered from brief sections'), f('--labels', false, '逗号分隔标签', 'comma-separated labels')], 'shadow-dev issue plan --name --labels feature', 'issue execute --name {name} --plan-hash {planHash} --confirm'), + 'issue.execute': c('issue execute', '创建 GitHub issue(正文读 brief 快照与 plan 校验)', 'create the GitHub issue (body re-derived from the brief and hash-checked)', [N, PH, CF], 'shadow-dev issue execute --name --confirm', null), 'branch.plan': c('branch plan', '预览建功能分支', 'plan creating the feature branch', [N], 'shadow-dev branch plan --name ', 'branch execute --name {name} --confirm'), 'branch.execute': c('branch execute', '从基线分支创建并切换', 'create and switch to the feature branch', [N, PH, CF], 'shadow-dev branch execute --name --confirm', null), 'sync.plan': c('sync plan', '预览 fast-forward 同步上游', 'plan a fast-forward sync with upstream', [N], 'shadow-dev sync plan --name ', 'sync execute --name {name} --confirm'), diff --git a/lib/domains/issue.mjs b/lib/domains/issue.mjs index 1737626..0af6feb 100644 --- a/lib/domains/issue.mjs +++ b/lib/domains/issue.mjs @@ -3,13 +3,24 @@ import { brief, write } from '../brief.mjs' import { name } from '../input.mjs' import { repo } from '../git.mjs' import { err } from '../errors.mjs' +import { renderIssueBody } from '../issue-render.mjs' +// 正文由 renderIssueBody 从 brief 现算(非陈旧快照):brief 变动会体现在 bodySha256 → planHash, +// 触发 PLAN_HASH_INVALID,「刷新 = 重跑 plan」天然成立。全文仍持久化进 issuePlan 供人读取。 export async function planData(r, o) { const n = name(o), b = brief(r, n), q = repo(r), w = b.data.workflow.issuePlan || {} + const titleRaw = o.title ?? w.titleRaw ?? null + const supplement = o.body ?? w.supplement ?? '' + const rendered = renderIssueBody(b, { titleRaw, supplement }) return { name: n, - title: o.title ?? w.title ?? null, - body: o.body ?? w.body ?? '', + title: rendered.title, + titleRaw, + supplement, + body: rendered.body, + sections: rendered.sections, + bodyBytes: rendered.bodyBytes, + bodySha256: rendered.bodySha256, labels: String(o.labels ?? w.labels ?? '').split(',').filter(Boolean).sort(), repository: repository(b, r), brief: b.data, @@ -18,7 +29,13 @@ export async function planData(r, o) { } export function persistPlan(b, e) { - b.data.workflow.issuePlan = { title: e.data.title, body: e.data.body, labels: e.data.labels } + const d = e.data + b.data.workflow.issuePlan = { title: d.title, titleRaw: d.titleRaw, supplement: d.supplement, body: d.body, labels: d.labels } +} + +// stdout 最小投影:重字段(全文/brief/repo/raw 输入)不进机器契约,「预览即提交」由 bodySha256 承担 +export function present(d) { + return { name: d.name, title: d.title, labels: d.labels, repository: d.repository, bodyBytes: d.bodyBytes, bodySha256: d.bodySha256, sections: d.sections } } export async function execute(r, o, x, b) { diff --git a/lib/i18n.mjs b/lib/i18n.mjs index 9f76612..bed4e58 100644 --- a/lib/i18n.mjs +++ b/lib/i18n.mjs @@ -52,7 +52,7 @@ export const HINTS = { REVIEW_NOT_PASSED: { zh: 'review 未通过或 HEAD 已变化;重新运行 review', en: 'review not passed for this HEAD; run review again' }, PR_NOT_MERGED: { zh: '关联 PR 尚未合并;先在 GitHub 合并再归档', en: 'the linked PR is not merged yet; merge it on GitHub first' }, PULL_REQUEST_REQUIRED: { zh: 'brief 未关联 PR;先 publish', en: 'brief has no linked PR; run publish first' }, - ISSUE_TITLE_REQUIRED: { zh: '缺少 --title(或在 plan 中持久化)', en: '--title is missing (or persisted from issue plan)' }, + ISSUE_TITLE_REQUIRED: { zh: '标题三源推导全空(--title / brief H1 / 变更名),属内部保护', en: 'title derivation exhausted (--title / brief H1 / change name); internal guard' }, GITHUB_TOKEN_REQUIRED: { zh: '未设置 GITHUB_TOKEN/GH_TOKEN 环境变量', en: 'GITHUB_TOKEN or GH_TOKEN environment variable is required' }, GITHUB_REPOSITORY_REQUIRED: { zh: '无法确定 GitHub 仓库;传 --repository 或设置 brief.github.repository', en: 'cannot resolve the GitHub repository; pass --repository or set brief.github.repository' }, GITHUB_API_ERROR: { zh: 'GitHub API 返回错误', en: 'the GitHub API returned an error' }, diff --git a/lib/issue-render.mjs b/lib/issue-render.mjs new file mode 100644 index 0000000..e3f8229 --- /dev/null +++ b/lib/issue-render.mjs @@ -0,0 +1,44 @@ +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' + +// issue 正文的唯一来源是 brief:纯字符串拼接,零 LLM 推导、零本地化分支。 +// 契约 = 人读分节骨架(白名单搬运)+ 尾部 brief 指针 + 底部一行机器 metadata 注释块。 +const CLI_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version + +const SECTIONS = ['动机', '引用规范', '决策', '任务'] +const MISSING = '(brief 缺少该节)' + +function splitSections(body) { + const map = new Map() + let cur = null + for (const line of body.replaceAll('\r\n', '\n').split('\n')) { + const h = /^## (.+?)\s*$/.exec(line) + if (h) { cur = h[1].trim(); map.set(cur, []) } else if (cur) map.get(cur).push(line) + } + for (const [k, v] of map) map.set(k, v.join('\n').trim()) + return map +} + +export function renderIssueBody(b, { titleRaw = null, supplement = '' } = {}) { + const d = b.data + const found = splitSections(b.body) + const raw = titleRaw || (b.body.replaceAll('\r\n', '\n').match(/^# (.+)$/m) || [])[1]?.trim() || d.name + const prefix = `[${d.type}] ` + const title = raw.startsWith(prefix) ? raw : prefix + raw + const briefPath = `shadow-docs/changes/${d.name}/brief.md` + const parts = SECTIONS.map(s => `## ${s}\n${found.get(s) || MISSING}`) + if (supplement && supplement.trim()) parts.push(`## 补充\n${supplement.trim()}`) + parts.push(`完整 brief:${briefPath}`) + const meta = { + name: d.name, type: d.type, scope: d.scope ?? null, status: d.status, + branch: d.branch ?? null, baseBranch: d.baseBranch ?? null, briefPath, cliVersion: CLI_VERSION, + prUrl: d.github?.pullRequestUrl ?? null, issueNumber: d.github?.issue ?? null, + } + const body = [...parts, ``].join('\n\n') + '\n' + return { + title, body, + sections: SECTIONS.map(s => (found.get(s) ? s : `-${s}`)), + bodyBytes: Buffer.byteLength(body), + bodySha256: createHash('sha256').update(body, 'utf8').digest('hex'), + } +} diff --git a/package.json b/package.json index 6eeac16..b4f137c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "shadow-dev-cli", - "version": "1.1.0", + "version": "1.2.0", "description": "Deterministic scaffolding CLI for the Shadow dev workflow: brief lifecycle, plan/execute with plan hashes, git and GitHub operations.", "type": "module", "engines": { diff --git a/shadow-docs/changes/20260917-feature-unified-issue-structure/brief.md b/shadow-docs/changes/20260917-feature-unified-issue-structure/brief.md new file mode 100644 index 0000000..ea93546 --- /dev/null +++ b/shadow-docs/changes/20260917-feature-unified-issue-structure/brief.md @@ -0,0 +1,100 @@ +--- +{ + "schema": "shadow-dev/v1", + "name": "20260917-feature-unified-issue-structure", + "type": "feature", + "scope": "lib/domains/issue.mjs,lib/issue-render.mjs,lib/commands.mjs", + "status": "reviewed", + "baseBranch": "main", + "branch": "feature/20260917-feature-unified-issue-structure", + "files": [ + "README.md", + "cli.mjs", + "lib/commands.mjs", + "lib/domains/issue.mjs", + "lib/i18n.mjs", + "lib/issue-render.mjs", + "test/cli.test.mjs" + ], + "github": { + "repository": "stack-wuh/shadow-dev-cli", + "issue": 19, + "issueUrl": "https://github.com/stack-wuh/shadow-dev-cli/issues/19", + "pullRequest": null, + "pullRequestUrl": null + }, + "review": { + "conclusion": "passed", + "verifiedCommit": "43e37ba27823f535a8087e0dddc9bc6ab73082c7", + "verifiedAt": "2026-09-17T13:29:34.011Z" + }, + "workflow": { + "operation": null, + "checkpoint": "issue:19", + "planHash": "04b25e73d2a3a5c21d96f3ee6f069833e8678421eb3ead957f9d558d499c6702", + "updatedAt": null, + "lastError": null, + "issuePlan": { + "title": "[feature] issue 正文统一结构:brief 确定性生成 + 机器 metadata 通道", + "titleRaw": null, + "supplement": "", + "body": "## 动机\n当前 `issue plan --body` 接受自由文本,不给则空 body,CLI 创建的 issue 结构完全随性。参照仓库 stack-wuh/x.wuh.site(GitHub Issues 即 CMS)以三层机制统一结构:YAML issue forms(人创建)、工作流分节骨架(动机/决策/任务/验收)、正文底部 `` 机器通道。但其工作流层靠人为自觉,已出现「决策/方案」分节名漂移(#381 vs #388)。shadow-dev 的 brief 本身就是结构化数据(frontmatter + 分节正文 + 任务复选框),可以把 issue 正文升级为「人读分节骨架 + 机器可读 metadata 注释块」双通道,并用 plan 快照机械锁定统一性。\n\n## 引用规范\n- shadow-docs/knowledge/plan-credential-chain.md\n - 当前结论: planHash = sha256(canon({command, data: norm(planData)}));norm 剥离 workflow.issuePlan;带 brief 的域以持久化 planHash 为凭证\n - 适用 scope: lib/domains/* 的 plan/execute\n- shadow-docs/knowledge/cli-output-contract.md\n - 当前结论: stdout JSON 契约不变;nextStep 为稳定英文模板;stderr 人用层\n - 适用 scope: 全 CLI help/stderr/nextStep\n\n## 决策\n- **选型:** 分节直通渲染。新增纯函数 `lib/issue-render.mjs`:解析 brief 正文分节,白名单搬运 `## 动机 / ## 引用规范 / ## 决策 / ## 任务`(剔除 `## 结果 / ## 知识评估`),可选 `## 补充`(--body),尾部完整 brief 指针行,正文底部 `` JSON 注释块(name/type/scope/status/branch/baseBranch/briefPath/cliVersion,预留 prUrl/issueNumber 空值字段)。标题自动补 `[type] ` 前缀(已存在同类前缀则幂等不重复)。\n- **理由:** plan 时渲染并快照进 workflow.issuePlan,execute 只 POST 快照、绝不二次渲染——plan→execute 间隔 brief 被改动不撕裂一致性,刷新只需重跑 plan;norm 已剥离 issuePlan,凭证链零改动。缺白名单分节时生成占位提示「(brief 缺少该节)」,保证骨架恒定形状。\n- **对比方案:** B 仅字段渲染(只用 frontmatter+复选框)——动机/决策内容丢失,issue 成空壳,否;C 活镜像(状态变化 PATCH 更新 issue)——需 update 通道、凭证边界复杂化、归档真相已在 brief,YAGNI,否(仅在 metadata 预留字段留接口)。\n- **入参契约:** --title 选填覆盖(缺省 = [type] 前缀 + brief 首行 H1,无 H1 回落 change name);--body 语义从「整段正文」改为「## 补充 节内容」;commands.mjs 入参描述与 nextStep 模板同步更新。\n- **零 LLM 生成:** renderIssueBody 是纯字符串拼接——直接搬运 brief 中已写好的分节文本,不做任何摘要、改写或 AI 推导步骤。正文的唯一来源是 brief 本身。\n- **token 契约(只留摘要,stdout 最小投影):** 现状 issue.plan stdout ~4.3KB 中,body 出现两份(data.body + nextStep 内嵌全文),且 data 还整段回显 brief/repo(~1.2KB 纯噪音)。改造后 `issue plan` stdout `data` 投影为最小摘要:`{name, title, labels, repository, bodyBytes, bodySha256, sections}`——`bodySha256` 即 execute 所 POST 快照的哈希(「预览即提交」由哈希承担),`sections` 为白名单分节命中清单(缺失节以 `-节名` 标记)。nextStep 收敛为 `issue execute --name {name} --plan-hash {hash} --confirm`。实现走域级输出投影钩子(planDomain 支持 `mod.present?.(x)`,默认恒等,不破坏其他域契约)。全文唯一存放处 = brief `workflow.issuePlan.body`(execute 提交源;需完整预览时直接读 brief)。\n- **非目标:** 不回填历史 issue;不改动 x.wuh.site 侧;不引入 issue update/PATCH;其他 plan 域(commit/publish/release 等)的 brief/repo 回显暂不裁剪(投影钩子已就位,可作后续独立变更)。\n\n## 任务\n### Phase 1 渲染器\n- [x] 新增 renderIssueBody(b, extra):分节解析 + 白名单搬运 + 缺节占位 + [type] 前缀幂等 + 底部 metadata JSON — `lib/issue-render.mjs`\n- [x] 渲染器确定性单测:同输入同输出、缺节占位、前缀幂等、--body 追加节、metadata 字段齐全 — `test/cli.test.mjs`\n### Phase 2 域接线\n- [x] planData 调渲染器:快照(title/body/labels)写 issuePlan;导出 present(x) 最小投影(去 body/brief/repo,加 bodyBytes/bodySha256/sections);execute 只读快照 POST(planHash 校验已绑定快照内容) — `lib/domains/issue.mjs`\n- [x] planDomain 支持域级输出投影钩子 `mod.present?.(x)`,默认恒等,其余域零变化 — `cli.mjs`\n- [x] issue.plan/issue.execute 入参描述与 nextStep 模板更新:nextStep 去除 body 回显,收敛为 --name --plan-hash --confirm — `lib/commands.mjs`\n- [x] ISSUE_TITLE_REQUIRED 提示措辞更新(仅 title 与 H1 双缺时触发) — `lib/i18n.mjs`\n- [x] 端到端契约测试:apiStub 收到的 body 含分节骨架与 metadata 块且 sha 与 plan 摘要一致;plan stdout 不含 body 全文与 brief/repo 回显;其他域投影零变化;plan 重跑刷新快照 — `test/cli.test.mjs`\n### Phase 3 文档\n- [x] README 增加 issue 结构契约节(双通道说明 + metadata 字段表 + 示例) — `README.md`\n\n完整 brief:shadow-docs/changes/20260917-feature-unified-issue-structure/brief.md\n\n\n", + "labels": [ + "feature" + ] + } + }, + "knowledge": { + "action": "新增", + "target": "shadow-docs/knowledge/issue-body-contract.md", + "reason": "issue 正文成为 CLI 与外部消费者(插件、x.wuh.site 类站点)间的稳定数据契约:分节骨架+metadata 机器通道+stdout 最小投影;ship 时同步更新 cli-output-contract.md 的 present 钩子与 token 契约段" + } +} +--- + +# issue 正文统一结构:brief 确定性生成 + 机器 metadata 通道 + +## 动机 +当前 `issue plan --body` 接受自由文本,不给则空 body,CLI 创建的 issue 结构完全随性。参照仓库 stack-wuh/x.wuh.site(GitHub Issues 即 CMS)以三层机制统一结构:YAML issue forms(人创建)、工作流分节骨架(动机/决策/任务/验收)、正文底部 `` 机器通道。但其工作流层靠人为自觉,已出现「决策/方案」分节名漂移(#381 vs #388)。shadow-dev 的 brief 本身就是结构化数据(frontmatter + 分节正文 + 任务复选框),可以把 issue 正文升级为「人读分节骨架 + 机器可读 metadata 注释块」双通道,并用 plan 快照机械锁定统一性。 + +## 引用规范 +- shadow-docs/knowledge/plan-credential-chain.md + - 当前结论: planHash = sha256(canon({command, data: norm(planData)}));norm 剥离 workflow.issuePlan;带 brief 的域以持久化 planHash 为凭证 + - 适用 scope: lib/domains/* 的 plan/execute +- shadow-docs/knowledge/cli-output-contract.md + - 当前结论: stdout JSON 契约不变;nextStep 为稳定英文模板;stderr 人用层 + - 适用 scope: 全 CLI help/stderr/nextStep + +## 决策 +- **选型:** 分节直通渲染。新增纯函数 `lib/issue-render.mjs`:解析 brief 正文分节,白名单搬运 `## 动机 / ## 引用规范 / ## 决策 / ## 任务`(剔除 `## 结果 / ## 知识评估`),可选 `## 补充`(--body),尾部完整 brief 指针行,正文底部 `` JSON 注释块(name/type/scope/status/branch/baseBranch/briefPath/cliVersion,预留 prUrl/issueNumber 空值字段)。标题自动补 `[type] ` 前缀(已存在同类前缀则幂等不重复)。 +- **理由:** plan 时渲染并快照进 workflow.issuePlan,execute 只 POST 快照、绝不二次渲染——plan→execute 间隔 brief 被改动不撕裂一致性,刷新只需重跑 plan;norm 已剥离 issuePlan,凭证链零改动。缺白名单分节时生成占位提示「(brief 缺少该节)」,保证骨架恒定形状。 +- **对比方案:** B 仅字段渲染(只用 frontmatter+复选框)——动机/决策内容丢失,issue 成空壳,否;C 活镜像(状态变化 PATCH 更新 issue)——需 update 通道、凭证边界复杂化、归档真相已在 brief,YAGNI,否(仅在 metadata 预留字段留接口)。 +- **入参契约:** --title 选填覆盖(缺省 = [type] 前缀 + brief 首行 H1,无 H1 回落 change name);--body 语义从「整段正文」改为「## 补充 节内容」;commands.mjs 入参描述与 nextStep 模板同步更新。 +- **零 LLM 生成:** renderIssueBody 是纯字符串拼接——直接搬运 brief 中已写好的分节文本,不做任何摘要、改写或 AI 推导步骤。正文的唯一来源是 brief 本身。 +- **token 契约(只留摘要,stdout 最小投影):** 现状 issue.plan stdout ~4.3KB 中,body 出现两份(data.body + nextStep 内嵌全文),且 data 还整段回显 brief/repo(~1.2KB 纯噪音)。改造后 `issue plan` stdout `data` 投影为最小摘要:`{name, title, labels, repository, bodyBytes, bodySha256, sections}`——`bodySha256` 即 execute 所 POST 快照的哈希(「预览即提交」由哈希承担),`sections` 为白名单分节命中清单(缺失节以 `-节名` 标记)。nextStep 收敛为 `issue execute --name {name} --plan-hash {hash} --confirm`。实现走域级输出投影钩子(planDomain 支持 `mod.present?.(x)`,默认恒等,不破坏其他域契约)。全文唯一存放处 = brief `workflow.issuePlan.body`(execute 提交源;需完整预览时直接读 brief)。 +- **非目标:** 不回填历史 issue;不改动 x.wuh.site 侧;不引入 issue update/PATCH;其他 plan 域(commit/publish/release 等)的 brief/repo 回显暂不裁剪(投影钩子已就位,可作后续独立变更)。 + +## 任务 +### Phase 1 渲染器 +- [x] 新增 renderIssueBody(b, extra):分节解析 + 白名单搬运 + 缺节占位 + [type] 前缀幂等 + 底部 metadata JSON — `lib/issue-render.mjs` +- [x] 渲染器确定性单测:同输入同输出、缺节占位、前缀幂等、--body 追加节、metadata 字段齐全 — `test/cli.test.mjs` +### Phase 2 域接线 +- [x] planData 调渲染器:快照(title/body/labels)写 issuePlan;导出 present(x) 最小投影(去 body/brief/repo,加 bodyBytes/bodySha256/sections);execute 只读快照 POST(planHash 校验已绑定快照内容) — `lib/domains/issue.mjs` +- [x] planDomain 支持域级输出投影钩子 `mod.present?.(x)`,默认恒等,其余域零变化 — `cli.mjs` +- [x] issue.plan/issue.execute 入参描述与 nextStep 模板更新:nextStep 去除 body 回显,收敛为 --name --plan-hash --confirm — `lib/commands.mjs` +- [x] ISSUE_TITLE_REQUIRED 提示措辞更新(仅 title 与 H1 双缺时触发) — `lib/i18n.mjs` +- [x] 端到端契约测试:apiStub 收到的 body 含分节骨架与 metadata 块且 sha 与 plan 摘要一致;plan stdout 不含 body 全文与 brief/repo 回显;其他域投影零变化;plan 重跑刷新快照 — `test/cli.test.mjs` +### Phase 3 文档 +- [x] README 增加 issue 结构契约节(双通道说明 + metadata 字段表 + 示例) — `README.md` + +## 结果 +- 实际耗时: — +- 验证: — + +## 知识评估 +- **预期影响:** 新增 + 更新 +- **候选卡片:** 新增 knowledge/issue-body-contract.md(issue 正文双通道契约,入 menu 路由);更新 cli-output-contract.md 的入参/nextStep 段 +- **理由:** issue 正文成为 CLI 与外部消费者(插件、x.wuh.site 类站点)间的稳定数据契约,值得独立成卡;输出面契约同步受影响 + +## 协调注意 +当前工作区存在并行会话 20260917-feature-change-list-archived(未提交,dirty 文件与本变更声明的 README.md/lib/commands.mjs/test/cli.test.mjs 重叠)。apply 前须等其合入或改用独立 worktree,commit 时只 add 本变更文件。 diff --git a/shadow-docs/knowledge/cli-output-contract.md b/shadow-docs/knowledge/cli-output-contract.md index 9175a6b..280cd91 100644 --- a/shadow-docs/knowledge/cli-output-contract.md +++ b/shadow-docs/knowledge/cli-output-contract.md @@ -10,7 +10,8 @@ source: - changes/20260917-feature-tty-human-default/brief.md - changes/20260917-fix-missing-arg-hints/brief.md - changes/20260917-feature-change-list-archived/brief.md -verified: 2026-09-17 + - changes/20260917-feature-unified-issue-structure/brief.md +verified: 2026-09-18 --- # CLI 双通道输出契约 @@ -27,6 +28,7 @@ verified: 2026-09-17 - 抑制 stdout 的分支必须仍然设置退出码;plan 的人用收场行必须透出 `planHash`(PTY 环境下的 agent 兜底)。`--json` 是跨环境逃生门,不得复用为其他语义。 - 错误 code 与 `data.nextStep` 模板永不本地化;语言链固定为 `--lang` > `SHADOW_DEV_LANG` > locale 探测 > 默认 zh,且只影响 stderr 文案。 - `nextStep` 为 additive 字段,写入发生在 planHash 持久化与计算之后,不得参与 hash 输入。 +- plan 域可导出 `present(x)` 声明 stdout data 的**最小投影**(`cli.mjs planDomain` 应用,缺省恒等):重字段(全文正文、整段 brief/repo 回显)不进机器契约,语义输入仍在完整 planData 内参与 planHash。首个用户是 `issue plan`(摘要三件套 bodyBytes/bodySha256/sections,见 [issue 正文双通道结构契约](issue-body-contract.md));新域裁剪须有 token 依据并测试钉住。 - help 的 `data.help` 恒为字符串(概览默认唯一字段,最小面 <1KB);结构化目录 `data.commands` 经 `--full` opt-in;`help <命令>` 组详情恒定返回该组 `commands`。 - 语言不变性由契约测试保护(同命令 zh/en stdout 逐字节一致),触碰输出面的变更必须保持其绿色。 diff --git a/shadow-docs/knowledge/issue-body-contract.md b/shadow-docs/knowledge/issue-body-contract.md new file mode 100644 index 0000000..cc1e3b2 --- /dev/null +++ b/shadow-docs/knowledge/issue-body-contract.md @@ -0,0 +1,38 @@ +--- +title: issue 正文双通道结构契约 +domain: github-integration +keywords: [issue, 正文, 渲染器, issue-render, issuePlan, metadata, 机器通道, bodySha256, sections, 前缀, 最小投影, token] +scope: [lib/issue-render.mjs, lib/domains/issue.mjs, test/cli.test.mjs] +status: active +source: + - changes/20260917-feature-unified-issue-structure/brief.md +verified: 2026-09-18 +--- + +# issue 正文双通道结构契约 + +## 当前结论 + +`issue plan/execute` 的 GitHub issue 正文**不是自由文本**,而是由 `lib/issue-render.mjs` 从 brief **确定性渲染**(纯字符串拼接,零 LLM 推导):人读分节骨架 + 机器 metadata 注释块双通道。骨架 = 白名单分节 `## 动机 / ## 引用规范 / ## 决策 / ## 任务`(缺节以 `(brief 缺少该节)` 占位;`结果/知识评估` 等内部节不进 issue)+ 可选 `## 补充`(`--body` 新语义)+ `完整 brief:` 指针行 + 末行 `` 单行 JSON(name/type/scope/status/branch/baseBranch/briefPath/cliVersion/prUrl/issueNumber)。标题自动补 `[type] ` 前缀,已带同类前缀幂等。 + +`issue plan` stdout 经域级 `present()` 投影只回摘要 `{name,title,labels,repository,bodyBytes,bodySha256,sections}`(实测 ~0.6KB,对比旧契约 7.5KB);`sections` 中缺失节带 `-` 前缀标记。全文唯一存放处 = brief `workflow.issuePlan.body`。 + +## 执行约束 + +- 正文内容只能来自 brief 现有分节的机械搬运,渲染器**禁止**引入摘要/改写/AI 推导或本地化分支(stdout 与语言、TTY 无关)。 +- 「预览即提交」由 `bodySha256` 承担:渲染结果(title/body/labels 及 raw 输入)参与 planHash——plan 后 brief 正文任何变动都会令 execute 报 `PLAN_HASH_INVALID`,**刷新 = 重跑 plan**,不得为绕过漂移引入陈旧快照 POST 或 PATCH 更新通道。 +- 机器消费方解析唯一入口 = 正则提取末行 `` JSON;新增字段向后兼容(只加不减),`prUrl`/`issueNumber` 为将来 update 通道预留。 +- 其他域不得滥用 stdout 投影:`present(x)` 只用于重字段(全文/整段 brief/repo 快照)瘦身,语义输入必须仍在完整 planData 内参与哈希。 + +## 适用边界 + +适用于 `issue plan/execute` 全链路及一切依赖 issue 正文结构的下游(shadow-dev-workflow 插件、x.wuh.site 类 issue-as-data 站点)。不适用于 `publish`(PR body 仍为自由文本/`--body` 全文语义)与 `release`——它们的入参投影与非目标声明见 brief。历史 issue(#1~#19)不回填。 + +## 验证方式 + +`node test/cli.test.mjs` 中三条契约用例仍绿即成立:`issue renderer: deterministic skeleton...`(确定性/占位/前缀幂等/字段表)、`issue plan projects a lean summary; execute posts the rendered skeleton bound to bodySha256`(投影无重字段 + POST 正文 sha 与摘要一致)、`issue plan drifts when the brief body changes; re-plan refreshes the render`(漂移→PLAN_HASH_INVALID)。手工复验:`shadow-dev issue plan --name <变更> --json` stdout <1KB 且 `sections` 无 `-` 前缀项。 + +## 关联知识 + +- [CLI 双通道输出契约](cli-output-contract.md) +- [plan/execute 凭证链](plan-credential-chain.md) diff --git a/shadow-docs/menu.md b/shadow-docs/menu.md index b63d16b..3498b1b 100644 --- a/shadow-docs/menu.md +++ b/shadow-docs/menu.md @@ -10,3 +10,4 @@ | CLI 输出面 | stdout stderr JSON 契约 语言 i18n 本地化 nextStep help 提示 QUIET --full 概览 摘要 减噪 TTY --json SHADOW_DEV_JSON | knowledge/cli-output-contract.md | | plan/execute 凭证 | planHash hash 漂移 norm changedFiles 脏工作区 porcelain trim 凭证链 | knowledge/plan-credential-chain.md | | 安装与分发 | install 安装器 shim 指针文件 LINK CURRENT 双轨 回滚 插件钩子 tarball Git Bash PowerShell | knowledge/install-distribution.md | +| issue 正文结构 | issue 正文 渲染器 issue-render issuePlan metadata 机器通道 bodySha256 sections 前缀 最小投影 统一结构 | knowledge/issue-body-contract.md | diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 96abc74..b20d780 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' import { execFileSync, spawn, spawnSync } from 'node:child_process' import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -445,9 +446,82 @@ test('issue plan is stable and includes GitHub payload', () => { const second = run(args, root) assert.equal(first.status, 0, first.stderr) assert.equal(JSON.parse(first.stdout).planHash, JSON.parse(second.stdout).planHash) - assert.equal(JSON.parse(first.stdout).data.title, 'Feature') + assert.equal(JSON.parse(first.stdout).data.title, '[feat] Feature') assert.equal(JSON.parse(first.stdout).data.repository, 'owner/repo') }) + +const sha256hex = s => createHash('sha256').update(s, 'utf8').digest('hex') + +test('issue renderer: deterministic skeleton, [type] prefix, supplement order and metadata channel', async () => { + const { renderIssueBody } = await import('../lib/issue-render.mjs') + const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) + const b = { + data: { name: 'n1', type: 'feature', scope: 's1', status: 'proposed', baseBranch: 'main', branch: null }, + body: '# 标题一\r\n\r\n## 动机\r\nwhy\r\n\r\n## 任务\n- [ ] t1\n\n## 结果\ninternal only\n\n## 协调注意\ninternal only 2\n', + } + const first = renderIssueBody(b, {}) + assert.deepEqual(renderIssueBody(b, {}), first, 'same input must render byte-identical output') + assert.equal(first.title, '[feature] 标题一') + assert.deepEqual(first.sections, ['动机', '-引用规范', '-决策', '任务']) + assert.ok(first.body.includes('## 动机\nwhy'), 'CRLF source must be normalized into the skeleton') + assert.ok(first.body.includes('(brief 缺少该节)'), 'missing whitelist section gets placeholder') + assert.ok(!first.body.includes('internal only'), 'non-whitelist sections are dropped') + const meta = JSON.parse(first.body.match(/^$/m)[1]) + assert.deepEqual(meta, { name: 'n1', type: 'feature', scope: 's1', status: 'proposed', branch: null, baseBranch: 'main', briefPath: 'shadow-docs/changes/n1/brief.md', cliVersion: version, prUrl: null, issueNumber: null }) + assert.match(first.body.trimEnd(), /-->$/, 'metadata comment is the last line') + assert.equal(renderIssueBody(b, { titleRaw: '[feature] 标题一' }).title, '[feature] 标题一', 'prefix is idempotent') + const s = renderIssueBody(b, { titleRaw: 'custom', supplement: 'extra note' }) + assert.equal(s.title, '[feature] custom') + assert.ok(s.body.includes('## 补充\nextra note')) + assert.ok(s.body.indexOf('## 补充') < s.body.indexOf('完整 brief:'), 'supplement sits before the brief pointer') +}) + +test('issue plan projects a lean summary; execute posts the rendered skeleton bound to bodySha256', () => { + const root = fixture() + execFileSync('git', ['remote', 'add', 'origin', 'git@github.com:owner/repo.git'], { cwd: root }) + const api = apiStub([{ method: 'POST', path: '/repos/owner/repo/issues', body: { number: 7, html_url: 'https://github.test/issues/7' } }]) + try { + const planned = run(['issue', 'plan', '--name', 'sample', '--labels', 'bug', '--json'], root) + assert.equal(planned.status, 0, planned.stderr) + const v = JSON.parse(planned.stdout) + const d = v.data + for (const heavy of ['body', 'brief', 'repo', 'titleRaw', 'supplement']) assert.ok(!(heavy in d), `projection must strip ${heavy}`) + assert.match(d.nextStep, /^issue execute --name sample --plan-hash [0-9a-f]{64} --confirm$/) + assert.equal(d.title, '[feat] Sample') + assert.deepEqual(d.sections, ['-动机', '-引用规范', '-决策', '任务']) + assert.equal(d.bodyBytes, d.bodyBytes | 0) + const result = run(['issue', 'execute', '--name', 'sample', '--confirm', '--json'], root, { GITHUB_TOKEN: 'token', SHADOW_GITHUB_API_URL: api.url }) + assert.equal(result.status, 0, result.stderr) + const post = JSON.parse(api.requests()[0].body) + assert.equal(post.title, '[feat] Sample') + assert.deepEqual(post.labels, ['bug']) + assert.match(post.body, /^## 动机\n(brief 缺少该节)/) + assert.ok(post.body.includes('shadow-dev:issue-metadata')) + assert.equal(sha256hex(post.body), d.bodySha256, 'POSTed body must match the previewed hash') + assert.equal(Buffer.byteLength(post.body), d.bodyBytes) + const persisted = readFileSync(join(root, 'shadow-docs', 'changes', 'sample', 'brief.md'), 'utf8') + assert.ok(persisted.includes('issuePlan'), 'full body persists in the brief snapshot') + } finally { api.close() } +}) + +test('issue plan drifts when the brief body changes; re-plan refreshes the render', () => { + const root = fixture() + execFileSync('git', ['remote', 'add', 'origin', 'git@github.com:owner/repo.git'], { cwd: root }) + const planned = run(['issue', 'plan', '--name', 'sample', '--json'], root) + assert.equal(planned.status, 0, planned.stderr) + const v1 = JSON.parse(planned.stdout) + const path = join(root, 'shadow-docs', 'changes', 'sample', 'brief.md') + writeFileSync(path, readFileSync(path, 'utf8') + '\n## 动机\n补上的动机\n') + const failed = run(['issue', 'execute', '--name', 'sample', '--confirm', '--json'], root, { GITHUB_TOKEN: 'token', SHADOW_GITHUB_API_URL: 'http://127.0.0.1:1' }) + assert.equal(failed.status, 1) + assert.equal(JSON.parse(failed.stdout).error.code, 'PLAN_HASH_INVALID') + const refreshed = run(['issue', 'plan', '--name', 'sample', '--json'], root) + const v2 = JSON.parse(refreshed.stdout) + assert.notEqual(v2.planHash, v1.planHash, 're-plan must produce a new credential') + assert.notEqual(v2.data.bodySha256, v1.data.bodySha256) + assert.ok(v2.data.sections.includes('动机')) + assert.ok(v2.data.sections[0] === '动机') +}) test('unsupported explicit add forms return code 4', () => { const root = fixture() const planned = run(['commit', 'plan', '--name', 'sample', '--files', '.', '--message', 'bad', '--json'], root)