Browse Source

Merge pull request #2211 from deepseek-harness/worktree/event-driven-issue-review-status-20260810

feat: direct Issue status from PR review events
Tianyi Cui 2 months ago
parent
commit
8521bfe606

+ 6 - 0
.agents/notes/archived/manifest.json

@@ -304,6 +304,12 @@
     "process/2026-07-27-wine-windows-gates-experiment.i18n.yaml": "sha256:6f4cbc12ee9cddbb297bf7e138ccabcd204f66898a0f7411b1633f03d5a9eab5",
     "process/2026-07-27-wine-windows-gates-experiment.md": "sha256:8d37dcdab058098c7de3da1de00ce61bef92bbc8d6ee71add959474c6fb3e936",
     "process/2026-07-27-wine-windows-gates-experiment.zh.md": "sha256:77fbf04df36af09e55007a93bd6b22d08ff99869efe8de3e97dac5b4701e0a9e",
+    "process/2026-08-04-forward-only-pr-issue-status.i18n.yaml": "sha256:af23e203a66a95674154899410e2f420d1d0685dbf856c24cfccdaa547a17925",
+    "process/2026-08-04-forward-only-pr-issue-status.md": "sha256:2d31077da47d95ab3ddf64d5efc6b1b8fb7c7709d39aca4a825ef9e9d382d501",
+    "process/2026-08-04-forward-only-pr-issue-status.zh.md": "sha256:b61f865b7a8a0ac901250a3edbb92ea73177067c4c25448c7088925c2caeccd7",
+    "process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml": "sha256:4c28c59d3fc323e7cd01eff31f1fe759834719c5bede1e82b39f868970bf856d",
+    "process/2026-08-08-review-driven-issue-lifecycle-triggers.md": "sha256:1b0514de5d030170e91e12e4d6ba788a9247f840e82700faa385a1c0c76ab857",
+    "process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md": "sha256:028d78d61f603d8bac64c4cce20b393a78f8e029d3bb4976e79a47ecaefa6032",
     "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml": "sha256:ad3d1263cb0051b885173bf064de62065e2c646ccaae2d7250723da3b4eab90c",
     "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md": "sha256:8fb061d51c8c23b47d2367814bab3623c6d5b972f38d207a273caa9030b579bd",
     "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md": "sha256:2ffeaca91f82844a5616d6dcce6b4af514bb8a7c46f78e47f668b204ac6edc04",

+ 2 - 2
.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml → .agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.md
-2026-08-04-forward-only-pr-issue-status.md: dd567707bc7fccd0a631943ab3ffd2838a7f2f76
-2026-08-04-forward-only-pr-issue-status.zh.md: f7fee58d6afb812f97569ae4d86c3d6504f35752
+2026-08-04-forward-only-pr-issue-status.md: 56004a39ce52c77429574f481d9945cdc4936d30
+2026-08-04-forward-only-pr-issue-status.zh.md: ee85319842d3245bdfab9668de0a42ab29597fac

+ 1 - 0
.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.md → .agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.md

@@ -1,6 +1,7 @@
 # Agent Note: Forward-only PR-to-Issue status projection
 
 Status: implemented
+Archived: 2026-08-10
 
 English | [中文](2026-08-04-forward-only-pr-issue-status.zh.md)
 

+ 1 - 0
.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.zh.md → .agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.zh.md

@@ -1,6 +1,7 @@
 # Agent Note: PR 到 Issue 的状态仅向前投射
 
 Status: implemented
+Archived: 2026-08-10
 
 [English](2026-08-04-forward-only-pr-issue-status.md) | 中文
 

+ 2 - 2
.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml → .agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.md
-2026-08-08-review-driven-issue-lifecycle-triggers.md: 8a2d48ee23da4c20bb832ae0109e2ea9912dac83
-2026-08-08-review-driven-issue-lifecycle-triggers.zh.md: 004739ff471815b0fe12e111eba0ec7aaaef9507
+2026-08-08-review-driven-issue-lifecycle-triggers.md: 444927968912d93f473e27ae8576e8371b9c287c
+2026-08-08-review-driven-issue-lifecycle-triggers.zh.md: 6e00e2a936b6421824743e779756011fcd4a1c9e

+ 1 - 0
.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.md → .agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.md

@@ -1,6 +1,7 @@
 # Agent Note: Review-driven Issue lifecycle triggers
 
 Status: implemented
+Archived: 2026-08-10
 
 English | [中文](2026-08-08-review-driven-issue-lifecycle-triggers.zh.md)
 

+ 1 - 0
.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md → .agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md

@@ -1,6 +1,7 @@
 # Agent Note: 由评审驱动的 Issue 生命周期触发器
 
 Status: implemented
+Archived: 2026-08-10
 
 [English](2026-08-08-review-driven-issue-lifecycle-triggers.md) | 中文
 

+ 6 - 0
.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md
+2026-08-10-event-directed-pr-review-status.md: 9db9c64fc87c1701028ae825357c3cbd7fef44d1
+2026-08-10-event-directed-pr-review-status.zh.md: 381a3f64a62930a584f48cfbc3571679bbcbcef7

+ 41 - 0
.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md

@@ -0,0 +1,41 @@
+# Agent Note: Event-directed PR review status commands
+
+Status: implemented
+
+English | [中文](2026-08-10-event-directed-pr-review-status.zh.md)
+
+## Problem
+
+The Issue Project status records who owns the next step of resolving work. Aggregate pull-request review state answers whether GitHub considers the pull request mergeable, but it cannot represent that handoff: an earlier `CHANGES_REQUESTED` review can remain effective after the author fixes the code and requests review again.
+
+A monotonic projection also cannot return an automation-owned Issue from `In review` to `In progress` when a reviewer requests changes. Reconstructing review rounds or reviewer blockers would add state that the required two-event contract does not need.
+
+## Decision
+
+The Issue lifecycle workflow treats review webhooks as commands. `pull_request.review_requested`, including a repeated request, targets `In review`. `pull_request_review.submitted` targets `In progress` only when `review.state` is `changes_requested`; the submitted event remains necessary because a reviewer can request changes without an earlier review-request event. Approved and commented submissions skip their lifecycle job before it creates a Project token, while dismissed reviews are not subscribed.
+
+Ordinary subscribed pull-request events remain forward-only implementation signals: they can move `Inbox`, `Backlog`, or `Ready` to `In progress`, but they cannot move `In review` backward. Review-request commands can move any earlier active status to `In review`. Changes-requested commands can move earlier active statuses forward to `In progress` and can move `In review` back only when the latest status event for the target Project was written by the configured lifecycle actor. A human or unknown latest actor preserves the current status.
+
+The handler resolves only exact same-repository `Fixes`, `Closes`, or `Resolves` references. It does not alter terminal statuses, add an Issue with no Project status, depend on PR metadata validity, query `reviewDecision`, reconstruct review rounds, look up pull requests from Issues, or run a scheduled reconciler.
+
+[Issue lifecycle](../../../../.github/workflows/issue-lifecycle.yml) remains unsubscribed from `pull_request.ready_for_review`; neither event command depends on that action. [Issue policy](../../../../.github/workflows/issue-policy.yml) retains `ready_for_review` because it owns required-check enforcement when a human pull request enters review.
+
+## Verification
+
+[Issue-management tests](../../../../.github/issue-management/policy.test.mjs) pin the event-to-command mapping, the repeated-review-request transition after a changes-requested command, the changes-requested regression, terminal protection, and human override preservation. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the subscribed events, the changes-requested job condition, and the separate `ready_for_review` policy trigger.
+
+## Alternatives considered
+
+**Derive status from `reviewDecision` or a reconstructed review round.** GitHub's aggregate can remain `CHANGES_REQUESTED` after a repeated review request, while a round reducer introduces reviewer and ordering semantics beyond the two explicit handoffs.
+
+**Keep the forward-only projection.** Monotonic advancement protects later statuses, but it leaves an Issue in `In review` while the author is implementing requested changes.
+
+**Apply every review command unconditionally.** This is the smallest event handler, but it lets automation overwrite a human-owned Project status. The latest target-Project status actor therefore guards the only backward transition.
+
+**Restore `ready_for_review` or add a debounce queue.** Ready status carries neither review handoff, while another queue adds latency and control-plane state without changing either command.
+
+## Consequences
+
+A repeated review request moves an automation-managed resolving Issue to `In review` even while GitHub still reports an older blocking review. A later changes-requested review returns it to `In progress`; approval, comments, dismissal, pushes, and reviewer removal leave the most recent command's status unchanged.
+
+The projection remains event-driven and does not repair an event that never runs. Replaying an old workflow run can replay its old command, and ProjectV2 still provides no atomic compare-and-swap between the latest-state read and mutation. Per-pull-request workflow concurrency and the human-ownership guard reduce these races without introducing durable lifecycle state.

+ 41 - 0
.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md

@@ -0,0 +1,41 @@
+# Agent Note: 由事件直接指定的 PR 评审状态命令
+
+Status: implemented
+
+[English](2026-08-10-event-directed-pr-review-status.md) | 中文
+
+## 问题
+
+Issue 所在 Project 中的状态记录了解决工作的下一步由谁负责。PR(Pull Request)的汇总评审状态可以回答 GitHub 是否认为该 PR 可合并,却无法表示这次交接:作者修复代码并重新请求评审后,先前的 `CHANGES_REQUESTED` 评审仍可能继续生效。
+
+单调投影也无法在评审人提出修改要求时,将由自动化管理的 Issue 从 `In review` 退回 `In progress`。重建评审轮次或评审人阻塞项会引入既定双事件约定并不需要的状态。
+
+## 决策
+
+Issue 生命周期工作流把评审 webhook 视为命令。`pull_request.review_requested`(包括重复请求)将目标状态指定为 `In review`。`pull_request_review.submitted` 将目标状态指定为 `In progress`,但仅在 `review.state` 为 `changes_requested` 时生效;submitted 事件仍不可省略,因为评审人即使没有先触发 review-request 事件,也可以直接提出修改要求。对于 approved 和 commented 提交,工作流会在生命周期作业创建 Project token 前跳过该作业;dismissed 评审则不在订阅范围内。
+
+工作流订阅的普通 PR 事件仍是只向前推进的实现信号:它们可以将 `Inbox`、`Backlog` 或 `Ready` 推进至 `In progress`,但不能让 `In review` 倒退。请求评审命令可将任意较早的活跃状态推进至 `In review`。请求修改命令可将较早的活跃状态推进至 `In progress`;它也可以让 `In review` 状态回退,但仅在目标 Project 的最新状态事件由配置的生命周期执行主体写入时进行。若最新状态事件的执行主体是人工用户或未知主体,则保留当前状态。
+
+处理器仅解析同一仓库内严格匹配的 `Fixes`、`Closes` 或 `Resolves` 引用。它不会更改终态、将没有 Project 状态的 Issue 添加到 Project、依赖 PR 元数据是否有效、查询 `reviewDecision`、重建评审轮次、从 Issue 反向查找 PR,或运行定时协调器。
+
+[Issue 生命周期](../../../../.github/workflows/issue-lifecycle.yml)仍不订阅 `pull_request.ready_for_review`;两条事件命令均不依赖该动作。[Issue 策略](../../../../.github/workflows/issue-policy.yml)保留 `ready_for_review`,因为人工提交的 PR 进入评审时,该工作流负责执行必需检查门禁。
+
+## 验证
+
+[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)锁定事件到命令的映射、请求修改命令后重复请求评审所触发的状态转换、请求修改后的状态回退、终态保护,以及保留人工覆盖状态。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定订阅事件、请求修改作业的条件,以及独立的 `ready_for_review` 策略触发器。
+
+## 考虑过的替代方案
+
+**根据 `reviewDecision` 或重建的评审轮次派生状态。** GitHub 的汇总状态在重复请求评审后仍可能保持为 `CHANGES_REQUESTED`,而轮次归约器会引入超出两个显式交接动作所需范围的评审人语义和顺序语义。
+
+**保留只向前推进的投影。** 单调推进可保护较后的状态不被回退,但作者正在按要求修改代码时,Issue 会一直停留在 `In review`。
+
+**无条件应用每条评审命令。** 这是最精简的事件处理器,但会让自动化覆盖由人工管理的 Project 状态。因此,处理器通过目标 Project 最新状态事件的执行主体保护唯一允许的回退转换。
+
+**恢复 `ready_for_review` 或添加防抖队列。** Ready 状态并不表示两种评审交接中的任何一种;新增队列只会增加延迟和控制平面状态,不会改变任一命令。
+
+## 后果
+
+即使 GitHub 仍报告一个较早的阻塞性评审,重复请求评审也会将正由当前 PR 解决且由自动化管理的 Issue 推进至 `In review`。后续提出修改要求的评审会将其退回 `In progress`;批准、评论、撤销评审、推送和移除评审人都不会改变最近一条命令设定的状态。
+
+投影仍由事件驱动;如果某个事件从未触发工作流运行,投影不会自行修复。回放旧的工作流运行可能会再次执行其中的旧命令;ProjectV2 仍不提供在读取最新状态与执行变更之间进行原子比较并交换(compare-and-swap)的能力。以单个 PR 为粒度的工作流并发控制和人工状态所有权保护机制可减少这些竞态,而无需引入持久化生命周期状态。

+ 1 - 0
.github/issue-management/config.json

@@ -3,6 +3,7 @@
   "repository": "deepseek-harness",
   "projectNumber": 1,
   "projectTitle": "DSH Issue Management",
+  "lifecycleActor": "dsh-issue-management",
   "priorityField": "Priority",
   "allowUnassignedOwner": true,
   "statuses": [

+ 107 - 24
.github/issue-management/policy.mjs

@@ -37,10 +37,21 @@ const LEGACY_LABELS = new Set([
 ])
 const TERMINAL_STATUSES = new Set(['Done', 'No action'])
 const ACTIVE_STATUS_ORDER = config.statuses.filter((status) => !TERMINAL_STATUSES.has(status))
+const IMPLEMENTATION_PULL_REQUEST_ACTIONS = new Set([
+  'opened',
+  'edited',
+  'synchronize',
+  'reopened',
+  'labeled',
+  'unlabeled',
+])
 
 for (const status of ['In progress', 'In review']) {
   if (!ACTIVE_STATUS_ORDER.includes(status)) throw new Error(`config.statuses 缺少 ${status}`)
 }
+if (typeof config.lifecycleActor !== 'string' || !config.lifecycleActor) {
+  throw new Error('config.lifecycleActor 未设置')
+}
 
 /**
  * Return Markdown outside balanced details elements.
@@ -159,18 +170,48 @@ export function requiresPullRequestPolicy({
 }
 
 /**
- * Derive a forward-only Issue status from the current PR phase.
+ * Translate a repository event into one resolving-Issue lifecycle command.
+ * @param {string} eventName GitHub event name.
+ * @param {{action?: string, review?: {state?: string}}} event GitHub event payload.
+ * @returns {'implementation'|'review-requested'|'changes-requested'|null} Lifecycle command.
+ */
+export function resolvingIssueStatusCommand(eventName, event) {
+  if (eventName === 'pull_request') {
+    if (event.action === 'review_requested') return 'review-requested'
+    return IMPLEMENTATION_PULL_REQUEST_ACTIONS.has(event.action) ? 'implementation' : null
+  }
+  if (
+    eventName === 'pull_request_review' &&
+    event.action === 'submitted' &&
+    event.review?.state?.toLowerCase() === 'changes_requested'
+  ) {
+    return 'changes-requested'
+  }
+  return null
+}
+
+/**
+ * Plan one event-directed resolving-Issue status transition.
  * @param {string|null} currentStatus Current Project status.
- * @param {{isDraft: boolean, reviewRequestCount: number, reviewCount: number}} pull PR phase.
- * @returns {string|null} Status to write, or null when no forward transition exists.
+ * @param {'implementation'|'review-requested'|'changes-requested'} command Lifecycle command.
+ * @param {string|null} currentStatusActor Actor that last set the current Project status.
+ * @returns {string|null} Status to write, or null when no permitted transition exists.
  */
-export function nextResolvingIssueStatus(currentStatus, pull) {
-  const target =
-    !pull.isDraft && (pull.reviewRequestCount > 0 || pull.reviewCount > 0)
-      ? 'In review'
-      : 'In progress'
+export function nextResolvingIssueStatus(currentStatus, command, currentStatusActor = null) {
+  let target
+  if (command === 'review-requested') target = 'In review'
+  else if (command === 'implementation' || command === 'changes-requested') target = 'In progress'
+  else throw new Error(`未知 lifecycle command:${command}`)
+
   const currentIndex = ACTIVE_STATUS_ORDER.indexOf(currentStatus)
   const targetIndex = ACTIVE_STATUS_ORDER.indexOf(target)
+  if (
+    command === 'changes-requested' &&
+    currentStatus === 'In review' &&
+    currentStatusActor === config.lifecycleActor
+  ) {
+    return target
+  }
   return currentIndex >= 0 && currentIndex < targetIndex ? target : null
 }
 
@@ -396,9 +437,15 @@ async function issueSnapshot(number, status = undefined) {
   }
 }
 
-async function projectContext(number) {
+async function projectContext(number, includeStatusActor = false) {
   const data = await graphql(
-    `query($organization: String!, $repository: String!, $number: Int!, $project: Int!) {
+    `query(
+      $organization: String!
+      $repository: String!
+      $number: Int!
+      $project: Int!
+      $includeStatusActor: Boolean!
+    ) {
       organization(login: $organization) {
         projectV2(number: $project) {
           id
@@ -413,6 +460,16 @@ async function projectContext(number) {
       repository(owner: $organization, name: $repository) {
         issue(number: $number) {
           id
+          timelineItems(last: 100, itemTypes: [PROJECT_V2_ITEM_STATUS_CHANGED_EVENT])
+            @include(if: $includeStatusActor) {
+            nodes {
+              ... on ProjectV2ItemStatusChangedEvent {
+                actor { login }
+                project { id }
+                status
+              }
+            }
+          }
           projectItems(first: 20, includeArchived: true) {
             nodes {
               id
@@ -430,6 +487,7 @@ async function projectContext(number) {
       repository: config.repository,
       number,
       project: config.projectNumber,
+      includeStatusActor,
     },
   )
   const project = data.organization?.projectV2
@@ -439,7 +497,14 @@ async function projectContext(number) {
   const statusField = project.fields.nodes.find((field) => field?.name === 'Status')
   if (!statusField) throw new Error('Project 缺少 Status 字段')
   const item = issue.projectItems.nodes.find((candidate) => candidate.project.id === project.id)
-  return { project, issue, statusField, item }
+  const latestStatusEvent = issue.timelineItems?.nodes
+    ?.filter((event) => event?.project?.id === project.id)
+    .at(-1)
+  const statusActor =
+    latestStatusEvent?.status === item?.fieldValueByName?.name
+      ? (latestStatusEvent.actor?.login ?? null)
+      : null
+  return { project, issue, statusField, item, statusActor }
 }
 
 async function projectStatus(number) {
@@ -530,12 +595,7 @@ async function auditIssue(number, extraErrors = [], status = undefined) {
   return errors
 }
 
-async function pullRequestSnapshot(number) {
-  const pull = await api(`/repos/${config.organization}/${config.repository}/pulls/${number}`)
-  const [reviewRequests, reviews] = await Promise.all([
-    api(`/repos/${config.organization}/${config.repository}/pulls/${number}/requested_reviewers`),
-    api(`/repos/${config.organization}/${config.repository}/pulls/${number}/reviews?per_page=100`),
-  ])
+async function resolvingReferencesSnapshot(number, pull) {
   const references = parseReferences({
     body: pull.body ?? '',
     repository: `${config.organization}/${config.repository}`,
@@ -547,20 +607,41 @@ async function pullRequestSnapshot(number) {
   }
   return {
     number,
+    references: retainIssueReferences(references, issues),
+    issues,
+  }
+}
+
+async function pullRequestSnapshot(number) {
+  const [pull, reviewRequests, reviews] = await Promise.all([
+    api(`/repos/${config.organization}/${config.repository}/pulls/${number}`),
+    api(`/repos/${config.organization}/${config.repository}/pulls/${number}/requested_reviewers`),
+    api(`/repos/${config.organization}/${config.repository}/pulls/${number}/reviews?per_page=100`),
+  ])
+  const resolving = await resolvingReferencesSnapshot(number, pull)
+  return {
+    ...resolving,
     isDraft: pull.draft,
     authorType: pull.user?.type ?? 'User',
     reviewRequestCount: reviewRequests.users.length + reviewRequests.teams.length,
     reviewCount: reviews.length,
     labels: pull.labels.map((label) => label.name),
-    references: retainIssueReferences(references, issues),
-    issues,
   }
 }
 
-async function advanceResolvingIssues(pull) {
+async function lifecyclePullRequestSnapshot(number) {
+  const pull = await api(`/repos/${config.organization}/${config.repository}/pulls/${number}`)
+  return resolvingReferencesSnapshot(number, pull)
+}
+
+async function transitionResolvingIssues(pull, command) {
   for (const number of pull.references.resolving) {
-    const context = await projectContext(number)
-    const target = nextResolvingIssueStatus(context.item?.fieldValueByName?.name ?? null, pull)
+    const context = await projectContext(number, command === 'changes-requested')
+    const target = nextResolvingIssueStatus(
+      context.item?.fieldValueByName?.name ?? null,
+      command,
+      context.statusActor,
+    )
     if (!target) continue
     // TODO: Replace this latest-state guard with per-Issue serialization or a
     // conditional ProjectV2 update; GraphQL currently has no compare-and-swap.
@@ -598,8 +679,10 @@ async function runLifecycle(eventName, event) {
   }
 
   if (eventName === 'pull_request' || eventName === 'pull_request_review') {
-    const pull = await pullRequestSnapshot(event.pull_request.number)
-    await advanceResolvingIssues(pull)
+    const command = resolvingIssueStatusCommand(eventName, event)
+    if (!command) return
+    const pull = await lifecyclePullRequestSnapshot(event.pull_request.number)
+    await transitionResolvingIssues(pull, command)
   }
 }
 

+ 64 - 22
.github/issue-management/policy.test.mjs

@@ -6,6 +6,7 @@ import {
   nextResolvingIssueStatus,
   parseReferences,
   retainIssueReferences,
+  resolvingIssueStatusCommand,
   requiresPullRequestPolicy,
   validateBody,
   validateIssue,
@@ -243,32 +244,73 @@ test('requires policy only after a human PR enters review', () => {
   )
 })
 
-test('advances resolving Issues to the live PR phase', () => {
-  const draft = { isDraft: true, reviewRequestCount: 1, reviewCount: 4 }
-  const open = { isDraft: false, reviewRequestCount: 0, reviewCount: 0 }
-  const requestedReview = { isDraft: false, reviewRequestCount: 1, reviewCount: 0 }
-  const submittedReview = { isDraft: false, reviewRequestCount: 0, reviewCount: 1 }
+test('maps only explicit review handoffs to review status commands', () => {
+  assert.equal(
+    resolvingIssueStatusCommand('pull_request', {
+      action: 'review_requested',
+    }),
+    'review-requested',
+  )
+  assert.equal(
+    resolvingIssueStatusCommand('pull_request_review', {
+      action: 'submitted',
+      review: { state: 'changes_requested' },
+    }),
+    'changes-requested',
+  )
+  for (const state of ['approved', 'commented']) {
+    assert.equal(
+      resolvingIssueStatusCommand('pull_request_review', {
+        action: 'submitted',
+        review: { state },
+      }),
+      null,
+    )
+  }
+  assert.equal(
+    resolvingIssueStatusCommand('pull_request_review', {
+      action: 'dismissed',
+      review: { state: 'changes_requested' },
+    }),
+    null,
+  )
+})
 
-  for (const status of ['Inbox', 'Backlog', 'Ready']) {
-    assert.equal(nextResolvingIssueStatus(status, draft), 'In progress')
-    assert.equal(nextResolvingIssueStatus(status, open), 'In progress')
-    assert.equal(nextResolvingIssueStatus(status, requestedReview), 'In review')
-    assert.equal(nextResolvingIssueStatus(status, submittedReview), 'In review')
+test('keeps ordinary pull request events as forward-only implementation signals', () => {
+  for (const action of ['opened', 'edited', 'synchronize', 'reopened', 'labeled', 'unlabeled']) {
+    assert.equal(resolvingIssueStatusCommand('pull_request', { action }), 'implementation')
   }
-  assert.equal(nextResolvingIssueStatus('In progress', requestedReview), 'In review')
-  assert.equal(nextResolvingIssueStatus('In progress', submittedReview), 'In review')
+  assert.equal(
+    resolvingIssueStatusCommand('pull_request', { action: 'review_request_removed' }),
+    null,
+  )
 })
 
-test('never regresses or reopens a resolving Issue', () => {
-  const implementation = { isDraft: false, reviewRequestCount: 0, reviewCount: 0 }
-  const review = { isDraft: false, reviewRequestCount: 0, reviewCount: 1 }
+test('toggles automation-owned work on request changes and repeated review request', () => {
+  for (const status of ['Inbox', 'Backlog', 'Ready']) {
+    assert.equal(nextResolvingIssueStatus(status, 'implementation'), 'In progress')
+    assert.equal(nextResolvingIssueStatus(status, 'review-requested'), 'In review')
+    assert.equal(nextResolvingIssueStatus(status, 'changes-requested'), 'In progress')
+  }
+  let status = nextResolvingIssueStatus(
+    'In review',
+    'changes-requested',
+    'dsh-issue-management',
+  )
+  assert.equal(status, 'In progress')
+  status = nextResolvingIssueStatus(status, 'review-requested')
+  assert.equal(status, 'In review')
+})
 
-  assert.equal(nextResolvingIssueStatus('In progress', implementation), null)
-  assert.equal(nextResolvingIssueStatus('In review', implementation), null)
-  assert.equal(nextResolvingIssueStatus('In review', review), null)
-  assert.equal(nextResolvingIssueStatus('Done', review), null)
-  assert.equal(nextResolvingIssueStatus('No action', review), null)
-  assert.equal(nextResolvingIssueStatus(null, review), null)
+test('preserves human review status and terminal Issues', () => {
+  assert.equal(nextResolvingIssueStatus('In progress', 'implementation'), null)
+  assert.equal(nextResolvingIssueStatus('In review', 'implementation'), null)
+  assert.equal(nextResolvingIssueStatus('In review', 'review-requested'), null)
+  assert.equal(nextResolvingIssueStatus('In review', 'changes-requested', 'tianyicui'), null)
+  assert.equal(nextResolvingIssueStatus('In review', 'changes-requested'), null)
+  assert.equal(nextResolvingIssueStatus('Done', 'review-requested'), null)
+  assert.equal(nextResolvingIssueStatus('No action', 'changes-requested'), null)
+  assert.equal(nextResolvingIssueStatus(null, 'review-requested'), null)
 })
 
 test('keeps lifecycle projection independent of PR metadata enforcement', () => {
@@ -283,7 +325,7 @@ test('keeps lifecycle projection independent of PR metadata enforcement', () =>
   }
 
   assert.ok(validatePullRequest(pull).length > 0)
-  assert.equal(nextResolvingIssueStatus('Inbox', pull), 'In review')
+  assert.equal(nextResolvingIssueStatus('Inbox', 'review-requested'), 'In review')
 })
 
 test('exempts Draft, Bot, and App PRs', () => {

+ 1 - 0
.github/workflows/issue-lifecycle.yml

@@ -36,6 +36,7 @@ concurrency:
 jobs:
   lifecycle:
     name: Issue lifecycle
+    if: ${{ github.event_name != 'pull_request_review' || (github.event.action == 'submitted' && github.event.review.state == 'changes_requested') }}
     runs-on: ubuntu-latest
     steps:
       - name: Check out trusted policy

+ 1 - 0
apps/web/tests/message-actions.e2e.ts

@@ -128,6 +128,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions-aria'))
     await page.getByRole('button', { name: /^Select model, current/ })
       .waitFor({ timeout: 10_000 })
+    await page.getByText(/Cache hit \d+%/u).first().waitFor({ timeout: 10_000 })
     // Keep a footer focused so opacity-hidden actions stay in the a11y tree
     // as an active/focused control during the capture.
     await page.getByRole('button', { name: 'Copy' }).first().focus()

+ 4 - 0
lefthook.yml

@@ -6,6 +6,8 @@ pre-commit:
   jobs:
     - name: translation pairing (staged records)
       glob: '*.i18n.yaml'
+      exclude:
+        - '.agents/notes/archived/**'
       run: node_modules/.bin/tsx scripts/verify-translation-pairing.ts --cached {staged_files}
 
     - name: lint (staged)
@@ -35,6 +37,8 @@ pre-merge-commit:
   jobs:
     - name: translation pairing (staged records)
       glob: '*.i18n.yaml'
+      exclude:
+        - '.agents/notes/archived/**'
       run: node_modules/.bin/tsx scripts/verify-translation-pairing.ts --cached {staged_files}
 
 pre-push:

+ 31 - 2
scripts/ci-workflow.spec.ts

@@ -113,20 +113,42 @@ describe('E2B e2e workflow', () => {
 })
 
 describe('Issue lifecycle workflow', () => {
-  it('uses review signals instead of rerunning when a draft becomes ready', () => {
+  it('uses explicit review handoff events without rerunning when a draft becomes ready', () => {
     const lifecycle = loadWorkflow('.github/workflows/issue-lifecycle.yml')
     const lifecyclePullRequest = workflowEvent(lifecycle, 'pull_request')
     const lifecycleReview = workflowEvent(lifecycle, 'pull_request_review')
+    const lifecycleJob = workflowJob(lifecycle, 'lifecycle')
     const policy = loadWorkflow('.github/workflows/issue-policy.yml')
     const policyPullRequest = workflowEvent(policy, 'pull_request')
 
     expect(lifecyclePullRequest.types).not.toContain('ready_for_review')
     expect(lifecyclePullRequest.types).toContain('review_requested')
-    expect(lifecycleReview.types).toContain('submitted')
+    expect(lifecycleReview.types).toEqual(['submitted'])
+    expect(lifecycleJob.if).toBe(
+      "${{ github.event_name != 'pull_request_review' || (github.event.action == 'submitted' && github.event.review.state == 'changes_requested') }}",
+    )
     expect(policyPullRequest.types).toContain('ready_for_review')
   })
 })
 
+describe('Git hooks', () => {
+  it('leaves frozen Agent Note sidecars to the archive verifier', () => {
+    const lefthook = loadWorkflow('lefthook.yml')
+
+    for (const hookName of ['pre-commit', 'pre-merge-commit']) {
+      const hook = lefthook[hookName]
+      if (!isRecord(hook) || !Array.isArray(hook.jobs)) {
+        throw new TypeError(`lefthook must define ${hookName} jobs`)
+      }
+      const pairing: unknown = hook.jobs.find(
+        (job: unknown) => isRecord(job) && job.name === 'translation pairing (staged records)',
+      )
+
+      expect(pairing).toMatchObject({ exclude: ['.agents/notes/archived/**'] })
+    }
+  })
+})
+
 function loadWorkflow(path: string): Record<string, unknown> {
   const workflow: unknown = yaml.load(readFileSync(resolve(root, path), 'utf8'))
   if (!isRecord(workflow)) throw new TypeError(`${path} must define a workflow`)
@@ -140,6 +162,13 @@ function workflowEvent(workflow: Record<string, unknown>, event: string): Record
   return workflow.on[event]
 }
 
+function workflowJob(workflow: Record<string, unknown>, job: string): Record<string, unknown> {
+  if (!isRecord(workflow.jobs) || !isRecord(workflow.jobs[job])) {
+    throw new TypeError(`workflow must define the ${job} job`)
+  }
+  return workflow.jobs[job]
+}
+
 function isRecord(value: unknown): value is Record<string, unknown> {
   return typeof value === 'object' && value !== null && !Array.isArray(value)
 }