Claude Skill

pdlc-retro

迭代复盘——读状态机历史出趋势报告

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download kanfu-panda-pdlc-skills-skills_pdlc-retro-3cd2f02.zip · 11 KB
Part of kanfu-panda/pdlc-skills — 36 skills

Install

skills CLI npx skills add https://github.com/kanfu-panda/pdlc-skills/tree/main/skills/pdlc-retro
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install kanfu-panda-pdlc-skills@llmmart
Git git clone https://github.com/kanfu-panda/pdlc-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole kanfu-panda/pdlc-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

PDLC 迭代复盘

读取 docs/.pdlc-state/ 下的状态机历史,按时间范围聚合,生成趋势复盘报告。

IRON LAW · 不可违反的硬门禁

以下规则为不可协商的执行约束:

  1. 文件必须落盘:所有带编号(功能ID / 缺陷ID)的文档,必须作为实际文件写入磁盘,不可仅在对话中输出。
  2. 阶段必须落章:每个阶段完成后必须在状态机 docs/.pdlc-state/<feature-id>.json 追加 history,不可跳过。
  3. 测试必须存在:进入 /pdlc-implement 前,对应测试必须存在且处于红灯状态。违反则中止。
  4. 自检必须执行:段二自检为强制步骤,不得以"已经很好了"为由跳过。
  5. 防循环:段三修复为单次,不递归。无法自动修复的问题记录到报告,继续往下走。
  6. 状态必推进:成功执行某 phase 后 current_stage 必须变更。收尾时若发现 current_stage 未推进,视为失败并报错,不得静默返回(防止外层循环拿滞后的状态空转烧额度)。唯一例外:命中人工点主动 block 时,current_stage 保持不变但必须写 last_phase_result.ok=false + blocked_reason

违反任一条 = 立即中止当前命令,输出违规详情,等待人工介入。

读状态机之前:先做契约体检

docs/.pdlc-state/*.json 不一定都是 /pdlc-* 命令写出来的——手写的、旧版本写的、照着格式仿写的 都可能混在里面。字段对不上契约时,不许边猜边算、再把猜出来的东西当结论。先体检,再计算; 体检发现的每一处偏差,都必须出现在输出的最前面

1. 跑体检

体检脚本随本 skill 一起分发,就在本 skill 目录下:scripts/pdlc-state-lint.sh(运行时会告知本 skill 的 所在目录)。它与正在运行的 skill 同一版本,体检规则——合法阶段名、偏差代码——与下方 §2 一致。

在项目根执行 bash <本 skill 目录>/scripts/pdlc-state-lint.sh .。stdout 每行一条偏差:文件<TAB>代码<TAB>说明。退出码三态:

  • 0 已体检、全部合契约 → 照常出结论,不输出体检块
  • 1 已体检、有偏差 → 照常计算,但输出最前面必须先放「输入契约体检」块(见 §3)
  • 2 无法体检(缺 jq / 无状态目录)→ 体检块写「无法体检:<原因>」,不得当成合契约

体检脚本不可用时(找不到,或当前平台没有随 skill 分发它),按 §2 的规则逐条人工核对, 体检块首行注明「体检脚本不可用,以下为人工核对」。

2. 体检规则(偏差代码)

读侧跳过 _ 前缀的索引文件(如 _relations.json)与 statusline.json,它们不是功能状态机。

代码 判定 读侧怎么处理
json-invalid 文件不是合法 JSON 跳过该文件,列入体检块
missing-field feature_id / current_stage / history / next_step / created_at 之一 用到该字段的指标,对这份文件记「不可判」
field-type-invalid 字段在、类型不对(如 current_stage 不是字符串、history 不是数组、last_phase_result 不是对象、history 条目不是对象) 按缺失处理:用到它的指标对这份文件记「不可判」。类型不对的时间戳不再另报 timestamp-no-time
missing-last_phase_result last_phase_result(多为旧文件) 不推断本阶段结果与 checks
terminal_state-in-instance 实例里出现 terminal_state 忽略该字段,判终态只看 §4
non-contract-field 其它表外顶层字段(如 title 忽略
stage-alias stage 用了已知别名 可按说明里的 别名→短名 归一化计数,但必须在体检块写明归一化了哪些
stage-unknown stage 既不是短名也不是已知别名 单独成行,不并入任何阶段
current_stage-unknown current_stage 既不是短名,也不以 _done 结尾 该功能归入「❓ 异常」
next_step-not-command next_step 不是纯命令名(如带散文后缀) 按原文展示,不据此推断下一步
timestamp-no-time 时间戳只有日期、没有时刻 依赖它的耗时类指标记「不可测」,不得算成 0
relations-not-object relations 不是六键对象(如数组) 这份文件的关系不入图,列入体检块
relations-unknown-type 关系键不在六类之内 该键下的关系不入图
relations-target-not-id 关系目标不是 feature ID(散文、模块路径等) 该条不入图
relations-dangling 目标 ID 没有对应的状态文件 标「悬空」,不参与影响半径计算
id-prefix-mismatch 走的是修复流程(有 fix 阶段),ID 却不以 B 开头 缺陷计数按 ID 前缀的契约口径算,同时在体检块点名这份
second-state-dir 仓库根另有一个 .pdlc-state/ 本命令只读 docs/.pdlc-state/,提示两处并存

「不入图」不是丢弃:关系目标是散文时,把它硬解读成某个 feature,等于把猜测升格成结构—— 影响半径会因此多出一条本不存在的强依赖。宁可在体检块里原样列出,交给人判断。

阶段短名全集

history[].stagelast_phase_result.stage 只能取下表的短名——每个短名由对应命令写入, 等于该命令 frontmatter 的 stage:。别名只为旧数据时容忍,归一化必须披露;的时候一律用短名。

短名 由哪个命令写入 已知别名(读侧可归一化,须披露)
requirements pdlc-prd prd
design pdlc-design
tdd pdlc-tdd
impl pdlc-implement implement, implementation
review pdlc-review
e2e pdlc-e2e
ship pdlc-ship
deploy pdlc-deploy
fix pdlc-fix bugfix
refactor pdlc-refactor
task pdlc-task
feature pdlc-feature

3. 体检块的格式(放在输出最前面)

⚠️ 输入契约体检:<N> 份状态文件,<M> 处偏差——以下结论建立在这些处理之上
  · 3/5 份缺 created_at → 时间窗改用 history 末条 done_at
  · 阶段别名 prd→requirements ×1、implementation→impl ×2 → 已归一化计数
  · 5/5 份含 terminal_state → 已忽略(判终态只看 current_stage)
  · 2 份的 done_at 只有日期 → 阶段耗时记「不可测」
  • 按「偏差类 × 份数 × 处理方式」汇总,不要把脚本输出原样倒出来
  • 放最前面,不放末尾——结论会被单独引用,偏差得跟着结论走;写在报告末尾的偏差表, 读者看到的时候已经信了前面的数字
  • 计数照抄脚本输出,不要自己重数:按代码计数用 cut -f2 | sort | uniq -c,份数按第一列去重。 真机验证时脚本报 25 处无时刻的时间戳,报告正文却自己数成了 26——同一份报告里两个数字对不上, 读者就不知道该信哪个
  • 同一次输出里,前面披露了「已忽略 X」,后面的计算就不得再用 X

4. 判终态的唯一依据 ⛔

已抵达终态 ⇔ current_stage_done 结尾(终态即「已发布」:/pdlc-shipship_done/pdlc-deploydeploy_done;旧版本留下的 feature_done / fix_done / review_done 也按终态读)。

  • 不看 terminal_state。状态机实例本就没有这个字段。skill frontmatter 里的 terminal_state: 说的是「这个命令走完后应当到达的终态名」——是目标,不是事实。拿它判终态, 等于把「打算完成」当成「已经完成」。
  • 不用封闭列表(如只认 [feature_done, fix_done])。封闭列表会把合法的终态漏判成「进行中」, 逼着读侧去别的字段里找答案——而最顺手的那个字段恰好就是 terminal_state
  • next_stepnull 也不等于终态/pdlc-task 写的 next_step 恒为 null,却未必完成;空的或残缺的状态文件也读得出 null

段一:聚合统计

1.1 参数解析

  • --range <N>d | all:时间窗口(默认 30d
  • --feature <feature-id>:只看单个功能的时间线(输出 history 详情而非聚合)

1.2 读取与过滤

  1. 列出 docs/.pdlc-state/*.json(跳过 _ 前缀的索引文件与 statusline.json
  2. 跑契约体检(见上方「读状态机之前:先做契约体检」),结果写进报告开头的「输入契约体检」节
  3. created_at 过滤到时间窗口内。缺 created_at 的文件改用 history 末条 done_at——这是口径替换,必须计入体检块,不得静默
  4. 解析每份文件的 history 数组

1.3 计算指标

对时间窗口内的所有功能聚合:

交付量

  • 完成功能数:current_stage_done 结尾——判终态的唯一依据,不看 terminal_state(它是目标不是事实)、不用封闭列表
  • 修复缺陷数:feature_idB 开头(契约口径)。体检报 id-prefix-mismatch 的文件在体检块点名,不据此改口径——两个口径打架时,报出来比悄悄挑一个更有用

质量趋势

  • 每阶段自检通过率(passed / (passed + failed + manual));某阶段一条自检记录都没有 → 写「无数据」,不写 0%
  • 各阶段自检平均 failedmanual
  • 行按阶段短名:stage-alias别名→短名 归一化后计数(归一化了哪些写进体检块);stage-unknown 单独成行,不并入任何阶段

阶段耗时

  • 相邻 history 条目的 done_at 差值作为阶段耗时,输出各阶段中位数
  • 任一端时间戳没有时刻(体检报 timestamp-no-time)→ 该间隔不可测,不计入中位数;全部不可测 → 整节写「不可测:<原因>」。不得输出 0.0h——它会被读成「快到不耗时」,实际是精度不够

卡点案例

  • 自检 failed > 2manual > 1 的阶段
  • 同一阶段耗时 > 3 天的功能

段二:自检

段二:自检(强制)

重新阅读本次产出物,按质量关卡清单逐项检查。勾选已通过,标注未通过原因。

注意:自检清单的具体内容由各命令自行定义,本片段只规定结构。

段三:修复(单次,不递归)

针对自检段标注为未通过的项:

  • 可自动修复:直接修复(如补缺字段、修正格式、补齐缺失段落)
  • 修复后回验:再次运行自检,确认被修复项现在通过
  • 无法自动修复:记录到自审报告,不再尝试,流程继续

⚠️ 单次修复原则:若一轮修复后仍有项未通过,不再递归修复,防止死循环。

复盘报告自检清单:

  • 时间窗口正确(参数解析无误)
  • 数值可加总(交付量、质量趋势)
  • 列出了至少 1 个卡点案例或明确说明"无卡点"
  • 体检有偏差时,报告开头有「输入契约体检」节,且后文计算没有再用已声明忽略的字段(如 terminal_state
  • 阶段耗时没有把「不可测」写成 0.0h

防循环规则

本命令所有的自检-修复循环均受以下约束:

  1. 单次检查:同一个自检清单在本次命令执行中只跑一次(起始 + 修复后验证共两次读)
  2. 单次修复:发现的问题只尝试修复一轮
  3. 不递归:修复后不再重新触发自检的全量重跑
  4. 失败降级:无法自动修复的问题 → 记录到自审报告 → 流程继续 → 最终报告标注待人工处理

这是为了防止 agent 在"修完再查、查完再修"的往返中陷入死循环。

段三:生成报告文件

  1. 确定输出路径:docs/07_reviews/retro/<YYYY-MM>-retro.md(按当月归档)
  2. 写入模板:
# <YYYY-MM> PDLC 迭代复盘

> 时间窗口:<起> ~ <止>
> 生成时间:<ISO 时间>

## 输入契约体检
(仅在体检有偏差或无法体检时出现;按「偏差类 × 份数 × 处理方式」汇总,放在全文最前面)

## 交付量
- 完成功能:<N> 个
- 修复缺陷:<M> 个

## 质量趋势
| 阶段 | 自检通过率 | 平均失败数 | 平均人工介入数 |
|------|----------|------------|--------------|
| requirements | XX% | N.N | N.N |
| design       | XX% | N.N | N.N |
| tdd          | XX% | N.N | N.N |
| impl         | XX% | N.N | N.N |
| review       | XX% | N.N | N.N |

## 阶段耗时中位数
- 需求: X.Xh  设计: X.Xh  TDD: X.Xh  实现: X.Xh  评审: X.Xh
  (时间戳缺时刻导致不可测时,写「不可测:<原因>」,不写 0.0h)

## 卡点案例
- <feature-id> 在 <stage> 阶段: <原因>

## 值得保留的做法
- <从高通过率/低耗时的功能中提炼>
  1. IRON LAW 落盘:文件必须实际写入磁盘,不可仅在对话中输出

段四:交接

段四:交接(Handoff)

命令完成后必须输出以下格式的最终消息:

✅ <阶段名> 完成:<主要产出物路径>
📊 自检:<通过数>/<总数> 通过(若有未通过,附要点)
📦 状态快照:docs/.pdlc-state/<feature-id>.json
👉 下一步:/pdlc-<next_step>
   (如果有分叉)或 /pdlc-<alt>(条件:<选择依据>)

规则:

  • 主流程命令(写状态机的命令;下一跳见正文里「本命令的状态机取值」)必须显式输出"下一步",不可省略
  • 工具型命令(Layer 3)可以没有 next_step,此时输出 👉 下一步:(本次流程结束,无后续)
  • 分叉场景必须说明选择条件,例如"若需补充测试用例 → /pdlc-tdd;若测试已齐 → /pdlc-review"

本命令的 handoff 输出:

✅ 复盘报告已生成:docs/07_reviews/retro/<YYYY-MM>-retro.md
📊 时间窗口:<起> ~ <止>,共 <N> 个功能
⚠️ 输入契约体检:<M> 处偏差(见报告开头;无偏差则省略本行)
👉 下一步:(本次流程结束,建议人工 review 报告)

参数:$ARGUMENTS

Files (pdlc-skills)
  • scripts
    • pdlc-state-lint.sh 11.3 KB
      #!/usr/bin/env bash
      # pdlc-state-lint.sh —— 读状态机之前的契约体检(确定性、只读)
      #
      # 用法:bash pdlc-state-lint.sh [项目根目录]      (缺省为当前目录)
      #
      # 逐份读 <项目根>/docs/.pdlc-state/*.json,对照状态机契约,把每一处偏差打成一行:
      #     <文件名><TAB><偏差代码><TAB><说明>
      # 偏差代码的含义,以及读侧(/pdlc-status、/pdlc-retro、/pdlc-relate)该怎么处理,
      # 见 references/templates/prompts/state-read.md 的偏差代码表。
      #
      # 为什么要有它:状态文件不一定都是 /pdlc-* 命令写的。字段对不上契约时,读侧的模型只能
      # 边猜边算——曾有两个不同的命令各自拿 terminal_state(契约里没有这个字段)判终态,
      # 把「目标是完成」报成了「已经完成」。把体检做成代码,同样的输入永远得到同样的偏差清单,
      # 读侧只负责把它们披露在结论前面。
      #
      # 退出码三态(与 check 命令的三态同一条纪律——「查不了」绝不能当成「没问题」):
      #   0  已体检,全部合契约
      #   1  已体检,有偏差(读侧可以继续,但结论必须先披露这些偏差)
      #   2  无法体检:没有 docs/.pdlc-state/、缺 jq,或体检自身出错
      #
      # 只读:不写任何文件。兼容 macOS 自带的 bash 3.2(不用 mapfile / 关联数组)。
      set -u
      
      # 下面三份清单由 tests/frontmatter-check.sh 断言:
      #   LEGAL_STAGES / STAGE_ALIASES 必须与 state-read.md 的 stage-names 表一致;
      #   FINDING_CODES 必须与 state-read.md 的偏差代码表逐一对应。
      # 本脚本在目标项目里运行、读不到插件的 skills/,所以清单只能内嵌——断言保证它不漂。
      LEGAL_STAGES="requirements design tdd impl review e2e ship deploy fix refactor task feature"
      STAGE_ALIASES="prd:requirements implement:impl implementation:impl bugfix:fix"
      # shellcheck disable=SC2034  # 脚本自己不读它:它是偏差代码的声明清单,供 frontmatter-check 与片段对账
      FINDING_CODES="json-invalid missing-field field-type-invalid missing-last_phase_result terminal_state-in-instance non-contract-field stage-alias stage-unknown current_stage-unknown next_step-not-command timestamp-no-time relations-not-object relations-unknown-type relations-target-not-id relations-dangling id-prefix-mismatch second-state-dir"
      
      ROOT="${1:-.}"
      STATE_DIR="$ROOT/docs/.pdlc-state"
      
      if ! command -v jq >/dev/null 2>&1; then
          echo "pdlc-state-lint: 缺 jq,无法体检" >&2
          exit 2
      fi
      if [ ! -d "$STATE_DIR" ]; then
          echo "pdlc-state-lint: 没有 ${STATE_DIR},无法体检" >&2
          exit 2
      fi
      
      # 与状态栏同一条规则:_ 前缀是自动生成的索引(如 _relations.json),
      # statusline.json 是配置,都不是功能状态机
      is_state_file() {
          case "${1##*/}" in
              _*|statusline.json) return 1 ;;
          esac
          return 0
      }
      
      # 现存的 feature ID(= 状态文件名去掉 .json),供判断关系目标是否悬空
      ids=""
      for f in "$STATE_DIR"/*.json; do
          [ -e "$f" ] || continue
          is_state_file "$f" || continue
          b="${f##*/}"
          ids="${ids}${b%.json}
      "
      done
      ids_json="$(printf '%s' "$ids" | jq -R -s 'split("\n") | map(select(length > 0))')"
      
      files=0
      findings=0
      broken=0
      
      for f in "$STATE_DIR"/*.json; do
          [ -e "$f" ] || continue
          is_state_file "$f" || continue
          base="${f##*/}"
          files=$((files + 1))
      
          # 先单独判合法性:把「文件坏了」和「体检程序自己出错」分开,后者属于无法体检
          if ! jq empty "$f" >/dev/null 2>&1; then
              printf '%s\t%s\t%s\n' "$base" "json-invalid" "不是合法 JSON,已跳过"
              findings=$((findings + 1))
              continue
          fi
      
          if ! out="$(jq -r --arg legal "$LEGAL_STAGES" --arg aliases "$STAGE_ALIASES" --argjson ids "$ids_json" '
              def emit($c; $d): "\($c)\t\($d | tostring | gsub("[\t\r\n]"; " "))";
              def inarr($a; $x): ($a | map(. == $x) | any);
              def legal: ($legal | split(" "));
              def aliasmap: ($aliases | split(" ") | map(split(":") | {(.[0]): .[1]}) | add);
              def canon($v): (aliasmap[$v] // $v);
              def hastime: (type == "string") and test("T[0-9]{2}:[0-9]{2}");
              def sixkeys: ["extends", "depends_on", "supersedes", "resolves", "conflicts_with", "relates_to"];
              def allowed: ["feature_id", "feature_name", "created_at", "current_stage", "run_mode",
                            "history", "last_phase_result", "relations", "next_step"];
              def idre: "^[FB][0-9]{8}-([0-9]{6}|[0-9]{2})$";
              def hist: (.history | if type == "array" then to_entries[] | select(.value | type == "object") else empty end);
              # 字段在、类型不对:只查 has() 会把 current_stage 是数字、history 不是数组这类文件放过去
              def typecheck($k; $want):
                  select(has($k)) | .[$k] as $v | select(($v | type) != $want)
                  | emit("field-type-invalid"; "\($k) 应为 \($want),实际是 \($v | type),按缺失处理");
              def stage_check($where; $v):
                  if ($v | type) != "string" then empty
                  elif inarr(legal; $v) then empty
                  elif aliasmap[$v] != null then
                      emit("stage-alias"; "\($where)=\($v) 是别名,读侧按 \($v)→\(aliasmap[$v]) 归一化(须披露)")
                  else
                      emit("stage-unknown"; "\($where)=\($v) 既不是阶段短名也不是已知别名")
                  end;
      
              if type != "object" then emit("json-invalid"; "顶层不是 JSON 对象,已跳过")
              else
                ( ["feature_id", "current_stage", "history", "next_step", "created_at"][] as $k
                  | select(has($k) | not) | emit("missing-field"; "缺 \($k)") ),
                ( typecheck("feature_id"; "string"), typecheck("feature_name"; "string"),
                  typecheck("created_at"; "string"), typecheck("current_stage"; "string"),
                  typecheck("run_mode"; "string"), typecheck("history"; "array"),
                  typecheck("last_phase_result"; "object") ),
                ( .history | if type == "array" then
                    to_entries[] | select(.value | type != "object")
                    | emit("field-type-invalid"; "history[\(.key)] 应为 object,实际是 \(.value | type),该条跳过")
                  else empty end ),
                ( hist | .key as $i | .value
                  | ( select(has("stage") and (.stage | type) != "string")
                      | emit("field-type-invalid"; "history[\($i)].stage 应为 string,实际是 \(.stage | type)") ),
                    ( select(has("done_at") and (.done_at | type) != "string")
                      | emit("field-type-invalid"; "history[\($i)].done_at 应为 string,实际是 \(.done_at | type)") ) ),
                ( select((.last_phase_result | type) == "object") | .last_phase_result
                  | ( select(has("stage") and (.stage | type) != "string")
                      | emit("field-type-invalid"; "last_phase_result.stage 应为 string,实际是 \(.stage | type)") ),
                    ( select(has("at") and (.at | type) != "string")
                      | emit("field-type-invalid"; "last_phase_result.at 应为 string,实际是 \(.at | type)") ) ),
                ( select(has("last_phase_result") | not)
                  | emit("missing-last_phase_result"; "缺 last_phase_result(多为旧文件),不推断本阶段结果与 checks") ),
                ( select(has("terminal_state"))
                  | emit("terminal_state-in-instance"; "实例含 terminal_state=\(.terminal_state),它是目标不是事实,已忽略;判终态只看 current_stage") ),
                ( keys[] as $k | select($k != "terminal_state" and (inarr(allowed; $k) | not))
                  | emit("non-contract-field"; "表外字段 \($k),已忽略") ),
                ( hist | .key as $i | .value | stage_check("history[\($i)].stage"; .stage) ),
                ( select((.last_phase_result | type) == "object")
                  | stage_check("last_phase_result.stage"; .last_phase_result.stage) ),
                ( .current_stage as $cs | select(($cs | type) == "string")
                  | select(($cs | endswith("_done")) | not) | select(inarr(legal; $cs) | not)
                  | emit("current_stage-unknown"; "current_stage=\($cs) 既不是阶段短名,也不以 _done 结尾") ),
                ( select(has("next_step")) | .next_step as $n | select($n != null)
                  | select((($n | type) == "string" and ($n | test("^pdlc-[a-z][a-z-]*$"))) | not)
                  | emit("next_step-not-command"; "next_step=\($n) 不是纯命令名,不据此推断下一步") ),
                ( select((.created_at | type) == "string") | select(.created_at | hastime | not)
                  | emit("timestamp-no-time"; "created_at=\(.created_at) 没有时刻") ),
                ( hist | .key as $i | .value | select((.done_at | type) == "string") | select(.done_at | hastime | not)
                  | emit("timestamp-no-time"; "history[\($i)].done_at=\(.done_at) 没有时刻,耗时类指标不可测") ),
                ( select((.last_phase_result | type) == "object" and ((.last_phase_result.at | type) == "string"))
                  | select(.last_phase_result.at | hastime | not)
                  | emit("timestamp-no-time"; "last_phase_result.at=\(.last_phase_result.at) 没有时刻") ),
                ( select(has("relations")) | .relations as $r
                  | if ($r | type) == "object" then
                      ( $r | to_entries[] | select(.key != "_updated_at") | . as $e
                        | if (inarr(sixkeys; $e.key) | not) then
                            emit("relations-unknown-type"; "关系类型 \($e.key) 不在六类之内,不入图")
                          else
                            ($e.value | if type == "array" then .[] else . end) as $t
                            | if (($t | type) == "string" and ($t | test(idre))) then
                                ( select(inarr($ids; $t) | not)
                                  | emit("relations-dangling"; "\($e.key) → \($t),没有对应的状态文件") )
                              else
                                emit("relations-target-not-id"; "\($e.key) → \($t),目标不是 feature ID,不入图")
                              end
                          end )
                    elif $r == null then empty
                    else
                      emit("relations-not-object"; "relations 是 \($r | type)\(if ($r | type) == "array" then "(\($r | length) 条)" else "" end),契约要求六键对象;该文件的关系不入图")
                    end ),
                ( select([hist | .value.stage | select(type == "string") | canon(.)] | inarr(.; "fix"))
                  | select(((.feature_id // "") | tostring | startswith("B")) | not)
                  | emit("id-prefix-mismatch"; "history 有修复阶段,feature_id=\(.feature_id // "缺") 却不以 B 开头;缺陷计数仍按 ID 前缀口径") )
              end
          ' "$f" 2>/dev/null)"; then
              echo "pdlc-state-lint: 体检 ${base} 时出错(体检自身的问题),该文件未完成体检" >&2
              broken=1
              continue
          fi
      
          while IFS= read -r line; do
              [ -n "$line" ] || continue
              printf '%s\t%s\n' "$base" "$line"
              findings=$((findings + 1))
          done <<< "$out"
      done
      
      # 仓库根另有一个 .pdlc-state/:常见于先有一份轻量登记、后来又照格式建了 docs/.pdlc-state/ 的项目
      if [ -d "$ROOT/.pdlc-state" ]; then
          printf '%s\t%s\t%s\n' ".pdlc-state/" "second-state-dir" "仓库根另有一个 .pdlc-state/;PDLC 只读写 docs/.pdlc-state/,两处并存容易让人以为状态在另一处"
          findings=$((findings + 1))
      fi
      
      echo "pdlc-state-lint: ${files} 份状态文件,${findings} 处偏差" >&2
      
      [ "$broken" -eq 1 ] && exit 2
      [ "$findings" -gt 0 ] && exit 1
      exit 0
      
  • SKILL.md 15.2 KB
    ---
    name: pdlc-retro
    description: 迭代复盘——读状态机历史出趋势报告
    argument-hint: [--range 7d|30d|all] [--feature <feature-id>]
    allowed-tools: Read, Glob, Bash
    layer: 2
    stage: retro
    produces:
      - docs/07_reviews/retro/<YYYY-MM>-retro.md
    requires:
      - docs/.pdlc-state/
    next_step: null
    terminal_state: retro_done
    ---
    
    # PDLC 迭代复盘
    
    读取 `docs/.pdlc-state/` 下的状态机历史,按时间范围聚合,生成趋势复盘报告。
    
    <!-- @include templates/prompts/iron-law.md(已内联于下方,无需另读) -->
    ⛔ **IRON LAW · 不可违反的硬门禁**
    
    以下规则为**不可协商**的执行约束:
    
    1. **文件必须落盘**:所有带编号(功能ID / 缺陷ID)的文档,必须作为实际文件写入磁盘,不可仅在对话中输出。
    2. **阶段必须落章**:每个阶段完成后必须在状态机 `docs/.pdlc-state/<feature-id>.json` 追加 history,不可跳过。
    3. **测试必须存在**:进入 `/pdlc-implement` 前,对应测试必须存在且处于红灯状态。违反则中止。
    4. **自检必须执行**:段二自检为强制步骤,不得以"已经很好了"为由跳过。
    5. **防循环**:段三修复为单次,不递归。无法自动修复的问题记录到报告,继续往下走。
    6. **状态必推进**:成功执行某 phase 后 `current_stage` 必须变更。收尾时若发现 `current_stage` 未推进,视为失败并报错,**不得静默返回**(防止外层循环拿滞后的状态空转烧额度)。唯一例外:命中人工点主动 block 时,`current_stage` 保持不变但必须写 `last_phase_result.ok=false` + `blocked_reason`。
    
    **违反任一条 = 立即中止当前命令,输出违规详情,等待人工介入。**
    <!-- @include-end templates/prompts/iron-law.md -->
    
    <!-- @include templates/prompts/state-read.md(已内联于下方,无需另读) -->
    ## 读状态机之前:先做契约体检
    
    `docs/.pdlc-state/*.json` 不一定都是 `/pdlc-*` 命令写出来的——手写的、旧版本写的、照着格式仿写的
    都可能混在里面。字段对不上契约时,**不许边猜边算、再把猜出来的东西当结论**。先体检,再计算;
    体检发现的每一处偏差,都必须出现在输出的**最前面**。
    
    ### 1. 跑体检
    
    体检脚本随本 skill 一起分发,就在本 skill 目录下:`scripts/pdlc-state-lint.sh`(运行时会告知本 skill 的
    所在目录)。它与正在运行的 skill 同一版本,体检规则——合法阶段名、偏差代码——与下方 §2 一致。
    
    在项目根执行 `bash <本 skill 目录>/scripts/pdlc-state-lint.sh .`。stdout 每行一条偏差:`文件<TAB>代码<TAB>说明`。退出码三态:
    
    - `0` 已体检、全部合契约 → 照常出结论,不输出体检块
    - `1` 已体检、有偏差 → 照常计算,但输出最前面必须先放「输入契约体检」块(见 §3)
    - `2` 无法体检(缺 jq / 无状态目录)→ 体检块写「无法体检:<原因>」,**不得当成合契约**
    
    体检脚本不可用时(找不到,或当前平台没有随 skill 分发它),按 §2 的规则逐条人工核对,
    体检块首行注明「体检脚本不可用,以下为人工核对」。
    
    ### 2. 体检规则(偏差代码)
    
    读侧跳过 `_` 前缀的索引文件(如 `_relations.json`)与 `statusline.json`,它们不是功能状态机。
    
    <!-- finding-codes:start -->
    | 代码 | 判定 | 读侧怎么处理 |
    |---|---|---|
    | `json-invalid` | 文件不是合法 JSON | 跳过该文件,列入体检块 |
    | `missing-field` | 缺 `feature_id` / `current_stage` / `history` / `next_step` / `created_at` 之一 | 用到该字段的指标,对这份文件记「不可判」 |
    | `field-type-invalid` | 字段在、类型不对(如 `current_stage` 不是字符串、`history` 不是数组、`last_phase_result` 不是对象、history 条目不是对象) | **按缺失处理**:用到它的指标对这份文件记「不可判」。类型不对的时间戳不再另报 `timestamp-no-time` |
    | `missing-last_phase_result` | 缺 `last_phase_result`(多为旧文件) | 不推断本阶段结果与 checks |
    | `terminal_state-in-instance` | 实例里出现 `terminal_state` | **忽略该字段**,判终态只看 §4 |
    | `non-contract-field` | 其它表外顶层字段(如 `title`) | 忽略 |
    | `stage-alias` | `stage` 用了已知别名 | 可按说明里的 `别名→短名` 归一化计数,**但必须在体检块写明归一化了哪些** |
    | `stage-unknown` | `stage` 既不是短名也不是已知别名 | 单独成行,不并入任何阶段 |
    | `current_stage-unknown` | `current_stage` 既不是短名,也不以 `_done` 结尾 | 该功能归入「❓ 异常」 |
    | `next_step-not-command` | `next_step` 不是纯命令名(如带散文后缀) | 按原文展示,不据此推断下一步 |
    | `timestamp-no-time` | 时间戳只有日期、没有时刻 | 依赖它的**耗时类指标记「不可测」**,不得算成 0 |
    | `relations-not-object` | `relations` 不是六键对象(如数组) | 这份文件的关系**不入图**,列入体检块 |
    | `relations-unknown-type` | 关系键不在六类之内 | 该键下的关系不入图 |
    | `relations-target-not-id` | 关系目标不是 feature ID(散文、模块路径等) | 该条不入图 |
    | `relations-dangling` | 目标 ID 没有对应的状态文件 | 标「悬空」,不参与影响半径计算 |
    | `id-prefix-mismatch` | 走的是修复流程(有 `fix` 阶段),ID 却不以 `B` 开头 | 缺陷计数按 ID 前缀的契约口径算,同时在体检块点名这份 |
    | `second-state-dir` | 仓库根另有一个 `.pdlc-state/` | 本命令只读 `docs/.pdlc-state/`,提示两处并存 |
    <!-- finding-codes:end -->
    
    **「不入图」不是丢弃**:关系目标是散文时,把它硬解读成某个 feature,等于把猜测升格成结构——
    影响半径会因此多出一条本不存在的强依赖。宁可在体检块里原样列出,交给人判断。
    
    #### 阶段短名全集
    
    `history[].stage` 与 `last_phase_result.stage` 只能取下表的短名——每个短名由对应命令写入,
    等于该命令 frontmatter 的 `stage:`。别名只为**读**旧数据时容忍,归一化必须披露;**写**的时候一律用短名。
    
    <!-- stage-names:start -->
    | 短名 | 由哪个命令写入 | 已知别名(读侧可归一化,须披露) |
    |---|---|---|
    | `requirements` | `pdlc-prd` | `prd` |
    | `design` | `pdlc-design` | |
    | `tdd` | `pdlc-tdd` | |
    | `impl` | `pdlc-implement` | `implement, implementation` |
    | `review` | `pdlc-review` | |
    | `e2e` | `pdlc-e2e` | |
    | `ship` | `pdlc-ship` | |
    | `deploy` | `pdlc-deploy` | |
    | `fix` | `pdlc-fix` | `bugfix` |
    | `refactor` | `pdlc-refactor` | |
    | `task` | `pdlc-task` | |
    | `feature` | `pdlc-feature` | |
    <!-- stage-names:end -->
    
    ### 3. 体检块的格式(放在输出最前面)
    
    ```
    ⚠️ 输入契约体检:<N> 份状态文件,<M> 处偏差——以下结论建立在这些处理之上
      · 3/5 份缺 created_at → 时间窗改用 history 末条 done_at
      · 阶段别名 prd→requirements ×1、implementation→impl ×2 → 已归一化计数
      · 5/5 份含 terminal_state → 已忽略(判终态只看 current_stage)
      · 2 份的 done_at 只有日期 → 阶段耗时记「不可测」
    ```
    
    - **按「偏差类 × 份数 × 处理方式」汇总**,不要把脚本输出原样倒出来
    - **放最前面,不放末尾**——结论会被单独引用,偏差得跟着结论走;写在报告末尾的偏差表,
      读者看到的时候已经信了前面的数字
    - **计数照抄脚本输出,不要自己重数**:按代码计数用 `cut -f2 | sort | uniq -c`,份数按第一列去重。
      真机验证时脚本报 25 处无时刻的时间戳,报告正文却自己数成了 26——同一份报告里两个数字对不上,
      读者就不知道该信哪个
    - 同一次输出里,前面披露了「已忽略 X」,后面的计算就不得再用 X
    
    ### 4. 判终态的唯一依据 ⛔
    
    **已抵达终态 ⇔ `current_stage` 以 `_done` 结尾**(终态即「已发布」:`/pdlc-ship` 写 `ship_done`、`/pdlc-deploy` 写 `deploy_done`;旧版本留下的 `feature_done` / `fix_done` / `review_done` 也按终态读)。
    
    - **不看 `terminal_state`**。状态机实例本就没有这个字段。skill frontmatter 里的 `terminal_state:`
      说的是「这个命令走完后**应当**到达的终态名」——是**目标**,不是**事实**。拿它判终态,
      等于把「打算完成」当成「已经完成」。
    - **不用封闭列表**(如只认 `[feature_done, fix_done]`)。封闭列表会把合法的终态漏判成「进行中」,
      逼着读侧去别的字段里找答案——而最顺手的那个字段恰好就是 `terminal_state`。
    - **`next_step` 为 `null` 也不等于终态**:`/pdlc-task` 写的 `next_step` 恒为 `null`,却未必完成;空的或残缺的状态文件也读得出 `null`。
    <!-- @include-end templates/prompts/state-read.md -->
    
    ## 段一:聚合统计
    
    ### 1.1 参数解析
    
    - `--range <N>d | all`:时间窗口(默认 `30d`)
    - `--feature <feature-id>`:只看单个功能的时间线(输出 history 详情而非聚合)
    
    ### 1.2 读取与过滤
    
    1. 列出 `docs/.pdlc-state/*.json`(跳过 `_` 前缀的索引文件与 `statusline.json`)
    2. **跑契约体检**(见上方「读状态机之前:先做契约体检」),结果写进报告开头的「输入契约体检」节
    3. 按 `created_at` 过滤到时间窗口内。缺 `created_at` 的文件改用 `history` 末条 `done_at`——**这是口径替换,必须计入体检块**,不得静默
    4. 解析每份文件的 `history` 数组
    
    ### 1.3 计算指标
    
    对时间窗口内的所有功能聚合:
    
    **交付量**:
    - 完成功能数:`current_stage` 以 `_done` 结尾——**判终态的唯一依据**,不看 `terminal_state`(它是目标不是事实)、不用封闭列表
    - 修复缺陷数:`feature_id` 以 `B` 开头(契约口径)。体检报 `id-prefix-mismatch` 的文件在体检块点名,**不据此改口径**——两个口径打架时,报出来比悄悄挑一个更有用
    
    **质量趋势**:
    - 每阶段自检通过率(`passed / (passed + failed + manual)`);某阶段一条自检记录都没有 → 写「无数据」,不写 0%
    - 各阶段自检平均 `failed`、`manual` 数
    - 行按阶段短名:`stage-alias` 按 `别名→短名` 归一化后计数(归一化了哪些写进体检块);`stage-unknown` 单独成行,不并入任何阶段
    
    **阶段耗时**:
    - 相邻 history 条目的 `done_at` 差值作为阶段耗时,输出各阶段中位数
    - 任一端时间戳没有时刻(体检报 `timestamp-no-time`)→ 该间隔**不可测**,不计入中位数;全部不可测 → 整节写「不可测:<原因>」。**不得输出 0.0h**——它会被读成「快到不耗时」,实际是精度不够
    
    **卡点案例**:
    - 自检 `failed > 2` 或 `manual > 1` 的阶段
    - 同一阶段耗时 > 3 天的功能
    
    ## 段二:自检
    
    <!-- @include templates/prompts/self-audit.md(已内联于下方,无需另读) -->
    ## 段二:自检(强制)
    
    重新阅读本次产出物,按质量关卡清单逐项检查。勾选已通过,标注未通过原因。
    
    > **注意**:自检清单的具体内容由各命令自行定义,本片段只规定结构。
    
    ## 段三:修复(单次,不递归)
    
    针对自检段标注为未通过的项:
    
    - **可自动修复**:直接修复(如补缺字段、修正格式、补齐缺失段落)
    - **修复后回验**:再次运行自检,确认被修复项现在通过
    - **无法自动修复**:记录到自审报告,不再尝试,流程继续
    
    ⚠️ 单次修复原则:若一轮修复后仍有项未通过,**不再递归修复**,防止死循环。
    <!-- @include-end templates/prompts/self-audit.md -->
    
    **复盘报告自检清单:**
    - [ ] 时间窗口正确(参数解析无误)
    - [ ] 数值可加总(交付量、质量趋势)
    - [ ] 列出了至少 1 个卡点案例或明确说明"无卡点"
    - [ ] 体检有偏差时,报告**开头**有「输入契约体检」节,且后文计算没有再用已声明忽略的字段(如 `terminal_state`)
    - [ ] 阶段耗时没有把「不可测」写成 0.0h
    
    <!-- @include templates/prompts/loop-prevention.md(已内联于下方,无需另读) -->
    ## 防循环规则
    
    本命令所有的自检-修复循环均受以下约束:
    
    1. **单次检查**:同一个自检清单在本次命令执行中只跑一次(起始 + 修复后验证共两次读)
    2. **单次修复**:发现的问题只尝试修复一轮
    3. **不递归**:修复后不再重新触发自检的全量重跑
    4. **失败降级**:无法自动修复的问题 → 记录到自审报告 → 流程继续 → 最终报告标注待人工处理
    
    这是为了防止 agent 在"修完再查、查完再修"的往返中陷入死循环。
    <!-- @include-end templates/prompts/loop-prevention.md -->
    
    ## 段三:生成报告文件
    
    1. 确定输出路径:`docs/07_reviews/retro/<YYYY-MM>-retro.md`(按当月归档)
    2. 写入模板:
    
    ```markdown
    # <YYYY-MM> PDLC 迭代复盘
    
    > 时间窗口:<起> ~ <止>
    > 生成时间:<ISO 时间>
    
    ## 输入契约体检
    (仅在体检有偏差或无法体检时出现;按「偏差类 × 份数 × 处理方式」汇总,放在全文最前面)
    
    ## 交付量
    - 完成功能:<N> 个
    - 修复缺陷:<M> 个
    
    ## 质量趋势
    | 阶段 | 自检通过率 | 平均失败数 | 平均人工介入数 |
    |------|----------|------------|--------------|
    | requirements | XX% | N.N | N.N |
    | design       | XX% | N.N | N.N |
    | tdd          | XX% | N.N | N.N |
    | impl         | XX% | N.N | N.N |
    | review       | XX% | N.N | N.N |
    
    ## 阶段耗时中位数
    - 需求: X.Xh  设计: X.Xh  TDD: X.Xh  实现: X.Xh  评审: X.Xh
      (时间戳缺时刻导致不可测时,写「不可测:<原因>」,不写 0.0h)
    
    ## 卡点案例
    - <feature-id> 在 <stage> 阶段: <原因>
    
    ## 值得保留的做法
    - <从高通过率/低耗时的功能中提炼>
    ```
    
    3. **IRON LAW 落盘**:文件必须实际写入磁盘,不可仅在对话中输出
    
    ## 段四:交接
    
    <!-- @include templates/prompts/handoff.md(已内联于下方,无需另读) -->
    ## 段四:交接(Handoff)
    
    命令完成后必须输出以下格式的最终消息:
    
    ```
    ✅ <阶段名> 完成:<主要产出物路径>
    📊 自检:<通过数>/<总数> 通过(若有未通过,附要点)
    📦 状态快照:docs/.pdlc-state/<feature-id>.json
    👉 下一步:/pdlc-<next_step>
       (如果有分叉)或 /pdlc-<alt>(条件:<选择依据>)
    ```
    
    **规则:**
    - 主流程命令(写状态机的命令;下一跳见正文里「本命令的状态机取值」)必须显式输出"下一步",不可省略
    - 工具型命令(Layer 3)可以没有 `next_step`,此时输出 `👉 下一步:(本次流程结束,无后续)`
    - 分叉场景必须说明**选择条件**,例如"若需补充测试用例 → `/pdlc-tdd`;若测试已齐 → `/pdlc-review`"
    <!-- @include-end templates/prompts/handoff.md -->
    
    **本命令的 handoff 输出:**
    
    ```
    ✅ 复盘报告已生成:docs/07_reviews/retro/<YYYY-MM>-retro.md
    📊 时间窗口:<起> ~ <止>,共 <N> 个功能
    ⚠️ 输入契约体检:<M> 处偏差(见报告开头;无偏差则省略本行)
    👉 下一步:(本次流程结束,建议人工 review 报告)
    ```
    
    ---
    
    **参数**:$ARGUMENTS
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related