# sdlc-cli

> SDLC 最小集 CLI —— **本地即真源**的中间产物仓库与工作区装配。

`sdlc-cli` 把软件开发生命周期(SDLC)的**中间产物**(需求、技术方案、测试用例/记录)以结构化文件 + git 提交的形式沉淀在本地,并按一次开发任务把 **中间产物 + 代码仓 worktree + 知识库** 装配进一个工作区。CLI 只负责**定位、校验、装配**;自然语言交互由配套的 `assembling-workspace` skill 承担,通过 needs 协议回报缺失项、由 skill 补齐后重调。

---

## 特性

- **中间产物仓(context-repo)**:Jira / PRD 的 `manifest.yaml`、`tech-spec/`、`prd/`、`test-cases/`、`test-runs/` 骨架,可版本化、可事务提交。
- **工作区装配**:一条命令把 context-repo、代码仓 worktree、知识库装进 `~/sdlc-workspaces/<product>/<id>/`（无产品时为 `_misc/<id>/`）。
- **两种装配入口**:`--jira`(dev 视图,装代码仓 + 建 Jira 骨架)与 `--prd`(product 视图,建 PRD 骨架)。
- **多知识库**:`knowledge_base` 支持单个或数组;每个 URL 全局共享一份克隆(`.kb-shares/<key>`),工作区内只是指向共享源的软链。
- **工作区引导文件**:装配时首次生成 `AGENTS.md` 真源与 `CLAUDE.md` import stub,refresh 不覆盖手改内容。
- **本地/远程代码仓**:`repos.<id>.local` 配本地路径则直接用(不拉 mirror),远程仓 clone 到全局共享 mirror `~/sdlc-workspaces/.mirrors/<urlKey>` 复用(按**仓库 URL** 规整出 `<basename>-<hash8>`,故不同 group 的同名项目、或改过 remote 地址的同一逻辑 id 都不会撞车);两者在**新建 worktree 前都会 best-effort `fetch`** 刷新,worktree 统一基于远程跟踪分支 `origin/<base>`。
- **worktree 基分支**:可在 `repos.<id>.base` 配仓级默认值，也可用 `--repo-base <逻辑id>=<分支>` 按次覆盖。优先级为 `--repo-base > workspace cache > repos.<id>.base > origin/main > origin/master`；最终分支不存在则报错。
- **worktree hook 隔离**:CLI 通过统一 `addWorktree()` 自动创建 context-repo 或代码仓 worktree 时,仅对本次 `git worktree add` 设置 `core.hooksPath=/dev/null`,避免用户全局 `post-checkout` hook 阻断装配或自动生成依赖目录;进入 worktree 后的日常 Git 操作仍使用用户原有 hook 配置。
- **needs 协议**:装配缺信息时不做破坏性动作,一次性回报所有 `*_MISSING`,便于上层对话补齐。
- **多仓交付**:`deliver plan / base set / apply` 三步把一个 Jira 的全部已挂载代码仓与 context-repo 提交、push 并建 GitLab Ready MR;`plan` 只读、`apply` 重校验 plan_id 后按仓顺序执行,任一仓失败即停。
- **采集上报**:`kb` 组走 32KB JSON 小事件,`session` 组走 MB 级会话 transcript(采集 / 投递 / 补传 / 取整包 / 会话反馈),两条通道刻意分开、只复用机制(目录锁、幂等键、指数退避、死信)。
- **只读透传与直连**:`db`(MySQL direct/Archery、Redis direct,审计集中写入工作区全局根)、`gapi`(DevCloud API 检索)、`apollo`(配置中心)、`jira`(REST v2 直连,写命令默认 dry-run)、`cp`(审核发起与状态)。

## 环境要求

- Node.js **≥ 20**
- git

## 安装

```bash
# 从 registry 安装(全局)
npm i -g @g7e6-sdlc/sdlc-cli

# 或用 npx 直接跑
npx @g7e6-sdlc/sdlc-cli --help
```

安装时 `postinstall` 会调用 `sdlc-cli install`:在 `~/.sdlc/config.yaml` **缺失时**写入默认配置模板(含 `archery.base_url` 与空 `access_key` 占位);已存在则做升级合并——补缺失的 `gapi.base_url` / `archery.base_url` / `apollo.server_urls` / `session_collector` 整键默认,用户已有值一律不覆盖、token/AK 不补写、无新增键则文件不动,并把随包 `skills/` 注册到 `~/.claude/skills` 与 `~/.agents/skills`。

若通过 `npx @g7e6-sdlc/sdlc-cli install` 运行(检测到从 npx 临时目录执行),`install` 还会自动 `npm i -g @g7e6-sdlc/sdlc-cli`(安装与当前相同的版本),让终端直接拥有全局 `sdlc-cli` 命令。已全局安装则跳过;在全局安装的 postinstall / 本地 `pnpm install` 场景下不会触发(避免递归与误装)。自安装失败仅告警、不影响退出码,可按提示手动 `npm i -g @g7e6-sdlc/sdlc-cli`。`pnpm dlx` / `yarn dlx` 不在自动识别范围,请手动全局安装。也可手动初始化:

```bash
sdlc-cli install          # 确保配置存在,并注册随包 skills
# 或
sdlc-cli config init      # 仅确保配置模板存在
```

## 快速开始

```bash
# 1) 初始化全局配置
sdlc-cli install

# 2) 登记代码仓逻辑地址(顶层全局,与产品无关)
sdlc-cli config set repos.backend.remote git@git.g7e6.com:checkout/backend.git
#   本地已有 clone 时可直接用本地路径(不拉 mirror,但建 worktree 前仍会 best-effort fetch):
sdlc-cli config set repos.backend.local  ~/code/backend
#   仓库长期基于 develop 开发时，可配置默认基准分支：
sdlc-cli config set repos.backend.base develop

# 3)(可选)登记产品预设,便于按 Jira 前缀自动带出地址
sdlc-cli config set products.checkout.context_repo   git@git.g7e6.com:sdlc/checkout-context.git
sdlc-cli config set products.checkout.knowledge_base '[git@git.g7e6.com:kb/checkout-kb.git]'
sdlc-cli config set products.checkout.jira_prefixes  '[PROJ, CHK]'
sdlc-cli config set products.checkout.keywords       '[结账, 支付, checkout]'
sdlc-cli config set products.checkout.epic           'CHK-100'
sdlc-cli config set products.checkout.repos          '[backend, frontend]'

# 4) 装配一个 dev 工作区(围绕 Jira)
sdlc-cli ws create --jira PROJ-123 \
  --epic EPIC-1 --title "登录 SSO 后端" \
  --repo backend --prd-ref PRD-login

# 装配结果:~/sdlc-workspaces/checkout/PROJ-123/  (若命中产品预设)
#   或:~/sdlc-workspaces/_misc/PROJ-123/  (若无产品匹配)
#   context-repo/   —— 中间产物(共享 mirror 的 worktree,已建 Jira 骨架并提交)
#   repos/backend/  —— 代码仓 worktree(分支 feature/PROJ-123-<时间戳>)
#   knowledge/      —— 知识库(指向全局共享源的软链)
#   AGENTS.md       —— 工作区操作引导(首次生成,refresh 不覆盖)
```

若装配缺少必需信息(如 context-repo / 知识库地址),命令不会做破坏性动作,而是以 `needs-input` 返回所有缺失项(如 `CONTEXT_REPO_MISSING`、`KNOWLEDGE_MISSING`),补齐后重跑即可。dev 视图的 `--prd-ref` 为必填，支持 `PRD-x`、`prds/PRD-x` 或 `prds/<模块>/PRD-x`；带模块路径用于精确定位，但 Jira manifest 仍保存稳定的 `PRD-x`。缺省时待 context-repo 就绪后报 `PRD_REF_REQUIRED`(回带 context-repo 已有 PRD 候选 `candidates[]`,供 skill 语义推导/选择),补齐后重跑;`--epic` / `--title` 为非必填:缺省不报 need、不阻断装配(epic 缺省不写 manifest,title 缺省兜底为主 id),可后补。

## 核心概念

### 中间产物仓(context-repo)

一个工作区**唯一**的中间产物 git 仓,存放结构化的需求/设计/测试产物。同一 context-repo URL 跨工作区共享一份全局 mirror(`.mirrors/<ctxKey>`),工作区里的 `context-repo/` 是该 mirror 的 git worktree(每个 jira/prd 各自一个分支):

```
context-repo/
├── requirements/<jira>/      # dev 视图:Jira 中间产物
│   ├── manifest.yaml         #   身份、标题、epic、prd.ref、repos[] 声明
│   ├── tech-spec/            #   技术方案
│   └── test-runs/            #   测试执行记录
└── prds/<prd>/               # product 视图:PRD 中间产物
    ├── manifest.yaml
    ├── prd/                  #   PRD 正文
    └── test-cases/           #   测试用例
```

"本地即真源" = 真相落在这套本地文件里,通过 `ctx` 命令读写、`ctx commit` 事务提交、`ctx impact` 做影响分析。

### 工作区布局

工作区分**两类**:产品工作区(`ws init`,一个产品建一次、长期复用)与任务工作区(`ws create`,随 Jira/PRD 生死)。二者结构同形(都是一个目录下挂 `context-repo/` + `knowledge/`),故产品工作区靠**标记文件** `.sdlc-product.yaml` 判别、目录名带 `-product` 后缀,与任务工作区的产品层级区分开。

```
~/sdlc-workspaces/
├── .mirrors/<urlKey>/          # 全局共享 mirror:context-repo 与远程代码仓共用(按仓库 URL 命名,跨工作区复用)
├── .kb-shares/<urlKey>/        # 全局共享知识库(同 URL 跨工作区共享一份克隆)
├── products/                   # 产品工作区根(config workspace.products_root,默认 <root>/products)
│   └── <product>-product/      # 产品工作区(如 geai-product);一个产品一份,长期资产
│       ├── .sdlc-product.yaml  # ★ 产品标记:判别产品工作区的唯一判据,产品名的唯一来源;含 context_repo + knowledge_base 地址表
│       ├── CLAUDE.md           # 工作区结构与初始约定(真源,首次生成、不覆盖)
│       ├── AGENTS.md           # 指向 CLAUDE.md(方向与任务工作区相反)
│       ├── context-repo/       # 独立 clone(不是 worktree)
│       └── knowledge/<name>/   # 独立 clone(不是软链——长期资产,独立副本更省心)
├── <product>/                  # 任务工作区的产品层(如 xjny / hualu / geai)
│   └── <id>/                   # 一个任务工作区(id = Jira 号或 PRD 号)
│       ├── context-repo/       # 共享 mirror 的 worktree(单实例)
│       ├── repos/<name>/       # 代码仓 worktree
│       ├── knowledge/<name>/   # 指向 .kb-shares/<urlKey> 的软链(可多个)
│       ├── AGENTS.md           # 工作区真源引导(首次生成)
│       ├── CLAUDE.md           # @AGENTS.md import stub
│       └── .workspace-cache.yaml # 身份缓存(再次装配可省略参数)
└── _misc/                      # 无产品兜底
    └── <id>/                   # 无产品匹配时的工作区
```

**任务工作区的产品层规则**：
- `<product>` 为命中的产品名（如 xjny / hualu），由 `resolveProduct(id, opts.product)` 或产品预设确定
- 无产品时统一落 `_misc/` 目录，保持「永远有产品层」的一致结构
- 定位时靠扫描 `<root>/*/<id>/context-repo`（跳过 `.` 开头的隐藏目录），不依赖 `jira_prefixes`
- 旧平铺 workspace（`<root>/<id>/`）作为 fallback 兼容保留，零命中时回退检查

**产品工作区判别规则**：
- **判据只认 `.sdlc-product.yaml`**，不看目录结构。两类工作区都是「一个目录挂 `context-repo/`」，靠结构无法区分——早先的 `prds/`+`requirements/` 判据会把任务工作区的 context-repo 也认成产品，`ws which` 因此报出一串以 Jira 号为名的假候选
- **产品名取自标记的 `product` 字段**，不从目录名推断。所以目录名可以带 `-product` 后缀（甚至叫别的）而不影响识别，`canonical_name` 也不会被后缀污染
- **扫描只看 `products_root` 的直接子目录**，不递归、不扫 cwd。cwd 的正当用途由「从 cwd 向上查找」承担（在 context-repo 里就地跑命令照样命中，且比向下猜更准）
- **无标记的旧布局目录不被识别**。判据换代前建的产品工作区（`<root>/<product>/context-repo`，无后缀无标记）会报 `PRODUCT_WORKSPACE_MISSING`，需重跑 `ws init --product <name>`
- **标记同时记录仓库地址**：`context_repo`（字符串）+ `knowledge_base`（`[{name, repo}]`，`name` 即 `knowledge/<name>` 目录名；无知识库时该字段不落）。产品工作区的两者都是独立 clone、离开 config 看不出血缘，落在标记里让 agent 不必回读 `~/.sdlc/config.yaml`

**产品工作区的引导文件**：`ws init` 在工作区根生成 `CLAUDE.md`（结构 + 初始约定的**真源**）与 `AGENTS.md`（`@CLAUDE.md` + 普通链接，两种读法都能落到同一份）。**方向与任务工作区相反**——任务工作区是 `AGENTS.md` 正文 + `CLAUDE.md` stub。两者都是缺失才建、永不覆盖，人改过的内容不会被重跑冲掉。内容含：目录结构（含 `context-repo/` 内部分层）、「路径向 CLI 要」「归属：定义在 PRD、执行在 Jira」「不在此写业务代码」「知识库只读、复用优先」等初始约定、常用命令、仓库地址回显。

`ws which` 的 resolved 信封回带 `workspace_kind` 字段标明命中来源：`product`（产品工作区）/ `dev`（任务工作区，cwd 向上查找时会命中）/ `unknown`（裸 context-repo，如 `--repo` 指到任意仓）。调用方按需自行判断——就地读写 PRD 时 `dev` 反而是对的，装配产品级资产时才须 `product`。

`<ctxKey>` / `<urlKey>` 均规整为 `<basename>-<hash8>`(url 的 sha1 前 8 位),同一 URL 幂等复用同一份共享源。

### 配置文件 `~/.sdlc/config.yaml`

单层全局配置:

```yaml
workspace:
  root: ~/sdlc-workspaces
  products_root: ~/sdlc-workspaces/products  # 产品工作区根（可选；默认 <workspace.root>/products）
kb:
  collector:
    server_url: http://sdlc-knowledge-base.g7in.com/  # base URL,固定拼 /api/collector/events
    server_token: ""                                     # 输出时脱敏
    timeout_ms: 1000
    flush_limit: 50
    worker_interval_seconds: 60
    worker_max_seconds: 900
session_collector:                 # 会话 transcript 采集（session 命令组）
  enabled: true
  include: []                      # 空 = 全采；非空 = 白名单，需命中其一
  exclude:                         # 优先于 include；内置默认见下
    - "~/.claude/**"
    - "~/.codex/**"
    - "~/.ssh/**"
    - "~/.gnupg/**"
    - "**/node_modules/**"
  collect_subagents: true
  floor_bytes:                     # 去抖阈值 = 硬崩溃时最多丢多少内容的上界
    claude: 1048576                # 1MB
    codex: 2097152                 # 2MB
  skip_files_larger_than: 0        # 0 = 不限
  feedback_flush_limit: 20         # session flush 每轮最多投多少条会话反馈；0 = 不限
  server_url: ""                   # 空则回落内置默认 https://devlake.chinawayltd.com
  server_token: ""                 # transcript 路由不读；配了则反馈请求带 Bearer 头
cp:                                # 可选:CP 审核（默认模板里是注释,按需打开）
  service:
    base_url: https://devlake.example.com     # cp request/status 的服务地址,未配即用法错误
  jira:                            # 只读 Jira 坐标；schema 收下并由 getCpConfig() 回带给上层消费方，
    base_url: https://jira.example.com        #   当前没有 CLI 命令读它（jira 组走环境变量，两者不共用）
    read_token: ""                 # 输出时脱敏
feishu:                            # 可选:sdlc-lite 定稿发布落点
  folder_token: ""                 # 目标文件夹标识（不是凭证,故 config get 回真值不脱敏）；不配则落个人空间根目录
products: {}                       # 可选:地址预设分组(缺产品也能装配)
#  checkout:
#    context_repo:   git@git.g7e6.com:sdlc/checkout-context.git
#    knowledge_base:                # 单个字符串或数组均可
#      - git@git.g7e6.com:kb/checkout-kb.git
#      - git@git.g7e6.com:kb/common-kb.git
#    jira_prefixes:  [PROJ, CHK]    # 便利:jira 号自动带出上面两个地址
#    description: "结账支付系统"    # 可选:展示用产品说明
#    keywords:                      # 可选:供语义匹配的关键词
#      - 结账
#      - 支付
#      - checkout
#    epic: DEMO-100                 # 本产品默认 epic/迭代 id（可选，首次装配自动带出）
#    repos: [backend, frontend]     # 本产品默认要装的逻辑仓 id（可选，对应下方 repos 表的 key）
#    databases: [checkout-mysql]    # 可选:数据库归属视图,供 db list/search --product 过滤
repos: {}                          # 逻辑 id → 地址(顶层全局)
#  backend:  { remote: git@git.g7e6.com:checkout/backend.git, local: ~/code/backend, base: develop }
#  frontend: { remote: git@git.g7e6.com:checkout/frontend.git, base: main }
#  sdlc/backend: { remote: git@git.g7e6.com:sdlc/backend.git }  # 同名项目可用 <group>/<project> 区分
#  tms/backend:  { remote: git@git.g7e6.com:tms/backend.git }
```

- 逻辑 id 是**自取的短名**,格式不限,含 `/` 时工作区里按层级落位(`repos/sdlc/backend/`)。不同 group 的同名项目建议写成 `<group>/<project>`,便于在工作区里一眼区分;共享 mirror 按**仓库 URL** 而非逻辑 id 命名,故即便两个 id 同名(或同一 id 改过 remote 地址)也不会取到错的仓。
- `repos.<id>.local` 存在(`~` 会展开)则**直接用本地路径**建 worktree、不拉 mirror,但建 worktree 前会对本地仓做 best-effort `fetch`(无 remote/离线则静默跳过);否则用 `remote` clone 到全局 mirror。两条路径的 worktree 都基于远程跟踪分支 `origin/<base>`。
- CLI 通过统一 `addWorktree()` 自动创建 worktree 时跳过用户 Git hooks；该行为不修改全局或仓库 Git 配置，也不影响 worktree 建成后的 commit / merge / rebase / push hooks。
- `repos.<id>.base` 是该仓新工作区的默认基准分支。实际优先级为 `--repo-base > workspace cache > repos.<id>.base > origin/main > origin/master`；缓存优先可防止全局配置变化后已有工作区重建到另一条基线。
- `products` 是**可选**的地址预设:通过 `jira_prefixes` 让 Jira 号自动带出 `context_repo` / `knowledge_base`;`description` / `keywords` 供上层 skill 展示与语义匹配;`epic` / `repos` 供装配首次兜底（命中产品后自动带出默认 epic 与代码仓清单，无需再手传 `--epic`/`--repo`）。缺产品不阻断装配,命令行显式地址优先。
- 配置写入按整体 **strict** schema 校验,顶层只接受这些键:`workspace`、`repos`、`products`、`kb`、`session_collector`、`cp`、`feishu`、`gapi`、`apollo`、`databases`、`archery`。写错一个键名不会被静默忽略,而是当场报错(旧版本遗留的 `kb.collector.schedule_*` 两个字段也因此需手工删除,见下方 kb 段的历史清理说明)。数据库连接字段与完整示例见下方 `db` 命令,应用 token 见 `gapi` 命令。
- `config get/list/set` 的输出对 password / token / access_key 及其父对象**递归脱敏**。**唯一的窄豁免是 `feishu.folder_token`**:它是目标文件夹标识而非凭证(就写在飞书文件夹 URL 里、本身不授权,鉴权走 `lark-cli` 的 OAuth),脱敏会让 skill 把字面量 `[REDACTED]` 当 token 传下去、只换回一个「文件夹不存在」。
- `kb.collector.server_url` 是采集服务 base URL,CLI 固定请求 `POST /api/collector/events`;`server_token` 只从配置文件读取,`config get/list/set` 输出会脱敏。
- 采集失败 outbox 固定在 `<workspaceRoot>/.sdlc/kb/`,不需要配置路径。
- 测试可用环境变量 `SDLC_CONFIG` 覆盖配置文件路径。
- `session_collector` 是会话 transcript 采集配置。`exclude` **键缺失**时用内置默认(两个 IDE 自己的日志目录 + `~/.ssh` / `~/.gnupg` / `node_modules`);显式配成空数组视为用户主动清空。`include` 为空即全采,非空则为白名单;`exclude` 始终优先。
- `session_collector.floor_bytes` 是去抖阈值,语义是「硬崩溃时最多丢多少内容」的上界。两侧取值不同是实测结论,**不要期望触发率一致**:Claude 1MB → 1.50x 放大 / 6% 轮次触发;Codex 单轮就长 143KB,2MB → 1.30x / 42% 触发,调 floor 压不下触发率。
- 去抖状态落在 `~/.sdlc/session-upload/<sha256(realpath)>.json`,**一文件一 key**。不放 workspace root 是因为 `workspace.root` 可配,改配置等于状态全丢、全量重传;这是本机簿记,与工作区无语义关系。
- `install` 在配置已存在时会补 `session_collector` 整段默认(已有该段则一律不动,段内缺键也不补)。`server_url` / `server_token` 不写入:后者本期根本不读,而 `server_url` 空串等于回落内置默认 `https://devlake.chinawayltd.com`(见下条)。
- `session_collector.server_url` 为空时回落内置默认 `https://devlake.chinawayltd.com`。**主机不回落 `kb.collector.server_url`**:上传接口在 devlake 上、kb 事件收集在 `sdlc-knowledge-base.g7in.com` 上,两个服务不同源,回落过去是稳定 404。`session_collector.server_token` 保留但不读(devlake 该路由无鉴权,客户端不发 `Authorization` 头)。投递的批量与超时**不进配置**:`--limit` 默认 20、`--timeout-ms` 默认 30000(单文件 p50 就 225KB、最大实测 14.7MB,kb 那边的 1 秒在这里必然全军超时),两者只由命令行覆盖,hook 会显式传参。

## 命令参考

研发常用命令与配置见 [GAPI / Apollo / DB / KB 速查](docs/gapi-apollo-db-kb.md)；面向人的上手教程见 [使用指南](docs/使用指南.md)。**子命令与参数的最终形态以 `sdlc-cli <组> -h` 为准**（clipanion 直接从命令类生成，不会与代码漂开）。

| 组 | 子命令 | 一句话 |
| --- | --- | --- |
| `config` | `init` / `get` / `set` / `list` | 全局单层配置 `~/.sdlc/config.yaml` |
| `ctx` | `resolve` / `new` / `commit` / `manifest` / `status` / `impact` / `feedback` / `assets` / `modules` / `scaffold` / `init-tech-spec` | 中间产物仓的落位、事务提交与读时计算 |
| `ws` | `create` / `sync` / `destroy` / `mount` / `unmount` / `which` / `init` | 工作区装配与变更 |
| `kb` | `send` / `send-batch` / `flush` / `worker` | 知识库事件采集上报（异步落盘 + 投递） |
| `session` | `collect` / `flush` / `status` / `download` / `backfill` / `feedback` | 会话 transcript 采集、取回、补传与会话反馈 |
| `deliver` | `plan` / `base set` / `apply` | 多仓 commit + push + GitLab Ready MR |
| `cp` | `request` / `status` | CP 审核发起与运行态查询 |
| `jira` | `check` / `get` / `comment` / `update` / `transition` / `link` | Jira 直连（读 / 评论 / 字段 / 流转 / 关联） |
| `gapi` | `search` / `list` / `detail` | DevCloud API 检索（只读透传） |
| `apollo` | `get` | Apollo 配置查询（只读透传） |
| `db` | `list` / `search` / `query` / `schema` / `check` | 配置驱动的数据库只读查询 |
| `install` | — | 初始化 / 升级合并配置，注册随包 skills |

所有命令支持 `--json` 输出结构化结果,便于 skill / 脚本消费。**两个例外**:`gapi` / `apollo` 组纯透传下游原始数据,无 `--json` 开关。

### `install`

初始化 `~/.sdlc/config.yaml`(缺失则写默认模板;已存在则补缺失的 `gapi.base_url` / `archery.base_url` / `apollo.server_urls` / `session_collector` 整键默认,已有地址和 token/AK 不覆盖、token/AK 不补写、无新增键则文件不动),并把随包 skills 覆盖注册到 `~/.claude/skills` 与 `~/.agents/skills`。skill 复制与升级合并失败只进入告警,不改变退出码。

### `config` —— 全局配置

| 命令 | 说明 |
| --- | --- |
| `config init` | 写入默认配置模板(不覆盖已有) |
| `config get <key>` | 读一项(点号 key,如 `repos.backend.remote`) |
| `config set <key> <value>` | 写一项(value 经 YAML 解析,支持数组/数字/布尔),写前整体 schema 校验 |
| `config list` | 打印全部配置 |

### `deliver` —— 多仓交付

交付一个 Jira 的全部已挂载代码仓和 `context-repo`。`plan` 只读，返回
`plan_id` 以及每仓的 `source -> target`、文件和动作；历史 cache 缺少代码仓
基准分支时，先用 `base set` 补齐，CLI 不自动采用推荐值。

```bash
sdlc-cli deliver plan --jira PROJ-123 --summary "登录改动" --json
sdlc-cli deliver base set --jira PROJ-123 --repo-base backend=develop --json
sdlc-cli deliver apply --jira PROJ-123 --plan <plan_id> --summary "登录改动" --json
```

`apply` 会重新校验 `plan_id`，然后按仓库顺序 commit、push 并创建或复用
GitLab Ready MR；任一仓失败即停止后续仓库。代码仓目标分支来自 workspace
cache，`context-repo` 固定为 `master`，不降级到 `main`。命令只支持 GitLab，
不 force-push、不合并 MR，也不写执行日志。

### `kb` —— 知识库采集上报

`kb` 命令组承接知识召回与知识蒸馏的采集上报。请求体保持服务端旧接口契约:固定 `POST /api/collector/events`,**顶层**字段只包含 `event_type`、`knowledge_base`、`event_time`、`idempotency_key`、`payload`;客户端身份走 `payload.client`(见下文),不新增顶层字段。

**上报一律异步**:`send` / `send-batch` 前台只把事件原子落盘到 outbox 排队(本进程不发网络请求、不等待投递),入 outbox 成功即视为上报成功。

**采集补偿机制**——实际投递统一收口到 `kb flush`(唯一 HTTP 出口),由两类触发源拉起,外加手工 flush:

- **落盘后异步触发**:`send` / `send-batch` 仅在本次**新建** outbox 文件时 best-effort 派生**一个**即弃的 detached 后台 `kb flush`(不等待、不读结果、不改退出码;单条命中同 key 去重(`deduped`)不派生,`send-batch` 无论多少条也只派生 1 次、`queued === 0` 则不派生)。当前入口不是打包产物 `.js` 时跳过(`no-runnable-entry`),仅少一次提速,不影响命令结果。
- **agents session hook**:同级 `agents` 仓的 session hook 在 Agent 生命周期节点(Claude `SessionStart` / `SessionEnd`、Codex `SessionStart` / `Stop`)前台调用一次 `kb flush --limit 50 --timeout-ms 1500 --include-auth-blocked --json`(总时长由 hook 外层 1500ms 硬超时约束),失败一律吞掉、不阻断会话。
- **agents session hook**:同级 `agents` 仓的 `session-hook.js` 在 Claude `Stop` / `SessionEnd`、Codex `Stop` / `SessionEnd` / `SubagentStop` 调用 `session collect`(参数见挂载矩阵),薄适配层不做任何判定;`kb-flush-hook.js` 在其既有挂载点并列投递两个 outbox(`kb flush --limit 50` 与 `session flush --limit 20`,两个子进程并发、共用同一个 1500ms 外层硬超时)。两者退出码恒 0,失败一律吞掉、不阻断会话。
- **手工 flush**:随时执行 `sdlc-cli kb flush` 立即投递。

各触发源跑的都是同一个 `kb flush`,靠 outbox 锁收敛并发(重入返回 `locked: true`、退出码 0)、靠 `idempotency_key` 状态文件保证不重复投递,因此叠加不会重复上报。没有新的 session、CLI 调用或手工 flush 时不会发生网络投递——投递失败的事件保留在 outbox,由下一次触发按退避与幂等规则补报。

> **历史 schedule 清理**(从旧版本升级的用户):本版本已移除 `kb schedule` 定时补偿,不再安装/检查/卸载任何系统定时任务、不再生成 wrapper 脚本。已安装的定时任务**不会自动清理**,需手工删除:Windows 任务 `G7E6SDLCCollectorFlush`(schtasks)、macOS launchd label `com.g7e6.sdlc.collector.flush`、Linux unit `sdlc-kb-collector-flush.timer` / `sdlc-kb-collector-flush.service`,以及 wrapper `~/.sdlc/kb-collector-flush.sh`(Windows `.cmd`)。同时请手工编辑 `~/.sdlc/config.yaml`,从 `kb.collector` 删除 `schedule_auto_install` 与 `schedule_interval_seconds` 两个字段(strict schema 已不再接受,不删会导致 `config set` / `install` 升级合并报错),删除后再执行任意 `config set` 即恢复正常。

| 命令 | 说明 |
| --- | --- |
| `sdlc-cli kb send --event-type knowledge_retrieval --knowledge-base <kb> [--payload-encoding utf-8\|gb18030\|utf-16le\|utf-16be] --payload-stdin --json` | 从 stdin 读取**单条** payload JSON(须为对象)并入 outbox |
| `sdlc-cli kb send-batch [--event-type <t>] [--knowledge-base <kb>] [--payload-encoding utf-8\|gb18030\|utf-16le\|utf-16be] --payload-stdin --json` | 从 stdin 读取**数组** JSON 批量入 outbox,字段与单条完全一致;逐条顺序处理,单条失败不影响其余(整批有失败时退出码 1) |
| `sdlc-cli kb flush [--limit 50] [--timeout-ms 1000] [--include-auth-blocked] [--force] --json` | 投递 `<workspaceRoot>/.sdlc/kb/events/*.json`;抢锁失败返回 `locked: true` 且退出码 0 |
| `sdlc-cli kb worker --interval 60 --max-seconds 900 --limit 50 --json` | 手工循环 flush；命令保留，顶层 help 不展示，日常补报直接用 flush |

- **不校验事件类型，也不校验 payload 内容**——payload 只校验「是 JSON」（单条须为对象、批量须为数组；批量的逐项要求为「项是对象、`payload` 是对象、`event_type` 能取到非空值」），**完全原样透传，不删字段、不脱敏、不改写**。CLI 是上报工具，结构与取值由使用方与服务端约定。超 32KB 的 payload 会截断字符串、砍数组、丢最大字段以瘦身，此时打 `payload_truncated: true` 标记。
  - **唯一例外是最外层的 `agent_platform`**:它标识调用方所在的 AI 工具,属于客户端身份而非业务数据,CLI 会**消费并从 payload 移除**它,归并进 `payload.client` 的 `ai_tool` / `ai_tool_version`(见下)。这是刻意为之,不是漏删。
- **客户端身份自动补 `payload.client`**:`kb.collector.server_token` 是可选项,未配置 token 时服务端拿不到身份线索,故 CLI 恒定补一份本地弱身份,便于归因到人。

  | 字段 | 来源 | 取不到时 |
  | --- | --- | --- |
  | `os_user` | `os.userInfo().username`,失败回退 `USER` / `USERNAME` / `LOGNAME` | 省略 |
  | `git_user` | `git config --get user.name`(按 cwd 取,仓库级配置覆盖全局) | 省略 |
  | `git_email` | `git config --get user.email`(同上) | 省略 |
  | `workspace_id` | 从 cwd 上溯找到的工作区目录名(判据为 `.workspace-cache.yaml` 或 `.sdlc-product.yaml`) | 省略 |
  | `ai_tool` | payload 最外层 `agent_platform` 的取值(约定 `claude-code` / `codex`),裁掉两侧空白后原样上报 | payload 无该字段、或取值为空/非字符串时省略(连同 `ai_tool_version`) |
  | `ai_tool_version` | `agent_platform` 取值在已知表内时,执行 `<工具> --version` 抽出的版本号(兼容 `2.1.220 (Claude Code)` 与 `codex-cli 0.147.0` 两种输出格式) | 省略 |
  | `sdlc_cli_version` | 本 CLI 自身版本,读运行包的 `package.json`(与 `sdlc-cli --version` 同一真源) | 省略 |
  | `os_platform` | `os.platform()`(`darwin` / `linux` / `win32` 等) | 省略 |
  | `os_version` | `os.release()` | 省略 |

  ```json
  { "event_type": "knowledge_retrieval", "payload": {
      "session_id": "session-1",
      "client": {
        "os_user": "dengfuwei", "git_user": "dengfuwei", "git_email": "dengfuwei@example.com",
        "workspace_id": "PROJ-123", "ai_tool": "codex", "ai_tool_version": "0.147.0",
        "sdlc_cli_version": "1.0.0", "os_platform": "darwin", "os_version": "25.4.0"
      }
  } }
  ```

  **AI 工具只认 payload 里的 `agent_platform`,CLI 不自行探测。** 调用方(skill)本就会在 payload 最外层带上 `agent_platform`,无需额外参数;CLI 不做任何自动探测——环境变量会继承给整个子进程树(在 Claude Code 里开 shell 再跑 codex,`CLAUDECODE` 仍在),父进程链也会被 wrapper 打断,两种探测都会误判,而错误归因比没有数据更糟。取值不在已知表(`claude-code` → `claude`、`codex` → `codex`)内时仍原样上报到 `ai_tool`,但不取版本:该值来自外部输入,不能直接当可执行命令名执行。同一批(`send-batch`)里各条可带不同 `agent_platform`,逐条独立解析;同名工具的版本在进程内缓存,不重复启动子进程。

  四条约束：① 无开关，恒定上报；取不到的字段直接省略，不发空字符串。② 身份在**入队时**解析并随事件落盘——投递可能发生在别的进程与工作目录，届时再取会拿到与事件无关的身份。③ **不参与 `idempotency_key` 推导**：同一份业务 payload 换机器、改 git 配置、换 AI 工具后仍是同一个键,去重语义不变;`agent_platform` 的摘除也发生在算键**之前**,故键始终能从落盘事件复算出来。④ 注入发生在**瘦身之后**，大 payload 硬裁剪不会丢掉身份（代价是最终 payload 可能略微超出 32KB 自我约束上限）。使用方自带的 `payload.client` 保留其余子字段，同名字段由 CLI 覆盖。
- stdin 默认自动识别 UTF-8、UTF-16LE/BE(BOM 或 JSON NUL 分布)与常见 GBK/CP936(`gb18030`)；遇到 GBK 字节同时也是合法 UTF-8 的歧义内容时,用 `--payload-encoding gb18030` 或环境变量 `SDLC_KB_PAYLOAD_ENCODING=gb18030` 强制指定。
- `send-batch` 的数组元素可分属不同知识库;元素内的 `event_type` / `knowledge_base` 优先,缺失时回退到命令行同名参数。
- outbox 固定为 `<workspaceRoot>/.sdlc/kb/`，包含 `events/`、`corrupt/` 和 `lock.d/`；根目录取配置项 `workspace.root`，配置文件的存放位置不决定队列位置。

### `session` —— 会话 transcript 采集、取回与补传

采集 Claude Code 与 Codex 在本机留下的会话 transcript(JSONL)。设计见 `docs/superpowers/specs/2026-09-09-session-transcript-collect-design.md`,决策编号即代码注释里引的「设计 §Dx」。

与 `kb` 组是**平级的第二条通道**,不是同一个 outbox:transcript 是 MB 级文件,而 kb 事件通道锁定了 32KB payload 上限与唯一 HTTP 出口两条不变量,两种载荷合并会让那条不变量名存实亡。复用的是机制(目录锁、幂等键、指数退避、死信),不是同一个队列。

> **当前进度：M4（hook 接线）+ M6（archive 下载）+ 补传（`session backfill`）**。判定链(M1)+ outbox 落盘与排障视图(M2)+ 投递(M3)+ 两个 IDE 的 hook 接线与两侧 subagent(M4)+ 按会话取整包(M6)+ 一键补传(见下方「补传(`session backfill`)」小节)。服务端两半(上传路由与 archive 路由)都已在 devlake 仓,**补传对服务端零改动**。读取接口(列表 / 详情 / 单文件读)仍在 M5。

| 命令 | 说明 |
| --- | --- |
| `sdlc-cli session collect --transcript <abs> --session-id <id> --ide <claude\|codex> --cwd <abs> [--hook-event <name>] [--final] [--scan-subagents] [--dry-run] --json` | 判定 → 落 outbox 条目；带 `--dry-run` 时只打印判定不写盘 |
| `sdlc-cli session flush [--limit <n>] [--timeout-ms <ms>] [--force] [--include-auth-blocked] --json` | 唯一 HTTP 出口：抢锁 → 挑条目 → gzip → multipart POST → 回写状态；恒退 0 |
| `sdlc-cli session status [--limit <n>] --json` | outbox 积压（ready / waiting / auth_blocked / dead / corrupt / pending_bytes）与去抖状态（每文件已上传大小与次数），只读 |
| `sdlc-cli session download <session-id> [--out <dir>] [--timeout-ms <ms>] [--force] --json` | 按会话 id 拉主会话 + 全部 subagent 的 zip 整包，原样落盘为 `session-<id>.zip` |
| `sdlc-cli session backfill [--ide <claude\|codex>] [--session-id <id>]… [--since <ISO>] [--until <ISO>] [--limit <n>] [--force] [--verify-sha256] [--dry-run] [--timeout-ms <ms>] [--retries <n>] [--max-failures <n>] [--git-email <addr>] [--claude-root <dir>] [--codex-root <dir>] --json` | 一键补传：扫全机两侧目录，把没传过的**直传**（不走 outbox）；失败退非 0 |
| `sdlc-cli session feedback --ide <claude\|codex> --session-id <id> --title <t> (--content <c> \| --content-file <path\|->) [--transcript <abs>] [--with-subagents] [--no-transcript] [--timeout-ms <ms>] [--dry-run] --json` | 记一条「这次会话哪里不对」的用户反馈，连正文一起落库；**唯一由人主动触发**的一条 |

判定链与退出码:

1. `session_collector.enabled` 为 `false` → `state: "skipped"`、`reason: "disabled"`,退 0。
2. 路径过滤:`realpath(--cwd)` 后先比 `exclude`(命中即拒)再比 `include`。未命中 → `state: "skipped"`、`reason: "filter-excluded"`,退 0。过滤用的是 `--cwd` 的**触发时刻**值,不是正文里的会话起始 `session_cwd`——实测 41% 的会话中途换过 cwd,用起始值会让 `cd` 出工作区后的轮次继续被采。
3. 主文件去抖判定:只 `stat`,不读内容。`size - 上次成功上传的大小 >= floor_bytes` 才上传;首次见到必传;`--final` 对 **subagent 文件**需确有增长才传(不是无条件上传),而**主文件**在 `--final` 时恒传(`reason: "final-main"`)——服务端的会话完成标记是单向且不自愈的,只能靠一个带 `final=true` 的请求置上,而 `Stop` 与 `SessionEnd` 之间文件没长是常态。代价是每次会话结束多一个幂等请求(内容没变时服务端返 `200 unchanged`)。
4. 主文件判定为上传时才发现 subagent:Claude 列 `<transcript 去 .jsonl>/subagents/`,Codex 读父文件收 `sub_agent_activity.agent_thread_id` 后按 id 精确定位(含 `archived_sessions` 回退,不扫日期目录)。因第 3 步的主文件在 `--final` 时恒判上传,`SessionEnd` 恒开这一步——那些在 `Stop` 之后又长了一点、但增量不足 `floor_bytes` 的子文件,尾部内容因此不会漏传。
5. 判定要上传的文件落进 outbox:`<workspace.root>/.sdlc/session/outbox/entries/<sha256(ide+session_id+file_kind)>.json`,**一条目一文件、存指针不存文件副本**。同一文件重复触发覆盖同一条目(不堆积),故 outbox 大小被「本机活跃文件数」封顶而非「触发次数」。合并规则:`expected_size` 取最新、`final` 一旦 true 保持 true、`cwd` 取最新而 `session_cwd` 保留最早、重试状态(`attempts` / `next_attempt_at` / `auth_blocked` / `dead`)**不因重新入队而重置**。

头部解析同时取 `client_surface`——**运行载体的 wire 原值**,与 `ide`(厂商)是两个维度:Claude 取头部行的 `entrypoint`(`cli` / `sdk-cli`),Codex 取首行 `payload.originator`(`codex-tui` 终端 / `codex_work_desktop` 桌面 App)。两侧都不归一成统一词表(归一表放哪一侧,那一侧就得跟着上游出新取值改代码),取不到写 `null`;与 `session_cwd` 共用同一次前 5 行读取,额外 IO 为零,且同样恒取**父会话**的值。

`--final` 且某个 subagent 文件本轮没长过(判定为 `below-threshold`)时,若条目已在队未投成,则**只给它补一次 `final` 标记**,不新建条目、不产生新的上传。这样 `SessionEnd` 的完成信号不会因为「这一轮没新内容」而丢失。条目已投成(被删)时补不上,那一半由主文件的 `final-main` 兜底。`--json` 顶层的 `queued` / `merged` / `final_patched` 就是这三种落盘结果各自的计数——`merged` 不是「什么都没做」,它把 `expected_size` 与 `final` 合进了已有条目;`failed` 是落盘写失败的文件数(逐个文件隔离,不影响其余文件,退出码仍是 0)。

#### 投递(`session flush`)

一次性进程,**不驻留、不自排程**:抢 outbox 锁 → 按状态挑至多 `--limit` 条(默认 20)→ **串行**发送 → 退出。抢不到锁返回 `locked: true` 并退 0,不排队等待(下一次 hook 还会再来)。

请求是 `POST <server_url>/api/ai-coding/transcript-sessions/files`(服务端在 devlake,默认 `https://devlake.chinawayltd.com`),`multipart/form-data` 两段:`meta`(JSON)与 `file`(`filename` = `main.jsonl.gz` / `agent-<id>.jsonl.gz` / `agent-<id>.meta.json`)。该路由无鉴权,客户端不发 `Authorization` 头。`.meta` 走 `encoding: identity` **不压缩**(143 字节 JSON,gzip 后反而更大,且服务端要直接当 JSON 读),其余走 gzip。gzip 放在 flush 而非 collect:collect 在 hook 的 1.5 秒预算内,压 2.7MB 要几百毫秒。

`meta.size` / `meta.sha256` 一律按**实际读到的明文字节**算,**不用条目里的 `expected_size`**(那只是入队快照,仅供排障对比)。入队后文件又长了时传的就是新版本——这正是全文替换语义想要的,因为下次上传本来也要覆盖它。

响应码 → 条目动作:

| 码 | 动作 |
| --- | --- |
| `201` / `200` | 删条目;去抖状态推进 `uploaded_size`(明文字节)与 `upload_count` |
| `400` / `413`、其余 4xx | 标 `dead` 留在盘上,`session flush --force` 可重试(attempts 归零) |
| `401` / `403` | 标 `auth_blocked`,**不加退避、不自增 attempts**;token 修好后自愈 |
| `408` / `429` / `5xx` / 网络 / 超时 | 指数退避写 `next_attempt_at`(60/120/300/900/1800/3600 秒,30 次判死信) |

三种不发请求的情形:源文件已删除 → **丢弃条目**(`discarded` 计数,不留死信,`--force` 一万次也不会成功);源文件为空 → 走退避留在队里(全文替换语义下上传空内容会把服务端已存的好版本整体覆盖掉);超 `session_collector.skip_files_larger_than` → 丢弃(省一次注定 `413` 的 MB 级上传)。

`--json` 的五个计数:`flushed`(已投)/ `failed`(留在盘上)/ `skipped`(被规则挡掉,没发请求)/ `discarded`(条目已删,没发请求)/ `corrupt`(条目文件读不出来,已搬进 `.sdlc/session/outbox/corrupt/`)。`--limit` 只约束 `flushed + failed`——它要控的是网络往返,不是 `stat` 与 `readdir`。

`--force` 与 `--include-auth-blocked` **刻意正交**:前者覆盖「退避未到点」与「已判死信」,后者覆盖「token 不对」。合并会让 `--force` 每次都捞上一批注定 401 的条目白发请求。

**投递触发有三层**(设计 §D7),都跑同一个 `session flush`、共用同一把锁:① `collect` 落盘后 best-effort 派生**一个**即弃的 detached `session flush`(`queued` 或 `merged` 时派生,整次命令最多 1 个进程,绝不按文件条数派生;`final_patched` 不派生);② 下一次任意 hook 触发时,`collect` 发现 `size - last >= FLOOR` 仍成立会重新入队(覆盖式,无副作用)——这是全文替换带来的自愈;③ M4 起 `agents` 侧 hook 在 SessionStart / SessionEnd 并列前台调一次。**没有定时任务、没有常驻进程**:彻底不再开 IDE 时,最后那批没投成的条目会一直躺在 outbox 里,这是刻意接受的语义。

去抖状态(`~/.sdlc/session-upload/`)**由投递写、不由 collect 写**:`uploaded_size` 只在上传成功后才推进(反序会在失败时永久跳过那段内容),写的是**明文**字节数,与 `statSync().size` 同口径。`upload_count` 同样在这里自增——它是去抖效果的唯一观测口径,刻意不进服务端。

session 通道与 kb 事件通道是**刻意的两条通道,不要合并**:那条走 32KB JSON 小事件,这条走 MB 级文件指针;复用的是机制(目录锁、幂等键、指数退避、死信),不是同一个队列。两者是 `.sdlc/` 下的同级兄弟,互不嵌套——`ls <workspace.root>/.sdlc` 的直接子目录即「有哪几条通道」:

```
<workspace.root>/.sdlc/
├── kb/                  # kb 事件通道
│   ├── events/
│   ├── corrupt/
│   └── lock.d
└── session/             # session transcript 通道
    ├── outbox/
    │   ├── entries/
    │   ├── corrupt/
    │   └── lock.d       # flush 的投递锁
    └── backfill/
        └── lock.d       # 补传的独占锁,与 flush 那把刻意分开
```

session 侧刻意多一层 `outbox/`(kb 侧是 `.sdlc/kb/events/`、没有中间层):session 通道下有**两件**东西,把补传锁塞进 outbox 根会让 outbox 根同时装条目和外来锁,将来给 outbox 根加一次清理就会连带端掉补传锁。

> **旧路径迁移(2026-09-14)**:session 的 outbox 与补传锁曾在 `<workspace.root>/.kb/session-outbox/` 与 `.kb/session-backfill/`,现统一迁到 `.sdlc/` 下与 kb 通道同级。**CLI 不扫描、不自动迁移旧目录**(照 kb 通道 2026-09-09 那次的先例):旧机器上如有积压条目,手工 `mv <workspace.root>/.kb/session-outbox/entries/*.json <workspace.root>/.sdlc/session/outbox/entries/` 即可,条目文件名与内容不变。`.kb/session-backfill/` 下只有瞬态锁,直接删。去抖状态 `~/.sdlc/session-upload/` **不在本次迁移范围**——它派生自配置文件所在目录(为的是让 `SDLC_CONFIG` 同时隔离配置与状态),跟着 `workspace.root` 走会让改配置等于状态全丢。

**用法错误退 2,其余采集路径自身的判定恒退 0**:过滤未命中、未达阈值、transcript 不存在一律算成功,采集是旁路,绝不因它让 hook 失败。**落盘失败也不例外**——目录不可写、磁盘满等写失败逐个文件隔离(一个文件失败不影响其余文件落盘),计进 `--json` 的顶层 `failed` 并附 `SESSION_ENTRY_WRITE_FAILED` 警告,退出码仍是 0。唯一的例外是配置文件本身损坏(`~/.sdlc/config.yaml` 解析失败)——这会让仓库里所有命令一样失败,`session collect` 不特殊化,按 `ctx` / `kb` 等命令统一的业务错误退 1。

`--ide` **不自动探测**,沿用 `kb` 组 `resolveAiToolIdentity` 的立场:环境变量会继承给整个子进程树、父进程链会被 wrapper 打断,两种探测都会误判,而错误归因比没有数据更糟。

**文件身份从文件自身推导,不信 `--session-id`**:Claude 看路径是否在 `.../<session-id>/subagents/` 下,Codex 看首行 `payload.thread_source`。命中即 `session_id` 取父、`file_kind` 取 `agent-<id>`。这样「父会话扫描」与「子文件被单独递进来」两条路径产生完全相同的身份,不会因触发顺序不同而漂移。`file_kind` 取值为 `main` / `agent-<id>` / `agent-<id>.meta`(`.meta` 仅 Claude)。

#### 补传(`session backfill`)

```bash
sdlc-cli session backfill --dry-run          # 先看要传什么，零请求
sdlc-cli session backfill                    # 扫全机、直传所有没传过的
sdlc-cli session backfill --ide claude --since 2026-08-01
sdlc-cli session backfill --session-id <id> --limit 20 --json
```

主链路是 **hook 驱动、只采当前会话**的,这留下三个缺口:hook 从未装过 / 装错了 / 某段时间被关掉;hook 装了但投递一直失败且**源文件已被清理**;换机器、`~/.sdlc/session-upload/` 被清、`workspace.root` 改过。三者共同的形状是「本机磁盘上有完整的 transcript,服务端没有,而投递的三层补偿一层都够不着」——它们都以「条目还在 outbox 里」为前提。`session backfill` 是补这三个缺口的**第二条上传路径**。

**不走 outbox**:读文件 → gzip → multipart POST,串行、进程内完成,不落条目、不排队、不退避。服务端零改动(复用同一个上传路由与 `kb.session.v1` 契约)。

四条必须记牢的语义:

- **一律不发 `final`**:服务端 `complete` 列是单向 0→1、撤销要手工改库,而补传是事后动作,没有任何本地信息能可靠证明一个会话真的结束了(「按 mtime 判闲置」会把「闲置 30 分钟后又继续」的真实会话误标成完成,且误标不自愈)。`meta.final` 显式发 `false`、不省略该键。代价明确且已接受:**hook 从未装过的那批历史会话 `complete` 恒为 0**,按 `complete` 筛选的看板口径覆盖不到它们;那些会话日后再被打开时 `SessionEnd` 会正常置上。
- **完全无视 outbox**:不读、不写、不删条目,**不抢 flush 那把锁**(用自己的 `<workspace.root>/.sdlc/session/backfill/lock.d`)。共用锁会让 backfill 首轮那十几分钟里所有 hook 触发的投递空转,而那正是主链路的常态路径。代价是重叠文件下次 `flush` 会白发一次拿 `200 unchanged`。有一个反直觉的正面效果:backfill 成功后**推进去抖状态**,于是 hook 那侧下次判 `below-threshold` 而不重传整份文件——两条路径共用同一份 `~/.sdlc/session-upload/` 簿记,「这个文件已经传到多大」只有一个真源。
- **身份解析按 `session_cwd` → `process.cwd()` → `--git-email` 回落**,顺序不可调。服务端 `storage_path` 与 `user_key` 是 first-write-wins,而 `user_key` 由 `git_email` 的 local part 推出;会话的 `session_cwd` 已被删(工作区销毁是常态)时 `git config` 取不到值 → 落 `os-<os_user>` → 与 hook 当初写的不同 → 字节落进新目录而会话行仍指向旧目录,**那批 `file_kind` 不可达且不自愈**。「hook 传了一半、事后 backfill 补剩下」正是本命令最常见的用法,所以这不是理论风险。已销毁工作区拿不到 `workspace_id` 是可接受的次要退化(服务端身份列在值为 `None` 时保持原值,不会污染 hook 写过的好值)。
- **时间窗按 `session_started_at`,不按文件 mtime**:mtime 是「最后一次追加的时刻」,一个跨天的长会话 mtime 落在结束那天,按它筛会把会话归到错误的日期。`session_started_at` 本来就必须读(它决定服务端的目录段),故这么筛是零额外成本。

判「已传过」**只比字节数**(`size === uploaded_size` 即跳过),与去抖判定同口径。transcript 是只追加文件,「长度不变而内容变了」在正常路径上不存在;要严格比对加 `--verify-sha256`(会把全部内容读一遍算哈希)。补传**不看 `floor_bytes`**——那是「硬崩溃时最多丢多少」的上界,与「这份历史文件传没传过」无关。

**遵守 `session_collector.enabled`**:配成 `false` 时拒绝执行并退 1。那个开关的语义是「不要上传我的 transcript」,一次批量补传正是它该挡住的动作,「人主动敲的」不构成例外。

失败处理没有队列可退避,所以要么当场重试、要么当场停:`5xx` / `408` / `429` / 超时 / 网络错误进程内重试 `--retries` 次(退避 1s、3s);`400` / `413` 及其余 4xx 不重试(请求本身不对);`401` / `403` **立刻中止全局**(配置问题,后面几百个必然同样失败);连续失败达 `--max-failures`(默认 10)也中止,**遇成功即归零**。

**串行,不并发,不提供 `--concurrency`**:单文件最大实测 14.7MB,并发几路会同时占住带宽;且服务端 17-POST 扇出的死锁风险是主链路已 park 的问题,补传一次几百个文件没有理由主动去撞它。

退出码(照 `session download` 的立场,人主动敲的命令失败必须退非 0):

| 码 | 含义 |
| --- | --- |
| `0` | 全部成功,或全部已传过(含 `--dry-run`) |
| `1` | 有文件失败;或 `session_collector.enabled` 为 `false`;或抢不到锁 |
| `2` | 用法错误(`--since` 不是合法时间、`--ide` 取值非法、`--limit` 非正整数等) |
| `3` | 中止(`401` / `403` 鉴权失败、超 `--max-failures`) |

`--json` 的计数:`scanned`(枚举到的候选会话数)/ `planned`(判定要传的文件数)/ `uploaded` / `unchanged` / `failed` / `skipped` / `not_attempted`(被 `--limit` 截断或中止后没轮到的)/ `bytes`(实际上行的明文合计)/ `elapsed_ms` / `aborted`,外加 `skipped_by_reason` 分类计数与 `files[]` 明细。跳过原因有 `already-uploaded` / `missing-started-at` / `empty` / `too-large` / `source-missing` / `unknown-cwd` / `path-excluded` / `out-of-window` / `subagents-disabled`。文本模式**逐文件流式打印**(500+ 文件不能憋到最后才出声),`--json` 只在末尾发一个信封。

#### 会话反馈(`session feedback`)

```bash
sdlc-cli session feedback --ide claude --session-id "$CLAUDE_CODE_SESSION_ID" \
  --title "改完测试没跑就说完成了" --content-file - --json
```

本组唯一**由人主动触发**的命令:把「用户觉得这次会话哪里不对」连同当次正文一起落库——那条信息事后无论怎么解析 transcript 都重建不出来。

- **先落盘、再发送、成功才删**(与 transcript 侧相反的可靠性口径):transcript 传丢了下次 hook 会重传,**反馈传丢了就真丢了**。崩在 POST 中间时条目还在盘上;发成功而进程被杀,最坏是下次 drain 白发一次拿 `200 unchanged`。落盘条目由 `session flush` 顺带投递(额度独立,见 `session_collector.feedback_flush_limit`,默认 20、`0` 为不限——不占 transcript 的 `--limit`,否则一批大文件会把用户的反馈挤到下一轮)。
- **`payload` 整块不可变**:投递时原样发出、不重算任何字段。transcript 每次 flush 都按实际字节重算 `size` / `sha256`(文件会长),反馈没有「会长」这回事,重算只会引入落盘值与发出值的漂移。
- **`--session-id` 必填,CLI 不猜**:猜错的后果是把反馈静默挂到别人的会话上、事后无人发现,宁可当场报取不到 id(Claude 取 `$CLAUDE_CODE_SESSION_ID`,Codex 取 `$CODEX_SESSION_ID`)。`--ide` 同样不自动探测。
- **正文默认强制推一次**(不等去抖),只推主文件;`--with-subagents` 连子会话一起推,`--no-transcript` 只落反馈不推正文。反馈落库与正文上传是**两件独立的事**,正文没传成不影响反馈已落库(输出第二行会写「已排队」,会话结束时自动补传)。
- 长度上限:`--title` ≤ 200 字符,正文 ≤ **16383** 字符(与服务端 `text` 列按全 4 字节字符换算的容量同值;客户端取更大值会让超出那段吃 413,而 413 记死信不重试 = 丢掉一条不可重建的反馈)。多行正文一律走 `--content-file`(`-` 读 stdin)。
- 端点 / token / 超时**不新增顶层配置键**,与 transcript 同源(同一服务、同一 blueprint);`schema_version` 固定 `kb.session.feedback.v1`,不复用 `kb.session.v1`。默认超时 10000ms(几 KB 的 JSON,且用户正在等)。
- 反馈是 **append-only**:提交后改不了,想改口就再提一条,两条都留着。别和 `ctx feedback add` 搞混——那个反馈 PRD 正文的缺口、落 context-repo 给需求方看,本命令反馈 AI 会话的问题、落 devlake 给工具建设方看。

#### 取整包(`session download`)

```bash
sdlc-cli session download ddfcb3b9-f80e-4ad5-8a47-783fccfb1ce9 --out ./tmp --json
# → ./tmp/session-ddfcb3b9-f80e-4ad5-8a47-783fccfb1ce9.zip
```

`GET <server_url>/api/ai-coding/transcript-sessions/<session_id>/archive` → **原样落盘,不解包**。包内是主会话与全部 subagent(设计 §D13):

```
session-<id>.zip
  manifest.json                        ← 固定首条目,从服务端两张索引表生成
  main.jsonl.gz                        ← 服务端磁盘字节原样,零解压零重压
  agent-<id>.jsonl.gz
  agent-<id>.meta.json                 ← Claude 才有
```

zip 用 **stored 模式**(不压缩):内容已经是 gzip,再压一遍压缩率退化到接近 1.0。选 zip 而非 tar 是因为消费方包含浏览器回放页面——那边有 `fflate` / `JSZip` 现成,解 tar 要自己写解析器。

`manifest.json` 是**首条目**,让流式消费方读到第一个条目就知道整包结构。它带 `client.*` 七列与父子关系、调用点(`tool_use_id` / `spawn_depth` / `agent_type`),消费端解包即可**离线**重建全部关系,不必回连服务端。要把一个会话交给别人,走这个命令而不是 rsync 目录。

**缺文件标记而不是报错**:服务端 DB 有行、磁盘找不到时,该条目在 `manifest.files[]` 里标 `"missing": true` 且不在包里,接口仍返 200。其余文件仍可用,消费端能明确知道缺了哪个。

**只能传父会话 id,子会话不单独成包**:子 agent 不占会话行(服务端会话表唯一键是 `session_id`,子文件只在文件表占 `(session_id, agent-<id>)` 一行),所以传 subagent 自己的 id 必然 404。但那种 404 会**反查出父会话 id 并告诉你**:

```
$ sdlc-cli session download a168a989331390044
error: a168a989331390044 是 subagent 的 id,子会话不单独成包——它随父会话
       ddfcb3b9-f80e-4ad5-8a47-783fccfb1ce9 一起打包。
       请改用:sdlc-cli session download ddfcb3b9-f80e-4ad5-8a47-783fccfb1ce9
```

三种输入形态都认(裸 `agent_id`、`agent-<id>`、`agent-<id>.meta`),它们在服务端归一成同一个查询键后走 `idx_transcript_file_agent` 索引。这次反查**只发生在 404 路径上**,正常出包一次都不查。旧服务端不带 `parent_session_id` 时客户端退回通用文案,两侧无需同步上线。

要某个 subagent 的正文就下父会话的包,从里面取 `agent-<id>.jsonl.gz`——`manifest.json` 里有它的 `agent_id` / `tool_use_id` / `description` / `spawn_depth`,认得出是哪个。

**落盘走 tmp + rename**:直接写目标名会在中途失败(网络断、磁盘满、Ctrl-C)时留下一个长度不足却看起来正常的 zip,而 zip 的坏损要解包才发现。校验只有一条——服务端声明的 `Content-Length` 与实际写入字节相等(stored 模式下服务端能精确预计算该值),通过才改名,故目标路径要么不存在、要么是一个完整的包。服务端未发该头时跳过校验(设计允许的形态)。客户端**不解析 zip 内容**,包里有什么是消费方的事。

**退出码与本组其余命令相反**:`collect` / `flush` / `status` 恒退 0(采集是旁路,绝不因它让 hook 失败),而 `download` 是人主动敲的,失败必须退非 0——否则 `sdlc-cli session download X && analyze session-X.zip` 会在 404 之后照样跑下一步。

| 退出码 | 情形 |
| --- | --- |
| `0` | 落盘成功 |
| `1` | 目标文件已存在（加 `--force` 覆盖） |
| `2` | 参数不合法（空 session-id、`--out` 指向普通文件、`--timeout-ms` 非正整数） |
| `3` | `404` 会话不存在（或传了 subagent id，此时报 `SESSION_ARCHIVE_IS_SUBAGENT` 并带 `parent_session_id`）/ `401`·`403` 鉴权 / `5xx` / 网络不可达 / 超时 / 下载被截断 |

端点与鉴权复用投递侧的同一组配置键(`session_collector.server_url` / `server_token`):两个方向打的是同一个服务,分成两个键会让「传上去了但拉不下来」变成一类可配置出来的故障。devlake 该 blueprint 目前无鉴权校验,但配了 `server_token` 就照契约发 `Bearer`——服务端将来加校验时客户端不必改代码、不必同步上线。

### `ctx` —— 中间产物仓库

> **context-repo 根定位(所有 `ctx` 子命令通用)**:①显式 `--repo <路径>` → ②从 cwd **向上查找**(目录同时含 `prds/` 和 `requirements/` 即为根)→ ③按 workspace id fallback → ③.5 **仅 PRD 实体**:按 PRD id 反查 `products_root` 下带 `.sdlc-product.yaml` 标记的产品工作区(唯一命中即用,多命中报错要求 `--repo`)→ ④从 cwd 向下探一层。因此**只要 cwd 在 context-repo 内(或其子目录),`--repo` 可省略,无需装配 workspace 即可就地运行**;cwd 不在 repo 内时用 `--repo <路径>`(当前目录即 repo 根可用 `--repo .`)。零环境依赖类 skill(如 `generating-tests`、`requirement-split-estimator`)据此在 context-repo 下直接落库,不强制走 workspace。
>
> 来源③.5 补的是这个缺口:③的匹配式是 `<root>/<x>/<id>/context-repo`,而产品工作区目录名是**产品名**(`<产品>-product`),永远匹配不上 PRD id——没有③.5 时「已 `ws init` 建好产品工作区、但 cwd 不在其中」会照样落空、skill 降级 pwd 留档,而同处境下 `ws which` 靠标记能命中,两条路能力不对等。**例外**:`ctx scaffold` 建的是还不存在的 PRD、无 id 可反查,故不吃③/③.5,须 cwd 在仓内或显式 `--repo`。

| 命令 | 关键选项 | 说明 |
| --- | --- | --- |
| `ctx resolve` | `--jira` / `--prd` / `--asset`,可选 `--kind`、`--repo` | 解析实体根目录或某类产物落位目录 |
| `ctx new [kind]` | `--jira` 或 `--prd`,可选 `--name`(默认 `draft`)、`--repo` | 在落位目录创建带 frontmatter 的空壳 `.md` |
| `ctx commit <entity>` | `-m/--message`,可选 `--local`、`--dry-run`、`--repo` | 事务提交该实体的中间产物改动,**提交后默认 `push -u origin <当前分支>`**(远程无同名分支则新建);`--local` 只提本地不推;推送失败只报 `PUSH_FAILED` / `PUSH_SKIPPED` warning,退出码仍 0 |
| `ctx manifest get <entity> <key>` | 可选 `--repo` | 读 manifest 字段 |
| `ctx manifest set <entity> <key> <value>` | 可选 `--repo` | 写 manifest 字段 |
| `ctx status <entity>` | 可选 `--repo` | 查看 Jira 派生阶段(仅 Jira 有定义) |
| `ctx impact` | `--prd` 或 `--asset`,可选 `--repo` | 分析 PRD / 共享资产的本地改动与受影响 Jira |
| `ctx feedback add` | `--prd`、`--type <defect\|supplement>`、`--from-jira`、`--title`,可选 `--repo` | 追加一条 PRD 反馈(下游发现的缺陷/补充) |
| `ctx feedback list` | `--prd` 或 `--jira`,可选 `--type`、`--open`、`--repo` | 列反馈(读时扫 `feedback/`) |
| `ctx feedback digest --prd <id>` | 可选 `--repo` | 聚合 open defect 成待回写清单 |
| `ctx assets list` | 可选 `--type`、`--group-by type`、`--repo` | 列共享资产(读时扫 `assets/<id>/manifest.yaml`,`--group-by` 目前只支持 `type`) |
| `ctx modules` | 可选 `--repo` | 列 `prds/` 下的功能模块及各自 PRD 数(判据:无 `manifest.yaml` 的目录才是模块) |
| `ctx scaffold <prd>` | `--module <name>` 或 `--no-module`,可选 `--title`、`--repo` | 创建 PRD 骨架(新结构 01-04 目录 + `manifest.created_at`) |
| `ctx init-tech-spec` | `--jira`,可选 `--mode <explore\|bounded\|lite-spec\|full>`、`--done`、`--repo` | 幂等创建 `requirements/<jira>/tech-spec/manifest.yaml`(已存在不覆盖);详见 [ctx-init-tech-spec](docs/ctx-init-tech-spec.md) |

`ctx feedback`:下游(技术方案)发现「PRD 需要改」时,就近沉淀为 append-only 反馈,落 `prds/<PRD>/feedback/`,不阻塞方案设计。`type` 只有 `defect`(PRD 缺陷待回写)/`supplement`(技术侧补充不必回写)两值;id 形如 `FB-<YYYYMMDDTHHmmss>-<rand4>`(时间戳+随机后缀,多 Jira 并发写同一 PRD 不撞名)。`list` 的 `--prd`/`--jira` 二选一(`--jira` 跨多 PRD 反查),`--open` 只留未闭环。闭环 = 人工把 `FB-*.md` 移入 `feedback/resolved/`,「是否闭环」由文件所在目录读时算出,不存状态字段。**本次不实现「据 digest 更新 PRD 正文」那一步(digest 到清单为止)。**

`ctx new/resolve` 的合法 `kind`(即路由表 `ROUTES` 的全部键,新增产物类型 = 加一行、skill 零改动):

| kind | 挂在 | 落位（旧结构 → 新结构） |
| --- | --- | --- |
| `prd-doc` | PRD | `prds/<id>/prd/` → `prd/02_ai_output/` |
| `origin-input` | PRD | `prds/<id>/prd/01_origin_input/`（仅新结构） |
| `iterate` | PRD | `prds/<id>/prd/04_iterate/`（仅新结构） |
| `req-analysis` | PRD | `prds/<id>/req-analysis/` → `prd/02_ai_output/` |
| `prototype` | PRD | `prds/<id>/prd/prototype/` → `prd/03_prototype/` |
| `test-cases` | PRD | `prds/<id>/test-cases/cases/` |
| `estimation` | PRD | `prds/<id>/estimation/` |
| `tech-spec` | Jira | `requirements/<id>/tech-spec/` |
| `task-breakdown` | Jira | `requirements/<id>/task-breakdown/` |
| `plan` | Jira | `requirements/<id>/plan/` |
| `retro` | Jira | `requirements/<id>/retro/` |
| `test-run` | Jira | `requirements/<id>/test-runs/` |
| `asset` | asset | `assets/<id>/`（自挂，无二级子路由） |

新旧结构自动分发:PRD 目录下存在 `prd/02_ai_output/` 即判为新结构,该 kind 有 `dirNew` 时优先用它。位置参数 `<entity>` 会自动判别:`PRD-*` 为 PRD,其余为 Jira;`--jira` / `--prd` 入口显式指定实体类型,共享资产只经 `--asset` 进入(不可字面判别的第三实体,首次 resolve 即视为创建、不校验父实体)。

### `ws` —— 工作区装配与变更

- `ws create` —— 装配工作区（缺信息时以 needs 协议回报，见下方说明）
- `ws sync` —— 同步工作区
- `ws destroy` —— 销毁工作区
- `ws mount` / `ws unmount` —— 挂载/卸载代码仓或知识库
- `ws init --product <name> [--root <path>]` —— 初始化产品工作区（克隆 context-repo 到 `<products_root>/<product>-product/context-repo`、克隆知识库到 `knowledge/<name>`，在工作区根写 `.sdlc-product.yaml` 标记（含两者仓库地址）+ `CLAUDE.md`/`AGENTS.md` 引导）
- `ws which [--repo <path>] [--product <name>] [--prd <id>] [--new-prd]` —— 定位当前产品工作区（按优先级梯度：显式 repo/product → cwd 向上 → 单 PRD 命中 → 单候选 → 歧义/缺失需求）；候选只取 `products_root` 下带标记的目录，回带 `workspace_kind` 标明命中来源

> 具体子命令形态以 `sdlc-cli ws -h` 为准。装配缺信息时以 needs 协议回报,常见码:`CONTEXT_REPO_MISSING`、`KNOWLEDGE_MISSING`、`REPO_REMOTE_MISSING`、`PRD_REF_REQUIRED`(dev 必填,回带 `candidates[]`;`--epic` / `--title` 非必填,缺省不报 need);非阻断告警包含 `PRD_REF_NOT_FOUND`(给的 PRD 尚不在 context-repo,仍先装配)、`ALREADY_PRESENT` 等。

`ws create` 的选项:`--jira` / `--prd`(二选一)、`--product`、`--epic`、`--title`、`--context-repo`、`--knowledge`(可重复)、`--prd-ref`、`--repo`(可重复)、`--repo-remote <id>=<url>`(可重复)、`--branch`、`--repo-base <id>=<分支>`(可重复)、`--module` / `--no-module`。`ws sync <entity>` 只对 context-repo 做 `pull --rebase`,**不 push、不动代码仓 worktree**(全仓库唯一的 `pull --rebase` 点);`ws destroy <entity>` 先注销各 worktree 再删工作区目录,**共享 mirror 与 `.kb-shares` 不删**。`ws unmount` 在工作树脏时报 `REPO_DIRTY` / `KNOWLEDGE_DIRTY`,确认丢弃才加 `--force`。

### `jira` —— Jira 直连（REST v2 + Basic auth）

登录信息按「环境变量 > 内置默认」解析:`JIRA_SERVER`(默认 `https://issues.chinawayltd.com/`)、`JIRA_USERNAME`、`JIRA_PASSWORD`。**不读 `~/.sdlc/config.yaml`**——`cp.jira.*` 是 CP 审核那条链路自己的只读配置,两者不共用。

**全组写命令默认 dry-run**:`comment` / `update` / `transition` / `link` 缺 `--confirm` 时只回将提交的 payload,不发请求、不解析凭证。

| 命令 | 关键选项 | 说明 |
| --- | --- | --- |
| `jira check` | — | 报告凭证与鉴权状态;**缺凭证不算失败**（`state: "auth-missing"`,退 0） |
| `jira get` | `--jira`,可选 `--field`(可重复) | 读 issue;缺省读默认字段集 |
| `jira comment` | `--jira`、`--body`,可选 `--dedupe-marker` / `--upsert-marker`、`--confirm` | 加评论;`--dedupe-marker` 命中即跳过,`--upsert-marker` 无匹配则新增、单条匹配则跳过或更新、多条匹配报 `JIRA_COMMENT_MARKER_CONFLICT`（两个 marker 互斥） |
| `jira update` | `--jira`、`--set <字段>=<JSON 或裸串>`(可重复),可选 `--confirm` | 改字段 |
| `jira transition` | `--jira`、`--to <状态名>`,可选 `--max-steps`(默认 6)、`--confirm` | 流转到目标状态,自动多步寻路 |
| `jira link` | `--from`、`--to`,可选 `--type`(默认 `Relates`)、`--confirm` | 建 issue 关联 |

错误码:`JIRA_AUTH_MISSING` / `JIRA_KEY_INVALID` / `JIRA_SET_INVALID` / `JIRA_ARG_INVALID` / `JIRA_COMMENT_EMPTY` / `JIRA_UPDATE_EMPTY` / `JIRA_COMMENT_MARKER_EXCLUSIVE` / `JIRA_COMMENT_UPSERT_MARKER_EMPTY` / `JIRA_TRANSITION_TARGET_EMPTY` / `JIRA_SERVER_INVALID`（exit 2）；`JIRA_TRANSITION_NO_PATH` / `JIRA_TRANSITION_MAX_STEPS` / `JIRA_COMMENT_MARKER_CONFLICT` / `JIRA_COMMENT_ID_MISSING`（exit 1）；`JIRA_AUTH_FAILED` / `JIRA_NOT_FOUND` / `JIRA_BAD_REQUEST` / `JIRA_SERVER_ERROR` / `JIRA_REQUEST_FAILED` / `JIRA_UNREACHABLE`（exit 3）。

### `cp` —— CP 审核

```bash
sdlc-cli cp request --jira PROJ-123 --cp CP3 --material <url>#<version> --reviewer zhangsan --json
sdlc-cli cp request --jira PROJ-123 --cp CP3 --material <url>#<version> --self-review --json
sdlc-cli cp status  --jira PROJ-123 [--cp CP3] --json
```

- `--cp` 只接 `CP1`..`CP6`（其余为用法错误）。`--material` 同时给出材料地址与版本,版本缺失回 `MATERIAL_VERSION_MISSING`。
- 未指定 `--reviewer` 时回 `REVIEWER_MISSING`;`--self-review` 表示执行人自审并自动按通过处理（`review_mode: "self"`）。
- 执行人身份取 `resolveClientIdentity(cwd)` 的 `git_email`,取不到即用法错误（`cp_executor_email_missing`）——审核记录必须归因到人。
- 幂等键由 `jira + cp + material_version` 推出,同一材料版本重复发起不产生第二条审核。
- 服务地址取 `cp.service.base_url`,未配即用法错误（`cp_service_config_missing` / `cp_service_base_url_missing`）;请求打 `POST /api/cp-review/requests`,查询打 `GET /api/cp-review/status`。旧配置的 `cp.builder.trigger_url` 作为兼容回退仍被接受（`cp.service` 缺失时由它推导服务根地址），但仅当该 URL 以 `/api/cp-review/requests` 结尾且不带 query / hash / 用户名密码——否则报错要求手工改成 `cp.service.base_url`。

### `db` —— 数据库发现、查询与检测

连接与凭证仅取 `~/.sdlc/config.yaml`,命令引用精确的 key/env,支持 MySQL direct、MySQL Archery、Redis direct 三种组合。SQL/cmd 通过简单只读校验后使用配置账号执行;认证、权限与执行错误由数据库或 Archery 返回。

| 命令（均支持 `--json`） | 作用 |
|---|---|
| `db list [--type mysql\|redis] [--product <名>]` | 枚举安全摘要与配置就绪状态 |
| `db search <关键字> [--type mysql\|redis] [--product <名>]` | 搜索指定配置字段,额外返回命中的字段路径 `matches` |
| `db query <key> [--env <env>] (--sql <SQL> \| --cmd <命令>) [--timeout-ms <ms>]` | 执行 MySQL SQL 或 Redis 命令;输入必须且只能给一个非空值,须与 type 匹配 |
| `db schema <key> [--env <env>] [--table <t>]` | MySQL 列配置库中的表名/注释,指定表时返回建表 SQL 与列信息;Redis 返回 INFO keyspace 与 DBSIZE |
| `db check <key> [--env <env> \| --all]` | MySQL/Archery 执行 SELECT 1,Redis 执行 PING;所有环境先预检,再按环境名顺序逐个检测 |

可合并到配置的完整示例（凭证为示例值）:

```yaml
archery:
  base_url: https://archery.chinawayltd.com  # install/config init 新建时写入;运行时不回落
  access_key: ""                          # 缺省/空串时使用 Archery 返回 need
databases:
  checkout-mysql:
    type: mysql
    note: 订单主库
    envs:
      dev: {host: mysql.example.com, user: reader, password: example-password, database: checkout}
      prod: {channel: archery, instance: mysql-prod-01, database: checkout}
  shared-redis:
    type: redis
    note: 热点缓存
    envs:
      dev: {host: redis.example.com, port: 6379, db: 0}
products:
  checkout:
    databases: [checkout-mysql, shared-redis]
```

- 每个数据库须有 `type: mysql|redis` 与至少一个完整 env;环境名自定义。缺省 `channel: direct`,MySQL/Redis 默认 port 分别为 3306/6379,port 必须为 1..65535 的整数。MySQL direct 的 host/user 非空,password 必须为字符串（允许空密码）,database 可省略;Redis host 必填,username/password 可省略,db 默认 0 且为非负整数。Archery 仅支持 MySQL,instance/database 非空,凭证取全局 access_key。
- 新库通过 `config set databases.<key> '<完整 YAML 对象>'` 一次写入,如 `sdlc-cli config set databases.cache '{type: redis, envs: {dev: {host: redis.example.com}}}'`。缺字段、空 envs、非法 type/channel 是配置错误（exit 1）;`config get/set/list` 对 password/access_key/token 及其父对象递归脱敏。默认模板与 install 不补 `databases` 条目。
- Archery 默认地址只在 `DEFAULT_CONFIG_TEMPLATE` 中定义:`install` / `config init` 新建配置时写入上述 base_url 与 `access_key: ""`;`install` 对已有配置只补缺失 base_url,保留自定义地址及任何 access_key;`config init` 对已有文件不覆盖。运行时仅读配置,AK 缺省/空串仍返回 `ARCHERY_KEY_MISSING`;AK 已有而 base_url 缺失时返回配置错误（exit 1）,执行 `sdlc-cli install` 补默认地址或 `sdlc-cli config set archery.base_url <地址>` 后重试。
- 单环境自动选择,多环境未传 `--env` 返回 `DB_ENV_REQUIRED`;所有 needs 都不连接。`--all` 与 `--env` 互斥,全部环境预检无 needs 后才执行,一项检测失败仍保留后续结果。MySQL 未配 database 时仍可 query/check,schema 要求先补配置;Redis 不支持 `--table`。
- list/search 按 key/env 排序,保留名称大小写。search 仅在 key/note/type/环境名/host/database/db/instance 中做不区分大小写的子串匹配。`--product` 按归属数组与 type/关键词取交集;未知产品 exit 1,空归属/无命中成功,悬空 key 忽略。未收录的 key 仍可查询。空 key/env/product/关键词/表名、非法 type 或参数组合是用法错误（exit 2）。
- SQL 扫描识别 `--` / `#` / `/* */` 普通注释;首 token 允许 SELECT/SHOW/DESCRIBE/DESC/EXPLAIN/WITH,拒绝分号、可执行注释、WITH 混写与 INTO OUTFILE/DUMPFILE;执行原 SQL,不自动改写。Redis 使用只读命令白名单,引号/转义在进程内拆分,不经过 shell;SELECT/EVAL/写命令均拒绝。仅在实际 direct 执行时加载对应驱动,list/search/needs/Archery 不加载数据库驱动。
- query 默认总超时 10000ms,`--timeout-ms` 须为正整数;schema 每次操作、check 每环境固定 10000ms。每次独立连接,超时销毁连接或 abort HTTP,不重试、不自动翻页。
- 普通结果最多 500 行/项,确实裁剪时才 `truncated:true`。MySQL `rowCount` 为输出行数;Redis null 为 0、标量为 1、数组按元素、HGETALL 按字段、ZRANGE WITHSCORES 按完整成员/分值对计数。SCAN 保留完整单页和独立 cursor,不套 500 项裁剪。Archery 请求 limit=501,服务端可能返回更少,不据此猜测总量或截断状态。

成功/needs 写 stdout,`--json` 使用 `{ok:true,state,...}`。业务错误 exit 1、参数错误 exit 2、连接/认证/权限/执行/超时错误 exit 3;错误 JSON 写 stderr,形如 `{error:{code:数字,message,details?}}`,stdout 为空。clipanion 解析错误沿用文本输出。check 任一环境失败时 exit 3,完整 `results` 位于 `stderr.error.details.results`;成功时单环境也用 results 数组。文本模式展示相同业务字段。

| 成功命令 | state | 信封业务字段 |
|---|---|---|
| list | `listed` | `databases: DbSummary[]`, `archery: {configured:boolean}` |
| search | `searched` | 同 list,命中条目增加 `matches: string[]` |
| query（MySQL） | `queried` | `columns, rows, rowCount, truncated, elapsedMs, matched, meta?` |
| query（Redis） | `queried` | `value, cursor?, rowCount, truncated, elapsedMs, matched` |
| schema | `schema` | `matched, schema, rowCount, truncated, elapsedMs` |
| check | `checked` | `results: [{ok,latencyMs,key,env,channel,error?}]` |

`DbSummary={key,type,note?,envs:[{env,channel,ready,missing:string[]}]}`;missing 是配置字段路径,Archery 摘要同时检查 `archery.base_url` 与 `archery.access_key`,两项齐备时才 `archery.configured:true` 且对应环境 ready。ready 不表示连通或权限已验证。`matched={key,env,type,channel}`。schema 的 MySQL 结果为 `{tables:[{name,comment}]}` 或 `{table,createSql,columns:[{name,type,nullable,default,extra,comment}]}`,Redis 为 `{keyspace,dbSize}`;rowCount 分别为表数、列数或 dbSize。摘要和 needs 不输出连接对象或凭证。

每环境的一次 query/schema/check（含执行失败）追加一条审计至 `<workspaceRoot>/.sdlc/db-audit.jsonl`。workspaceRoot 取 `workspace.root`,未配置默认 `~/sdlc-workspaces`;不随 cwd 分散落盘。日志仅含时间、可识别的 os_user/git_user/workspace_id、key/env/channel、kind、statement、计数/耗时与成功/错误;无法识别的身份字段省略。query 保存原 SQL/cmd,schema/check 保存 `{operation:"schema",table?}` / `{operation:"check"}`。不记录凭证和查询数据行;参数/配置/needs/只读拒绝不写执行审计,写日志失败仅 stderr 告警。

### `gapi` —— DevCloud API 检索（只读透传）

在 DevCloud 平台检索 API 定义。stdout 直接输出接口原始 JSON——无 `--json`、无渲染，适合管道与脚本消费。

```bash
sdlc-cli gapi search --keyword 订单 --scope app [--app checkout] [--page 1] [--size 15]
sdlc-cli gapi list --scope app [--page 1] [--size 15]
sdlc-cli gapi detail --id 123
```

| 命令 | 作用 | 备注 |
|---|---|---|
| `gapi search` | 按关键字搜索 API | `--scope app/product/user`（默认 app），`--app` 仅限 app 维度；user 维度不支持分页 |
| `gapi list` | 列出 API | 仅 `--scope app/product` |
| `gapi detail` | 查 API 详情（OPENAPI 格式） | 无需 token |

配置（`~/.sdlc/config.yaml`，全部可选；`base_url` 缺省用内置默认）：

```yaml
gapi:
  base_url: http://devcloud.chinawayltd.com   # 可选，内置默认同值
  # app_token: <按检索维度配置，app / product / user 三选或多选；留空视为未配置，用到时报 GAPI_TOKEN_MISSING>
  # product_token: ...
  # user_token: ...
  apps:
    checkout: {app_token: example-app-token}
```

`--app <应用名>` 优先取 `gapi.apps.<应用名>.app_token`;映射不存在、token 缺省或空串均回落全局 `gapi.app_token`,最终仍为空时报 `GAPI_TOKEN_MISSING`。不传 --app 保留全局 token 行为。`gapi.apps` 每项仅接受可选字符串 app_token;配置输出递归脱敏。--keyword 仍必填,--app 显式空值或与 product/user scope 同用在请求前报用法错误;list/detail 不接受 --app。

错误码：`GAPI_TOKEN_MISSING`（exit 1，缺对应维度 token）；`GAPI_AUTH_FAILED` / `GAPI_NOT_FOUND` / `GAPI_BAD_REQUEST` / `GAPI_SERVER_ERROR` / `GAPI_REQUEST_FAILED` / `GAPI_UNREACHABLE`（exit 3）；`GAPI_APP_INVALID` / `GAPI_SCOPE_INVALID` / `GAPI_LIST_SCOPE_INVALID` / `GAPI_USER_SCOPE_NO_PAGING` / `GAPI_ID_INVALID` / `GAPI_PAGING_INVALID`（exit 2）。

### `apollo` —— Apollo 配置查询（只读透传）

查询 Apollo 配置中心应用配置，stdout 输出配置原文。

```bash
sdlc-cli apollo get --app order-app --env dev [--cluster default] [--namespace application] [--format properties|json|raw]
sdlc-cli apollo get --app order-app --server http://apollo-api.dev.chinawayltd.com:8080
```

- `--env`（`dev/test/demo/pro`，不区分大小写）与 `--server` 必填其一，`--server` 优先。
- `--format` 对应 Apollo 官方路径前缀：`json` → `/configfiles/json/...`，`raw` → `/configfiles/raw/...`，`properties`（默认）无前缀。
- 四环境地址内置默认，可在 config.yaml 覆盖：

```yaml
apollo:
  server_urls:
    dev: http://apollo-api.dev.chinawayltd.com:8080
    test: http://apollo-api.test.chinawayltd.com:8080
    demo: http://apollo-api.demo.chinawayltd.com:8080
    pro:  http://apollo.merak.chinawayltd.com:8000
```

错误码：`APOLLO_TARGET_REQUIRED` / `APOLLO_ENV_INVALID` / `APOLLO_SERVER_INVALID` / `APOLLO_APP_REQUIRED` / `APOLLO_FORMAT_INVALID`（exit 2）；`APOLLO_AUTH_FAILED` / `APOLLO_NOT_FOUND` / `APOLLO_BAD_REQUEST` / `APOLLO_SERVER_ERROR` / `APOLLO_REQUEST_FAILED` / `APOLLO_UNREACHABLE`（exit 3）。

## 随包 skills

`skills/` 下每个含 `SKILL.md` 的子目录都会在 `sdlc-cli install` 时覆盖注册到 `~/.claude/skills` 与 `~/.agents/skills`(新增 skill 无需改代码,放进目录即被识别)。它们是 CLI 的**语义前端**:负责从对话里抽参数、按 needs 追问、转述结果;破坏性动作仍由 CLI 执行。

| skill | 负责什么 |
| --- | --- |
| `configuring-sdlc` | `~/.sdlc/config.yaml` 的自然语言前端:识别要配的维度(workspace / products / repos)、补齐缺失项、覆盖前确认,再调 `config set/get/list` |
| `initializing-context-repo` | 把一个目录初始化成规范 context-repo:建 `prds/` + `requirements/` + `assets/` 三层骨架、按模板生成 `AGENTS.md` 导航(真源)与 `CLAUDE.md` 软链、写根 `manifest.yaml`(产品名,供 `ws init` 校验)、按需建 `prds/` 下功能模块。导航按需求产出形态分两套(走标准流程写四段式 `prd/` 与命名规范,不走则只写粗略结构)。新仓从零建与已有仓补齐都走它;只落文件,不碰 git、不写配置 |

> 工作区装配的对话前端(`assembling-workspace`)与各研发阶段 skill(`ingesting-prd`、`generating-tests`、`extracting-assets` 等)在**独立的 `agents` 仓**维护,不随本包发布;本包 skill 与它们通过命令契约协作。

## needs 码

needs 是**阻断性缺失**:回报 needs 时 CLI **绝不做破坏性动作**(装配的经典顺序是「先把所有 needs 收集完,非空则早返回,否则才动 git」)。与之相对,warnings 是非阻断提示(如 `PRD_REF_NOT_FOUND`、`ALREADY_PRESENT`、`SESSION_ENTRY_WRITE_FAILED`、`ctx commit` 的 `PUSH_FAILED` / `PUSH_SKIPPED`),不改状态、不改退出码。needs 里只有 `PRD_REF_REQUIRED` 额外回带 `candidates[]`(context-repo 已有 PRD 清单),供 skill 语义推导/选择。

| 码 | 场景 | skill 行为 |
|---|---|---|
| `CONTEXT_REPO_MISSING` | ws create 拿不到 context-repo 地址 | 问产品或直接给 `--context-repo` |
| `KNOWLEDGE_MISSING` | ws create 拿不到知识库地址 | 问产品或给 `--knowledge`（可多个） |
| `REPO_REMOTE_MISSING` | 某逻辑仓既无 `repos.<id>.remote` 也无 `local`，带 `repo` 字段 | 补配置或给 `--repo-remote <id>=<url>` |
| `REPO_BASE_PENDING` / `REPO_BASE_REQUIRED` | 基准分支未定（装配 / 交付发现阶段），带候选 ref | 让用户选定后 `--repo-base` 或 `deliver base set` 持久化 |
| `PRODUCT_WORKSPACE_MISSING` | ws which 未找到带 `.sdlc-product.yaml` 标记的产品工作区（含「目录在但无标记」的旧布局） | 提示运行 ws init |
| `PRODUCT_AMBIGUOUS` | ws which 找到多个产品工作区 | 列出候选，要求 --product 指定 |
| `REPO_MANIFEST_MISSING` | ws init 时 context-repo 根缺 `manifest.yaml`（产品名无从校验） | 先走 `initializing-context-repo` 补齐 |
| `PRD_REF_REQUIRED` | ws create 的 dev 视图未给 `--prd-ref`（dev 必填），回带 `candidates[]` | 从候选语义推导→确认/选择后补 `--prd-ref` 重调 |
| `PRD_MODULE_REQUIRED` | ctx scaffold 未指定 --module/--no-module | 列出已有模块（或提示命名第一个），要求用户选择 |
| `MATERIAL_VERSION_MISSING` | cp request 的 `--material` 没带版本 | 问清材料版本后重调 |
| `REVIEWER_MISSING` | cp request 未指定 `--reviewer` 且非自审 | 问审核人，或确认改走 `--self-review` |
| `DB_NOT_FOUND` | databases 无此 key,附安全摘要 `databases` | 先搜索已有库;新库用 config set 一次写完整条目 |
| `DB_ENV_REQUIRED` | 多环境未指定 --env,附安全摘要 `environments` | 选择已配环境后重调 |
| `DB_ENV_MISSING` | 指定环境缺失,带 key/env | 补完整环境条目后重调 |
| `ARCHERY_KEY_MISSING` | Archery AK 缺省或为空,带 key/env | 补 archery.access_key 后重调 |

db 的 needs 返回 exit 0,stdout 为 `{ok:true,state:"needs-input",needs:[...]}`;数据库候选用 `databases`、环境候选用 `environments`,不复用 PRD 的 `candidates` 字段。

`deliver` 的阻断项不是「缺信息」而是「仓库状态不允许交付」,统一以同样的信封回报,但要人去处理工作树而不是补参数:`DETACHED_HEAD`、`SOURCE_EQUALS_TARGET`、`ORIGIN_MISSING`、`OPERATION_IN_PROGRESS`（rebase/merge 进行中）、`CONFLICTS_PRESENT`、`TARGET_MISSING`、`REMOTE_OBJECT_MISSING`、`UNSUPPORTED_GIT_HOST`（只支持 GitLab）、`GITLAB_AUTH_REQUIRED`、`MR_CREATE_UNCONFIRMED`、`MR_TERMINAL_WITH_NEW_COMMITS`（已有终态 MR 又出现新提交）、`PLAN_STALE`（plan_id 已失效,需重新 `plan`）、`PLAN_BLOCKED`。

## 开发

```bash
pnpm install
pnpm build        # tsup 打包到 dist/
pnpm test         # vitest 运行测试
pnpm typecheck    # tsc --noEmit
pnpm dev          # tsup --watch
```

- 技术栈:TypeScript(ESM)、clipanion(命令)、zod(schema 校验)、simple-git、gray-matter / yaml。
- 测试通过 `SDLC_CONFIG` 环境变量隔离配置路径,用临时目录 + git 仓构造夹具。

## License

私有 / 内部使用。
