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
Expand Up @@ -9,7 +9,7 @@ Shadow dev workflow 的确定性脚手架 CLI。所有命令走 plan → execute
| 命令 | 说明 |
|------|------|
| `repo inspect` | 查看仓库状态(分支、HEAD、脏文件) |
| `change create\|approve\|list` | 创建/批准变更 brief;列出活动变更 |
| `change create\|approve\|list` | 创建/批准变更 brief;`list` 默认只列活动变更,`--all` 合并归档、`--archived` 只列归档(条目带 `archived` 布尔) |
| `issue plan\|execute` | 创建 GitHub issue |
| `branch plan\|execute` | 建功能分支 |
| `sync plan\|execute` | fast-forward 同步上游 |
Expand Down Expand Up @@ -79,7 +79,7 @@ bash scripts/install-cli.sh install --from dist/shadow-dev-cli-v1.1.0.tar.gz #
## 开发

```bash
npm test # node --test,49 项 CLI 契约 + 6 项安装器契约(离线产物全链、冲突保护、回滚、自校验)
npm test # node --test,52 项 CLI 契约 + 6 项安装器契约(离线产物全链、冲突保护、回滚、自校验)
```

行为契约:命令、JSON 输出结构、错误码、planHash 机制保持稳定;`test/cli.test.mjs` 是唯一契约规格。
2 changes: 1 addition & 1 deletion cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ async function handle(r, p, o) {
if (d === 'task' && a === 'set') return { ok: true, command: 'task.set', data: task.set(r, o) }
if (d === 'change' && a === 'create') return { ok: true, command: 'change.create', data: change.create(r, o) }
if (d === 'change' && a === 'approve') return { ok: true, command: 'change.approve', data: change.approve(r, o) }
if (d === 'change' && a === 'list') return { ok: true, command: 'change.list', data: change.list(r) }
if (d === 'change' && a === 'list') return { ok: true, command: 'change.list', data: change.list(r, o) }
if (Object.hasOwn(DOMAINS, d)) {
const verb = a === 'rebuild' ? s : a, c = a === 'rebuild' ? `${d}.rebuild` : d
if (verb === 'plan') return await planDomain(c, DOMAINS[d], r, o)
Expand Down
2 changes: 1 addition & 1 deletion lib/args.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export function args(a) {
const p = [], o = {}
for (let i = 0; i < a.length; i++) {
if (!a[i].startsWith('--')) p.push(a[i])
else { const k = a[i].slice(2); if (['confirm', 'json', 'full', 'help'].includes(k)) o[k] = true; else o[k] = a[++i] }
else { const k = a[i].slice(2); if (['confirm', 'json', 'full', 'help', 'all', 'archived'].includes(k)) o[k] = true; else o[k] = a[++i] }
}
return { p, o }
}
2 changes: 1 addition & 1 deletion lib/commands.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const COMMANDS = {
'repo.inspect': c('repo inspect', '查看仓库状态(分支、HEAD、脏文件)', 'show repository state (branch, HEAD, dirty files)', [], 'shadow-dev repo inspect'),
'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 active changes with name, type, status and branch', [], 'shadow-dev change list'),
'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),
'branch.plan': c('branch plan', '预览建功能分支', 'plan creating the feature branch', [N], 'shadow-dev branch plan --name <change-name>', 'branch execute --name {name} --confirm'),
Expand Down
27 changes: 17 additions & 10 deletions lib/domains/change.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,24 @@ export function create(r, o) {
return { name: n, path: b.path }
}

// 活动变更发现入口:变更名即 shadow-docs/changes/ 下子目录名;archive 与解析失败的目录跳过(与 indexer 同规则)
export function list(r) {
const base = join(r, 'shadow-docs', 'changes'), changes = []
if (existsSync(base)) for (const e of readdirSync(base, { withFileTypes: true })) {
if (!e.isDirectory() || e.name === 'archive') continue
try {
const d = brief(r, e.name).data
changes.push({ name: d.name ?? e.name, type: d.type ?? null, status: d.status ?? null, branch: d.branch ?? null })
} catch {}
// 变更发现入口:变更名即 shadow-docs/changes/ 下子目录名;解析失败的目录跳过(与 indexer 同规则)。
// 默认只列活动目录;--all 合并活动与归档,--archived 只列归档;两参同传按超集 --all 处理
export function list(r, o = {}) {
const all = !!o.all, only = !!o.archived && !all
const dirs = only ? [] : [[join(r, 'shadow-docs', 'changes'), false]]
if (all || only) dirs.push([join(r, 'shadow-docs', 'changes', 'archive'), true])
const changes = []
for (const [dir, a] of dirs) {
if (!existsSync(dir)) continue
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (!e.isDirectory() || (!a && e.name === 'archive')) continue
try {
const d = brief(r, e.name, a).data
changes.push({ name: d.name ?? e.name, type: d.type ?? null, status: d.status ?? null, branch: d.branch ?? null, archived: a })
} catch {}
}
}
changes.sort((a, b) => a.name.localeCompare(b.name))
changes.sort((x, y) => x.name.localeCompare(y.name))
return { changes }
}

Expand Down
91 changes: 91 additions & 0 deletions shadow-docs/changes/20260917-feature-change-list-archived/brief.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
---
{
"schema": "shadow-dev/v1",
"name": "20260917-feature-change-list-archived",
"type": "feature",
"scope": null,
"status": "reviewed",
"baseBranch": "main",
"branch": "feature/20260917-feature-change-list-archived",
"files": [],
"github": {
"repository": "stack-wuh/shadow-dev-cli",
"issue": 17,
"issueUrl": "https://github.com/stack-wuh/shadow-dev-cli/issues/17",
"pullRequest": null,
"pullRequestUrl": null
},
"review": {
"conclusion": "passed",
"verifiedCommit": "f10148117080397c1d76b75ae12a7492fb7867af",
"verifiedAt": "2026-09-17T11:59:26.844Z"
},
"workflow": {
"operation": null,
"checkpoint": "issue:17",
"planHash": "e5ee5e90f8f71653279dd3e219f28d4aabf002430d986f6131d74a78871b662a",
"updatedAt": null,
"lastError": null,
"issuePlan": {
"title": "change list 支持查询已归档变更(--all / --archived)",
"body": "",
"labels": [
"feature"
]
}
},
"knowledge": {
"action": "更新",
"target": "shadow-docs/knowledge/cli-output-contract.md",
"reason": "本变更直接修订该卡片的 change list 契约条款(opt-in 归档参数与 archived 字段),并已随变更写回卡片"
}
}
---

# change list 支持查询已归档变更(--all / --archived)

## 动机

`change list` 按现行契约只列活动变更、静默跳过 archive。实际使用中用户的第一个问题就是"做过的提案有哪些"——当前 main 上活动变更为空,`change list` 恒返回 `[]`,8 个已归档提案只能翻 `shadow-docs/changes/archive/` 目录或 `INDEX.md`,CLI 无法回答。需要把归档可见性纳入 `change list`,且不破坏刚钉住的默认输出契约。

## 引用规范

- shadow-docs/knowledge/cli-output-contract.md
- 当前结论: stdout 单行 JSON 为机器契约;`change list` 现约束为"只列活动目录,archive 与解析失败目录静默跳过"——本变更修订该条款为 opt-in 参数形态
- 适用 scope: cli.mjs, lib/commands.mjs, lib/human.mjs, lib/i18n.mjs, lib/output.mjs
- norms/code-style.md
- 当前结论: 渐进式治理,只改与本变更相关的;不为未来场景提前抽象(故不新增 path/pullRequest 等未要求字段,不新增互斥错误码)
- 适用 scope: lib/domains/change.mjs
- norms/tdd-verification.md
- 当前结论: 先写失败测试确认红,再最小实现转绿
- 适用 scope: test/cli.test.mjs

## 决策

- **选型:** 扁平合并列表 + 每条 `archived` 布尔字段;`--all` 列活动+归档(按 name 排序),`--archived` 只列归档,默认仍只列活动(条目同样带 `archived:false`,视为契约演进,同步更新既有 deepEqual 钉住用例)。
- **对比方案:** 分组返回 `{changes, archivedChanges}` 未选——同一命令在不同 flag 下 JSON 形状不一致,机器流需分支处理;独立子命令 `change archived` 未选——多一个命令面且"看全部"需跑两次(接口形态已在对齐阶段与用户确认为 opt-in 参数)。
- **理由:** 单数组 + 判别字段是消费者最稳定的形状,jq 过滤即可还原两个视图;双目录扫描直接复用 `lib/indexer.mjs` 既有规则(含 `brief(r, name, archived)` 第三参与解析失败静默跳过),不引入第二套目录遍历事实源;`--all` 与 `--archived` 同传按超集 `--all` 处理,不新增错误码,避免无谓扩大契约面;参数描述进 `COMMANDS` 目录后 help 与缺参提示零成本派生,符合单一事实源约束。

## 任务

### Phase 1(TDD:红 → 绿)

- [x] task-1 — `test/cli.test.mjs` — 新增失败用例:`--all` 返回活动+归档合并且排序、`--archived` 只列归档、默认只列活动;所有条目含 `archived` 布尔;既有 `change list` 用例的 deepEqual 同步补 `archived: false`;跑一遍确认红
- [x] task-2 — `lib/commands.mjs` — `change.list` 目录补 `--all`/`--archived` 参数描述(zh/en,注明同传按 --all),example 不变
- [x] task-3 — `lib/domains/change.mjs`、`cli.mjs` — `list(r, o)` 按参数决定扫描目录集合(复用 indexer 的目录规则与 `brief(r, name, archived)`),条目加 `archived` 字段;`cli.mjs` dispatch 透传 `o`;测试转绿

### Phase 2(文档与知识回写)

- [x] task-4 — `README.md` — 命令表 `change list` 行补 `--all`/`--archived` 说明
- [x] task-5 — `shadow-docs/knowledge/cli-output-contract.md` — 修订执行约束第 3 条为 opt-in 契约(含 `archived` 字段与两参数同传语义),验证方式补新用例名,source 追加本 brief

## 结果

- 实际耗时: —
- 验证: —

## 知识评估

- **预期影响:** 更新
- **候选卡片:** shadow-docs/knowledge/cli-output-contract.md
- **理由:** 本变更直接修订该卡片中 `change list` 的执行约束条款("只列活动目录,跳过 archive"→ opt-in 参数契约),不新增卡片
5 changes: 3 additions & 2 deletions shadow-docs/knowledge/cli-output-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ source:
- changes/20260917-feature-help-compact-noise/brief.md
- 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
---

Expand All @@ -22,7 +23,7 @@ verified: 2026-09-17

- 任何新增输出必须二选一:进 stdout JSON 契约(视为公开 API,需测试钉住),或进 stderr 人用层;**禁止**向 stdout 写非 JSON 内容。
- 缺必填参数报错时,stderr 必须从 `COMMANDS` 逐行列出缺失参数的目录描述(`flag * desc`,经 `human.argLine` 与 help 详情共用同一渲染),示例行占位符与目录 example 一致(变更名为 `<change-name>`);`HINTS` 只保留 code 级短句兜底,不得重复目录中的参数说明。
- `shadow-dev change list` 是变更名发现入口:stdout 契约 `data.changes:[{name,type,status,branch}]`(按 name 排序),只列 `shadow-docs/changes/` 活动目录,archive 与解析失败目录静默跳过(与 indexer 同规则)。
- `shadow-dev change list` 是变更名发现入口:stdout 契约 `data.changes:[{name,type,status,branch,archived}]`(按 name 排序)。默认只列 `shadow-docs/changes/` 活动目录(条目 `archived:false`);`--all` 合并归档条目(`archived:true`),`--archived` 只列归档,两参同传按超集 `--all`;解析失败目录静默跳过(与 indexer 同规则,双目录扫描复用 `brief(r, name, archived)` 第三参)。新增无值布尔 flag 必须在 `lib/args.mjs` 白名单登记,否则会被当作取值 flag 吞掉后随参数。
- 抑制 stdout 的分支必须仍然设置退出码;plan 的人用收场行必须透出 `planHash`(PTY 环境下的 agent 兜底)。`--json` 是跨环境逃生门,不得复用为其他语义。
- 错误 code 与 `data.nextStep` 模板永不本地化;语言链固定为 `--lang` > `SHADOW_DEV_LANG` > locale 探测 > 默认 zh,且只影响 stderr 文案。
- `nextStep` 为 additive 字段,写入发生在 planHash 持久化与计算之后,不得参与 hash 输入。
Expand All @@ -35,7 +36,7 @@ verified: 2026-09-17

## 验证方式

`node --test test/cli.test.mjs` 全绿即契约成立(关键用例:`jsonEnabled routes the JSON surface by environment and explicit flags`、`TTY suppresses stdout JSON; --json and env restore it; planHash surfaces on stderr`、`human layer: banners and hints on stderr, stdout contract language-invariant`、`SHADOW_DEV_QUIET silences the human channel`、`missing required args render from the command catalog on stderr`、`change list enumerates active briefs and skips archive and unreadable dirs`)。手工复验:管道中 `shadow-dev repo inspect | jq .` 有 JSON;TTY 终端里 `shadow-dev help` 只见中文表、`shadow-dev --json help` 恢复 JSON;`SHADOW_DEV_QUIET=1 shadow-dev repo inspect` 的 stderr 应为空;`shadow-dev task list`(不带参数)的 stderr 应含 `--name * 变更名(shadow-docs/changes/ 下的子目录…)` 参数行。
`node --test test/cli.test.mjs` 全绿即契约成立(关键用例:`jsonEnabled routes the JSON surface by environment and explicit flags`、`TTY suppresses stdout JSON; --json and env restore it; planHash surfaces on stderr`、`human layer: banners and hints on stderr, stdout contract language-invariant`、`SHADOW_DEV_QUIET silences the human channel`、`missing required args render from the command catalog on stderr`、`change list enumerates active briefs and skips archive and unreadable dirs`、`change list --all merges active and archived; --archived scopes to archive`)。手工复验:`shadow-dev change list --archived` 应列出 `changes/archive/` 下全部条目且 `archived:true`;管道中 `shadow-dev repo inspect | jq .` 有 JSON;TTY 终端里 `shadow-dev help` 只见中文表、`shadow-dev --json help` 恢复 JSON;`SHADOW_DEV_QUIET=1 shadow-dev repo inspect` 的 stderr 应为空;`shadow-dev task list`(不带参数)的 stderr 应含 `--name * 变更名(shadow-docs/changes/ 下的子目录…)` 参数行。

## 关联知识

Expand Down
20 changes: 18 additions & 2 deletions test/cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -175,12 +175,28 @@ test('change list enumerates active briefs and skips archive and unreadable dirs
assert.equal(result.status, 0, result.stderr)
const payload = JSON.parse(result.stdout)
assert.equal(payload.command, 'change.list')
assert.deepEqual(payload.data.changes, [{ name: 'sample', type: 'feat', status: 'draft', branch: null }])
assert.deepEqual(payload.data.changes, [{ name: 'sample', type: 'feat', status: 'draft', branch: null, archived: false }])
mkdirSync(join(root, 'shadow-docs', 'changes', 'archive', 'old'), { recursive: true })
writeFileSync(join(root, 'shadow-docs', 'changes', 'archive', 'old', 'brief.md'), '---\n{"schema":"shadow-dev/v1","name":"old","type":"feat","status":"archived"}\n---\nbody\n')
mkdirSync(join(root, 'shadow-docs', 'changes', 'broken'), { recursive: true })
const second = JSON.parse(run(['change', 'list'], root).stdout)
assert.deepEqual(second.data.changes, [{ name: 'sample', type: 'feat', status: 'draft', branch: null }])
assert.deepEqual(second.data.changes, [{ name: 'sample', type: 'feat', status: 'draft', branch: null, archived: false }])
})

test('change list --all merges active and archived; --archived scopes to archive', () => {
const root = fixture()
mkdirSync(join(root, 'shadow-docs', 'changes', 'archive', 'aaa-old'), { recursive: true })
writeFileSync(join(root, 'shadow-docs', 'changes', 'archive', 'aaa-old', 'brief.md'), '---\n{"schema":"shadow-dev/v1","name":"aaa-old","type":"feat","status":"archived","branch":null}\n---\nbody\n')
mkdirSync(join(root, 'shadow-docs', 'changes', 'archive', 'broken'), { recursive: true })
const both = JSON.parse(run(['change', 'list', '--all'], root).stdout)
assert.deepEqual(both.data.changes, [
{ name: 'aaa-old', type: 'feat', status: 'archived', branch: null, archived: true },
{ name: 'sample', type: 'feat', status: 'draft', branch: null, archived: false },
])
const only = JSON.parse(run(['change', 'list', '--archived'], root).stdout)
assert.deepEqual(only.data.changes, [{ name: 'aaa-old', type: 'feat', status: 'archived', branch: null, archived: true }])
const superset = JSON.parse(run(['change', 'list', '--all', '--archived'], root).stdout)
assert.deepEqual(superset.data.changes, both.data.changes, 'passing both flags behaves as --all')
})

test('task list exposes stable task identifiers', () => {
Expand Down
Loading