Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 文件重叠 |
Expand All @@ -36,6 +36,17 @@ CLI 在 stderr 渲染一层人类提示:进场横幅(命令+参数)、收
- 缺必填参数报错时,stderr 逐行列出该命令在命令目录中的完整参数描述(`flag * 说明`,含示例值与来源位置),示例行的占位符与目录一致(如 `--name <change-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/<name>/brief.md` 指针行。
3. 正文末行 `<!-- shadow-dev:issue-metadata {...} -->` 机器通道(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 比对对齐。
Expand Down Expand Up @@ -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-<ver>/` + `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-<ver>/` + `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 下运行。
Expand All @@ -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` 是唯一契约规格。
6 changes: 4 additions & 2 deletions cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions lib/commands.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <change-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 <change-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 <change-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 <change-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 <change-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 <change-name> --confirm', null),
'branch.plan': c('branch plan', '预览建功能分支', 'plan creating the feature branch', [N], 'shadow-dev branch plan --name <change-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 <change-name> --confirm', null),
'sync.plan': c('sync plan', '预览 fast-forward 同步上游', 'plan a fast-forward sync with upstream', [N], 'shadow-dev sync plan --name <change-name>', 'sync execute --name {name} --confirm'),
Expand Down
23 changes: 20 additions & 3 deletions lib/domains/issue.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion lib/i18n.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
44 changes: 44 additions & 0 deletions lib/issue-render.mjs
Original file line number Diff line number Diff line change
@@ -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, `<!-- shadow-dev:issue-metadata ${JSON.stringify(meta)} -->`].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'),
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
Loading
Loading