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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# shadow-dev-cli

Shadow dev workflow 的确定性脚手架 CLI。所有命令走 plan → execute 两段式:`plan` 输出 planHash 并持久化进 brief,`execute` 必须携带确认与匹配的 planHash 才会落盘或调用外部系统,杜绝不可复现的隐式变更。
Shadow dev workflow 的确定性脚手架 CLI。所有命令走 plan → execute 两段式:`plan` 输出 planHash 并持久化进 brief,`execute` 必须携带确认与匹配的 planHash 才会落盘或调用外部系统,杜绝不可复现的隐式变更。planHash 覆盖命令的语义输入,但剥离 plan 自身的副作用(写回 brief 的凭证字段、易变 worktree 快照 `changedFiles`/`clean`)——干净树上 plan→execute 同样可复现。

纯 Node.js(>=20)、零 npm 依赖、单命令入口 `shadow-dev`。

Expand Down Expand Up @@ -64,7 +64,7 @@ stdout 的 JSON 契约之外,CLI 在 stderr 渲染一层人类提示:进场
## 开发

```bash
npm test # node --test,45 个契约测试覆盖全部命令域与 stderr 人用层
npm test # node --test,47 个契约测试覆盖全部命令域、stderr 人用层与凭证链
```

行为契约:命令、JSON 输出结构、错误码、planHash 机制保持稳定;`test/cli.test.mjs` 是唯一契约规格。
3 changes: 2 additions & 1 deletion lib/git.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { execFileSync } from 'node:child_process'
import { err } from './errors.mjs'

// 只剥尾部换行:porcelain 状态行以空格开头(如 " M path"),全局 trim 会削掉首行前导空格
export function git(r, a, o = {}) {
return execFileSync('git', a, { cwd: r, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...o }).trim()
return execFileSync('git', a, { cwd: r, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...o }).trimEnd()
}

export function root() {
Expand Down
22 changes: 16 additions & 6 deletions lib/plan.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,23 @@ export function canon(v) {

export function hash(v) { return createHash('sha256').update(canon(v)).digest('hex') }

// plan 对比前剥离 plan 产物本身,保证 plan 与 execute 重算结果一致
// plan 对比前剥离 plan 产物本身,保证 plan 与 execute 重算结果一致。
// 剥离边界=「plan 自身的副作用」:workflow 凭证字段,以及 repo 的易变 worktree 快照
// (changedFiles/clean 会因 plan 写回 brief 而变化;dirty 行为检查仍由 sync 等命令域在 planData 内联执行,安全语义不丢)
export function norm(d) {
if (!d || typeof d !== 'object' || !d.brief || !d.brief.workflow) return d
const v = { ...d, brief: { ...d.brief, workflow: { ...d.brief.workflow } } }
delete v.brief.workflow.planHash
delete v.brief.workflow.release
delete v.brief.workflow.issuePlan
if (!d || typeof d !== 'object') return d
let v = d
if (d.brief && d.brief.workflow) {
v = { ...v, brief: { ...d.brief, workflow: { ...d.brief.workflow } } }
delete v.brief.workflow.planHash
delete v.brief.workflow.release
delete v.brief.workflow.issuePlan
}
if (d.repo && (d.repo.changedFiles !== undefined || d.repo.clean !== undefined)) {
v = { ...v, repo: { ...v.repo } }
delete v.repo.changedFiles
delete v.repo.clean
}
return v
}

Expand Down
97 changes: 97 additions & 0 deletions shadow-docs/changes/20260917-fix-plan-credential-chain/brief.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
---
{
"schema": "shadow-dev/v1",
"name": "20260917-fix-plan-credential-chain",
"type": "fix",
"scope": "lib",
"status": "branched",
"baseBranch": "main",
"branch": "fix/20260917-fix-plan-credential-chain",
"files": [
"README.md",
"lib/git.mjs",
"lib/plan.mjs",
"shadow-docs/knowledge/plan-credential-chain.md",
"shadow-docs/menu.md",
"test/cli.test.mjs"
],
"github": {
"repository": "stack-wuh/shadow-dev-cli",
"issue": 5,
"issueUrl": "https://github.com/stack-wuh/shadow-dev-cli/issues/5",
"pullRequest": null,
"pullRequestUrl": null
},
"review": {
"conclusion": "pending",
"verifiedCommit": null,
"verifiedAt": null
},
"workflow": {
"operation": null,
"checkpoint": "issue:5",
"planHash": "a92fc185a61148c301916c72c6501c29fa085e9124e4ab4feaf8b1a181a0108e",
"updatedAt": null,
"lastError": null,
"issuePlan": {
"title": "plan 凭证链修复:execute hash 漂移与 porcelain 首行截字",
"body": "两个 dogfood 实锤缺陷:① plan 回写自脏 worktree,干净树上 plan→execute 必然 PLAN_HASH_INVALID;② git() 全局 trim() 削掉 porcelain 首行前导空格,changedFiles 首条路径截字并污染 DIRTY_WORKTREE 判断。修复:norm() 剥离 repo.changedFiles/clean;git() 改 trimEnd()。详见 shadow-docs/changes/20260917-fix-plan-credential-chain/brief.md",
"labels": [
"fix"
]
}
}
}
---

# plan 凭证链修复:execute hash 漂移与 porcelain 首行截字

## 动机

今天两个变更在真实 dogfood 中各踩中一个存量缺陷,均已定位根因并有现场复现路径:

1. **plan 自脏漂移**:`planDomain` 把 planHash 写回 brief 后 worktree 必然变脏;若 plan 之前树是干净的,`executeDomain` 重算的 planData 里 `repo.changedFiles` 从 `[]` 变成 `[brief]`,hash 必然不匹配 → 干净树上 plan→execute 永远 `PLAN_HASH_INVALID`(建 feature 分支时实测踩中,重跑 plan 才绕过)。
2. **porcelain 首行截字**:`git()` 对全部输出做 `.trim()`,porcelain 行 ` M path` 以空格开头,首行前导空格被削掉后 `slice(3)` 吃掉路径首字符(release 阶段实测:`shadow-docs` → `hadow-docs`),污染 `changedFiles`,进而使 sync 的 `DIRTY_WORKTREE` 判断和 conflict 比对失真。

## 引用规范

- norms/code-style.md(通用规范)
- 当前结论: 渐进式治理,一次变更只做直接相关的事;修改旧代码只修与当前改动直接相关的问题。
- 适用 scope: `lib/plan.mjs`、`lib/git.mjs`
- shadow-docs/knowledge/cli-output-contract.md
- 当前结论: stdout 恒为单行 JSON 契约,语言/环境无关;stderr 人用层与之隔离。
- 适用 scope: 本变更不改输出通道结构,但新增的 norm() 剥离规则必须保持 planHash 语言无关——由存量逐字节测试保护。

## 决策

- **选型:** ① `lib/plan.mjs` 的 `norm()` 在哈希前剥离 `repo.changedFiles` 与 `repo.clean`(易变 worktree 快照,本就是 plan 自身写入的副作用源);`repo.head`/`branch`/`root` 保留参与哈希。DIRTY_WORKTREE 行为检查保留在 `sync.planData` 内联执行(plan 与 execute 都会跑,安全语义不丢)。② `lib/git.mjs` 的 `git()` 由 `.trim()` 改 `.trimEnd()`:只剥尾部换行,首行前导空格不再丢失,rev-parse 等其余消费方无影响(它们的输出无前导空白)。
- **对比方案:** ①的另一路线是让 changedFiles 不出现在各域 planData(分支/审查/提交等域的 plan JSON 会丢失 repo 可见性,agent 消费退化),或把 planHash 挪出 brief(推翻已归档的凭证设计),均否。②的另一路线是 repo() 内特判首行补空格(在错误的层面打补丁,掩盖 git() 的有损 trim),否。
- **理由:** norm() 的既有职责就是"剥离 plan 产物自身造成的漂移源"(先例:workflow.planHash),本次是同构扩展;两处修复均为最小根因修复,不触任何外部契约。

## 任务

### Phase 1 — 失败测试(TDD)

- [x] 契约测试:干净树(shadow-docs 已提交)上 `branch plan` 后立即 `branch execute` 应成功 —— `test/cli.test.mjs`
- [x] 契约测试:仅跟踪文件被修改时 `repo inspect` 的 `changedFiles[0]` 路径完整(首行 ` M` 状态不再截字) —— `test/cli.test.mjs`

### Phase 2 — 根因修复

- [x] `norm()` 剥离 `repo.changedFiles`/`repo.clean`,补注释说明剥离边界 —— `lib/plan.mjs`
- [x] `git()` 改 `trimEnd()`,注释标明 porcelain 前导空格约束 —— `lib/git.mjs`

### Phase 3 — 回归与知识

- [x] 全量回归;README 环境变量/行为无需变更则不动,`plan/execute` 语义描述如有出入顺手校正 —— `README.md`
- [x] 知识卡片 `plan-credential-chain.md`(planHash 输入边界)落盘并加 menu 路由 —— `shadow-docs/knowledge/plan-credential-chain.md` `shadow-docs/menu.md`

## 结果

- 实际耗时: 约 20 分钟
- 验证: `node --test` 47/47 通过(45 存量 + 2 新增:干净树 plan→execute 可复现、porcelain 首行路径完整);两条新测试在修复前均实测为红;README 计划哈希边界描述与卡片 `plan-credential-chain.md` + menu 路由已落盘。

## 知识评估

- **预期影响:** 新增
- **候选卡片:** shadow-docs/knowledge/plan-credential-chain.md(domain: cli-infrastructure,scope: lib/plan.mjs, cli.mjs)
- **理由:** 「planHash 输入 = 语义输入,不含易变 worktree 快照;凭证在 brief、DIRTY 检查在 planData」是本次确立的核心机制边界,后续所有新增域都要遵守,属非显然约束。
36 changes: 36 additions & 0 deletions shadow-docs/knowledge/plan-credential-chain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
title: plan/execute 凭证链与哈希边界
domain: cli-infrastructure
keywords: [planHash, plan, execute, norm, changedFiles, 凭证, hash 漂移, porcelain, trim]
scope: [lib/plan.mjs, cli.mjs, lib/git.mjs]
status: active
source:
- changes/20260917-fix-plan-credential-chain/brief.md
verified: 2026-09-17
---

# plan/execute 凭证链与哈希边界

## 当前结论

`planHash = sha256(canon({command, data: norm(planData)}))`。`norm()` 的剥离边界是**「plan 自身的副作用」**,共两类:① brief `workflow` 中由 plan 写回的凭证字段(`planHash`、`release`、`issuePlan`);② `repo` 的易变 worktree 快照(`changedFiles`、`clean`)——plan 写回 brief 必然改变脏文件集合,不剥离则干净树上 execute 必漂移。`repo.head`/`branch`/`root` 属于语义输入,保留参与哈希。凭证存放:带 `--name` 的域以 brief 中持久化的 planHash 为准;无 brief 的域(`index rebuild`)以 `--plan-hash` 参数为唯一凭证。

## 执行约束

- 新增命令域导出 `planData` 时,凡包含 `repo` 状态,其 `changedFiles`/`clean` 已被 `norm()` 剥离,无需自行处理;不得为了「哈希稳定」把 head/branch 也剥掉。
- 脏工作区的行为门禁(如 `sync` 的 `DIRTY_WORKTREE`)必须在命令域 `planData` 内联执行——plan 与 execute 都会跑一次,这是哈希剥离脏状态后唯一的脏检查通道,不得移入哈希输入。
- `git()` 输出只做尾部裁剪(`trimEnd`):`git status --porcelain` 状态行以空格开头(` M path`),消费方从下标 3 取路径,全局 `trim()` 会截掉首行路径首字符。
- execute 端若命令的 `planData` 依赖 flag(如 `commit`/`publish`/`release` 的 `--files`/`--message`/`--title`/`--body`),execute 必须传与 plan 完全一致的参数,否则 `PLAN_HASH_INVALID` 属预期行为。

## 适用边界

适用于所有走 `DOMAINS` 统一通道的 plan/execute 命令域。不适用于直接命令(`change create/approve`、`task set`、`repo inspect`),它们无哈希凭证。

## 验证方式

`node --test test/cli.test.mjs` 全绿即成立,关键用例:`plan to execute survives the clean-tree write of planHash`(干净树 plan→execute 可复现)、`porcelain first-line status keeps the full path in changed files`(首行路径完整)、`plan persists the hash so execute runs without copying it`(brief 凭证链)。

## 关联知识

- [CLI 双通道输出契约](cli-output-contract.md)
- [brief.md frontmatter 行尾契约](brief-frontmatter-crlf.md)
1 change: 1 addition & 0 deletions shadow-docs/menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@
|--------|--------|--------|
| brief 读写 | brief frontmatter 行尾 CRLF autocrlf BRIEF_FRONTMATTER_REQUIRED planHash | knowledge/brief-frontmatter-crlf.md |
| CLI 输出面 | stdout stderr JSON 契约 语言 i18n 本地化 nextStep help 提示 QUIET | knowledge/cli-output-contract.md |
| plan/execute 凭证 | planHash hash 漂移 norm changedFiles 脏工作区 porcelain trim 凭证链 | knowledge/plan-credential-chain.md |
18 changes: 18 additions & 0 deletions test/cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,24 @@ test('file lists normalize Windows backslash separators', () => {
assert.deepEqual(JSON.parse(conflict.stdout).data.overlaps, [{ change: 'backslash', files: ['src/example.js'] }])
})

test('plan to execute survives the clean-tree write of planHash', () => {
const root = fixture()
execFileSync('git', ['add', '--', 'shadow-docs'], { cwd: root })
execFileSync('git', ['commit', '-m', 'docs: seed briefs'], { cwd: root })
const planned = run(['branch', 'plan', '--name', 'sample', '--json'], root)
assert.equal(planned.status, 0, planned.stderr)
const result = run(['branch', 'execute', '--name', 'sample', '--plan-hash', JSON.parse(planned.stdout).planHash, '--confirm', '--json'], root)
assert.equal(result.status, 0, JSON.parse(result.stdout).error?.message)
})

test('porcelain first-line status keeps the full path in changed files', () => {
const root = fixture()
writeFileSync(join(root, 'README.md'), '# changed\n')
const result = run(['repo', 'inspect', '--json'], root)
assert.equal(result.status, 0, result.stderr)
assert.ok(JSON.parse(result.stdout).data.changedFiles.includes('README.md'))
})

test('human layer: banners and hints on stderr, stdout contract language-invariant', () => {
const root = fixture()
const zh = run(['task', 'list', '--name', 'sample', '--lang', 'zh'], root)
Expand Down
Loading