mirror of
https://github.com/codestable/CodeStable.git
synced 2026-09-19 09:03:09 +08:00
Merge pull request #51 from codestable/fix/core-skill-contract-gaps
fix(codestable): close core skill contract gaps
This commit is contained in:
@@ -51,13 +51,20 @@ selectTaskAgent r e
|
||||
reviewGate :: AgentSelection -> AgentRun -> Maybe OwnerApproval -> AgentDecision
|
||||
reviewGate _ (Finished findings) _ = MergeVerified findings
|
||||
reviewGate _ (Active ref) _ = Await ref
|
||||
reviewGate _ (Failed _) (Just ApproveLocalOnly) = LocalReview
|
||||
reviewGate selection (Failed reason) (Just ApproveLocalOnly)
|
||||
| explicitPinBlocksLocal selection = Blocked (ExplicitConfigRunFailed reason)
|
||||
| otherwise = LocalReview
|
||||
reviewGate _ (Failed reason) _ = Blocked reason
|
||||
reviewGate (SelectionBlocked reason) NotStarted _ = Blocked reason
|
||||
reviewGate (SelectionNeedsOwnerApproval _) NotStarted (Just ApproveLocalOnly) = LocalReview
|
||||
reviewGate (SelectionNeedsOwnerApproval reason) NotStarted _ = NeedOwnerApproval reason
|
||||
reviewGate (Start agent config) NotStarted _ = Launch agent config
|
||||
|
||||
explicitPinBlocksLocal :: AgentSelection -> Bool
|
||||
explicitPinBlocksLocal (Start _ config) = isExplicit config
|
||||
explicitPinBlocksLocal (SelectionBlocked ExplicitConfigUnavailable) = True
|
||||
explicitPinBlocksLocal _ = False
|
||||
|
||||
toReviewLane :: AgentDecision -> Either Reason ReviewLane
|
||||
toReviewLane (MergeVerified _) = Right IndependentLane
|
||||
toReviewLane LocalReview = Right OwnerApprovedLocalLane
|
||||
@@ -82,8 +89,9 @@ review 优先选择与主 agent provider 或 model family 不同的 `Heterogeneo
|
||||
可证明时才这样标记,未知配置仍算 `Independent`。异构候选不可用不阻塞独立 review,继续使用
|
||||
隔离的同类 reviewer。prompt 不带主 agent 结论;findings 经本地事实核验后才写 verdict。
|
||||
|
||||
`SelectionBlocked ExplicitConfigUnavailable` 表示 owner 显式 pin 的配置当前不可满足;
|
||||
`ApproveLocalOnly` 不覆盖这个配置事实,owner 需要先修改或清除显式配置再重新选择。
|
||||
`SelectionBlocked ExplicitConfigUnavailable` 表示 owner 显式 pin 的配置当前不可满足;已按显式
|
||||
配置启动但运行失败时同样由 `explicitPinBlocksLocal` 保留这个约束。`ApproveLocalOnly` 不覆盖
|
||||
上述配置事实,owner 需要先修改或清除显式配置再重新选择;共享 gate 的直接消费者也不得绕过。
|
||||
|
||||
每轮 review 都调用同一 `selectTaskAgent` / `reviewGate`。批量、赶时间、已自查或自评低风险
|
||||
都不构成 `ApproveLocalOnly`;降级前按 `approval-conventions.md` 取得 owner 明确授权。
|
||||
|
||||
@@ -49,12 +49,14 @@ data ReviewState = ReviewState -- 从来源 spec、review report lane 字
|
||||
, priorReview : Maybe Verdict -- 已有 {slug}-review.md 的 status
|
||||
, priorIndependentReview : Bool -- 旧报告已有可复用 reviewer gate 锚点
|
||||
, changeClass : ChangeClass
|
||||
, agentConfig : AgentConfig -- 从 attention/owner 配置恢复;explicit pin 绑定本轮恢复
|
||||
, pendingReviewDecision : Maybe ReviewDecision
|
||||
, rejectedReviewDecision : Maybe ReviewDecision
|
||||
}
|
||||
data LaneName = LaneA | LaneB
|
||||
data ExternalRunRef = TaskRunRef AgentRef | OcrRunRef Text
|
||||
data LaneStatus = ReadyToLaunch | Pending ExternalRunRef | Completed | Failed Reason | Skipped | Unavailable Reason
|
||||
data LaneStatus = ReadyToLaunch | Pending ExternalRunRef | Completed | Failed ExternalRunRef Reason | Skipped | Unavailable Reason
|
||||
data LaneResult = LaneCompleted Findings | LaneFailed Reason
|
||||
data ReviewResume = ResumeLane LaneName ExternalRunRef LaneResult | ResumeSelfReviewDowngrade ApprovalRef
|
||||
data ChangeClass = Initial | ClosureOnly | Material | Unknown
|
||||
data Verdict = Passed | ChangesRequested | Blocked
|
||||
data ReviewOutcome
|
||||
@@ -65,7 +67,7 @@ data ReviewOutcome
|
||||
| HumanCheckpoint CheckpointReason -- 停下等用户确认,不越过继续
|
||||
| NeedsHuman ReviewBlocker -- 缺输入或来源事实,无法开审
|
||||
data ReviewWait = LaneStillPending LaneName ExternalRunRef
|
||||
data CheckpointReason = SelfReviewDowngrade
|
||||
data CheckpointReason = SelfReviewDowngrade | SkipFailedOcr
|
||||
data ReviewBlocker
|
||||
= AttentionMissing
|
||||
| SpecNotFinalized -- 来源 spec 产物缺失 / 未定稿 → 退回来源实现技能
|
||||
@@ -77,13 +79,6 @@ restoreReviewState facts
|
||||
| fullRereviewRequired facts = Right (resetLanesForNewRound facts)
|
||||
| otherwise = Right (normalizeCurrentRound facts)
|
||||
applyReviewResume :: Maybe ReviewResume -> ReviewState -> Either ReviewBlocker ReviewState
|
||||
applyReviewResume Nothing s = Right s
|
||||
applyReviewResume (Just (ResumeLane lane ref result)) s
|
||||
| pendingLaneRef lane s == Just ref = Right (persistLaneResult lane ref result s)
|
||||
| otherwise = Left InvalidReviewResume
|
||||
applyReviewResume (Just (ResumeSelfReviewDowngrade ref)) s
|
||||
| pendingSelfReviewDowngrade s && approvalArtifactApproved s ref "code-review-local-only" = Right (persistReviewDowngrade ref s)
|
||||
| otherwise = Left InvalidReviewResume
|
||||
csCodeReview req = either NeedsHuman selectReviewOutcome
|
||||
(restoreReviewState req.repoFacts >>= applyReviewResume req.resumeInput)
|
||||
```
|
||||
@@ -96,6 +91,9 @@ selectReviewOutcome s
|
||||
| attentionMissing s -> NeedsHuman AttentionMissing
|
||||
| not s.specFinalized -> NeedsHuman SpecNotFinalized
|
||||
| not s.diffAttributed -> NeedsHuman DiffNotAttributable
|
||||
| Just _ <- rejectedReviewDecision s -> ReviewWritten Blocked
|
||||
| Just decision <- pendingReviewDecision s -> HumanCheckpoint (reviewDecisionCheckpoint decision)
|
||||
| laneAMissing s && isExplicit s.agentConfig -> ReviewWritten Blocked
|
||||
| anyLaneFailed s -> ReviewWritten Blocked
|
||||
| Just lane <- firstLaunchableLane s -> Launching lane
|
||||
| Just wait <- firstPendingLane s -> Awaiting wait
|
||||
@@ -105,21 +103,21 @@ selectReviewOutcome s
|
||||
| hasBlocking s -> ReviewWritten ChangesRequested
|
||||
| otherwise -> ReviewWritten Passed
|
||||
focusedClosureEligible :: ReviewState -> Bool
|
||||
focusedClosureEligible s = s.priorIndependentReview
|
||||
&& s.priorReview `elem` [Just Passed, Just ChangesRequested]
|
||||
&& s.changeClass == ClosureOnly
|
||||
focusedClosureEligible s = s.priorIndependentReview && s.priorReview `elem` [Just Passed, Just ChangesRequested] && s.changeClass == ClosureOnly
|
||||
laneFailed :: LaneStatus -> Bool
|
||||
laneFailed (Failed _) = True
|
||||
laneFailed (Failed _ _) = True
|
||||
laneFailed _ = False
|
||||
anyLaneFailed :: ReviewState -> Bool
|
||||
anyLaneFailed s = laneFailed s.laneA || laneFailed s.laneB
|
||||
laneAMissing :: ReviewState -> Bool
|
||||
laneAMissing s = case s.laneA of Unavailable _ -> True; Skipped -> True; _ -> False
|
||||
```
|
||||
|
||||
`anyLaneFailed` 包含环节 A 的 `Blocked` / failed 和已启动环节 B 的 `OcrFailed`;失败优先于等待,
|
||||
必须先写 `status: blocked`,不能被 `hasBlocking` 或默认 passed 分支吞掉。`Material` / `Unknown`
|
||||
永远不满足 `focusedClosureEligible`,必须重新走完整独立复审。
|
||||
resume 的 ref 匹配、pending/approved/rejected/superseded 生命周期和 decision 清除时机见
|
||||
`references/recovery/protocol.md`。显式 pin 的 failed / unavailable 路径都不得降级。
|
||||
`Material` / `Unknown` 不满足 `focusedClosureEligible`,必须重新走完整独立复审。
|
||||
|
||||
`restoreReviewState` 校验报告当前 `round`:旧 `status: blocked` 缺 lane/ref 或非法 enum 直接 `Left InvalidReviewResume`;`ClosureOnly` 保留同 round 的 `Completed`,`Initial` / `Material` / `Unknown` 完整复审必须增加 round,并按当前能力把两 lane 重置为 `ReadyToLaunch` / `Unavailable`,不得复用旧 completed reviewer。`Launching` 成功后先持久化 `Pending ref` 再返回 `Awaiting`。self-review 降级先按 approval conventions 写 pending `code-review-local-only` 命名决策,只消费可机械核验的 `ApprovalRef`。
|
||||
`restoreReviewState` 校验报告当前 `round`:旧 `status: blocked` 缺 lane/ref 或非法 enum 直接 `Left InvalidReviewResume`;`ClosureOnly` 保留同 round 的 `Completed`,`Initial` / `Material` / `Unknown` 完整复审必须增加 round,并按当前能力把两 lane 重置为 `ReadyToLaunch` / `Unavailable`,不得复用旧 completed reviewer。`Launching` 成功后先持久化 `Pending ref` 再返回 `Awaiting`。self-review 降级先按 approval conventions 写 pending `code-review-local-only` 命名决策;failed lane 绑定 run ref,unavailable lane 使用无 ref 的 typed decision,二者都只消费可机械核验的 `ApprovalRef`。
|
||||
|
||||
## 进入来源(横切)
|
||||
|
||||
@@ -128,17 +126,14 @@ anyLaneFailed s = laneFailed s.laneA || laneFailed s.laneB
|
||||
| `cs-feat` Standard lane | impl 完成、accept-inline 前 | design + checklist | `cs-feat` acceptance(Inline Verification Matrix) |
|
||||
| `cs-feat` Goal lane | impl 完成、QA 前 | design + checklist + goal evidence | `cs-feat` QA 阶段 |
|
||||
| `cs-feat` fastforward mode | ff-note 落盘、commit 前 | ff-note + 用户原始需求 | 收尾提交 |
|
||||
| `cs-issue` fix 阶段 | fix-note 落盘、commit 前 | report + analysis + fix-note | 收尾提交 |
|
||||
| `cs-issue` fix 阶段 | fix-note 落盘、commit 前 | report + fix-note;`issue_path: standard` 另需 analysis;fast-track 另需 `approval-report.md#issue-fast-path` | 收尾提交 |
|
||||
| `cs-refactor` standard mode | apply-notes 完成、commit 前 | scan + refactor-design + checklist | 收尾提交 |
|
||||
| `cs-refactor` fastforward mode | 自证通过、commit 前 | 用户确认的重构范围 + 验证命令 | 收尾提交 |
|
||||
| ad-hoc / pre-merge | 用户要求 | 用户指定范围 / git range | 给结论 |
|
||||
|
||||
**不是 `cs-audit`**:audit 主动扫一片代码找潜在问题;code review 只审当前变更范围。
|
||||
|
||||
本次调用参数:$ARGUMENTS。非空且不是字面 `$ARGUMENTS` 时,按 ad-hoc 来源处理;`--range <git-range>` 指定提交范围,其余文本作为范围说明或文件 scope。仍需按「启动检查」核对范围内确有可归因改动。
|
||||
|
||||
无参数默认行为:参数为空或仍是字面 `$ARGUMENTS` 时,按「进入来源」表从当前流程产物和 git diff 推断来源;没有可归因 diff、定稿 spec 或 git range 时,不做空 review,退回来源实现技能或请用户补范围。
|
||||
|
||||
ad-hoc 参数如果含 `--range`,审查范围来自 `git diff {range}`,不要求工作区有未提交 diff。历史裸 git range(如 `main..HEAD`、`origin/main...HEAD` 或一个 commit/ref)可兼容识别;新文档和新调用一律用 `--range`。参数如果是文件路径、自然语言范围或 pre-merge 说明,则先解析为明确文件 / diff 来源;解析不清时先问清楚。
|
||||
|
||||
## 输入
|
||||
@@ -146,7 +141,7 @@ ad-hoc 参数如果含 `--range`,审查范围来自 `git diff {range}`,不
|
||||
进入 review 前必须读取:
|
||||
|
||||
- `.codestable/attention.md`
|
||||
- 来源的 spec 产物(feature 看 `{slug}-design.md` + `{slug}-checklist.yaml`;issue 看 report+analysis+fix-note;refactor 看 scan+refactor-design+checklist;ff / ad-hoc 看用户确认范围)
|
||||
- 来源的 spec 产物(feature 看 `{slug}-design.md` + `{slug}-checklist.yaml`;issue 看 report+fix-note,并仅在 confirmed report 为 `issue_path: standard` 时要求 analysis;refactor 看 scan+refactor-design+checklist;ff / ad-hoc 看用户确认范围)
|
||||
- 实现完成汇报 / 最近实现记录(如果在对话里,按对话事实引用;如果已落文件,读文件)
|
||||
- `git status --short`
|
||||
- `git diff`(有 staged diff 时也读 `git diff --cached`;ad-hoc git range 读 `git diff {range}`)
|
||||
@@ -161,7 +156,7 @@ ad-hoc 参数如果含 `--range`,审查范围来自 `git diff {range}`,不
|
||||
|
||||
先按「进入来源」表确认本轮来源,再做对应前置校验:
|
||||
|
||||
1. 来源的 spec 产物存在且已定稿——feature 看 `{slug}-design.md`(`doc_type=feature-design`、`status=approved`、`feature` 与目录一致)+ `{slug}-checklist.yaml`(`steps` 全 `done`);issue 看 report+analysis+fix-note;refactor 看 scan+refactor-design+checklist;ff 看用户确认范围;ad-hoc 看用户指定范围 / git range。缺定稿 spec 时退回对应实现技能,不硬审。
|
||||
1. 来源的 spec 产物存在且已定稿——feature 看 `{slug}-design.md`(`doc_type=feature-design`、`status=approved`、`feature` 与目录一致)+ `{slug}-checklist.yaml`(`steps` 全 `done`);issue 必须有 confirmed report+fix-note,report 为 `issue_path: standard` 时还必须有 confirmed analysis,`issue_path: fast-track` 时以同 unit `approval-report.md#issue-fast-path` 的命名批准和已批准修复方案替代 analysis;refactor 看 scan+refactor-design+checklist;ff 看用户确认范围;ad-hoc 看用户指定范围 / git range。缺定稿 spec 时退回对应实现技能,不硬审。
|
||||
2. goal / gate 模式下,先读取 `{slug}-evidence-pack.md`、`{slug}-gate-results.json` 和 `{slug}-dod-results.json`;缺失或 gate blocking 未解释时退回 implementation.before_review。
|
||||
3. 流程来源必须在当前 unstaged / staged diff 或本轮提交范围里看到实现改动;ad-hoc git range 必须 `git diff {range}` 非空。ad-hoc range 审查允许工作区干净;非 range 且完全没有可归因改动时退回来源实现技能或请用户补范围。
|
||||
4. 如果已有 `{slug}-review.md`:
|
||||
@@ -248,7 +243,7 @@ ad-hoc 参数如果含 `--range`,审查范围来自 `git diff {range}`,不
|
||||
|
||||
## review-fix 衔接
|
||||
|
||||
下一步去向按「进入来源」表确定(feature 来源即 review-fix→`cs-feat` implementation 阶段、通过→`cs-feat` QA 阶段;issue/refactor/ff 各回对应主入口阶段或提交收尾)。
|
||||
下一步去向按「进入来源」表确定(feature review-fix→`cs-feat` implementation;Standard feature 通过→accept-inline,Goal feature 通过→QA;issue/refactor/ff 各回对应主入口阶段或提交收尾)。
|
||||
|
||||
如果有 `blocking`:
|
||||
|
||||
@@ -273,6 +268,7 @@ focused closure 只在首次独立审查已完成、当前主 agent 能精确归
|
||||
进入具体环节才加载对应 reference,不在启动时读全部(progressive reference loading):
|
||||
|
||||
- 启动独立 reviewer(进入「审查流程」第 2 步)前 → `references/independent-review/protocol.md`;没读它不能启动 reviewer,也不能写 `reviewer` 字段。
|
||||
- 已有 failed/unavailable lane、pending/rejected review decision 或 `resumeInput` → `references/recovery/protocol.md`。
|
||||
- 落盘报告前 → `references/report-template.md`(按已加载 `SKILL.md` 所在目录解析)取完整 frontmatter 与章节模板。
|
||||
|
||||
禁止:启动即读全部 references;跳过 `references/independent-review/protocol.md` 直接写 `reviewer`;任一已启动环节未返回就定稿 `passed`。
|
||||
@@ -284,8 +280,8 @@ focused closure 只在首次独立审查已完成、当前主 agent 能精确归
|
||||
- `NeedsHuman`:无法开审。`.codestable/attention.md` 缺失(→ `cs-onboard`);来源 spec 未定稿或 diff 无法归因时不做空 review,退回来源实现技能或请用户补范围。
|
||||
- `Launching`:按 protocol 启动指定 lane 一次;拿到 run id 后先写入报告,再进入 `Awaiting`,不得把启动命令当成 pending 状态重复执行。
|
||||
- `Awaiting`:独立 reviewer 已启动但尚未返回。保留 lane 与 `ExternalRunRef`,只接受匹配的 `ResumeLane`;报告 `status: blocked`,不定稿 `passed`,也不把等待伪装成 owner approval。
|
||||
- `HumanCheckpoint`:只有缺独立 Task agent 能力、需要 owner 明确接受 self/ocr 降级时返回 `SelfReviewDowngrade`。
|
||||
- `ReviewWritten Blocked`:任一 reviewer 已失败或明确 blocked。记录失败事实和重试 / 改配置 / 明确降级选项,不写虚假的 completed reviewer。
|
||||
- `HumanCheckpoint`:缺独立 Task agent 能力时先落无 ref typed decision,或匹配 ref 的降级 / OCR skip decision 为 pending。显式 pin 不生成不可消费的 self-review checkpoint。
|
||||
- `ReviewWritten Blocked`:任一 reviewer 失败、显式 pin 不可满足、或 owner 拒绝 pending decision。保留失败 ref/reason,列出 typed retry / 改配置选项,不伪造 completed reviewer。
|
||||
|
||||
五种情况都要报告:来源与 `{slug}-review.md` 路径、当前 verdict / status、阻塞或 checkpoint 原因、需要的用户决策或下一步动作(退回哪个来源实现技能 / 补什么范围 / 等哪个 reviewer)、已启动环节的状态,以及是否可安全重跑本审查。不要在环节未返回或有 blocking 时假装通过。
|
||||
|
||||
|
||||
@@ -97,6 +97,10 @@ reviewerField _ laneB
|
||||
`NeedOwnerApproval` 写 pending approval;`LocalReview` 需要 `ApproveLocalOnly`;只有
|
||||
`MergeVerified` / `LocalReview` 可让环节 A 放行。
|
||||
|
||||
启动命令在宿主返回 run ref 之前失败时不得伪造 `Failed ExternalRunRef`;记录为带原因的
|
||||
`Unavailable`,本轮停止自动重启并在下一次恢复时重新评估能力。已有 run ref 后失败才进入
|
||||
`Failed ref reason` 的 typed resume 路径。
|
||||
|
||||
本协议列出的 `ocr review` CLI 正常同步执行:`OcrReady command` 直接转 `OcrFinished` / `OcrFailed`,不写 pending/ref。只有宿主明确提供可观察异步 OCR run id 时才可写 `OcrActive id`;不得自行合成 id。
|
||||
|
||||
独立 Task agent reviewer prompt(只给原始材料,不透露主 agent 的任何 review 结论):
|
||||
@@ -163,7 +167,7 @@ OCR 不做 spec-fit 判断;mapping 后的 finding 必须经主 agent 本地事
|
||||
A、B 两环节可并行启动。一旦某环节已启动,主 agent **不能在其返回前定稿 `{slug}-review.md`、给出 `passed` 或进入通过后去向**。
|
||||
|
||||
- 已启动的 reviewer 返回 → 逐条本地事实核验,去重,合并进报告,保留来源标注(`heterogeneous-agent` / `independent-agent` / `ocr` / `local`)。
|
||||
- reviewer 失败 / 卡住 / 权限阻塞 → 报告 `status: blocked`,记录 `pending|failed|blocked` 和原因,让用户决定:重试、等待或明确降级。
|
||||
- reviewer 失败 / 卡住 / 权限阻塞 → 报告 `status: blocked`,记录 `pending|failed|blocked`、run ref 和原因。失败 lane 只通过匹配 ref 的 `RetryFailedLane` 重试;环节 A 失败后申请降级走 `RequestSelfReviewDowngrade ref`,能力为 `Unavailable` 时走无 ref 的 `RequestUnavailableSelfReviewDowngrade`,且显式 pin 存在时两者都不得申请或批准 local-only;环节 B 可用 `RequestSkipFailedLaneB ref` 写 pending `code-review-skip-failed-ocr`,只在 `ResumeSkipFailedLaneB ref approvalRef` 同时匹配失败 ref 与命名批准后转为 `Skipped`。
|
||||
- 不要无限轮询;等通知或用户带回结果。
|
||||
- 环节 A reviewer 结果被核验并合并进报告后,按 `.codestable/reference/agent-conventions.md`
|
||||
的 Task agent 生命周期关闭该 reviewer。遇到 `agent thread limit reached` 等容量失败时,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# Code Review Recovery Protocol
|
||||
|
||||
本协议只在已有 review lane 为 `failed` / `unavailable`,存在 pending/rejected review decision,
|
||||
或调用方携带 `ReviewResume` 时加载。正常首次 review 不读取本文件。
|
||||
|
||||
## Spec
|
||||
|
||||
```haskell
|
||||
data ReviewDecision
|
||||
= SelfReviewDowngradeDecision ExternalRunRef
|
||||
| UnavailableSelfReviewDowngradeDecision
|
||||
| SkipFailedOcrDecision ExternalRunRef
|
||||
|
||||
data ReviewResume
|
||||
= ResumeLane LaneName ExternalRunRef LaneResult
|
||||
| RetryFailedLane LaneName ExternalRunRef
|
||||
| RequestSelfReviewDowngrade ExternalRunRef
|
||||
| RequestUnavailableSelfReviewDowngrade
|
||||
| ResumeSelfReviewDowngrade ApprovalRef
|
||||
| RequestSkipFailedLaneB ExternalRunRef
|
||||
| ResumeSkipFailedLaneB ExternalRunRef ApprovalRef
|
||||
|
||||
applyReviewResume :: Maybe ReviewResume -> ReviewState -> Either ReviewBlocker ReviewState
|
||||
applyReviewResume Nothing s = Right s
|
||||
applyReviewResume (Just (ResumeLane lane ref result)) s
|
||||
| pendingLaneRef lane s == Just ref = Right (persistLaneResultAndClearDecision lane ref result s)
|
||||
| otherwise = Left InvalidReviewResume
|
||||
applyReviewResume (Just (RetryFailedLane lane ref)) s
|
||||
| failedLaneRef lane s == Just ref = Right (persistLaneRetryAndSupersedeDecision lane ref s)
|
||||
| otherwise = Left InvalidReviewResume
|
||||
applyReviewResume (Just (RequestSelfReviewDowngrade ref)) s
|
||||
| failedLaneRef LaneA s == Just ref
|
||||
, not (isExplicit s.agentConfig) = Right (persistPendingReviewDecision (SelfReviewDowngradeDecision ref) s)
|
||||
| otherwise = Left InvalidReviewResume
|
||||
applyReviewResume (Just RequestUnavailableSelfReviewDowngrade) s
|
||||
| laneAMissing s
|
||||
, not (isExplicit s.agentConfig) = Right (persistPendingReviewDecision UnavailableSelfReviewDowngradeDecision s)
|
||||
| otherwise = Left InvalidReviewResume
|
||||
applyReviewResume (Just (ResumeSelfReviewDowngrade approvalRef)) s
|
||||
| Just decision <- pendingReviewDecision s
|
||||
, isSelfReviewDowngradeDecision decision
|
||||
, approvalArtifactStatus s approvalRef "code-review-local-only" == Approved
|
||||
= Right (persistReviewDowngradeAndClearDecision decision s)
|
||||
| Just decision <- pendingReviewDecision s
|
||||
, isSelfReviewDowngradeDecision decision
|
||||
, approvalArtifactStatus s approvalRef "code-review-local-only" == Rejected
|
||||
= Right (persistRejectedReviewDecision decision s)
|
||||
| otherwise = Left InvalidReviewResume
|
||||
applyReviewResume (Just (RequestSkipFailedLaneB ref)) s
|
||||
| failedLaneRef LaneB s == Just ref = Right (persistPendingReviewDecision (SkipFailedOcrDecision ref) s)
|
||||
| otherwise = Left InvalidReviewResume
|
||||
applyReviewResume (Just (ResumeSkipFailedLaneB failedRef approvalRef)) s
|
||||
| pendingReviewDecision s == Just (SkipFailedOcrDecision failedRef)
|
||||
, approvalArtifactStatus s approvalRef "code-review-skip-failed-ocr" == Approved
|
||||
= Right (persistLaneSkipAndClearDecision LaneB failedRef s)
|
||||
| Just decision@(SkipFailedOcrDecision failedRef) <- pendingReviewDecision s
|
||||
, approvalArtifactStatus s approvalRef "code-review-skip-failed-ocr" == Rejected
|
||||
= Right (persistRejectedReviewDecision decision s)
|
||||
| otherwise = Left InvalidReviewResume
|
||||
|
||||
isSelfReviewDowngradeDecision :: ReviewDecision -> Bool
|
||||
isSelfReviewDowngradeDecision (SelfReviewDowngradeDecision _) = True
|
||||
isSelfReviewDowngradeDecision UnavailableSelfReviewDowngradeDecision = True
|
||||
isSelfReviewDowngradeDecision _ = False
|
||||
|
||||
reviewDecisionCheckpoint :: ReviewDecision -> CheckpointReason
|
||||
reviewDecisionCheckpoint (SelfReviewDowngradeDecision _) = SelfReviewDowngrade
|
||||
reviewDecisionCheckpoint UnavailableSelfReviewDowngradeDecision = SelfReviewDowngrade
|
||||
reviewDecisionCheckpoint (SkipFailedOcrDecision _) = SkipFailedOcr
|
||||
```
|
||||
|
||||
## Decision Lifecycle
|
||||
|
||||
- failed lane 的 `Request*` 只接受精确匹配的 run ref;lane A 为 `Unavailable` 时只接受
|
||||
`RequestUnavailableSelfReviewDowngrade`,且显式 pin 下拒绝。`persistPendingReviewDecision` 把同
|
||||
lane 旧 rejected decision 标为 `superseded` 并清除,再把新命名 decision 写成 `pending`。
|
||||
- `Resume*` 同时核验 pending decision、失败 ref、同 unit approval ref 和 decision id;`approved`
|
||||
执行降级/skip,`rejected` 清除 pending 并持久化 rejected fact,其他值 fail-closed。
|
||||
- `persistRejectedReviewDecision` 保留原 `Failed ref reason`,让主入口返回 `ReviewWritten Blocked`;
|
||||
后续只能重试、修复配置,或在配置变化后发起新的匹配 decision。
|
||||
- `persistReviewDowngradeAndClearDecision` 在 approved 后把 lane A 置为 `Skipped`,持久化
|
||||
`userAcceptedDowngrade = True` 并清除同 lane pending/rejected decision;批准后不得继续命中
|
||||
`anyLaneFailed` 或重复降级 checkpoint。
|
||||
- `persistLaneRetryAndSupersedeDecision` 必须把同 lane 的 pending/rejected decision 标为
|
||||
`superseded` 并从 `ReviewState` 清除,再把 lane 置为 `ReadyToLaunch`。
|
||||
- `persistLaneResultAndClearDecision` 在 lane 进入 `Completed` / `Failed` 时清除同 lane 的旧 decision;
|
||||
不得让已完成或正在等待的新 run 被旧 checkpoint guard 吞掉。
|
||||
- 所有 decision 都使用同 unit `approval-report.md`;self review id 为 `code-review-local-only`,
|
||||
OCR skip id 为 `code-review-skip-failed-ocr`。聊天里的同意/拒绝不构成恢复事实。
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
ref、lane、pending decision、decision id 或 approval path 任一不匹配时返回
|
||||
`InvalidReviewResume`。owner 明确拒绝不是 invalid resume:必须消费为 rejected fact,再由主入口以
|
||||
`ReviewWritten Blocked` 报告可恢复选项。
|
||||
@@ -129,6 +129,6 @@ lane_b_reason: ""
|
||||
- Classification: {为什么是 test/docs/type/metadata/nit-only,且未改变行为、公开契约、安全、数据、并发或架构}
|
||||
```
|
||||
|
||||
lane 字段是中间状态的恢复事实并绑定当前 `round`:`pending` 必须带对应 ref,`unavailable` / `failed` 必须带 reason,恢复输入精确匹配 lane/ref。focused closure 复用同 round 的 completed;完整复审增加 round 并重置 lane。旧 `status: blocked` 没有字段、ref 缺失或类型错误时 fail-closed,不得推断为 Awaiting 或重复启动。
|
||||
lane 字段是中间状态的恢复事实并绑定当前 `round`:lane `pending` 必须带对应 ref,`unavailable` / `failed` 必须带 reason,`failed` 还必须保留对应 run ref;failed self-review 降级和 OCR skip decision 绑定该 ref,unavailable self-review decision 使用无 ref typed variant。两类降级分别使用同 unit `approval-report.md#code-review-local-only` / `#code-review-skip-failed-ocr`;owner 拒绝时清 pending、保留原 lane 并写 rejected fact,new request / typed retry / terminal lane result 会把同 lane 旧 decision 标为 `superseded` 后清除。批准 local-only 后 lane A 记 `skipped` 并持久化 `userAcceptedDowngrade`,不得在 lane 字段伪造 completed reviewer。focused closure 复用同 round 的 completed;完整复审增加 round 并重置 lane。旧 `status: blocked` 没有字段、lane ref 缺失或类型错误时 fail-closed,不得推断为 Awaiting、重试或重复启动。
|
||||
|
||||
没有某类 finding 时写 `none`,不要删除章节;下一轮复审要能对比。
|
||||
|
||||
@@ -69,6 +69,7 @@ data DocsState = DocsState -- 全部从 docs/ + manifest.yaml + 源
|
||||
, manifest : NoManifest | HasManifest -- docs/api/manifest.yaml
|
||||
, entryStatus : Pending | Draft | Current | Outdated | Skipped
|
||||
, codeDrift : InSync | Drifted -- 相关源码/spec 是否已变
|
||||
, workflowStage : Maybe Stage -- draft 的临时 workflow_stage;current 时必须清除
|
||||
, pendingCheckpoint : Maybe CheckpointReason
|
||||
, rejectedCheckpoint : Maybe CheckpointReason
|
||||
}
|
||||
@@ -104,6 +105,8 @@ restoreDocsStage(s, intent)
|
||||
| s.rejectedCheckpoint == Just ConfirmOverwrite -> Completed (preservedExistingDocSummary s)
|
||||
| Just reason <- s.rejectedCheckpoint -> Blocked (OwnerRejectedDocsCheckpoint reason)
|
||||
| Just reason <- s.pendingCheckpoint -> HumanCheckpoint reason
|
||||
| s.docStatus == Draft && s.workflowStage == Just FocusedEdit
|
||||
-> RoutedTo FocusedEdit -- 批准后跨会话完成最小 patch
|
||||
| requestedMode intent == Just Api || wantsReference(intent)
|
||||
-> RoutedTo ApiStage -- 无 manifest 则初始化,缺条目则补
|
||||
| s.manifest == HasManifest && s.entryStatus in [Pending, Draft, Outdated]
|
||||
@@ -126,7 +129,7 @@ restoreDocsStage(s, intent)
|
||||
1. **`preflight`** — 读 `.codestable/attention.md`;缺失则 `route to cs-onboard`;不得用 `AGENTS.md`/`CLAUDE.md` 代替 CodeStable attention。
|
||||
2. **`parseEntryIntent`** — 优先级 `flag > compat-preset > utterance`;`repoFacts override requestedMode`;空参不推断 mode,先按仓库事实恢复。
|
||||
3. **`restoreDocsStage`** — 扫 `docs/`、`README*`、`manifest.yaml` + 相关源码公开表面恢复 `DocsState`;先用 `applyDocsResume` 精确匹配并持久化 typed resume,再选 next stage;全局同步 / 记忆整理(非对外文档)→ `route to cs-docs-neat`。
|
||||
4. **`loadStageProtocol`** — progressive reference loading:进某 stage 才加载该 stage 一个 protocol,禁止 eager 读全部 references。
|
||||
4. **`loadStageProtocol`** — progressive reference loading:进某 stage 才按 `stageProtocol` 加载该 stage 一个 protocol,禁止 eager 读全部 references。
|
||||
5. **`executeOrRoute`** — 先读代码和既有文档再落盘;tutorial/api 生成或增量更新,`status` 落到合法值;遇 `HumanCheckpoint` 必停。
|
||||
6. **`exitRecoverable`** — 文档 `status` / manifest 状态明确、可从源码追溯,next stage 或 checkpoint reason 明确。
|
||||
|
||||
@@ -134,8 +137,13 @@ restoreDocsStage(s, intent)
|
||||
|
||||
## Reference 加载
|
||||
|
||||
- tutorial:`references/tutorial/protocol.md`
|
||||
- api:`references/api/protocol.md`,必要时 `references/api/reference.md`
|
||||
```haskell
|
||||
stageProtocol :: Stage -> Protocol
|
||||
stageProtocol TutorialStage = "references/tutorial/protocol.md"
|
||||
stageProtocol ApiStage = "references/api/protocol.md" -- 必要时 references/api/reference.md
|
||||
stageProtocol FocusedEdit = "references/focused-edit/protocol.md"
|
||||
stageProtocol NeatHandoff = skill "cs-docs-neat"
|
||||
```
|
||||
|
||||
先读代码和既有文档,再按对应模式生成或更新文档。不要凭记忆写 API。
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Docs Focused Edit Protocol
|
||||
|
||||
`FocusedEdit` 只处理已是 `current`、源码事实未漂移、目标路径明确的小范围文字修订。它不新建
|
||||
文档、不调整读者定位、不重组 API manifest,也不借“小改”更新公开契约。
|
||||
|
||||
## Spec
|
||||
|
||||
```haskell
|
||||
data FocusedEditStep = VerifyFacts | DraftPatch | PersistPatch
|
||||
data FocusedEditOutcome
|
||||
= RunFocusedEdit FocusedEditStep | Checkpoint CheckpointReason
|
||||
| Route Stage | NeedsHuman Reason | Complete
|
||||
|
||||
focusedApprovalGate :: DocsState -> EditIntent -> Maybe CheckpointReason
|
||||
focusedApprovalGate s intent
|
||||
| not (overwriteApproved s) = Just ConfirmOverwrite
|
||||
| changesPublicContract intent
|
||||
, not (contractWordingApproved s) = Just ConfirmContractWording
|
||||
| otherwise = Nothing
|
||||
|
||||
advanceFocusedEdit :: DocsState -> EditIntent -> FocusedEditOutcome
|
||||
advanceFocusedEdit s intent
|
||||
| isNothing s.targetDoc = NeedsHuman "which document?"
|
||||
| not (focusedEditStateValid s) || s.codeDrift == Drifted
|
||||
= Route (fullDocsStage s intent)
|
||||
| not (focusedEditIntentValid s intent) = Route (fullDocsStage s intent)
|
||||
| Just reason <- focusedApprovalGate s intent = Checkpoint reason
|
||||
| not (sourceFactsVerified s intent) = RunFocusedEdit VerifyFacts
|
||||
| not (focusedDraftPersisted s intent) = RunFocusedEdit DraftPatch
|
||||
| not (ownerApproved s) = Checkpoint ReviewDraft
|
||||
| not (focusedPatchCurrent s intent) = RunFocusedEdit PersistPatch
|
||||
| otherwise = Complete
|
||||
|
||||
focusedEditStateValid :: DocsState -> Bool
|
||||
focusedEditStateValid s = s.docStatus == Current ||
|
||||
(s.docStatus == Draft && s.workflowStage == Just FocusedEdit)
|
||||
|
||||
focusedEditIntentValid :: DocsState -> EditIntent -> Bool
|
||||
focusedEditIntentValid s intent = smallEdit intent || s.workflowStage == Just FocusedEdit
|
||||
```
|
||||
|
||||
`fullDocsStage` 依据目标文档的既有类型返回 `ApiStage` 或 `TutorialStage`;不能判定时返回
|
||||
`NeedsHuman "which reader?"`。`overwriteApproved`、`ownerApproved` 与
|
||||
`contractWordingApproved` 只消费主入口已验证并持久化的 `ResumeDocsCheckpoint`,聊天中的口头
|
||||
同意不能替代。
|
||||
|
||||
## 执行规则
|
||||
|
||||
1. 读取目标文档、用户点名段落,以及支撑该段表述的源码/spec;记录明确的事实来源。
|
||||
2. 只生成目标段落的最小 patch,保持原有 `doc_type`、读者、目录结构和 manifest 归属。
|
||||
3. `DraftPatch` 把目标文档标为 `status: draft`、`workflow_stage: focused-edit` 后进入
|
||||
`ReviewDraft`;`persistDocsDecision ReviewDraft ApproveDocs` 保留该临时 stage,用户批准后仍由主入口路由回本协议执行 `PersistPatch`,恢复
|
||||
`status: current`、删除临时 `workflow_stage` 并更新 `last_reviewed`(字段存在时)。
|
||||
4. 一旦发现代码漂移、API 条目增删、结构重组、读者定位变化或多文件同步需求,停止
|
||||
FocusedEdit,回到对应 tutorial/api protocol;全局同步仍转 `cs-docs-neat`。
|
||||
|
||||
## 退出条件
|
||||
|
||||
- 目标文件和改动段落唯一明确,diff 只包含已批准的小范围文字修订。
|
||||
- 每项新表述都能追溯到当前源码或项目事实,公开契约措辞已通过对应 checkpoint。
|
||||
- 文档最终为 `status: current`,且没有遗留 manifest、README 或全局入口同步工作。
|
||||
@@ -28,7 +28,7 @@ contracts:
|
||||
|
||||
无参数默认行为:没有 flag / 问题描述时,不猜阶段;扫描 `.codestable/issues/`、目标产物和当前 git diff,用状态机恢复下一步。若没有可恢复 issue 且用户原话也没有问题目标,返回 `NeedsHuman` 问处理哪个 issue。
|
||||
|
||||
入口意图不覆盖仓库事实。若 report 已存在但用户从 report 兼容入口进来,继续 analyze;若代码已改但无 fix-note,进入 fix 验证/记录。
|
||||
入口意图不覆盖仓库事实。若 report 已存在但用户从 report 兼容入口进来,按 confirmed `issue_path` 恢复:standard 继续 analyze,fast-track 在同 unit `approval-report.md#issue-fast-path` 已批准时直接 fix;若代码已改但无 fix-note,进入 fix 验证/记录。
|
||||
|
||||
## Spec
|
||||
|
||||
@@ -62,7 +62,7 @@ data IssueState = IssueState -- 全部从 .codestable/issues/{slug}/
|
||||
, reviewStatus : ReviewStatus
|
||||
, pendingCheckpoint : Maybe CheckpointReason -- approval-report.md 当前 pending decision
|
||||
, rejectedCheckpoint : Maybe CheckpointReason
|
||||
, fixCompletionApproval : ApprovalStatus -- review passed 后的最终 owner sign-off
|
||||
, fixCompletionApproval : ApprovalStatus -- approval-report.md#issue-fix-completion
|
||||
}
|
||||
|
||||
data IssueOutcome
|
||||
@@ -98,6 +98,9 @@ normalizeIssuePath report _
|
||||
| issuePathField report == Just StandardPath = StandardPath
|
||||
| isNothing (issuePathField report) && reportStatus report == ArtifactConfirmed = StandardPath
|
||||
| otherwise = PathUndecided
|
||||
|
||||
fastPathApproval :: ApprovalReport -> ApprovalStatus
|
||||
fastPathApproval approval = namedApproval approval "issue-fast-path"
|
||||
```
|
||||
|
||||
`restoreIssueStage` 从仓库事实选下一步(新增能力而非坏掉的既有行为 → 路由 `cs-feat`):
|
||||
@@ -164,7 +167,7 @@ exitRecoverable -- fix-note 必出(根因/改动/验证/遗留风险),ne
|
||||
```text
|
||||
.codestable/issues/{YYYY-MM-DD}-{slug}/
|
||||
├── {slug}-report.md
|
||||
├── {slug}-analysis.md
|
||||
├── {slug}-analysis.md # standard 路径必有;fast-track 不生成
|
||||
├── {slug}-fix-note.md
|
||||
├── {slug}-review.md
|
||||
└── approval-report.md # 仅需 owner 决策时;fast-path 选择从这里恢复
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Issue Fix Protocol
|
||||
|
||||
根因和方案已经确定(标准路径在 analysis、快速通道在 report 阶段口头确认过),你的活是按方案改代码、验证效果、写下修复记录。
|
||||
根因和方案已经确定(标准路径在 analysis,快速通道在 confirmed report + approval-report),你的活是按方案改代码、验证效果、写下修复记录。
|
||||
|
||||
fix 阶段最容易出问题的不是改代码本身,而是**改的过程中冒出的"顺手"冲动**——顺手优化、顺手重构、顺手加抽象。每项单独看说得通,但合在一个 PR 里让别人分不清"这次到底为了修 bug 改了什么"。
|
||||
|
||||
@@ -18,10 +18,13 @@ data FixOutcome
|
||||
|
||||
selectEntry :: FixState -> FixEntry
|
||||
selectEntry state
|
||||
| confirmedAnalysis state = Enter Standard
|
||||
| rootCauseLocated state && fixPlanConfirmed state = Enter FastPath
|
||||
| reportExists state = RouteAnalyze
|
||||
| otherwise = RouteReport
|
||||
| issuePath state in [PathUndecided, FastPathPending] = RouteReport
|
||||
| issuePath state in [StandardPath, FastPathRejected] && confirmedAnalysis state = Enter Standard
|
||||
| issuePath state == FastPathApproved
|
||||
, rootCauseLocated state && approvedFixPlanArtifact state = Enter FastPath
|
||||
| issuePath state == FastPathApproved = RouteReport
|
||||
| confirmedReport state = RouteAnalyze
|
||||
| otherwise = RouteReport
|
||||
|
||||
afterVerification :: Verification -> FixOutcome
|
||||
afterVerification Passed = Run WriteFixNote
|
||||
@@ -47,7 +50,8 @@ advance s
|
||||
| otherwise = Complete
|
||||
```
|
||||
|
||||
`PersistCheckpoint` 先复用 `approval-report.md` 写 pending `ConfirmFixCompletion`,再返回
|
||||
`PersistCheckpoint` 先复用 `approval-report.md` 写 pending 命名决策
|
||||
`approvals.issue-fix-completion`(ref 为 `approval-report.md#issue-fix-completion`),再返回
|
||||
`HumanCheckpoint`;owner 的批准 / 拒绝分别恢复为 `ApprovalApproved` / `ApprovalRejected`,`ReviseCheckpoint feedback` 恢复为 `ApprovalRevisionRequested feedback` 并回 `Apply`。
|
||||
该状态变化只消费主入口已与 `ConfirmFixCompletion` 匹配的 `ResumeIssueCheckpoint`。
|
||||
|
||||
@@ -55,7 +59,7 @@ advance s
|
||||
|
||||
## 执行前检查与完成 gate
|
||||
|
||||
CodeStable 不决定分支或检出策略;按当前宿主 / owner 已选择的检出环境推进。进入修复前先确认 report / analysis、修复范围和当前 dirty scope,避免把无关改动混入本 issue。
|
||||
CodeStable 不决定分支或检出策略;按当前宿主 / owner 已选择的检出环境推进。进入修复前先确认 confirmed report、路径对应的已批准修复方案(standard 为 analysis,fast-track 为 approval-report)和当前 dirty scope,避免把无关改动混入本 issue。
|
||||
|
||||
修复完成、输出汇报前必须进入 `cs-code-review` 做独立 diff 评审;blocking 未清零、important 未修复或未被 owner 明确接受时不算完成。需要 commit 时按仓库既有提交规范或 owner 指示执行。
|
||||
|
||||
@@ -82,11 +86,11 @@ CodeStable 不决定分支或检出策略;按当前宿主 / owner 已选择的
|
||||
|
||||
## 实现期间的约束
|
||||
|
||||
先做 `shared-conventions.md` 第 7 节的**第一性原则 pre-pass**:外部行为就是"复现路径不再失败",约束来自 report / analysis / fix 方案,最小充分改动只服务根因;从这三项推不出的抽象、兜底和顺手重构都不写。修复若要用 fake / 正则凑或"够跑就行"绕过根因,先做 `.codestable/reference/solution-depth-conventions.md` 的方案深度 pre-pass,按场景论证不默认降级。
|
||||
先做 `shared-conventions.md` 第 7 节的**第一性原则 pre-pass**:外部行为就是“复现路径不再失败”,约束来自 report + 路径对应的已批准修复方案,最小充分改动只服务根因;从这两项推不出的抽象、兜底和顺手重构都不写。修复若要用 fake / 正则凑或“够跑就行”绕过根因,先做 `.codestable/reference/solution-depth-conventions.md` 的方案深度 pre-pass,按场景论证不默认降级。
|
||||
|
||||
### 只改 analysis 里声明的文件
|
||||
### 只改已确认方案声明的文件
|
||||
|
||||
修复范围来自 analysis 第 5 节"推荐方案"的"影响面"。超出范围的文件——哪怕顺眼——**不动**。
|
||||
标准路径的修复范围来自 analysis 第 5 节“推荐方案”的“影响面”;快速通道来自 confirmed report + approval-report 中已批准方案的影响面。超出对应范围的文件——哪怕顺眼——**不动**。
|
||||
|
||||
发现范围外值得改的记一条"顺手发现"不改代码:
|
||||
|
||||
@@ -120,7 +124,7 @@ issue-fix 比 feature-implement 更谨慎:**触发反射信号但结论是"该
|
||||
|
||||
- [ ] **复现步骤验证**——按 report 第 2 节走一遍,问题不再出现
|
||||
- [ ] **期望行为验证**——report 第 3 节"期望行为"现在确实发生
|
||||
- [ ] **影响面回归**——analysis 第 4 节"潜在受害模块"每个走一遍最基本的冒烟路径
|
||||
- [ ] **影响面回归**——标准路径按 analysis 第 4 节、快速通道按 approval-report 已批准影响面逐项跑最基本的冒烟路径
|
||||
- [ ] **前端改动浏览器验证**(如涉及)——按 `.codestable/attention.md` 的硬要求执行,不能只 typecheck
|
||||
- [ ] **相关测试通过**——有测试覆盖到修复区域就跑一遍
|
||||
|
||||
@@ -158,14 +162,14 @@ issue-fix 比 feature-implement 更谨慎:**触发反射信号但结论是"该
|
||||
|
||||
按 `shared-conventions.md` 第 4 节"scoped-commit"规则执行。本阶段:
|
||||
|
||||
- **提交范围**:修复代码 + `{slug}-fix-note.md` + 本次一并更新的 report / analysis
|
||||
- **提交范围**:修复代码 + `{slug}-fix-note.md` + report + 标准路径存在的 analysis + 本次更新的 `approval-report.md`
|
||||
- 修复闭环后告诉用户"修复验证已完成,`{slug}-fix-note.md` 已落盘",紧接着问是否需要 commit
|
||||
|
||||
---
|
||||
|
||||
## 退出后
|
||||
|
||||
告诉用户:"issue 修复完成,工作流闭环。report + analysis + fix-note 已存档。"
|
||||
按路径告诉用户已存档产物:标准路径为 `report + analysis + fix-note`;快速通道为 `report + approval-report + fix-note`,不得声称生成了不存在的 analysis。
|
||||
|
||||
按 `shared-conventions.md` 第 3 节"issue-fix"收尾推荐顺序各问一句(用户"不用"立即跳过):
|
||||
|
||||
@@ -183,7 +187,7 @@ issue-fix 比 feature-implement 更谨慎:**触发反射信号但结论是"该
|
||||
## 容易踩的坑
|
||||
|
||||
- 修完没走验证清单就宣告"修好了"
|
||||
- 顺手改了 analysis 范围外的代码
|
||||
- 顺手改了路径对应已批准方案范围外的代码
|
||||
- 修复引入新抽象 / 接口但没停下来确认
|
||||
- `{slug}-fix-note.md` 没建就宣告完成
|
||||
- 发现影响面回归有问题但写"轻微影响可忽略"——要修到干净
|
||||
|
||||
@@ -41,7 +41,8 @@ advance FastPath _ Nothing = PersistDraftAndCheckpoint ConfirmFixPlan
|
||||
```
|
||||
|
||||
`PersistDraftAndCheckpoint` 先写 `status: draft` 的 report,并按 approval 约定把同一 decision 写成
|
||||
pending;只有持久状态成功后才返回 `HumanCheckpoint`。因此 standard / fast-track 的 owner 回复都能
|
||||
pending;fast-track 使用命名决策 `approvals.issue-fast-path`(ref 为
|
||||
`approval-report.md#issue-fast-path`)。只有持久状态成功后才返回 `HumanCheckpoint`。因此 standard / fast-track 的 owner 回复都能
|
||||
从仓库恢复,不依赖聊天历史。两条路径共用第一条 `nextQuestion` guard,fast-track 不得绕过五问。
|
||||
`advance` 的 owner 参数只来自主入口已按同一 `CheckpointReason` 验证的 `ResumeIssueCheckpoint`,不得从聊天文本构造;`ReviseCheckpoint feedback` 先修订 draft 再写新 pending decision,Standard report 的 `RejectCheckpoint` 终止本次 issue,不能重发同一 checkpoint。
|
||||
|
||||
@@ -56,7 +57,7 @@ pending;只有持久状态成功后才返回 `HumanCheckpoint`。因此 standa
|
||||
3. 和用户确定 slug,日期前缀用 `currentDate`;两种路径都创建 issue 目录。
|
||||
|
||||
进入 Standard 后不二次改判。`ProposeFastPath` 必须先向用户展示 file:line 根因与小范围方案,
|
||||
并按 approval 约定写 `approval-report.md`;得到 `ConfirmFixPlan` 后把报告写成 `status: confirmed`、
|
||||
并按 approval 约定写 `approval-report.md#issue-fast-path`;得到 `ConfirmFixPlan` 后把报告写成 `status: confirmed`、
|
||||
`issue_path: fast-track`,记录 fast-path 已批准再进入 fix。用户拒绝则记录 `Rejected`,把
|
||||
`issue_path` 写成 `standard`,写 confirmed report 后进入 analyze。普通路径同样写 `standard`。
|
||||
---
|
||||
@@ -173,9 +174,10 @@ tags: []
|
||||
|
||||
## 退出后
|
||||
|
||||
告诉用户:"问题报告已就绪。下一步阶段 2 根因分析,进入 `cs-issue` analyze 阶段。"
|
||||
按 confirmed report 的 `issue_path` 给出唯一下一步:
|
||||
|
||||
别自己顺手开始分析根因——阶段间的人工 checkpoint 是工作流硬约束。
|
||||
- `standard`:告诉用户“问题报告已就绪。下一步阶段 2 根因分析,进入 `cs-issue` analyze 阶段。”别自己顺手开始分析根因,阶段间的人工 checkpoint 是硬约束。
|
||||
- `fast-track`:确认同 unit `approval-report.md#issue-fast-path` 已批准后,告诉用户“问题报告与小范围修复方案已确认。跳过 analysis,进入 `cs-issue` fix 阶段。”不得再无条件指向 analyze。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -85,11 +85,11 @@ resumeOnboard _ _ = Left InvalidOnboardDecision
|
||||
**先检查一次现状**:
|
||||
|
||||
1. **检查 `.codestable/`**:不存在 → 空仓库候选;存在 → 迁移(部分补齐并刷新 runtime 资产);用户显式传 `--mode refresh-runtime` → 只刷新 runtime
|
||||
2. **旧 CodeStable兼容** CodeStable 经过多次改名,从 easysdd 到 codestable 再到 .codestable,如果遇到旧版的codestable目录,提示用户:
|
||||
2. **旧 CodeStable 兼容** CodeStable 经过多次改名,从 `easysdd/` 到 `codestable/` 再到 `.codestable/`。先检测实际存在的旧根目录:只存在一个且 `.codestable/` 不存在时记为 `<legacy-root>`;两个旧根同时存在,或任一旧根与 `.codestable/` 并存时,停止并让用户选择迁移源/内容,不输出 `git mv` 建议,不能合并或猜测。只有安全的单旧根场景才提示用户:
|
||||
|
||||
> 检测到旧版codestable。建议直接 `git mv easysdd .codestable`,结构 / frontmatter 完全兼容,rename 后即用。要我执行吗?
|
||||
> 检测到旧版 `<legacy-root>/`。建议直接 `git mv <legacy-root> .codestable`,结构 / frontmatter 完全兼容,rename 后即用。要我执行吗?
|
||||
|
||||
同意 → `git mv easysdd .codestable`,按迁移路径走(这时只需补齐可能缺失的 `attention.md`、`gates/` 和 `reference/`)。想保留旧目录 → 告诉他子技能只读 `.codestable/`,旧目录不会被读;按空仓库路径走新骨架
|
||||
同意 → 将 `<legacy-root>` 替换为检测到的 `easysdd` 或 `codestable` 后执行,按迁移路径走(这时只需补齐可能缺失的 `attention.md`、`gates/` 和 `reference/`)。想保留旧目录 → 告诉他子技能只读 `.codestable/`,旧目录不会被读;按空仓库路径走新骨架
|
||||
|
||||
3. **Glob 全仓库 `.md`**(排除 `node_modules/` `.git/`):根目录 `DESIGN.md` / `ARCHITECTURE.md` / `SPEC.md` / `README.md`;`docs/` `doc/` `design/` `spec/` `wiki/`;现有 `.codestable/` 下文件
|
||||
4. **检查 `.codestable/attention.md`**:缺失则列为骨架待补齐项
|
||||
|
||||
@@ -51,13 +51,20 @@ selectTaskAgent r e
|
||||
reviewGate :: AgentSelection -> AgentRun -> Maybe OwnerApproval -> AgentDecision
|
||||
reviewGate _ (Finished findings) _ = MergeVerified findings
|
||||
reviewGate _ (Active ref) _ = Await ref
|
||||
reviewGate _ (Failed _) (Just ApproveLocalOnly) = LocalReview
|
||||
reviewGate selection (Failed reason) (Just ApproveLocalOnly)
|
||||
| explicitPinBlocksLocal selection = Blocked (ExplicitConfigRunFailed reason)
|
||||
| otherwise = LocalReview
|
||||
reviewGate _ (Failed reason) _ = Blocked reason
|
||||
reviewGate (SelectionBlocked reason) NotStarted _ = Blocked reason
|
||||
reviewGate (SelectionNeedsOwnerApproval _) NotStarted (Just ApproveLocalOnly) = LocalReview
|
||||
reviewGate (SelectionNeedsOwnerApproval reason) NotStarted _ = NeedOwnerApproval reason
|
||||
reviewGate (Start agent config) NotStarted _ = Launch agent config
|
||||
|
||||
explicitPinBlocksLocal :: AgentSelection -> Bool
|
||||
explicitPinBlocksLocal (Start _ config) = isExplicit config
|
||||
explicitPinBlocksLocal (SelectionBlocked ExplicitConfigUnavailable) = True
|
||||
explicitPinBlocksLocal _ = False
|
||||
|
||||
toReviewLane :: AgentDecision -> Either Reason ReviewLane
|
||||
toReviewLane (MergeVerified _) = Right IndependentLane
|
||||
toReviewLane LocalReview = Right OwnerApprovedLocalLane
|
||||
@@ -82,8 +89,9 @@ review 优先选择与主 agent provider 或 model family 不同的 `Heterogeneo
|
||||
可证明时才这样标记,未知配置仍算 `Independent`。异构候选不可用不阻塞独立 review,继续使用
|
||||
隔离的同类 reviewer。prompt 不带主 agent 结论;findings 经本地事实核验后才写 verdict。
|
||||
|
||||
`SelectionBlocked ExplicitConfigUnavailable` 表示 owner 显式 pin 的配置当前不可满足;
|
||||
`ApproveLocalOnly` 不覆盖这个配置事实,owner 需要先修改或清除显式配置再重新选择。
|
||||
`SelectionBlocked ExplicitConfigUnavailable` 表示 owner 显式 pin 的配置当前不可满足;已按显式
|
||||
配置启动但运行失败时同样由 `explicitPinBlocksLocal` 保留这个约束。`ApproveLocalOnly` 不覆盖
|
||||
上述配置事实,owner 需要先修改或清除显式配置再重新选择;共享 gate 的直接消费者也不得绕过。
|
||||
|
||||
每轮 review 都调用同一 `selectTaskAgent` / `reviewGate`。批量、赶时间、已自查或自评低风险
|
||||
都不构成 `ApproveLocalOnly`;降级前按 `approval-conventions.md` 取得 owner 明确授权。
|
||||
|
||||
@@ -16,7 +16,9 @@ DECISION_SURFACE_RE = re.compile(
|
||||
|
||||
HASKELL_CONTRACT_REFERENCES = {
|
||||
"cs-code-review/references/independent-review/protocol.md",
|
||||
"cs-code-review/references/recovery/protocol.md",
|
||||
"cs-docs/references/api/protocol.md",
|
||||
"cs-docs/references/focused-edit/protocol.md",
|
||||
"cs-docs/references/tutorial/protocol.md",
|
||||
"cs-epic/references/goal/protocol.md",
|
||||
"cs-epic/references/goal/support/protocol-audit.md",
|
||||
|
||||
@@ -414,26 +414,45 @@ def test_refactor_fails_closed_on_invalid_restored_state() -> None:
|
||||
|
||||
def test_code_review_lane_launch_wait_and_resume_preserve_run_identity() -> None:
|
||||
review = skill("cs-code-review/SKILL.md")
|
||||
recovery = skill("cs-code-review/references/recovery/protocol.md")
|
||||
review_contract = f"{review}\n{recovery}"
|
||||
protocol = skill("cs-code-review/references/independent-review/protocol.md")
|
||||
report = skill("cs-code-review/references/report-template.md")
|
||||
conventions = skill("cs-onboard/references/agent-conventions.md")
|
||||
|
||||
for phrase in (
|
||||
"data ExternalRunRef = TaskRunRef AgentRef | OcrRunRef Text",
|
||||
"Failed ExternalRunRef Reason",
|
||||
"| Launching LaneName",
|
||||
"data ReviewWait = LaneStillPending LaneName ExternalRunRef",
|
||||
"ResumeLane LaneName ExternalRunRef LaneResult",
|
||||
"RetryFailedLane LaneName ExternalRunRef",
|
||||
"RequestSelfReviewDowngrade ExternalRunRef",
|
||||
"RequestUnavailableSelfReviewDowngrade",
|
||||
"ResumeSelfReviewDowngrade ApprovalRef",
|
||||
"RequestSkipFailedLaneB ExternalRunRef",
|
||||
"ResumeSkipFailedLaneB ExternalRunRef ApprovalRef",
|
||||
"restoreReviewState :: RepoFacts -> Either ReviewBlocker ReviewState",
|
||||
"invalidPersistedLaneState facts = Left InvalidReviewResume",
|
||||
"fullRereviewRequired facts = Right (resetLanesForNewRound facts)",
|
||||
"restoreReviewState req.repoFacts >>= applyReviewResume req.resumeInput",
|
||||
"pendingLaneRef lane s == Just ref",
|
||||
'approvalArtifactApproved s ref "code-review-local-only"',
|
||||
"failedLaneRef lane s == Just ref",
|
||||
"failedLaneRef LaneA s == Just ref",
|
||||
"failedLaneRef LaneB s == Just ref",
|
||||
"Just decision@(SkipFailedOcrDecision failedRef) <- pendingReviewDecision s",
|
||||
"not (isExplicit s.agentConfig)",
|
||||
'approvalArtifactStatus s approvalRef "code-review-local-only" == Approved',
|
||||
'approvalArtifactStatus s approvalRef "code-review-skip-failed-ocr" == Approved',
|
||||
"persistRejectedReviewDecision decision s",
|
||||
"persistReviewDowngradeAndClearDecision decision s",
|
||||
"UnavailableSelfReviewDowngradeDecision",
|
||||
"persistLaneRetryAndSupersedeDecision lane ref s",
|
||||
"persistLaneResultAndClearDecision lane ref result s",
|
||||
"InvalidReviewResume",
|
||||
"旧 `status: blocked` 缺 lane/ref 或非法 enum 直接 `Left InvalidReviewResume`",
|
||||
):
|
||||
assert phrase in review
|
||||
assert phrase in review_contract
|
||||
for field in (
|
||||
"lane_a_state:",
|
||||
"lane_a_ref:",
|
||||
@@ -454,6 +473,9 @@ def test_code_review_lane_launch_wait_and_resume_preserve_run_identity() -> None
|
||||
assert "| Await AgentRef" in conventions
|
||||
assert "reviewGate _ (Active ref) _ = Await ref" in conventions
|
||||
assert "toReviewLane (Await _) = Left AgentLaneNotReturned" in conventions
|
||||
assert "explicitPinBlocksLocal selection" in conventions
|
||||
assert "#code-review-local-only" in report
|
||||
assert "#code-review-skip-failed-ocr" in report
|
||||
epic_review = skill("cs-epic/references/review/protocol.md")
|
||||
design_review = skill("cs-feat/references/design-review/protocol.md")
|
||||
assert "ref == awaitedRef" in epic_review
|
||||
@@ -461,6 +483,60 @@ def test_code_review_lane_launch_wait_and_resume_preserve_run_identity() -> None
|
||||
assert "`Await ref` 必须把同一 `ref` 写入 `reviewer_id`" in design_review
|
||||
|
||||
|
||||
def test_reviewed_contract_regressions_stay_closed() -> None:
|
||||
review = skill("cs-code-review/SKILL.md")
|
||||
recovery = skill("cs-code-review/references/recovery/protocol.md")
|
||||
conventions = skill("cs-onboard/references/agent-conventions.md")
|
||||
report = skill("cs-issue/references/report/protocol.md")
|
||||
fix = skill("cs-issue/references/fix/protocol.md")
|
||||
epic_goal = skill("cs-epic/references/goal/protocol.md")
|
||||
onboard = skill("cs-onboard/SKILL.md")
|
||||
|
||||
assert "reviewGate _ (Failed _) (Just ApproveLocalOnly)" not in conventions
|
||||
assert conventions.index("explicitPinBlocksLocal selection") < conventions.index(
|
||||
"otherwise = LocalReview"
|
||||
)
|
||||
selector = review[review.index("selectReviewOutcome ::"):review.index("focusedClosureEligible ::")]
|
||||
assert selector.index("rejectedReviewDecision s") < selector.index("pendingReviewDecision s")
|
||||
assert selector.index("pendingReviewDecision s") < selector.index("anyLaneFailed s")
|
||||
assert selector.index("laneAMissing s && isExplicit s.agentConfig") < selector.index("anyLaneFailed s")
|
||||
assert "failedLaneRef lane s == Just ref" in recovery
|
||||
assert "laneFailed (Failed _ _) = True" in review
|
||||
assert "显式 pin 的 failed / unavailable 路径都不得降级" in review
|
||||
assert "persistLaneRetryAndSupersedeDecision" in recovery
|
||||
assert "persistLaneResultAndClearDecision" in recovery
|
||||
assert "persistRejectedReviewDecision" in recovery
|
||||
assert "persistPendingReviewDecision" in recovery
|
||||
assert "userAcceptedDowngrade = True" in recovery
|
||||
|
||||
assert "`issue_path: standard` 另需 analysis" in review
|
||||
assert "approval-report.md#issue-fast-path" in review
|
||||
assert "跳过 analysis,进入 `cs-issue` fix 阶段" in report
|
||||
assert "不得再无条件指向 analyze" in report
|
||||
assert "快速通道为 `report + approval-report + fix-note`" in fix
|
||||
issue = skill("cs-issue/SKILL.md")
|
||||
assert "standard 继续 analyze" in issue
|
||||
assert "fast-track 在同 unit `approval-report.md#issue-fast-path` 已批准时直接 fix" in issue
|
||||
assert "快速通道按 approval-report 已批准影响面" in fix
|
||||
assert "issuePath state in [PathUndecided, FastPathPending]" in fix
|
||||
assert "issuePath state in [StandardPath, FastPathRejected]" in fix
|
||||
assert "approval-report.md#issue-fix-completion" in fix
|
||||
|
||||
assert "通过→`cs-feat` QA 阶段" not in review
|
||||
assert "Standard feature 通过→accept-inline,Goal feature 通过→QA" in review
|
||||
|
||||
assert "status: awaiting-authorization" in epic_goal
|
||||
assert "acceptance_authorization: approved #" not in epic_goal
|
||||
assert "commit_authorization: approved #" not in epic_goal
|
||||
|
||||
assert "`easysdd/` 到 `codestable/` 再到 `.codestable/`" in onboard
|
||||
assert "git mv <legacy-root> .codestable" in onboard
|
||||
assert "任一旧根与 `.codestable/` 并存时" in onboard
|
||||
assert "不输出 `git mv` 建议" in onboard
|
||||
assert "git mv codestable .codestable" not in onboard
|
||||
assert "git mv easysdd .codestable" not in onboard
|
||||
|
||||
|
||||
def test_primary_workflow_checkpoint_resumes_are_typed_matched_and_consumed() -> None:
|
||||
issue = skill("cs-issue/SKILL.md")
|
||||
refactor = skill("cs-refactor/SKILL.md")
|
||||
|
||||
@@ -2,10 +2,11 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -96,6 +97,88 @@ class Action:
|
||||
target: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReviewRecoveryState:
|
||||
lane: str
|
||||
status: str
|
||||
run_ref: str | None
|
||||
pending_decision: tuple[str, str | None] | None = None
|
||||
rejected_decision: tuple[str, str | None] | None = None
|
||||
|
||||
|
||||
def request_review_decision(
|
||||
state: ReviewRecoveryState, decision: str, failed_ref: str | None
|
||||
) -> ReviewRecoveryState:
|
||||
unavailable_request = (
|
||||
decision == "local-only-unavailable"
|
||||
and state.status == "unavailable"
|
||||
and failed_ref is None
|
||||
)
|
||||
failed_request = (
|
||||
state.status == "failed"
|
||||
and failed_ref is not None
|
||||
and state.run_ref == failed_ref
|
||||
)
|
||||
if not (unavailable_request or failed_request):
|
||||
raise ValueError("invalid review decision request")
|
||||
return replace(
|
||||
state,
|
||||
pending_decision=(decision, failed_ref),
|
||||
rejected_decision=None,
|
||||
)
|
||||
|
||||
|
||||
def resolve_review_decision(
|
||||
state: ReviewRecoveryState, decision: str, failed_ref: str | None, verdict: str
|
||||
) -> ReviewRecoveryState:
|
||||
expected = (decision, failed_ref)
|
||||
if state.status not in {"failed", "unavailable"} or state.run_ref != failed_ref:
|
||||
raise ValueError("invalid review decision resume")
|
||||
if state.pending_decision != expected:
|
||||
raise ValueError("invalid review decision resume")
|
||||
if verdict == "rejected":
|
||||
return replace(state, pending_decision=None, rejected_decision=expected)
|
||||
if verdict == "approved" and decision == "skip-failed-ocr":
|
||||
return replace(
|
||||
state,
|
||||
status="skipped",
|
||||
pending_decision=None,
|
||||
rejected_decision=None,
|
||||
)
|
||||
if verdict == "approved" and decision in {"local-only", "local-only-unavailable"}:
|
||||
return replace(
|
||||
state,
|
||||
status="local-review",
|
||||
pending_decision=None,
|
||||
rejected_decision=None,
|
||||
)
|
||||
raise ValueError("invalid review decision verdict")
|
||||
|
||||
|
||||
def retry_review_lane(state: ReviewRecoveryState, failed_ref: str) -> ReviewRecoveryState:
|
||||
if state.status != "failed" or state.run_ref != failed_ref:
|
||||
raise ValueError("invalid review retry")
|
||||
return replace(
|
||||
state,
|
||||
status="ready-to-launch",
|
||||
pending_decision=None,
|
||||
rejected_decision=None,
|
||||
)
|
||||
|
||||
|
||||
def resume_review_lane(
|
||||
state: ReviewRecoveryState, pending_ref: str, result: str
|
||||
) -> ReviewRecoveryState:
|
||||
if state.status != "pending" or state.run_ref != pending_ref:
|
||||
raise ValueError("invalid lane result")
|
||||
return replace(
|
||||
state,
|
||||
status=result,
|
||||
pending_decision=None,
|
||||
rejected_decision=None,
|
||||
)
|
||||
|
||||
|
||||
def skill_text(skill: str, rel_path: str = "SKILL.md") -> str:
|
||||
return (SKILLS / skill / rel_path).read_text(encoding="utf-8")
|
||||
|
||||
@@ -137,19 +220,23 @@ def init_isolated_repo(tmp_path: Path) -> Path:
|
||||
return repo
|
||||
|
||||
|
||||
def frontmatter(path: Path) -> dict[str, str]:
|
||||
def frontmatter(path: Path) -> dict[str, object]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if not text.startswith("---"):
|
||||
return {}
|
||||
block = text.split("---", 2)[1]
|
||||
result = {}
|
||||
for line in block.splitlines():
|
||||
if ":" in line:
|
||||
key, value = line.split(":", 1)
|
||||
result[key.strip()] = value.strip().strip('"')
|
||||
return result
|
||||
result = yaml.safe_load(block)
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
|
||||
def named_approval(path: Path, decision_id: str) -> str | None:
|
||||
approvals = frontmatter(path).get("approvals")
|
||||
if not isinstance(approvals, dict):
|
||||
return None
|
||||
status = approvals.get(decision_id)
|
||||
return status if isinstance(status, str) else None
|
||||
|
||||
|
||||
def top_level_yaml(path: Path) -> dict[str, str]:
|
||||
@@ -389,13 +476,36 @@ def issue_dir(repo: Path, slug: str) -> Path:
|
||||
return repo / ".codestable/issues" / f"2026-07-02-{slug}"
|
||||
|
||||
|
||||
def write_issue_doc(repo: Path, slug: str, name: str, status: str = "draft") -> None:
|
||||
def write_issue_doc(
|
||||
repo: Path,
|
||||
slug: str,
|
||||
name: str,
|
||||
status: str = "draft",
|
||||
issue_path: str | None = None,
|
||||
) -> None:
|
||||
path_field = f"issue_path: {issue_path}\n" if issue_path is not None else ""
|
||||
write(
|
||||
issue_dir(repo, slug) / f"{slug}-{name}.md",
|
||||
f"---\ndoc_type: issue-{name}\nissue: 2026-07-02-{slug}\nstatus: {status}\n---\n# {name}\n",
|
||||
f"---\ndoc_type: issue-{name}\nissue: 2026-07-02-{slug}\nstatus: {status}\n{path_field}---\n# {name}\n",
|
||||
)
|
||||
|
||||
|
||||
def write_issue_approval(
|
||||
repo: Path,
|
||||
slug: str,
|
||||
*,
|
||||
status: str,
|
||||
approvals: dict[str, str],
|
||||
) -> Path:
|
||||
approval_lines = "\n".join(f" {decision}: {decision_status}" for decision, decision_status in approvals.items())
|
||||
path = issue_dir(repo, slug) / "approval-report.md"
|
||||
write(
|
||||
path,
|
||||
f"---\ndoc_type: approval-report\nstatus: {status}\napprovals:\n{approval_lines}\n---\n# Approval\n",
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def issue_next(repo: Path, slug: str) -> Action:
|
||||
directory = issue_dir(repo, slug)
|
||||
report = directory / f"{slug}-report.md"
|
||||
@@ -403,11 +513,25 @@ def issue_next(repo: Path, slug: str) -> Action:
|
||||
return Action("load-reference", "cs-issue/references/report/protocol.md")
|
||||
if frontmatter(report).get("status") != "confirmed":
|
||||
return Action("user-checkpoint", "issue-report-confirmation")
|
||||
analysis = directory / f"{slug}-analysis.md"
|
||||
if not analysis.exists():
|
||||
return Action("load-reference", "cs-issue/references/analyze/protocol.md")
|
||||
if frontmatter(analysis).get("status") != "confirmed":
|
||||
return Action("user-checkpoint", "issue-fix-plan-confirmation")
|
||||
issue_path = frontmatter(report).get("issue_path") or "standard"
|
||||
if issue_path not in {"standard", "fast-track"}:
|
||||
return Action("blocked", "invalid-issue-path")
|
||||
if issue_path == "fast-track":
|
||||
fast_path_status = named_approval(directory / "approval-report.md", "issue-fast-path")
|
||||
if fast_path_status is None:
|
||||
return Action("needs-human", "fast-path-approval-missing")
|
||||
if fast_path_status == "pending":
|
||||
return Action("blocked", "fast-path-approval-pending-without-checkpoint")
|
||||
if fast_path_status == "rejected":
|
||||
issue_path = "standard"
|
||||
elif fast_path_status != "approved":
|
||||
return Action("blocked", "invalid-fast-path-approval")
|
||||
if issue_path == "standard":
|
||||
analysis = directory / f"{slug}-analysis.md"
|
||||
if not analysis.exists():
|
||||
return Action("load-reference", "cs-issue/references/analyze/protocol.md")
|
||||
if frontmatter(analysis).get("status") != "confirmed":
|
||||
return Action("user-checkpoint", "issue-fix-plan-confirmation")
|
||||
if not (directory / f"{slug}-fix-note.md").exists():
|
||||
return Action("load-reference", "cs-issue/references/fix/protocol.md")
|
||||
review_status = frontmatter(directory / f"{slug}-review.md").get("status")
|
||||
@@ -416,8 +540,7 @@ def issue_next(repo: Path, slug: str) -> Action:
|
||||
if review_status in {"changes-requested", "blocked"}:
|
||||
return Action("load-reference", "cs-issue/references/fix/protocol.md#review-fix")
|
||||
if review_status == "passed":
|
||||
approval = directory / "approval-report.md"
|
||||
approval_status = frontmatter(approval).get("status")
|
||||
approval_status = named_approval(directory / "approval-report.md", "issue-fix-completion")
|
||||
if not approval_status:
|
||||
return Action("load-reference", "cs-issue/references/fix/protocol.md#completion-checkpoint")
|
||||
if approval_status == "pending":
|
||||
@@ -473,8 +596,12 @@ def docs_next(repo: Path, request: str, mode: str | None = None) -> Action:
|
||||
if mode == "api" or "api" in request:
|
||||
return Action("load-reference", "cs-docs/references/api/protocol.md")
|
||||
guide = repo / "docs/dev/widget-guide.md"
|
||||
if guide.exists() and frontmatter(guide).get("status") == "current" and "small edit" in request:
|
||||
return Action("focused-edit", "docs/dev/widget-guide.md")
|
||||
if guide.exists():
|
||||
metadata = frontmatter(guide)
|
||||
if metadata.get("status") == "draft" and metadata.get("workflow_stage") == "focused-edit":
|
||||
return Action("focused-edit", "docs/dev/widget-guide.md")
|
||||
if metadata.get("status") == "current" and "small edit" in request:
|
||||
return Action("focused-edit", "docs/dev/widget-guide.md")
|
||||
return Action("load-reference", "cs-docs/references/tutorial/protocol.md")
|
||||
|
||||
|
||||
@@ -620,7 +747,7 @@ def test_review_outcomes_do_not_treat_waiting_or_missing_input_as_approval() ->
|
||||
"not s.diffAttributed -> NeedsHuman DiffNotAttributable",
|
||||
"Just lane <- firstLaunchableLane s -> Launching lane",
|
||||
"Just wait <- firstPendingLane s -> Awaiting wait",
|
||||
"HumanCheckpoint SelfReviewDowngrade",
|
||||
"HumanCheckpoint (reviewDecisionCheckpoint decision)",
|
||||
):
|
||||
assert phrase in text
|
||||
for forbidden in (
|
||||
@@ -629,19 +756,23 @@ def test_review_outcomes_do_not_treat_waiting_or_missing_input_as_approval() ->
|
||||
"HumanCheckpoint LaneStillPending",
|
||||
):
|
||||
assert forbidden not in text
|
||||
selector = text[text.index("selectReviewOutcome ::"):text.index("focusedClosureEligible ::")]
|
||||
guard_order = (
|
||||
"not s.specFinalized",
|
||||
"not s.diffAttributed",
|
||||
"rejectedReviewDecision s",
|
||||
"pendingReviewDecision s",
|
||||
"laneAMissing s && isExplicit s.agentConfig",
|
||||
"anyLaneFailed s",
|
||||
"firstLaunchableLane s",
|
||||
"firstPendingLane s",
|
||||
"focusedClosureEligible s && hasBlocking s",
|
||||
"focusedClosureEligible s -> FocusedClosure Passed",
|
||||
"laneAMissing s",
|
||||
"laneAMissing s && not (userAcceptedDowngrade s)",
|
||||
"hasBlocking s -> ReviewWritten ChangesRequested",
|
||||
)
|
||||
assert [text.index(guard) for guard in guard_order] == sorted(
|
||||
text.index(guard) for guard in guard_order
|
||||
assert [selector.index(guard) for guard in guard_order] == sorted(
|
||||
selector.index(guard) for guard in guard_order
|
||||
)
|
||||
for phrase in (
|
||||
"mergeGate (Launch agent config) _ = MergeLaunch LaneA (TaskCommand agent config)",
|
||||
@@ -667,6 +798,79 @@ def test_review_outcomes_do_not_treat_waiting_or_missing_input_as_approval() ->
|
||||
assert "pending (RunCommitted _)" not in protocol
|
||||
|
||||
|
||||
def test_review_recovery_rejection_is_consumed_and_retryable() -> None:
|
||||
failed = ReviewRecoveryState(lane="lane-a", status="failed", run_ref="agent-17")
|
||||
pending = request_review_decision(failed, "local-only", "agent-17")
|
||||
|
||||
assert pending.pending_decision == ("local-only", "agent-17")
|
||||
rejected = resolve_review_decision(pending, "local-only", "agent-17", "rejected")
|
||||
assert rejected.status == "failed"
|
||||
assert rejected.pending_decision is None
|
||||
assert rejected.rejected_decision == ("local-only", "agent-17")
|
||||
|
||||
reopened = request_review_decision(rejected, "local-only", "agent-17")
|
||||
assert reopened.pending_decision == ("local-only", "agent-17")
|
||||
assert reopened.rejected_decision is None
|
||||
|
||||
retried = retry_review_lane(reopened, "agent-17")
|
||||
assert retried.status == "ready-to-launch"
|
||||
assert retried.pending_decision is None
|
||||
assert retried.rejected_decision is None
|
||||
|
||||
|
||||
def test_review_retry_supersedes_pending_decision_before_new_result() -> None:
|
||||
failed = ReviewRecoveryState(lane="lane-b", status="failed", run_ref="ocr-4")
|
||||
pending_skip = request_review_decision(failed, "skip-failed-ocr", "ocr-4")
|
||||
|
||||
retried = retry_review_lane(pending_skip, "ocr-4")
|
||||
assert retried.pending_decision is None
|
||||
relaunched = replace(retried, status="pending", run_ref="ocr-5")
|
||||
completed = resume_review_lane(relaunched, "ocr-5", "completed")
|
||||
assert completed.status == "completed"
|
||||
assert completed.pending_decision is None
|
||||
assert completed.rejected_decision is None
|
||||
|
||||
|
||||
def test_review_failed_ocr_can_be_explicitly_skipped() -> None:
|
||||
failed = ReviewRecoveryState(lane="lane-b", status="failed", run_ref="ocr-9")
|
||||
pending = request_review_decision(failed, "skip-failed-ocr", "ocr-9")
|
||||
skipped = resolve_review_decision(pending, "skip-failed-ocr", "ocr-9", "approved")
|
||||
|
||||
assert skipped.status == "skipped"
|
||||
assert skipped.pending_decision is None
|
||||
with pytest.raises(ValueError, match="invalid review decision request"):
|
||||
request_review_decision(failed, "skip-failed-ocr", "ocr-stale")
|
||||
with pytest.raises(ValueError, match="invalid review decision request"):
|
||||
request_review_decision(skipped, "skip-failed-ocr", "ocr-9")
|
||||
|
||||
|
||||
def test_review_unavailable_lane_has_ref_free_downgrade_resumes() -> None:
|
||||
unavailable = ReviewRecoveryState(lane="lane-a", status="unavailable", run_ref=None)
|
||||
pending = request_review_decision(
|
||||
unavailable,
|
||||
"local-only-unavailable",
|
||||
None,
|
||||
)
|
||||
approved = resolve_review_decision(
|
||||
pending,
|
||||
"local-only-unavailable",
|
||||
None,
|
||||
"approved",
|
||||
)
|
||||
assert approved.status == "local-review"
|
||||
assert approved.pending_decision is None
|
||||
|
||||
rejected = resolve_review_decision(
|
||||
pending,
|
||||
"local-only-unavailable",
|
||||
None,
|
||||
"rejected",
|
||||
)
|
||||
assert rejected.status == "unavailable"
|
||||
assert rejected.pending_decision is None
|
||||
assert rejected.rejected_decision == ("local-only-unavailable", None)
|
||||
|
||||
|
||||
def test_issue_fast_path_confirmation_is_persisted_and_resumable() -> None:
|
||||
skill = skill_text("cs-issue")
|
||||
report = skill_text("cs-issue", "references/report/protocol.md")
|
||||
@@ -697,6 +901,7 @@ def test_issue_fast_path_confirmation_is_persisted_and_resumable() -> None:
|
||||
"PersistDraftAndCheckpoint ConfirmFixPlan",
|
||||
"fast-track 不得绕过五问",
|
||||
"记录 fast-path 已批准再进入 fix",
|
||||
"approval-report.md#issue-fast-path",
|
||||
"issue_path: undecided # undecided | standard | fast-track",
|
||||
):
|
||||
assert phrase in report
|
||||
@@ -717,7 +922,13 @@ def test_issue_fast_path_confirmation_is_persisted_and_resumable() -> None:
|
||||
assert 's.issuePath == FastPathPending -> Blocked "pending fast-path approval lacks checkpoint state"' in skill
|
||||
assert "ApprovalRevisionRequested Feedback" in skill
|
||||
assert "ReviseFixOptions feedback" in skill_text("cs-issue", "references/analyze/protocol.md")
|
||||
assert "fixCompletionApproval s is ApprovalRevisionRequested _" in skill_text("cs-issue", "references/fix/protocol.md")
|
||||
fix = skill_text("cs-issue", "references/fix/protocol.md")
|
||||
assert "fixCompletionApproval s is ApprovalRevisionRequested _" in fix
|
||||
assert "issuePath state in [PathUndecided, FastPathPending]" in fix
|
||||
assert "issuePath state == FastPathApproved" in fix
|
||||
assert "approvedFixPlanArtifact state" in fix
|
||||
assert "issuePath state in [StandardPath, FastPathRejected]" in fix
|
||||
assert "approval-report.md#issue-fix-completion" in fix
|
||||
|
||||
|
||||
def test_req_review_checkpoint_precedes_persistence() -> None:
|
||||
@@ -744,6 +955,7 @@ def test_docs_checkpoint_domain_matches_stage_protocols() -> None:
|
||||
skill = skill_text("cs-docs")
|
||||
tutorial = skill_text("cs-docs", "references/tutorial/protocol.md")
|
||||
api = skill_text("cs-docs", "references/api/protocol.md")
|
||||
focused = skill_text("cs-docs", "references/focused-edit/protocol.md")
|
||||
reasons = {
|
||||
"ReviewDraft",
|
||||
"ReviewManifest",
|
||||
@@ -758,6 +970,15 @@ def test_docs_checkpoint_domain_matches_stage_protocols() -> None:
|
||||
assert 'targetAmbiguous state = NeedsHuman "which reader?"' in tutorial
|
||||
assert "Just reason <- approvalGate state" in tutorial
|
||||
assert "Just reason <- approvalGate s" in api
|
||||
assert 'stageProtocol FocusedEdit = "references/focused-edit/protocol.md"' in skill
|
||||
assert "advanceFocusedEdit :: DocsState -> EditIntent -> FocusedEditOutcome" in focused
|
||||
assert "Just ConfirmOverwrite" in focused
|
||||
assert "Just ConfirmContractWording" in focused
|
||||
assert "Checkpoint ReviewDraft" in focused
|
||||
assert "s.docStatus == Draft && s.workflowStage == Just FocusedEdit" in skill
|
||||
assert "workflow_stage: focused-edit" in focused
|
||||
assert "focusedEditStateValid" in focused
|
||||
assert "focusedEditIntentValid s intent = smallEdit intent || s.workflowStage == Just FocusedEdit" in focused
|
||||
assert "ConfirmNewDoc" not in skill
|
||||
assert "ConfirmReaderAndScope" not in skill
|
||||
assert tutorial.index("targetAmbiguous state") < tutorial.index("Just reason <- approvalGate state")
|
||||
@@ -939,7 +1160,11 @@ def test_acceptance_separates_evidence_causes_and_reaches_repeated_gap_handoff()
|
||||
|
||||
def test_review_focused_closure_never_masks_failed_or_pending_lanes() -> None:
|
||||
review = skill_text("cs-code-review")
|
||||
selector = review[review.index("selectReviewOutcome ::"):review.index("focusedClosureEligible ::")]
|
||||
ordered_guards = (
|
||||
"rejectedReviewDecision s",
|
||||
"pendingReviewDecision s",
|
||||
"laneAMissing s && isExplicit s.agentConfig",
|
||||
"anyLaneFailed s",
|
||||
"firstLaunchableLane s",
|
||||
"firstPendingLane s",
|
||||
@@ -947,7 +1172,7 @@ def test_review_focused_closure_never_masks_failed_or_pending_lanes() -> None:
|
||||
"focusedClosureEligible s -> FocusedClosure Passed",
|
||||
)
|
||||
|
||||
positions = [review.index(guard) for guard in ordered_guards]
|
||||
positions = [selector.index(guard) for guard in ordered_guards]
|
||||
assert positions == sorted(positions)
|
||||
assert "anyLaneFailed s -> ReviewWritten Blocked" in review
|
||||
assert "s.priorIndependentReview" in review
|
||||
@@ -1252,15 +1477,69 @@ def test_issue_scenario_progresses_through_main_entry_references(tmp_path: Path)
|
||||
assert issue_next(repo, slug) == Action(
|
||||
"load-reference", "cs-issue/references/fix/protocol.md#completion-checkpoint"
|
||||
)
|
||||
write(
|
||||
issue_dir(repo, slug) / "approval-report.md",
|
||||
"---\ndoc_type: approval-report\nstatus: pending\n---\n# Approval\n",
|
||||
write_issue_approval(
|
||||
repo,
|
||||
slug,
|
||||
status="pending",
|
||||
approvals={"issue-fix-completion": "pending"},
|
||||
)
|
||||
assert issue_next(repo, slug) == Action("user-checkpoint", "issue-fix-completion")
|
||||
replace_status(issue_dir(repo, slug) / "approval-report.md", "approved")
|
||||
write_issue_approval(repo, slug, status="approved", approvals={"issue-fix-completion": "approved"})
|
||||
assert issue_next(repo, slug) == Action("complete", "issue-reviewed")
|
||||
|
||||
|
||||
def test_issue_fast_path_closes_without_analysis(tmp_path: Path) -> None:
|
||||
repo = init_isolated_repo(tmp_path)
|
||||
slug = "header-typo"
|
||||
|
||||
write_issue_doc(repo, slug, "report", status="confirmed", issue_path="fast-track")
|
||||
assert issue_next(repo, slug) == Action("needs-human", "fast-path-approval-missing")
|
||||
write_issue_approval(
|
||||
repo,
|
||||
slug,
|
||||
status="approved",
|
||||
approvals={"issue-fast-path": "approved"},
|
||||
)
|
||||
assert issue_next(repo, slug) == Action("load-reference", "cs-issue/references/fix/protocol.md")
|
||||
write_issue_doc(repo, slug, "fix-note", status="confirmed")
|
||||
assert issue_next(repo, slug) == Action("load-skill", "cs-code-review")
|
||||
write_issue_doc(repo, slug, "review", status="passed")
|
||||
assert issue_next(repo, slug) == Action(
|
||||
"load-reference", "cs-issue/references/fix/protocol.md#completion-checkpoint"
|
||||
)
|
||||
write_issue_approval(
|
||||
repo,
|
||||
slug,
|
||||
status="pending",
|
||||
approvals={"issue-fast-path": "approved", "issue-fix-completion": "pending"},
|
||||
)
|
||||
assert issue_next(repo, slug) == Action("user-checkpoint", "issue-fix-completion")
|
||||
write_issue_approval(
|
||||
repo,
|
||||
slug,
|
||||
status="approved",
|
||||
approvals={"issue-fast-path": "approved", "issue-fix-completion": "approved"},
|
||||
)
|
||||
assert issue_next(repo, slug) == Action("complete", "issue-reviewed")
|
||||
assert not (issue_dir(repo, slug) / f"{slug}-analysis.md").exists()
|
||||
|
||||
|
||||
def test_rejected_fast_path_rejoins_standard_analysis_then_fix(tmp_path: Path) -> None:
|
||||
repo = init_isolated_repo(tmp_path)
|
||||
slug = "header-typo-rejected"
|
||||
|
||||
write_issue_doc(repo, slug, "report", status="confirmed", issue_path="fast-track")
|
||||
write_issue_approval(
|
||||
repo,
|
||||
slug,
|
||||
status="rejected",
|
||||
approvals={"issue-fast-path": "rejected"},
|
||||
)
|
||||
assert issue_next(repo, slug) == Action("load-reference", "cs-issue/references/analyze/protocol.md")
|
||||
write_issue_doc(repo, slug, "analysis", status="confirmed")
|
||||
assert issue_next(repo, slug) == Action("load-reference", "cs-issue/references/fix/protocol.md")
|
||||
|
||||
|
||||
def test_refactor_scenario_respects_scan_design_and_human_validation_gates(tmp_path: Path) -> None:
|
||||
assert_doc_contains("cs-refactor", "SKILL.md", "scan → 用户勾选 → design → 用户确认 → apply → cs-code-review")
|
||||
assert_doc_contains("cs-refactor", "references/standard/protocol.md", "用户勾选", "HUMAN 验证")
|
||||
@@ -1319,6 +1598,13 @@ def test_docs_scenario_selects_docs_entry_or_neat_hygiene(tmp_path: Path) -> Non
|
||||
"---\ndoc_type: dev-guide\nstatus: current\n---\n# Widget Guide\n",
|
||||
)
|
||||
assert docs_next(repo, "small edit to widget guide") == Action("focused-edit", "docs/dev/widget-guide.md")
|
||||
write(
|
||||
repo / "docs/dev/widget-guide.md",
|
||||
"---\ndoc_type: dev-guide\nstatus: draft\nworkflow_stage: focused-edit\n---\n# Widget Guide\n",
|
||||
)
|
||||
assert docs_next(repo, "continue approved draft") == Action(
|
||||
"focused-edit", "docs/dev/widget-guide.md"
|
||||
)
|
||||
assert docs_next(repo, "sync memory and README") == Action("load-skill", "cs-docs-neat")
|
||||
|
||||
|
||||
@@ -1436,7 +1722,7 @@ def test_review_selector_preserves_guard_order_and_scope() -> None:
|
||||
review_gate_order = (
|
||||
"reviewGate _ (Finished findings)",
|
||||
"reviewGate _ (Active ref)",
|
||||
"reviewGate _ (Failed _) (Just ApproveLocalOnly)",
|
||||
"reviewGate selection (Failed reason) (Just ApproveLocalOnly)",
|
||||
"reviewGate _ (Failed reason) _",
|
||||
"reviewGate (SelectionBlocked reason) NotStarted",
|
||||
"reviewGate (SelectionNeedsOwnerApproval _) NotStarted (Just ApproveLocalOnly)",
|
||||
@@ -1446,6 +1732,8 @@ def test_review_selector_preserves_guard_order_and_scope() -> None:
|
||||
review_gate_positions = [selector.index(fragment) for fragment in review_gate_order]
|
||||
assert review_gate_positions == sorted(review_gate_positions)
|
||||
assert "toReviewLane (NeedOwnerApproval reason) = Left reason" in selector
|
||||
assert "explicitPinBlocksLocal (Start _ config) = isExplicit config" in selector
|
||||
assert "explicitPinBlocksLocal (SelectionBlocked ExplicitConfigUnavailable) = True" in selector
|
||||
assert "data ReviewVerdict = Passed | ChangesRequested | ReviewBlocked Reason" in selector
|
||||
assert_doc_contains(
|
||||
"cs-code-review",
|
||||
|
||||
Reference in New Issue
Block a user