Browse Source

Merge pull request #3688 from deepseek-harness/policy-ci/policy-modules

refactor(ci): 分离 Issue policy 规则、GitHub IO 与生命周期
Tianyi Cui 1 week ago
parent
commit
663a534d1f
22 changed files with 1605 additions and 780 deletions
  1. 2 2
      .agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.i18n.yaml
  2. 2 2
      .agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md
  3. 2 2
      .agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md
  4. 6 0
      .agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.i18n.yaml
  5. 29 0
      .agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.md
  6. 29 0
      .agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.zh.md
  7. 6 0
      .agents/notes/implemented/process/2026-09-07-selective-issue-policy-evaluation.i18n.yaml
  8. 43 0
      .agents/notes/implemented/process/2026-09-07-selective-issue-policy-evaluation.md
  9. 43 0
      .agents/notes/implemented/process/2026-09-07-selective-issue-policy-evaluation.zh.md
  10. 6 0
      .github/issue-management/README.i18n.yaml
  11. 93 0
      .github/issue-management/README.md
  12. 93 0
      .github/issue-management/README.zh.md
  13. 300 0
      .github/issue-management/github.mjs
  14. 155 0
      .github/issue-management/lifecycle.mjs
  15. 6 737
      .github/issue-management/policy.mjs
  16. 313 9
      .github/issue-management/policy.test.mjs
  17. 126 0
      .github/issue-management/pull-request.mjs
  18. 303 0
      .github/issue-management/rules.mjs
  19. 1 0
      .github/issue-management/selective-preflight.json
  20. 5 12
      .github/workflows/issue-lifecycle.yml
  21. 20 2
      .github/workflows/issue-policy.yml
  22. 22 14
      scripts/ci-workflow.spec.ts

+ 2 - 2
.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-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-10-event-directed-pr-review-status.md
-2026-08-10-event-directed-pr-review-status.md: de4dc0700f2083772321fdf5f26c05fdf39928be
-2026-08-10-event-directed-pr-review-status.zh.md: b8a8fbaa25673a376542965700e864f82ab0d739
+2026-08-10-event-directed-pr-review-status.md: 37b8086a081145e8c16ceb29d489fbc38a7f0d43
+2026-08-10-event-directed-pr-review-status.zh.md: cc0dad8554f869d51965c30d2648937c54dc2b75

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

@@ -12,7 +12,7 @@ A monotonic projection also cannot return an automation-owned Issue from `In rev
 
 ## 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 run their lifecycle job but no-op (they never reach the Project token step), while dismissed reviews are not subscribed.
+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 do not allocate a lifecycle runner, while dismissed reviews are not subscribed. [Selective policy evaluation](2026-09-07-selective-issue-policy-evaluation.md) owns lifecycle scheduling.
 
 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.
 
@@ -22,7 +22,7 @@ The status projection resolves only exact same-repository `Fixes`, `Closes`, or
 
 ## 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 job-level absence of `if` plus the step-level gate on the token/board steps (so approved/commented reviews pass without minting a token), and the separate `ready_for_review` policy trigger.
+[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 job-level condition that excludes approved/commented reviews before runner allocation, and the separate `ready_for_review` policy trigger.
 
 ## Alternatives considered
 

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

@@ -12,7 +12,7 @@ Issue 所在 Project 中的状态记录了解决工作的下一步由谁负责
 
 ## 决策
 
-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 评审则不在订阅范围内
+Issue 生命周期工作流把评审 webhook 视为命令。`pull_request.review_requested`(包括重复请求)将目标状态指定为 `In review`。`pull_request_review.submitted` 将目标状态指定为 `In progress`,但仅在 `review.state` 为 `changes_requested` 时生效;submitted 事件仍不可省略,因为评审人即使没有先触发 review-request 事件,也可以直接提出修改要求。对于 approved 和 commented 提交,工作流不分配生命周期 runner;dismissed 评审则不在订阅范围内。[选择性策略求值](2026-09-07-selective-issue-policy-evaluation.zh.md)拥有生命周期调度规则
 
 工作流订阅的普通 PR 事件仍是只向前推进的实现信号:它们可以将 `Inbox`、`Backlog` 或 `Ready` 推进至 `In progress`,但不能让 `In review` 倒退。请求评审命令可将任意较早的活跃状态推进至 `In review`。请求修改命令可将较早的活跃状态推进至 `In progress`;它也可以让 `In review` 状态回退,但仅在目标 Project 的最新状态事件由配置的生命周期执行主体写入时进行。若最新状态事件的执行主体是人工用户或未知主体,则保留当前状态。
 
@@ -22,7 +22,7 @@ Issue 生命周期工作流把评审 webhook 视为命令。`pull_request.review
 
 ## 验证
 
-[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)锁定事件到命令的映射、请求修改命令后重复请求评审所触发的状态转换、请求修改后的状态回退、终态保护,以及保留人工覆盖状态。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定订阅事件、job 级无 `if` 且 token/看板步骤带 step 级门控(使 approved/commented 评审以 pass 呈现且不铸 token),以及独立的 `ready_for_review` 策略触发器。
+[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)锁定事件到命令的映射、请求修改命令后重复请求评审所触发的状态转换、请求修改后的状态回退、终态保护,以及保留人工覆盖状态。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定订阅事件、在 runner 分配前排除 approved/commented 评审的 job 级条件,以及独立的 `ready_for_review` 策略触发器。
 
 ## 考虑过的替代方案
 

+ 6 - 0
.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.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-09-07-issue-policy-module-ownership.md
+2026-09-07-issue-policy-module-ownership.md: 696343ddffa3f6a6e411330d038bb4a470ce78d8
+2026-09-07-issue-policy-module-ownership.zh.md: abe539a5baed35cf2f356194eab8e53d61b67314

+ 29 - 0
.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.md

@@ -0,0 +1,29 @@
+# Agent Note: Issue policy module ownership
+
+Status: implemented
+
+English | [中文](2026-09-07-issue-policy-module-ownership.zh.md)
+
+## Problem
+
+Issue validation and Project lifecycle processing use the same reference parser, metadata rules, and GitHub reads, but they make different decisions and perform different writes. Keeping those responsibilities in the command entry makes it harder to reuse policy rules without also depending on event dispatch and output handling.
+
+## Decision
+
+Issue management separates pure decisions, GitHub access, pull-request evaluation, lifecycle mutations, and command dispatch into owner modules. [The owner reference](../../../../.github/issue-management/README.md#module-ownership) maps those responsibilities to source files. Callers and tests import the module that owns the operation rather than using the command entry as an export collection.
+
+The separation preserves policy results, diagnostics, credentials, API requests, lifecycle mutations, and workflow entry commands. [Selective evaluation](2026-09-07-selective-issue-policy-evaluation.md) remains the independent owner of eligibility, Project-read selection, and event scheduling; this decision does not redefine those behaviors.
+
+## Alternatives considered
+
+**Keep one command module.** A single file avoids imports between local owners, but ties reusable decisions and GitHub access to command handling. Separate owners let PR validation and lifecycle processing share their existing rules and reads without sharing command dispatch.
+
+## Consequences
+
+Maintainers can locate a rule, network operation, or event handler by responsibility. Reuse occurs through ordinary local ESM imports, not a new package or plugin API. The added modules introduce imports that must stay coordinated when a shared function changes.
+
+Module separation does not grant stronger credentials, change check authority, serialize Project mutations, or prevent a future behavior regression. Existing lifecycle races and field-management limitations remain documented by their behavior owners.
+
+## Verification
+
+[Policy tests](../../../../.github/issue-management/policy.test.mjs) exercise the owner modules and check observable policy and lifecycle behavior. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) cover the command wiring and workflow declarations. Their evidence concerns the checked implementation, not a guarantee that later edits preserve behavior.

+ 29 - 0
.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: Issue 策略模块归属
+
+Status: implemented
+
+[English](2026-09-07-issue-policy-module-ownership.md) | 中文
+
+## 问题
+
+Issue 校验与 Project 生命周期处理使用相同的引用解析器、元数据规则和 GitHub 读取,但各自作出不同决策、执行不同写入。将这些职责放在命令入口中,会使复用策略规则时更难摆脱对事件分派与输出处理的依赖。
+
+## 决策
+
+Issue 管理将纯决策、GitHub 访问、PR(Pull Request)求值、生命周期 mutation 和命令分派分配给各自的所属模块。[所属参考文档](../../../../.github/issue-management/README.zh.md#module-ownership)将这些职责对应到源文件。调用方和测试直接导入拥有该操作的模块,不把命令入口用作导出集合。
+
+此职责分离保留策略结果、诊断、凭据、API 请求、生命周期 mutation 和工作流入口命令。[选择性求值](2026-09-07-selective-issue-policy-evaluation.zh.md)仍独立拥有强制范围、Project 读取选择和事件调度规则;本决策不重新定义这些行为。
+
+## 考虑过的替代方案
+
+**保留单一命令模块。** 单文件可以避免本地所属模块之间的导入,但会把可复用的决策与 GitHub 访问绑定到命令处理。独立的所属模块让 PR 校验和生命周期处理可以共享既有规则与读取,而无需共享命令分派。
+
+## 影响
+
+维护者可以按职责定位规则、网络操作或事件处理器。复用通过普通的本地 ESM 导入完成,而不是新增包或插件 API。新增模块引入的导入关系需要在共享函数变更时同步维护。
+
+模块分离不会授予更强的凭据、更改检查权威来源、串行化 Project mutation,也不能防止未来的行为回归。既有生命周期竞态与字段管理限制仍由各自的行为文档记录。
+
+## 验证
+
+[策略测试](../../../../.github/issue-management/policy.test.mjs)运行所属模块,并检查可观察的策略与生命周期行为。[工作流测试](../../../../scripts/ci-workflow.spec.ts)覆盖命令接线与工作流声明。这些证据针对被检查的实现,不保证后续编辑仍保留行为。

+ 6 - 0
.agents/notes/implemented/process/2026-09-07-selective-issue-policy-evaluation.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-09-07-selective-issue-policy-evaluation.md
+2026-09-07-selective-issue-policy-evaluation.md: 5e05bef320980b6069ede9edb0d40446ddd27b0f
+2026-09-07-selective-issue-policy-evaluation.zh.md: 37c531e14e485499a3cb76d042d4c17438d413a0

+ 43 - 0
.agents/notes/implemented/process/2026-09-07-selective-issue-policy-evaluation.md

@@ -0,0 +1,43 @@
+# Agent Note: Selective Issue policy evaluation
+
+Status: implemented
+
+English | [中文](2026-09-07-selective-issue-policy-evaluation.zh.md)
+
+## Problem
+
+Informational Issue references provide context, while resolving references carry a Priority obligation. Requiring Project access for both makes unrelated board configuration or App availability block context-only PRs. Looking up referenced Issues before determining enforcement eligibility also spends credentials and API requests on PRs that cannot fail policy.
+
+Lifecycle events have a separate cost: an approval, comment, push, label change, or assignment change need not perform a Project status handoff. Allocating a runner for a known no-op consumes capacity without changing the Issue.
+
+## Decision
+
+[Issue policy](../../../../.github/workflows/issue-policy.yml) keeps its required job and trusted default-branch implementation. Enforcement eligibility precedes reference reads and Project App token creation: draft PRs, Bot/App authors, and human PRs with neither review requests nor submitted reviews do not require policy validation.
+
+The workflow checks the trusted checkout for a selective-preflight capability marker before invoking the command. A checkout without the marker uses full legacy validation for human PRs and preserves the legacy Bot/App exemption. This supports PR workflow YAML running against default-branch code that lacks preflight; execution errors never select the fallback.
+
+Eligible PRs resolve references through repository REST reads. Informational references prove Issue identity without Project access. Only actual Issues named by resolving references require Project Priority reads; a PR number cannot satisfy the Issue requirement or cause a Project query. [The owner reference](../../../../.github/issue-management/README.md) defines metadata validation and failure behavior.
+
+[Issue lifecycle](../../../../.github/workflows/issue-lifecycle.yml) subscribes to status-relevant PR events and filters title-only edits. It does not subscribe to PR pushes or label changes, or Issue assignment changes. Its job condition rejects approved/commented reviews before runner allocation. Changes-requested reviews retain their status command.
+
+This scheduling decision partially supersedes the no-op-job scheduling in [event-directed review status](2026-08-10-event-directed-pr-review-status.md), not its handoff semantics or human-ownership protection. [Project-local planning fields](2026-09-02-project-local-issue-planning-fields.md) still own opened-only, empty-only Start Date initialization for every referenced Issue, including informational references. The validation read exemption does not exempt that lifecycle mutation.
+
+## Alternatives considered
+
+**Read every referenced Issue's Project fields.** Informational references do not constrain Priority, so these queries add failure dependencies without contributing a validation result.
+
+**Keep successful no-op lifecycle jobs for approvals and comments.** That preserves a green job presentation but allocates a runner for an event with no lifecycle command. Lifecycle is separate from the retained required policy job.
+
+**Remove the required policy job or redesign check authority.** Selective reads and lifecycle scheduling can reduce avoidable work without changing which required check GitHub expects. Check-authority redesign is not part of this decision.
+
+## Consequences
+
+Informational-only validation needs repository access but not Project credentials. Resolving validation still fails when required Project reads or field checks fail. Preflight and final validation each read live REST state, duplicating repository requests rather than caching a verdict. Avoiding Project reads and token creation does not guarantee fewer total API requests. The required policy job still allocates a runner; this is not a zero-cost required check.
+
+Maintainers manually manage the Project custom Priority field. Native Issue-field skill guidance does not update that value. There is no Priority synchronization, field migration, or change to [presentation-neutral policy](2026-09-03-semantic-issue-templates-and-policy.md).
+
+Omitted lifecycle events cannot repair stale Project state. Event replay and concurrent writes retain the races documented by the lifecycle and planning-field owners. Actual Actions-minute savings and live GitHub App access require operational observation, not inference from a mocked API test.
+
+## Verification
+
+[Policy tests](../../../../.github/issue-management/policy.test.mjs) verify early exemptions, REST-only informational references, actual-Issue filtering, resolving Priority reads and failures, and the lifecycle command selection. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) verify Project-token conditions, the retained required job, pruned subscriptions, and runner-level lifecycle filtering. Local fixtures do not establish live webhook delivery or billing outcomes.

+ 43 - 0
.agents/notes/implemented/process/2026-09-07-selective-issue-policy-evaluation.zh.md

@@ -0,0 +1,43 @@
+# Agent Note: Issue 策略的选择性求值
+
+Status: implemented
+
+[English](2026-09-07-selective-issue-policy-evaluation.md) | 中文
+
+## 问题
+
+信息型 Issue 引用提供背景,解决型引用则带有 Priority 义务。两者都要求 Project 访问,会让无关的看板配置或 App 可用性阻止仅提供背景的 PR(Pull Request)。在确定强制范围之前读取被引用 Issue,也会为不可能因策略而失败的 PR 消耗凭据与 API 请求。
+
+生命周期事件有独立成本:批准、评论、推送、标签变更或指派变更不一定需要执行 Project 状态交接。为已知无操作的事件分配 runner,会占用容量而不改变 Issue。
+
+## 决策
+
+[Issue policy](../../../../.github/workflows/issue-policy.yml)保留必需 job 与受信任的默认分支实现。强制范围判定先于引用读取与 Project App token 创建:草稿 PR、Bot/App 作者,以及既无评审请求也无已提交评审的人类 PR 均不需要策略校验。
+
+工作流在调用命令前检查受信任检出中的选择性预检能力标记。缺少标记的检出对人类 PR 执行完整旧版校验,并保留旧版 Bot/App 豁免。这支持 PR 工作流 YAML 与缺少预检功能的默认分支代码配合执行;执行错误不会触发回退。
+
+强制范围内的 PR 通过仓库 REST 读取解析引用。信息型引用无需 Project 访问即可证明 Issue 身份。只有解决型引用指向的实际 Issue 需要读取 Project Priority;PR 编号既不能满足 Issue 引用要求,也不会引发 Project 查询。[所属参考文档](../../../../.github/issue-management/README.zh.md)定义元数据校验与失败行为。
+
+[Issue lifecycle](../../../../.github/workflows/issue-lifecycle.yml)订阅与状态相关的 PR 事件,并过滤仅标题编辑。它不订阅 PR 推送、PR 标签变更或 Issue 指派变更。job 条件在 runner 分配前排除 approved/commented 评审。请求修改的评审保留其状态命令。
+
+本调度决策部分取代[事件驱动评审状态](2026-08-10-event-directed-pr-review-status.zh.md)中无操作 job 的调度方式,但不取代交接语义或人工状态归属保护。[Project 局部规划字段](2026-09-02-project-local-issue-planning-fields.zh.md)仍拥有对每个被引用 Issue(包括信息型引用)仅在 PR 打开时、仅对空值初始化 Start Date 的规则。校验读取豁免不豁免该生命周期 mutation。
+
+## 考虑过的替代方案
+
+**读取每个被引用 Issue 的 Project 字段。** 信息型引用不约束 Priority,因此这些查询只增加失败依赖,不贡献校验结果。
+
+**为批准与评论保留成功的无操作生命周期 job。** 这可以保持绿色 job 展示,却会为没有生命周期命令的事件分配 runner。生命周期与保留的必需策略 job 相互独立。
+
+**删除必需策略 job 或重新设计检查权威来源。** 选择性读取和生命周期调度可以减少可避免的工作,无需改变 GitHub 期待的必需检查。检查权威来源的重新设计不属于本决策。
+
+## 影响
+
+仅含信息型引用的校验需要仓库访问,但不需要 Project 凭据。解决型校验在所需 Project 读取或字段检查失败时仍会失败。预检与最终校验各自读取实时 REST 状态,重复仓库请求而不缓存结论。避免 Project 读取与 token 创建不保证减少 API 请求总数。必需策略 job 仍分配 runner;它并非零成本的必需检查。
+
+维护者手动管理 Project 自定义 Priority 字段。原生 Issue 字段的 skill 指引不会更新该值。不提供 Priority 同步或字段迁移,也不改变[不检查展示形式的策略](2026-09-03-semantic-issue-templates-and-policy.zh.md)。
+
+被省略的生命周期事件不能修复过时的 Project 状态。事件重放和并发写入仍有生命周期与规划字段文档记录的竞态。实际 Actions 分钟节省与 GitHub App 实际访问权限需要运营观察,不能从模拟 API 测试推断。
+
+## 验证
+
+[策略测试](../../../../.github/issue-management/policy.test.mjs)验证早期豁免、仅使用 REST 的信息型引用、实际 Issue 过滤、解决型 Priority 读取及失败,以及生命周期命令选择。[工作流测试](../../../../scripts/ci-workflow.spec.ts)验证 Project token 条件、保留的必需 job、精简后的订阅及 runner 级生命周期过滤。本地 fixture 不能证明实际 webhook 交付或计费结果。

+ 6 - 0
.github/issue-management/README.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 .github/issue-management/README.md
+README.md: a936b9358810fd3a8b8104609a5523e305e1aaa5
+README.zh.md: df743cac581c2125df9ac9e76f04d2528204eb74

+ 93 - 0
.github/issue-management/README.md

@@ -0,0 +1,93 @@
+---
+description: "Issue policy enforcement, Project access, and lifecycle events for repository maintainers."
+---
+
+# Issue management
+
+English | [中文](README.zh.md)
+
+## Summary
+
+Contributors can link Issues as context without coupling pull-request validation to Project availability. Resolving references additionally enforce Project Priority. The required `Issue policy` job and the separate lifecycle workflow use trusted default-branch code.
+
+## Table of Contents
+
+- [Pull-request policy](#pull-request-policy)
+- [Lifecycle events](#lifecycle-events)
+- [Configuration and limitations](#configuration-and-limitations)
+- [Module ownership](#module-ownership)
+- [Verification](#verification)
+- [Dev Note](#dev-note)
+
+-----
+
+<a id="pull-request-policy"></a>
+## Pull-request policy
+
+[Issue policy](../workflows/issue-policy.yml) applies to non-draft, human-authored PRs with a requested review or submitted review. Exempt PRs finish successfully without resolving Issue references, minting a Project App token, or querying ProjectV2. Eligibility uses live repository state before expensive reads; the required job remains present for subscribed events. Final validation re-reads live state: preflight is not a cached verdict or an exemption for metadata edits.
+
+Selective preflight requires [selective-preflight.json](selective-preflight.json) in the trusted checkout. Without that marker, the workflow preserves legacy behavior: human PRs receive a Project token and full legacy validation; Bot/App PRs skip both. A failed supported preflight fails the job rather than falling back.
+
+Eligible PRs need at least one same-repository Issue reference, exactly one canonical `kind/*`, at least one `area/*`, and at most one `p0`–`p3` label. Unsupported kinds, retired aliases, and `source/*` labels fail validation; [label taxonomy](../../.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md) owns their meanings.
+
+- Informational references, such as `Refs #3624`, establish context. Validation uses REST to distinguish Issues from PR numbers and does not read their Project fields. An informational-only PR can carry its own Priority without matching the referenced Issue.
+- Resolving references use closing keywords such as `Fixes #123`, `Closes #123`, or `Resolves #123`. Only references that resolve to actual Issues require Project reads during validation. A PR Priority must match the highest resolving-Issue Priority; a resolving PR with a Priority label requires every resolving Issue to have Priority. If all resolving Priorities are empty, the PR may omit Priority.
+- References inside HTML comments, code fences, or inline code do not count. Cross-repository references and references to PRs do not satisfy the Issue requirement.
+
+REST reads use the repository `GITHUB_TOKEN`. Project validation uses a separate App token with Issues and organization Projects read permissions. Missing required Project access or invalid field configuration fails validation rather than bypassing resolving-Issue Priority checks.
+
+-----
+
+<a id="lifecycle-events"></a>
+## Lifecycle events
+
+[Issue lifecycle](../workflows/issue-lifecycle.yml) mutates Project data independently of PR validation eligibility. PR opened/reopened events and body edits can advance resolving Issues to `In progress`; title-only edits do not. Review requests target `In review`. Changes-requested reviews target `In progress`, with the [human-ownership and terminal-status protections](../../.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md).
+
+Approval-only and comment-only reviews do not allocate a lifecycle runner. PR pushes and label changes, and Issue assignment changes, do not trigger lifecycle work. Other subscribed Issue events maintain membership, state, and audit comments; exact subscriptions live in the workflow.
+
+PR opening initializes an empty Project `Start Date` for every referenced Issue, including informational references, using the PR creation date in the configured time zone. This lifecycle operation can add Project membership and needs Project write access; the informational-reference read exemption applies only to PR validation. [Planning-field ownership](../../.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.md) defines date preservation.
+
+-----
+
+<a id="configuration-and-limitations"></a>
+## Configuration and limitations
+
+[config.json](config.json) selects the repository, Project, field names, statuses, lifecycle actor, and time zone. The policy reads the Project custom single-select `Priority` field, not a native organization Issue Priority field. Maintainers set Project Priority manually; skill guidance that directs edits to native Issue fields does not populate this value. Issue audits remove PR-only kinds and retired label aliases before validating the remaining metadata. There is no field migration or Priority synchronization.
+
+Lifecycle processing is event-driven, not a reconciler. Omitted events do not repair Project state, and concurrent Project mutations have no atomic compare-and-swap. Selective evaluation does not redesign required-check authority or guarantee measured Actions-minute savings. The [selective-evaluation decision](../../.agents/notes/implemented/process/2026-09-07-selective-issue-policy-evaluation.md) records the trade-offs.
+
+-----
+
+<a id="module-ownership"></a>
+## Module ownership
+
+Maintainers reuse the owning module directly; [policy.mjs](policy.mjs) only reads the event file, dispatches commands, and reports command failures. The [module-ownership decision](../../.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.md) explains this separation.
+
+<details>
+<summary>Implementation owners</summary>
+
+[rules.mjs](rules.mjs) owns pure validation, reference parsing, status decisions, and date conversion. [github.mjs](github.mjs) owns credential selection, REST/GraphQL transport, Issue/Project reads, and Project membership and field writes.
+
+[pull-request.mjs](pull-request.mjs) assembles read-only PR snapshots and runs policy preflight and validation, including their workflow outputs. [lifecycle.mjs](lifecycle.mjs) reuses the PR reference reader and shared rules to coordinate Project mutations, Issue label repairs, and audit comments. Snapshot readers do not mutate GitHub; the shared transport also supports writes, so importing it does not restrict a caller's permissions.
+
+</details>
+
+-----
+
+<a id="verification"></a>
+## Verification
+
+The focused, keyless policy suite runs from the repository root:
+
+```sh
+node --test .github/issue-management/policy.test.mjs
+```
+
+[Workflow tests](../../scripts/ci-workflow.spec.ts) verify trigger and permission declarations. Local tests do not establish live GitHub delivery, App installation access, or actual runner cost; repository maintainers verify those in Actions.
+
+-----
+
+<a id="dev-note"></a>
+## Dev Note
+
+None.

+ 93 - 0
.github/issue-management/README.zh.md

@@ -0,0 +1,93 @@
+---
+description: "面向仓库维护者的 Issue 策略强制范围、Project 访问与生命周期事件参考。"
+---
+
+# Issue 管理
+
+[English](README.md) | 中文
+
+## 摘要
+
+贡献者可以引用 Issue 作为背景,而无需让 PR(Pull Request)校验依赖 Project 可用性。解决型引用还会强制检查 Project Priority。必需的 `Issue policy` job 与独立的生命周期工作流使用受信任的默认分支代码。
+
+## 目录
+
+- [PR 策略](#pull-request-policy)
+- [生命周期事件](#lifecycle-events)
+- [配置与限制](#configuration-and-limitations)
+- [模块归属](#module-ownership)
+- [验证](#verification)
+- [开发备注](#dev-note)
+
+-----
+
+<a id="pull-request-policy"></a>
+## PR 策略
+
+[Issue policy](../workflows/issue-policy.yml)适用于已请求评审或已有评审、非草稿且由人类创建的 PR。豁免 PR 成功结束,不解析 Issue 引用、不签发 Project App token,也不查询 ProjectV2。工作流在昂贵读取前根据仓库实时状态判断强制范围;订阅事件仍保留必需 job。最终校验重新读取实时状态:预检不是缓存结论,也不是元数据编辑的豁免。
+
+选择性预检要求受信任的检出中存在 [selective-preflight.json](selective-preflight.json)。缺少该标记时,工作流保留旧版行为:人类 PR 获取 Project token 并执行完整旧版校验;Bot/App PR 跳过两者。受支持的预检执行失败时,job 失败而不回退。
+
+强制范围内的 PR 至少需要一个同仓库 Issue 引用、恰好一个规范的 `kind/*`、至少一个 `area/*`,以及最多一个 `p0`–`p3` 标签。不支持的 kind、退役别名和 `source/*` 标签会使校验失败;[标签分类](../../.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.zh.md)定义其含义。
+
+- 信息型引用(如 `Refs #3624`)提供背景。校验通过 REST 区分 Issue 与 PR 编号,不读取这些引用的 Project 字段。仅含信息型引用的 PR 可以使用自己的 Priority,无需匹配所引用的 Issue。
+- 解决型引用使用关闭关键词,如 `Fixes #123`、`Closes #123` 或 `Resolves #123`。校验期间只有解析为实际 Issue 的解决型引用需要读取 Project。PR Priority 必须匹配被解决 Issue 中的最高 Priority;带 Priority 标签的解决型 PR 要求每个被解决 Issue 均有 Priority。若所有被解决 Issue 的 Priority 均为空,PR 可以省略 Priority。
+- HTML 注释、代码围栏或行内代码中的引用不计入。跨仓库引用与指向 PR 的引用不能满足 Issue 引用要求。
+
+REST 读取使用仓库 `GITHUB_TOKEN`。Project 校验使用独立的 App token,具有 Issues 和组织 Projects 读取权限。缺少所需 Project 访问权限或字段配置无效时,校验失败,而不是绕过解决型 Issue 的 Priority 检查。
+
+-----
+
+<a id="lifecycle-events"></a>
+## 生命周期事件
+
+[Issue lifecycle](../workflows/issue-lifecycle.yml)独立于 PR 校验强制范围修改 Project 数据。PR 打开、重新打开和正文编辑可将解决型 Issue 推进至 `In progress`;仅编辑标题不会。请求评审以 `In review` 为目标。请求修改的评审以 `In progress` 为目标,并遵守[人工状态归属与终态保护](../../.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md)。
+
+仅批准或仅评论的评审不分配生命周期 runner。PR 推送与标签变更,以及 Issue 指派变更,不触发生命周期工作。其他已订阅的 Issue 事件维护 Project 归属、状态及审计评论;精确订阅列表由工作流定义。
+
+PR 打开时,工作流按配置时区中的 PR 创建日期,为每个被引用 Issue(包括信息型引用)初始化空的 Project `Start Date`。此生命周期操作可以添加 Project 归属,并需要 Project 写权限;信息型引用的读取豁免仅适用于 PR 校验。[规划字段归属](../../.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.zh.md)定义日期保留规则。
+
+-----
+
+<a id="configuration-and-limitations"></a>
+## 配置与限制
+
+[config.json](config.json)选择仓库、Project、字段名、状态、生命周期操作者和时区。策略读取 Project 自定义单选 `Priority` 字段,而非组织原生 Issue Priority 字段。维护者手动设置 Project Priority;指引编辑原生 Issue 字段的 skill 不会填充该值。Issue 审计先移除 PR 专用 kind 标签和已停用的标签别名,再校验其余元数据。不提供字段迁移或 Priority 同步。
+
+生命周期处理由事件驱动,不是协调器。被省略的事件不会修复 Project 状态,并发 Project mutation 也没有原子比较并交换保护。选择性求值不重新设计必需检查的权威来源,也不保证已测得的 Actions 分钟节省。[选择性求值决策](../../.agents/notes/implemented/process/2026-09-07-selective-issue-policy-evaluation.zh.md)记录取舍。
+
+-----
+
+<a id="module-ownership"></a>
+## 模块归属
+
+维护者直接复用所属模块;[policy.mjs](policy.mjs)仅负责读取事件文件、分派命令并报告命令失败。[模块归属决策](../../.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.zh.md)解释此职责分离。
+
+<details>
+<summary>实现职责</summary>
+
+[rules.mjs](rules.mjs)拥有纯校验、引用解析、状态决策和日期转换。[github.mjs](github.mjs)拥有凭据选择、REST/GraphQL 传输、Issue/Project 读取,以及 Project 归属与字段写入。
+
+[pull-request.mjs](pull-request.mjs)组装只读 PR 快照,并执行策略预检和校验,包括其工作流输出。[lifecycle.mjs](lifecycle.mjs)复用 PR 引用读取器与共享规则,协调 Project mutation、Issue 标签修复和审计评论。快照读取器不修改 GitHub;共享传输也支持写入,因此导入该模块不会限制调用方权限。
+
+</details>
+
+-----
+
+<a id="verification"></a>
+## 验证
+
+在仓库根目录运行聚焦的无密钥策略测试:
+
+```sh
+node --test .github/issue-management/policy.test.mjs
+```
+
+[工作流测试](../../scripts/ci-workflow.spec.ts)验证触发器与权限声明。本地测试不能证明 GitHub 实际事件交付、App 安装访问权限或实际 runner 成本;仓库维护者在 Actions 中验证这些内容。
+
+-----
+
+<a id="dev-note"></a>
+## 开发备注
+
+无。

+ 300 - 0
.github/issue-management/github.mjs

@@ -0,0 +1,300 @@
+/** GitHub transport, Issue/Project readers, and Project membership and field writes. */
+
+import process from 'node:process'
+
+import config from './config.json' with { type: 'json' }
+
+const API_VERSION = '2026-03-10'
+
+function token() {
+  const value = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
+  if (!value) throw new Error('GH_TOKEN 或 GITHUB_TOKEN 未设置')
+  return value
+}
+
+function projectToken() {
+  return process.env.PROJECT_TOKEN || token()
+}
+
+/**
+ * Send one GitHub REST request; HTTP failures retain the method, path, status, and body.
+ * @param {string} path API path including any query string.
+ * @param {object} options Fetch options; allow404 returns null for a 404.
+ * @returns {Promise<object|null>} Decoded JSON, or null for an allowed 404 or a 204.
+ */
+export async function api(path, options = {}) {
+  const { allow404 = false, ...requestOptions } = options
+  const response = await fetch(`${process.env.GITHUB_API_URL ?? 'https://api.github.com'}${path}`, {
+    ...requestOptions,
+    headers: {
+      Accept: 'application/vnd.github+json',
+      Authorization: `Bearer ${token()}`,
+      'X-GitHub-Api-Version': API_VERSION,
+      'User-Agent': 'dsh-issue-policy',
+      ...options.headers,
+    },
+  })
+  if (allow404 && response.status === 404) return null
+  if (!response.ok) {
+    const body = await response.text()
+    throw new Error(`${requestOptions.method ?? 'GET'} ${path}: ${response.status} ${body}`)
+  }
+  if (response.status === 204) return null
+  return response.json()
+}
+
+/**
+ * Send one Project-authenticated GraphQL request; reject joined GraphQL error messages.
+ * @param {string} query GraphQL document.
+ * @param {object} variables GraphQL variables.
+ * @returns {Promise<object>} GraphQL data.
+ */
+export async function graphql(query, variables) {
+  const result = await api('/graphql', {
+    method: 'POST',
+    body: JSON.stringify({ query, variables }),
+    headers: {
+      Authorization: `Bearer ${projectToken()}`,
+      'Content-Type': 'application/json',
+    },
+  })
+  if (result.errors?.length) throw new Error(result.errors.map((error) => error.message).join('; '))
+  return result.data
+}
+
+/**
+ * Read one Issue together with its Project planning values.
+ * @param {number} number Same-repository Issue number.
+ * @param {string|null|undefined} status Optional known Project status.
+ * @returns {Promise<object|null>} Issue snapshot, or null when the number identifies a pull request.
+ */
+export async function issueSnapshot(number, status = undefined) {
+  const issue = await api(`/repos/${config.organization}/${config.repository}/issues/${number}`)
+  if (issue.pull_request) return null
+  const context = await projectContext(number)
+  return {
+    number,
+    nodeId: issue.node_id,
+    labels: issue.labels.map((label) => label.name),
+    type: issue.type?.name ?? null,
+    priority: context.item?.priorityValue?.name ?? null,
+    status: status === undefined ? (context.item?.fieldValueByName?.name ?? null) : status,
+    state: issue.state,
+    stateReason: issue.state_reason ?? null,
+  }
+}
+
+/**
+ * Read and validate the configured Project fields and one Issue’s membership.
+ * @param {number} number Same-repository Issue number.
+ * @param {boolean} includeStatusActor Include the latest matching status-event actor.
+ * @param {boolean} includeStartDate Include and validate the Project Start Date field.
+ * @returns {Promise<object>} Project, Issue, fields, optional item, and status actor; never writes.
+ */
+export async function projectContext(number, includeStatusActor = false, includeStartDate = false) {
+  const data = await graphql(
+    `query(
+      $organization: String!
+      $repository: String!
+      $number: Int!
+      $project: Int!
+      $includeStatusActor: Boolean!
+      $includeStartDate: Boolean!
+      $priorityField: String!
+      $startDateField: String!
+    ) {
+      organization(login: $organization) {
+        projectV2(number: $project) {
+          id
+          title
+          fields(first: 50) {
+            nodes {
+              ... on ProjectV2Field {
+                id
+                name
+                dataType
+                isIssueField
+              }
+              ... on ProjectV2SingleSelectField {
+                id
+                name
+                dataType
+                isIssueField
+                options { id name }
+              }
+            }
+          }
+        }
+      }
+      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
+              project { id }
+              fieldValueByName(name: "Status") {
+                ... on ProjectV2ItemFieldSingleSelectValue { name optionId }
+              }
+              priorityValue: fieldValueByName(name: $priorityField) {
+                ... on ProjectV2ItemFieldSingleSelectValue { name optionId }
+              }
+              startDateValue: fieldValueByName(name: $startDateField)
+                @include(if: $includeStartDate) {
+                ... on ProjectV2ItemFieldDateValue { date }
+              }
+            }
+          }
+        }
+      }
+    }`,
+    {
+      organization: config.organization,
+      repository: config.repository,
+      number,
+      project: config.projectNumber,
+      includeStatusActor,
+      includeStartDate,
+      priorityField: config.priorityField,
+      startDateField: config.startDateField,
+    },
+  )
+  const project = data.organization?.projectV2
+  const issue = data.repository?.issue
+  if (!project || project.title !== config.projectTitle) throw new Error('目标 Project 不存在或标题不匹配')
+  if (!issue) throw new Error(`#${number} 不存在`)
+  const statusField = project.fields.nodes.find((field) => field?.name === 'Status')
+  if (!statusField) throw new Error('Project 缺少 Status 字段')
+  const priorityField = project.fields.nodes.find((field) => field?.name === config.priorityField)
+  if (!priorityField) throw new Error(`Project 缺少 ${config.priorityField} 字段`)
+  if (priorityField.dataType !== 'SINGLE_SELECT') {
+    throw new Error(`Project ${config.priorityField} 字段必须为 Single Select`)
+  }
+  if (priorityField.isIssueField) {
+    throw new Error(`Project ${config.priorityField} 字段必须为 Project custom field`)
+  }
+  const startDateField = includeStartDate
+    ? project.fields.nodes.find((field) => field?.name === config.startDateField)
+    : null
+  if (includeStartDate && !startDateField) {
+    throw new Error(`Project 缺少 ${config.startDateField} 字段`)
+  }
+  if (startDateField && startDateField.dataType !== 'DATE') {
+    throw new Error(`Project ${config.startDateField} 字段必须为 Date`)
+  }
+  if (startDateField?.isIssueField) {
+    throw new Error(`Project ${config.startDateField} 字段必须为 Project Date 字段`)
+  }
+  const item = issue.projectItems.nodes.find((candidate) => candidate.project.id === project.id)
+  const latestStatusEvent = issue.timelineItems?.nodes
+    ?.filter((event) => event?.project?.id === project.id)
+    .at(-1)
+  const statusActor =
+    latestStatusEvent && latestStatusEvent.status === item?.fieldValueByName?.name
+      ? (latestStatusEvent.actor?.login ?? null)
+      : null
+  return { project, issue, statusField, priorityField, startDateField, item, statusActor }
+}
+
+/**
+ * Read Project membership and add the Issue when absent.
+ * @param {number} number Same-repository Issue number.
+ * @param {boolean} includeStartDate Include and validate the Project Start Date field.
+ * @returns {Promise<object>} Project context with an existing or newly added item.
+ */
+export async function ensureProjectItem(number, includeStartDate = false) {
+  const context = await projectContext(number, false, includeStartDate)
+  if (context.item) return context
+  const data = await graphql(
+    `mutation($projectId: ID!, $contentId: ID!) {
+      addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
+        item { id }
+      }
+    }`,
+    { projectId: context.project.id, contentId: context.issue.id },
+  )
+  return {
+    ...context,
+    item: {
+      id: data.addProjectV2ItemById.item.id,
+      fieldValueByName: null,
+      priorityValue: null,
+      startDateValue: null,
+    },
+  }
+}
+
+/**
+ * Initialize one Issue's Project Start Date when it is empty.
+ * @param {number} number Same-repository Issue number.
+ * @param {string} date Date in YYYY-MM-DD form.
+ * @returns {Promise<void>} Resolves after the conditional Project update.
+ */
+export async function initializeIssueStartDate(number, date) {
+  const context = await ensureProjectItem(number, true)
+  if (context.item.startDateValue?.date) return
+  await graphql(
+    `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $date: Date!) {
+      updateProjectV2ItemFieldValue(input: {
+        projectId: $projectId,
+        itemId: $itemId,
+        fieldId: $fieldId,
+        value: {date: $date}
+      }) { projectV2Item { id } }
+    }`,
+    {
+      projectId: context.project.id,
+      itemId: context.item.id,
+      fieldId: context.startDateField.id,
+      date,
+    },
+  )
+}
+
+/**
+ * Write the named Status option unless the supplied item already has it.
+ * @param {object} context Project context with an item and Status options.
+ * @param {string} status Configured Status option name.
+ * @returns {Promise<void>} Resolves after the optional update; rejects an unknown option.
+ */
+export async function updateStatus(context, status) {
+  const option = context.statusField.options.find((candidate) => candidate.name === status)
+  if (!option) throw new Error(`Status 不存在:${status}`)
+  if (context.item.fieldValueByName?.name === status) return
+  await graphql(
+    `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
+      updateProjectV2ItemFieldValue(input: {
+        projectId: $projectId,
+        itemId: $itemId,
+        fieldId: $fieldId,
+        value: {singleSelectOptionId: $optionId}
+      }) { projectV2Item { id } }
+    }`,
+    {
+      projectId: context.project.id,
+      itemId: context.item.id,
+      fieldId: context.statusField.id,
+      optionId: option.id,
+    },
+  )
+}
+
+/**
+ * Ensure Project membership before updating an Issue’s Status.
+ * @param {number} number Same-repository Issue number.
+ * @param {string} status Configured Status option name.
+ * @returns {Promise<void>} Resolves after membership and Status updates.
+ */
+export async function setStatus(number, status) {
+  await updateStatus(await ensureProjectItem(number), status)
+}

+ 155 - 0
.github/issue-management/lifecycle.mjs

@@ -0,0 +1,155 @@
+/** Repository event orchestration and Issue audit comments. */
+
+import config from './config.json' with { type: 'json' }
+import {
+  api,
+  ensureProjectItem,
+  initializeIssueStartDate,
+  issueSnapshot,
+  projectContext,
+  setStatus,
+  updateStatus,
+} from './github.mjs'
+import { lifecyclePullRequestSnapshot } from './pull-request.mjs'
+import {
+  isInvalidIssueLabel,
+  nextResolvingIssueStatus,
+  projectDate,
+  resolvingIssueStatusCommand,
+  validateIssue,
+} from './rules.mjs'
+
+const AUDIT_MARKER = '<!-- dsh-issue-policy -->'
+
+/**
+ * Initialize every referenced Issue from a newly opened PR.
+ * @param {{createdAt: string, references: {all: number[]}}} pull Pull-request snapshot.
+ * @param {string} action Pull-request event action.
+ * @param {(number: number, date: string) => Promise<void>} initialize Date writer.
+ * @returns {Promise<void>} Resolves after all eligible Issues are processed.
+ */
+export async function initializePullRequestStartDates(
+  pull,
+  action,
+  initialize = initializeIssueStartDate,
+) {
+  if (action !== 'opened') return
+  const date = projectDate(pull.createdAt)
+  for (const number of pull.references.all) await initialize(number, date)
+}
+
+async function upsertAudit(number, errors) {
+  const comments = await api(
+    `/repos/${config.organization}/${config.repository}/issues/${number}/comments?per_page=100`,
+  )
+  const existing = comments.find(
+    (comment) => comment.user?.type === 'Bot' && comment.body?.includes(AUDIT_MARKER),
+  )
+  if (errors.length === 0) {
+    if (existing) {
+      await api(`/repos/${config.organization}/${config.repository}/issues/comments/${existing.id}`, {
+        method: 'DELETE',
+      })
+    }
+    return
+  }
+  const body = `${AUDIT_MARKER}\n⚠️ Issue policy 未通过:\n\n${errors.map((error) => `- ${error}`).join('\n')}`
+  if (existing) {
+    if (existing.body === body) return
+    await api(`/repos/${config.organization}/${config.repository}/issues/comments/${existing.id}`, {
+      method: 'PATCH',
+      body: JSON.stringify({ body }),
+      headers: { 'Content-Type': 'application/json' },
+    })
+  } else {
+    await api(`/repos/${config.organization}/${config.repository}/issues/${number}/comments`, {
+      method: 'POST',
+      body: JSON.stringify({ body }),
+      headers: { 'Content-Type': 'application/json' },
+    })
+  }
+}
+
+/**
+ * Remove pull-request kinds and retired aliases from one Issue snapshot.
+ * @param {{number: number, labels: string[]}} issue Issue snapshot.
+ * @returns {Promise<object>} Snapshot containing only labels that remain on the Issue.
+ */
+export async function repairIssueLabels(issue) {
+  const invalidLabels = issue.labels.filter(isInvalidIssueLabel)
+  for (const label of invalidLabels) {
+    await api(
+      `/repos/${config.organization}/${config.repository}/issues/${issue.number}/labels/${encodeURIComponent(label)}`,
+      { method: 'DELETE', allow404: true },
+    )
+  }
+  return {
+    ...issue,
+    labels: issue.labels.filter((label) => !isInvalidIssueLabel(label)),
+  }
+}
+
+/**
+ * Repair deterministic Issue metadata violations and publish the remaining audit result.
+ * @param {number} number Same-repository Issue number.
+ * @param {string[]} extraErrors Errors supplied by the triggering lifecycle operation.
+ * @param {string|null|undefined} status Optional known Project status.
+ * @returns {Promise<string[]>} Violations that remain after repair.
+ */
+export async function auditIssue(number, extraErrors = [], status = undefined) {
+  const issue = await issueSnapshot(number, status)
+  if (!issue) return []
+  const repairedIssue = await repairIssueLabels(issue)
+  const errors = [...extraErrors, ...validateIssue(repairedIssue)]
+  await upsertAudit(number, errors)
+  return errors
+}
+
+async function transitionResolvingIssues(pull, command) {
+  for (const number of pull.references.resolving) {
+    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.
+    await updateStatus(context, target)
+    await auditIssue(number)
+  }
+}
+
+/**
+ * Apply repository lifecycle events; irrelevant PR events perform no requests.
+ * @param {string} eventName GitHub event name.
+ * @param {object} event GitHub event payload.
+ * @returns {Promise<void>} Resolves after lifecycle updates and audits.
+ */
+export async function runLifecycle(eventName, event) {
+  if (eventName === 'issues') {
+    const number = event.issue.number
+    if (event.action === 'opened') await setStatus(number, 'Inbox')
+    if (event.action === 'closed') {
+      const target = event.issue.state_reason === 'not_planned' ? 'No action' : 'Done'
+      await setStatus(number, target)
+    }
+    if (event.action === 'reopened') {
+      await setStatus(number, 'Inbox')
+    }
+    await ensureProjectItem(number)
+    await auditIssue(number)
+    return
+  }
+
+  if (eventName === 'pull_request' || eventName === 'pull_request_review') {
+    const command = resolvingIssueStatusCommand(eventName, event)
+    if (!command) return
+    const pull = await lifecyclePullRequestSnapshot(event.pull_request.number)
+    await transitionResolvingIssues(pull, command)
+    if (eventName === 'pull_request') {
+      await initializePullRequestStartDates(pull, event.action)
+    }
+  }
+}

+ 6 - 737
.github/issue-management/policy.mjs

@@ -4,741 +4,9 @@ import fs from 'node:fs'
 import process from 'node:process'
 import { pathToFileURL } from 'node:url'
 
-import config from './config.json' with { type: 'json' }
-
-const API_VERSION = '2026-03-10'
-const AUDIT_MARKER = '<!-- dsh-issue-policy -->'
-const TYPES = new Set(['Idea', 'Feature', 'Bug', 'Research', 'Task'])
-const PRIORITIES = ['p0', 'p1', 'p2', 'p3']
-const PR_KINDS = new Set([
-  'kind/feature',
-  'kind/bug-fix',
-  'kind/doc',
-  'kind/testing',
-  'kind/cleanup',
-  'kind/dependency',
-])
-// Retired label aliases stay reserved so they cannot be recreated.
-const LEGACY_LABELS = new Set([
-  'kind/bug',
-  'kind/documentation',
-  'feature',
-  'bug-fix',
-  'doc',
-  'cleanup',
-  'testing',
-  'dependencies',
-  'ci',
-  'cli',
-  'llm',
-  'web-search',
-])
-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 未设置')
-}
-if (typeof config.priorityField !== 'string' || !config.priorityField) {
-  throw new Error('config.priorityField 未设置')
-}
-if (typeof config.startDateField !== 'string' || !config.startDateField) {
-  throw new Error('config.startDateField 未设置')
-}
-if (typeof config.projectTimeZone !== 'string' || !config.projectTimeZone) {
-  throw new Error('config.projectTimeZone 未设置')
-}
-Intl.DateTimeFormat('en-US', { timeZone: config.projectTimeZone })
-
-/**
- * Decide whether the human-review policy applies to a PR.
- * @param {{isDraft: boolean, authorType: string, reviewRequestCount: number, reviewCount: number}} input PR state.
- * @returns {boolean} Whether the PR policy is mandatory.
- */
-export function requiresPullRequestPolicy({
-  isDraft,
-  authorType,
-  reviewRequestCount,
-  reviewCount,
-}) {
-  const automated = authorType === 'Bot' || authorType === 'App'
-  return !isDraft && !automated && (reviewRequestCount > 0 || reviewCount > 0)
-}
-
-/**
- * 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 {'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, 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
-}
-
-/**
- * Convert a GitHub timestamp to a Project date in one configured time zone.
- * @param {string} timestamp ISO timestamp.
- * @param {string} timeZone IANA time-zone name.
- * @returns {string} Calendar date in YYYY-MM-DD form.
- */
-export function projectDate(timestamp, timeZone = config.projectTimeZone) {
-  const instant = new Date(timestamp)
-  if (Number.isNaN(instant.getTime())) throw new Error(`无效的 PR 创建时间:${timestamp}`)
-  const parts = Object.fromEntries(
-    new Intl.DateTimeFormat('en-US', {
-      timeZone,
-      year: 'numeric',
-      month: '2-digit',
-      day: '2-digit',
-    })
-      .formatToParts(instant)
-      .filter((part) => part.type !== 'literal')
-      .map((part) => [part.type, part.value]),
-  )
-  return `${parts.year}-${parts.month}-${parts.day}`
-}
-
-function stripIgnoredMarkdown(body) {
-  const lines = body.replace(/<!--[\s\S]*?-->/g, '').split(/\r?\n/)
-  const kept = []
-  let fence = null
-  for (const line of lines) {
-    const marker = line.match(/^\s*([\u0060~]{3,})/)
-    if (marker) {
-      if (fence === null) fence = marker[1][0]
-      else if (marker[1][0] === fence) fence = null
-      continue
-    }
-    if (fence === null) kept.push(line)
-  }
-  return kept.join('\n').replace(/\u0060[^\u0060]*\u0060/g, ' ')
-}
-
-/**
- * Parse same-repository resolving and informational references.
- * @param {{body: string, repository: string}} input PR body and repository.
- * @returns {{all: number[], resolving: number[], related: number[]}} References.
- */
-export function parseReferences({ body, repository }) {
-  const source = stripIgnoredMarkdown(body)
-  const expected = repository.toLowerCase()
-  const all = new Set()
-  const resolving = new Set()
-  const reference =
-    /(?:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#|#)(\d+)|https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/issues\/(\d+)/gi
-  const closing =
-    /\b(?:close(?:s|d)?|fix(?:es|ed)?|resolve(?:s|d)?)\s*:?\s+(?:(?:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#|#)(\d+)|https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/issues\/(\d+))/gi
-
-  for (const match of source.matchAll(reference)) {
-    const explicit = (match[1] ?? match[3] ?? '').toLowerCase()
-    const number = Number(match[2] ?? match[4])
-    if (!explicit || explicit === expected) all.add(number)
-  }
-  for (const match of source.matchAll(closing)) {
-    const explicit = (match[1] ?? match[3] ?? '').toLowerCase()
-    const number = Number(match[2] ?? match[4])
-    if (!explicit || explicit === expected) {
-      all.add(number)
-      resolving.add(number)
-    }
-  }
-  return {
-    all: [...all].sort((left, right) => left - right),
-    resolving: [...resolving].sort((left, right) => left - right),
-    related: [...all].filter((number) => !resolving.has(number)).sort((a, b) => a - b),
-  }
-}
-
-/**
- * Retain only references that resolve to Issues rather than pull requests.
- * @param {{all: number[], resolving: number[], related: number[]}} references Parsed references.
- * @param {Map<number, unknown>} issues Resolved same-repository Issues.
- * @returns {{all: number[], resolving: number[], related: number[]}} Issue-only references.
- */
-export function retainIssueReferences(references, issues) {
-  return {
-    all: references.all.filter((number) => issues.has(number)),
-    resolving: references.resolving.filter((number) => issues.has(number)),
-    related: references.related.filter((number) => issues.has(number)),
-  }
-}
-
-/**
- * Validate one Issue with its Project status.
- * @param {{labels: string[], type: string|null, priority: string|null, status: string|null, state: string, stateReason: string|null}} issue Issue snapshot.
- * @returns {string[]} Validation errors.
- */
-export function validateIssue(issue) {
-  const errors = []
-  const status = issue.status
-  const invalidLabels = issue.labels.filter(isInvalidIssueLabel)
-
-  if (invalidLabels.length > 0) {
-    errors.push(`Issue 不得使用 PR kind 或旧版标签:${invalidLabels.join(', ')}`)
-  }
-  if (!TYPES.has(issue.type ?? '')) errors.push('Type 必须是五种原生英文 Type 之一')
-  if (!status || !config.statuses.includes(status)) errors.push('Issue 必须在 Project 中且具有合法 Status')
-  if (issue.priority !== null && !PRIORITIES.includes(issue.priority.toLowerCase())) {
-    errors.push('Priority 必须为空或为 P0–P3')
-  }
-  if (status === 'Done' && (issue.state !== 'closed' || issue.stateReason !== 'completed')) {
-    errors.push('Done 必须对应 Completed 关闭原因')
-  }
-  if (
-    status === 'No action' &&
-    (issue.state !== 'closed' || issue.stateReason !== 'not_planned')
-  ) {
-    errors.push('No action 必须对应 Not planned 关闭原因')
-  }
-  if (!['Done', 'No action'].includes(status ?? '') && issue.state !== 'open') {
-    errors.push(`${status} 必须对应开放 Issue`)
-  }
-  return errors
-}
-
-function isInvalidIssueLabel(label) {
-  return label.startsWith('kind/') || LEGACY_LABELS.has(label)
-}
-
-/**
- * Validate PR metadata and its referenced Issues.
- * @param {{authorType: string, labels: string[], references: ReturnType<typeof parseReferences>, issues: Map<number, {priority: string|null}>}} input PR snapshot.
- * @returns {string[]} Validation errors.
- */
-export function validatePullRequest(input) {
-  if (!requiresPullRequestPolicy(input)) return []
-  const errors = []
-  const kinds = input.labels.filter((label) => PR_KINDS.has(label))
-  const unknownKinds = input.labels.filter(
-    (label) => label.startsWith('kind/') && !PR_KINDS.has(label) && !LEGACY_LABELS.has(label),
-  )
-  const legacyLabels = input.labels.filter((label) => LEGACY_LABELS.has(label))
-  const sourceLabels = input.labels.filter((label) => label.startsWith('source/'))
-  const priorities = input.labels.filter((label) => PRIORITIES.includes(label))
-  const areas = input.labels.filter((label) => label.startsWith('area/'))
-
-  if (input.references.all.length === 0) errors.push('PR 正文必须引用至少一个同仓库 Issue')
-  if (kinds.length !== 1) {
-    errors.push(`PR 必须恰好有一个允许的 kind/*,当前为 ${kinds.length}`)
-  }
-  if (unknownKinds.length > 0) {
-    errors.push(`PR 含不支持的 kind/*:${unknownKinds.join(', ')}`)
-  }
-  if (legacyLabels.length > 0) errors.push(`PR 含旧版标签:${legacyLabels.join(', ')}`)
-  if (sourceLabels.length > 0) errors.push(`source/* 仅用于 Issue:${sourceLabels.join(', ')}`)
-  if (priorities.length > 1) errors.push(`PR 最多有一个 p0–p3,当前为 ${priorities.length}`)
-  if (areas.length === 0) errors.push('PR 必须至少有一个 area/*')
-  for (const number of input.references.all) {
-    if (!input.issues.has(number)) errors.push(`#${number} 不是同仓库 Issue`)
-  }
-
-  const resolving = input.references.resolving
-    .map((number) => [number, input.issues.get(number)])
-    .filter((entry) => entry[1])
-  if (resolving.length === 0) return errors
-
-  const issuePriorities = resolving
-    .map(([, issue]) => issue.priority?.toLowerCase())
-    .filter((priority) => PRIORITIES.includes(priority))
-  if (priorities.length === 0 && issuePriorities.length > 0) {
-    const highest = issuePriorities.sort(
-      (left, right) => PRIORITIES.indexOf(left) - PRIORITIES.indexOf(right),
-    )[0]
-    errors.push(`PR Priority 应为 ${highest}`)
-  } else if (priorities.length === 1 && issuePriorities.length !== resolving.length) {
-    errors.push('有 Priority 的解决型 PR 要求每个被解决 Issue 都设置 Priority')
-  } else if (priorities.length === 1) {
-    const highest = issuePriorities.sort(
-      (left, right) => PRIORITIES.indexOf(left) - PRIORITIES.indexOf(right),
-    )[0]
-    if (priorities[0] !== highest) errors.push(`PR Priority 应为 ${highest}`)
-  }
-  return errors
-}
-
-function token() {
-  const value = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
-  if (!value) throw new Error('GH_TOKEN 或 GITHUB_TOKEN 未设置')
-  return value
-}
-
-function projectToken() {
-  return process.env.PROJECT_TOKEN || token()
-}
-
-async function api(path, options = {}) {
-  const { allow404 = false, ...requestOptions } = options
-  const response = await fetch(`${process.env.GITHUB_API_URL ?? 'https://api.github.com'}${path}`, {
-    ...requestOptions,
-    headers: {
-      Accept: 'application/vnd.github+json',
-      Authorization: `Bearer ${token()}`,
-      'X-GitHub-Api-Version': API_VERSION,
-      'User-Agent': 'dsh-issue-policy',
-      ...options.headers,
-    },
-  })
-  if (allow404 && response.status === 404) return null
-  if (!response.ok) {
-    const body = await response.text()
-    throw new Error(`${requestOptions.method ?? 'GET'} ${path}: ${response.status} ${body}`)
-  }
-  if (response.status === 204) return null
-  return response.json()
-}
-
-async function graphql(query, variables) {
-  const result = await api('/graphql', {
-    method: 'POST',
-    body: JSON.stringify({ query, variables }),
-    headers: {
-      Authorization: `Bearer ${projectToken()}`,
-      'Content-Type': 'application/json',
-    },
-  })
-  if (result.errors?.length) throw new Error(result.errors.map((error) => error.message).join('; '))
-  return result.data
-}
-
-/**
- * Read one Issue together with its Project planning values.
- * @param {number} number Same-repository Issue number.
- * @param {string|null|undefined} status Optional known Project status.
- * @returns {Promise<object|null>} Issue snapshot, or null when the number identifies a pull request.
- */
-export async function issueSnapshot(number, status = undefined) {
-  const issue = await api(`/repos/${config.organization}/${config.repository}/issues/${number}`)
-  if (issue.pull_request) return null
-  const context = await projectContext(number)
-  return {
-    number,
-    nodeId: issue.node_id,
-    labels: issue.labels.map((label) => label.name),
-    type: issue.type?.name ?? null,
-    priority: context.item?.priorityValue?.name ?? null,
-    status: status === undefined ? (context.item?.fieldValueByName?.name ?? null) : status,
-    state: issue.state,
-    stateReason: issue.state_reason ?? null,
-  }
-}
-
-async function projectContext(number, includeStatusActor = false, includeStartDate = false) {
-  const data = await graphql(
-    `query(
-      $organization: String!
-      $repository: String!
-      $number: Int!
-      $project: Int!
-      $includeStatusActor: Boolean!
-      $includeStartDate: Boolean!
-      $priorityField: String!
-      $startDateField: String!
-    ) {
-      organization(login: $organization) {
-        projectV2(number: $project) {
-          id
-          title
-          fields(first: 50) {
-            nodes {
-              ... on ProjectV2Field {
-                id
-                name
-                dataType
-                isIssueField
-              }
-              ... on ProjectV2SingleSelectField {
-                id
-                name
-                dataType
-                isIssueField
-                options { id name }
-              }
-            }
-          }
-        }
-      }
-      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
-              project { id }
-              fieldValueByName(name: "Status") {
-                ... on ProjectV2ItemFieldSingleSelectValue { name optionId }
-              }
-              priorityValue: fieldValueByName(name: $priorityField) {
-                ... on ProjectV2ItemFieldSingleSelectValue { name optionId }
-              }
-              startDateValue: fieldValueByName(name: $startDateField)
-                @include(if: $includeStartDate) {
-                ... on ProjectV2ItemFieldDateValue { date }
-              }
-            }
-          }
-        }
-      }
-    }`,
-    {
-      organization: config.organization,
-      repository: config.repository,
-      number,
-      project: config.projectNumber,
-      includeStatusActor,
-      includeStartDate,
-      priorityField: config.priorityField,
-      startDateField: config.startDateField,
-    },
-  )
-  const project = data.organization?.projectV2
-  const issue = data.repository?.issue
-  if (!project || project.title !== config.projectTitle) throw new Error('目标 Project 不存在或标题不匹配')
-  if (!issue) throw new Error(`#${number} 不存在`)
-  const statusField = project.fields.nodes.find((field) => field?.name === 'Status')
-  if (!statusField) throw new Error('Project 缺少 Status 字段')
-  const priorityField = project.fields.nodes.find((field) => field?.name === config.priorityField)
-  if (!priorityField) throw new Error(`Project 缺少 ${config.priorityField} 字段`)
-  if (priorityField.dataType !== 'SINGLE_SELECT') {
-    throw new Error(`Project ${config.priorityField} 字段必须为 Single Select`)
-  }
-  if (priorityField.isIssueField) {
-    throw new Error(`Project ${config.priorityField} 字段必须为 Project custom field`)
-  }
-  const startDateField = includeStartDate
-    ? project.fields.nodes.find((field) => field?.name === config.startDateField)
-    : null
-  if (includeStartDate && !startDateField) {
-    throw new Error(`Project 缺少 ${config.startDateField} 字段`)
-  }
-  if (startDateField && startDateField.dataType !== 'DATE') {
-    throw new Error(`Project ${config.startDateField} 字段必须为 Date`)
-  }
-  if (startDateField?.isIssueField) {
-    throw new Error(`Project ${config.startDateField} 字段必须为 Project Date 字段`)
-  }
-  const item = issue.projectItems.nodes.find((candidate) => candidate.project.id === project.id)
-  const latestStatusEvent = issue.timelineItems?.nodes
-    ?.filter((event) => event?.project?.id === project.id)
-    .at(-1)
-  const statusActor =
-    latestStatusEvent && latestStatusEvent.status === item?.fieldValueByName?.name
-      ? (latestStatusEvent.actor?.login ?? null)
-      : null
-  return { project, issue, statusField, priorityField, startDateField, item, statusActor }
-}
-
-async function ensureProjectItem(number, includeStartDate = false) {
-  const context = await projectContext(number, false, includeStartDate)
-  if (context.item) return context
-  const data = await graphql(
-    `mutation($projectId: ID!, $contentId: ID!) {
-      addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
-        item { id }
-      }
-    }`,
-    { projectId: context.project.id, contentId: context.issue.id },
-  )
-  return {
-    ...context,
-    item: {
-      id: data.addProjectV2ItemById.item.id,
-      fieldValueByName: null,
-      priorityValue: null,
-      startDateValue: null,
-    },
-  }
-}
-
-/**
- * Initialize one Issue's Project Start Date when it is empty.
- * @param {number} number Same-repository Issue number.
- * @param {string} date Date in YYYY-MM-DD form.
- * @returns {Promise<void>} Resolves after the conditional Project update.
- */
-export async function initializeIssueStartDate(number, date) {
-  const context = await ensureProjectItem(number, true)
-  if (context.item.startDateValue?.date) return
-  await graphql(
-    `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $date: Date!) {
-      updateProjectV2ItemFieldValue(input: {
-        projectId: $projectId,
-        itemId: $itemId,
-        fieldId: $fieldId,
-        value: {date: $date}
-      }) { projectV2Item { id } }
-    }`,
-    {
-      projectId: context.project.id,
-      itemId: context.item.id,
-      fieldId: context.startDateField.id,
-      date,
-    },
-  )
-}
-
-/**
- * Initialize every referenced Issue from a newly opened PR.
- * @param {{createdAt: string, references: {all: number[]}}} pull Pull-request snapshot.
- * @param {string} action Pull-request event action.
- * @param {(number: number, date: string) => Promise<void>} initialize Date writer.
- * @returns {Promise<void>} Resolves after all eligible Issues are processed.
- */
-export async function initializePullRequestStartDates(
-  pull,
-  action,
-  initialize = initializeIssueStartDate,
-) {
-  if (action !== 'opened') return
-  const date = projectDate(pull.createdAt)
-  for (const number of pull.references.all) await initialize(number, date)
-}
-
-async function updateStatus(context, status) {
-  const option = context.statusField.options.find((candidate) => candidate.name === status)
-  if (!option) throw new Error(`Status 不存在:${status}`)
-  if (context.item.fieldValueByName?.name === status) return
-  await graphql(
-    `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
-      updateProjectV2ItemFieldValue(input: {
-        projectId: $projectId,
-        itemId: $itemId,
-        fieldId: $fieldId,
-        value: {singleSelectOptionId: $optionId}
-      }) { projectV2Item { id } }
-    }`,
-    {
-      projectId: context.project.id,
-      itemId: context.item.id,
-      fieldId: context.statusField.id,
-      optionId: option.id,
-    },
-  )
-}
-
-async function setStatus(number, status) {
-  await updateStatus(await ensureProjectItem(number), status)
-}
-
-/**
- * Remove pull-request kinds and retired aliases from one Issue snapshot.
- * @param {{number: number, labels: string[]}} issue Issue snapshot.
- * @returns {Promise<object>} Snapshot containing only labels that remain on the Issue.
- */
-export async function repairIssueLabels(issue) {
-  const invalidLabels = issue.labels.filter(isInvalidIssueLabel)
-  for (const label of invalidLabels) {
-    await api(
-      `/repos/${config.organization}/${config.repository}/issues/${issue.number}/labels/${encodeURIComponent(label)}`,
-      { method: 'DELETE', allow404: true },
-    )
-  }
-  return {
-    ...issue,
-    labels: issue.labels.filter((label) => !isInvalidIssueLabel(label)),
-  }
-}
-
-async function upsertAudit(number, errors) {
-  const comments = await api(
-    `/repos/${config.organization}/${config.repository}/issues/${number}/comments?per_page=100`,
-  )
-  const existing = comments.find(
-    (comment) => comment.user?.type === 'Bot' && comment.body?.includes(AUDIT_MARKER),
-  )
-  if (errors.length === 0) {
-    if (existing) {
-      await api(`/repos/${config.organization}/${config.repository}/issues/comments/${existing.id}`, {
-        method: 'DELETE',
-      })
-    }
-    return
-  }
-  const body = `${AUDIT_MARKER}\n⚠️ Issue policy 未通过:\n\n${errors.map((error) => `- ${error}`).join('\n')}`
-  if (existing) {
-    if (existing.body === body) return
-    await api(`/repos/${config.organization}/${config.repository}/issues/comments/${existing.id}`, {
-      method: 'PATCH',
-      body: JSON.stringify({ body }),
-      headers: { 'Content-Type': 'application/json' },
-    })
-  } else {
-    await api(`/repos/${config.organization}/${config.repository}/issues/${number}/comments`, {
-      method: 'POST',
-      body: JSON.stringify({ body }),
-      headers: { 'Content-Type': 'application/json' },
-    })
-  }
-}
-
-/**
- * Repair deterministic Issue metadata violations and publish the remaining audit result.
- * @param {number} number Same-repository Issue number.
- * @param {string[]} extraErrors Errors supplied by the triggering lifecycle operation.
- * @param {string|null|undefined} status Optional known Project status.
- * @returns {Promise<string[]>} Violations that remain after repair.
- */
-export async function auditIssue(number, extraErrors = [], status = undefined) {
-  const issue = await issueSnapshot(number, status)
-  if (!issue) return []
-  const repairedIssue = await repairIssueLabels(issue)
-  const errors = [...extraErrors, ...validateIssue(repairedIssue)]
-  await upsertAudit(number, errors)
-  return errors
-}
-
-async function resolvingReferencesSnapshot(number, pull) {
-  const references = parseReferences({
-    body: pull.body ?? '',
-    repository: `${config.organization}/${config.repository}`,
-  })
-  const issues = new Map()
-  for (const issueNumber of references.all) {
-    const issue = await issueSnapshot(issueNumber, null)
-    if (issue) issues.set(issueNumber, issue)
-  }
-  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),
-  }
-}
-
-async function lifecyclePullRequestSnapshot(number) {
-  const pull = await api(`/repos/${config.organization}/${config.repository}/pulls/${number}`)
-  return {
-    ...(await resolvingReferencesSnapshot(number, pull)),
-    createdAt: pull.created_at,
-  }
-}
-
-async function transitionResolvingIssues(pull, command) {
-  for (const number of pull.references.resolving) {
-    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.
-    await updateStatus(context, target)
-    await auditIssue(number)
-  }
-}
-
-async function runPullRequestCheck(event) {
-  const pull = await pullRequestSnapshot(event.pull_request.number)
-  const errors = validatePullRequest(pull)
-  if (errors.length > 0) {
-    for (const error of errors) process.stdout.write(`::error::${error}\n`)
-    throw new Error(`Issue policy 未通过,共 ${errors.length} 项`)
-  }
-  process.stdout.write(
-    requiresPullRequestPolicy(pull) ? 'Issue policy 通过。\n' : 'PR 尚未进入 Issue policy 强制范围。\n',
-  )
-}
-
-async function runLifecycle(eventName, event) {
-  if (eventName === 'issues') {
-    const number = event.issue.number
-    if (event.action === 'opened') await setStatus(number, 'Inbox')
-    if (event.action === 'closed') {
-      const target = event.issue.state_reason === 'not_planned' ? 'No action' : 'Done'
-      await setStatus(number, target)
-    }
-    if (event.action === 'reopened') {
-      await setStatus(number, 'Inbox')
-    }
-    await ensureProjectItem(number)
-    await auditIssue(number)
-    return
-  }
-
-  if (eventName === 'pull_request' || eventName === 'pull_request_review') {
-    const command = resolvingIssueStatusCommand(eventName, event)
-    if (!command) return
-    const pull = await lifecyclePullRequestSnapshot(event.pull_request.number)
-    await transitionResolvingIssues(pull, command)
-    if (eventName === 'pull_request') {
-      await initializePullRequestStartDates(pull, event.action)
-    }
-  }
-}
+/** Command-line dispatch for PR policy checks and Issue lifecycle events. */
+import { runLifecycle } from './lifecycle.mjs'
+import { runPullRequestCheck, runPullRequestPreflight } from './pull-request.mjs'
 
 function readEvent() {
   if (!process.env.GITHUB_EVENT_PATH) throw new Error('GITHUB_EVENT_PATH 未设置')
@@ -747,9 +15,10 @@ function readEvent() {
 
 async function main(argv) {
   const [command] = argv
-  if (command === 'pr') await runPullRequestCheck(readEvent())
+  if (command === 'pr-preflight') await runPullRequestPreflight(readEvent())
+  else if (command === 'pr') await runPullRequestCheck(readEvent())
   else if (command === 'lifecycle') await runLifecycle(process.env.GITHUB_EVENT_NAME, readEvent())
-  else throw new Error('用法:policy.mjs pr|lifecycle')
+  else throw new Error('用法:policy.mjs pr-preflight|pr|lifecycle')
 }
 
 if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {

+ 313 - 9
.github/issue-management/policy.test.mjs

@@ -1,22 +1,28 @@
 import assert from 'node:assert/strict'
-import { readFileSync, readdirSync } from 'node:fs'
+import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
+import { spawnSync } from 'node:child_process'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
 import test from 'node:test'
 
+import { api, graphql, initializeIssueStartDate, issueSnapshot } from './github.mjs'
+import { auditIssue, initializePullRequestStartDates, repairIssueLabels, runLifecycle } from './lifecycle.mjs'
+import {
+  lifecyclePullRequestSnapshot,
+  pullRequestSnapshot,
+  runPullRequestCheck,
+  runPullRequestPreflight,
+} from './pull-request.mjs'
 import {
-  auditIssue,
-  initializeIssueStartDate,
-  initializePullRequestStartDates,
-  issueSnapshot,
   nextResolvingIssueStatus,
   parseReferences,
   projectDate,
-  repairIssueLabels,
   retainIssueReferences,
   resolvingIssueStatusCommand,
   requiresPullRequestPolicy,
   validateIssue,
   validatePullRequest,
-} from './policy.mjs'
+} from './rules.mjs'
 
 const projectGraphqlData = ({
   projectItem = true,
@@ -636,10 +642,21 @@ test('maps only explicit review handoffs to review status commands', () => {
   )
 })
 
-test('keeps ordinary pull request events as forward-only implementation signals', () => {
-  for (const action of ['opened', 'edited', 'synchronize', 'reopened', 'labeled', 'unlabeled']) {
+test('keeps PR opening, reopening, and body edits as implementation signals', () => {
+  for (const action of ['opened', 'reopened']) {
     assert.equal(resolvingIssueStatusCommand('pull_request', { action }), 'implementation')
   }
+  assert.equal(
+    resolvingIssueStatusCommand('pull_request', { action: 'edited', changes: { body: { from: '' } } }),
+    'implementation',
+  )
+  for (const action of ['synchronize', 'labeled', 'unlabeled', 'edited']) {
+    assert.equal(resolvingIssueStatusCommand('pull_request', { action }), null)
+  }
+  assert.equal(
+    resolvingIssueStatusCommand('pull_request', { action: 'edited', changes: { title: { from: '' } } }),
+    null,
+  )
   assert.equal(
     resolvingIssueStatusCommand('pull_request', { action: 'review_request_removed' }),
     null,
@@ -749,6 +766,293 @@ test('rejects multiple, unknown, legacy, and Issue-source PR labels', () => {
   )
 })
 
+const mockPolicyApi = (t, { pull = {}, requested = true, reviews = [], issues = {}, priority = 'P1', projectError = false } = {}) => {
+  const environment = ['GH_TOKEN', 'GITHUB_TOKEN', 'PROJECT_TOKEN', 'GITHUB_API_URL', 'GITHUB_OUTPUT']
+  const previous = new Map(environment.map((key) => [key, process.env[key]]))
+  const directory = mkdtempSync(join(tmpdir(), 'dsh-policy-'))
+  t.after(() => {
+    for (const [key, value] of previous) {
+      if (value === undefined) delete process.env[key]
+      else process.env[key] = value
+    }
+    rmSync(directory, { recursive: true, force: true })
+  })
+  for (const key of environment) delete process.env[key]
+  process.env.GITHUB_TOKEN = 'repository-token'
+  process.env.GITHUB_OUTPUT = join(directory, 'output')
+  const requests = []
+  const output = []
+  t.mock.method(process.stdout, 'write', (text) => { output.push(text); return true })
+  t.mock.method(globalThis, 'fetch', async (url, options) => {
+    const path = new URL(url).pathname + new URL(url).search
+    requests.push(path)
+    if (path.endsWith('/pulls/10')) return Response.json({
+      draft: false, user: { type: 'User' }, body: 'Refs #2',
+      labels: [{ name: 'kind/cleanup' }, { name: 'area/infra' }], ...pull,
+    })
+    if (path.endsWith('/requested_reviewers')) {
+      return Response.json({ users: requested ? [{}] : [], teams: [] })
+    }
+    if (path.endsWith('/reviews?per_page=100')) return Response.json(reviews)
+    if (path === '/graphql') {
+      assert.equal(options.headers.Authorization, 'Bearer repository-token')
+      if (projectError) return Response.json({ errors: [{ message: 'Project access denied' }] })
+      return Response.json({ data: projectGraphqlData({ priority }) })
+    }
+    const number = Number(path.match(/\/issues\/(\d+)$/)?.[1])
+    assert.ok(Object.hasOwn(issues, number), 'Unexpected request: ' + path)
+    const issue = issues[number]
+    return Response.json(issue ?? { message: 'Not Found' }, { status: issue === null ? 404 : 200 })
+  })
+  return { requests, output, workflowOutput: () => readFileSync(process.env.GITHUB_OUTPUT, 'utf8') }
+}
+
+for (const [name, pull, requested, count] of [
+  ['draft', { draft: true }, true, 1],
+  ['Bot', { user: { type: 'Bot' } }, true, 1],
+  ['App', { user: { type: 'App' } }, true, 1],
+  ['not reviewed', {}, false, 3],
+]) {
+  test('reads no Issue or Project for a currently exempt ' + name + ' PR', async (t) => {
+    const fixture = mockPolicyApi(t, { pull: { body: 'Fixes #999', ...pull }, requested })
+    const event = { pull_request: { number: 10, draft: false, user: { type: 'User' } } }
+    assert.deepEqual(await runPullRequestPreflight(event), { eligible: false, needsProject: false })
+    assert.equal(fixture.requests.length, count)
+    assert.equal(fixture.workflowOutput(), 'eligible=false\nexempt=true\nneeds-project=false\n')
+    await runPullRequestCheck(event)
+    assert.equal(fixture.requests.length, count * 2)
+    assert.ok(fixture.output.every((text) => text.includes('Issue policy exempt')))
+  })
+}
+
+test('validates informational Issues and ignores PR numbers without Project reads', async (t) => {
+  const fixture = mockPolicyApi(t, { pull: { body: 'Refs #2; Fixes #3' }, issues: { 2: {}, 3: { pull_request: {} } } })
+  const event = { pull_request: { number: 10, draft: true, body: 'Fixes #999' } }
+  assert.deepEqual(await runPullRequestPreflight(event), { eligible: true, needsProject: false })
+  assert.equal(fixture.requests.length, 5)
+  assert.equal(fixture.workflowOutput(), 'eligible=true\nexempt=false\nneeds-project=false\n')
+  await runPullRequestCheck(event)
+  assert.equal(fixture.requests.length, 10)
+  assert.ok(!fixture.requests.includes('/graphql'))
+})
+
+test('requires a real Issue and explains why stacked PR references do not qualify', async (t) => {
+  const fixture = mockPolicyApi(t, { pull: { body: 'Fixes #3' }, issues: { 3: { pull_request: {} } } })
+  await assert.rejects(runPullRequestCheck({ pull_request: { number: 10 } }), /Issue policy 未通过/)
+  assert.equal(fixture.requests.length, 4)
+  assert.match(fixture.output.join(''), /PR 编号(包括堆叠依赖 PR)不算 Issue 引用/)
+})
+
+test('fetches Project Priority only for resolving Issues and enforces mismatch', async (t) => {
+  const fixture = mockPolicyApi(t, { pull: { body: 'Fixes #2; Refs #4; Fixes #3' }, issues: { 2: {}, 4: {}, 3: { pull_request: {} } } })
+  const event = { pull_request: { number: 10 } }
+  assert.deepEqual(await runPullRequestPreflight(event), { eligible: true, needsProject: true })
+  assert.equal(fixture.requests.length, 6)
+  assert.equal(fixture.workflowOutput(), 'eligible=true\nexempt=false\nneeds-project=true\n')
+  assert.ok(!fixture.requests.includes('/graphql'))
+  await assert.rejects(runPullRequestCheck(event), /Issue policy 未通过/)
+  assert.equal(fixture.requests.length, 13)
+  assert.equal(fixture.requests.filter((path) => path === '/graphql').length, 1)
+  assert.match(fixture.output.join(''), /PR Priority 应为 p1/)
+})
+
+test('enforces current metadata on title edits and prior reviews without requested reviewers', async (t) => {
+  const fixture = mockPolicyApi(t, { requested: false, reviews: [{}], pull: { labels: [] }, issues: { 2: {} } })
+  await assert.rejects(runPullRequestCheck({ action: 'edited', changes: { title: { from: 'old' } }, pull_request: { number: 10 } }), /Issue policy 未通过/)
+  assert.equal(fixture.requests.length, 4)
+  assert.match(fixture.output.join(''), /PR 必须至少有一个 area/)
+})
+
+test('fails closed on missing referenced numbers and unavailable Project access', async (t) => {
+  const fixture = mockPolicyApi(t, { pull: { body: 'Fixes #2' }, issues: { 2: null } })
+  await assert.rejects(runPullRequestPreflight({ pull_request: { number: 10 } }), /404/)
+  assert.equal(fixture.requests.length, 4)
+})
+
+test('fails closed when current resolving Issues need a Project token preflight did not mint', async (t) => {
+  const pull = { draft: true, body: 'Fixes #2' }
+  const fixture = mockPolicyApi(t, { pull, issues: { 2: {} }, projectError: true })
+  const event = { pull_request: { number: 10 } }
+  assert.deepEqual(await runPullRequestPreflight(event), { eligible: false, needsProject: false })
+  pull.draft = false
+  await assert.rejects(runPullRequestCheck(event), /Project access denied/)
+  assert.equal(fixture.requests.length, 6)
+})
+
+test('performs no lifecycle requests for removed signals or title-only edits', async (t) => {
+  const fixture = mockPolicyApi(t)
+  for (const action of ['synchronize', 'labeled', 'unlabeled']) {
+    await runLifecycle('pull_request', { action, pull_request: { number: 10 } })
+  }
+  await runLifecycle('pull_request', { action: 'edited', changes: { title: { from: '' } }, pull_request: { number: 10 } })
+  for (const state of ['approved', 'commented']) {
+    await runLifecycle('pull_request_review', { action: 'submitted', review: { state }, pull_request: { number: 10 } })
+  }
+  assert.deepEqual(fixture.requests, [])
+})
+
+test('keeps trusted preflight before token minting and required policy unconditional', () => {
+  const source = readFileSync(new URL('../workflows/issue-policy.yml', import.meta.url), 'utf8')
+  const job = source.slice(source.indexOf('  policy:'))
+  assert.ok(job.includes('    name: Issue policy'))
+  assert.ok(!job.slice(0, job.indexOf('    steps:')).includes('    if:'))
+  assert.ok(source.includes('types: [opened, edited, synchronize, reopened, labeled, unlabeled, ready_for_review, review_requested]'))
+  const steps = job.split('      - name: ').slice(1)
+  assert.equal(steps.length, 4)
+  assert.ok(steps[0].includes('ref: ${{ github.event.repository.default_branch }}'))
+  assert.ok(steps[0].includes('persist-credentials: false'))
+  assert.doesNotMatch(source, /pull_request\.head|pull_request_target/)
+  assert.ok(steps[1].includes('id: preflight'))
+  assert.ok(steps[1].includes('GITHUB_TOKEN: ${{ github.token }}'))
+  assert.ok(steps[1].includes('node .github/issue-management/policy.mjs pr-preflight'))
+  assert.ok(steps[1].includes('if [ -f .github/issue-management/selective-preflight.json ]; then'))
+  assert.doesNotMatch(steps[1], /secrets\.|PROJECT_TOKEN|if:/)
+  assert.ok(steps[2].includes("if: ${{ steps.preflight.outputs.needs-project == 'true' }}"))
+  assert.ok(steps[2].includes('permission-organization-projects: read'))
+  assert.ok(steps[3].includes('PROJECT_TOKEN: ${{ steps.app-token.outputs.token }}'))
+  assert.ok(steps[3].includes('run: node .github/issue-management/policy.mjs pr'))
+  assert.ok(steps[3].includes("if: ${{ steps.preflight.outputs.legacy-automated != 'true' }}"))
+})
+
+test('runs trusted rollout selection with absent and present capability markers', { skip: process.platform === 'win32' ? 'The policy workflow executes under hosted Ubuntu bash' : false }, (t) => {
+  const directory = mkdtempSync(join(tmpdir(), 'dsh-policy-rollout-'))
+  t.after(() => rmSync(directory, { recursive: true, force: true }))
+  const source = readFileSync(new URL('../workflows/issue-policy.yml', import.meta.url), 'utf8')
+  const script = source.split('        run: |\n')[1].split('      - name: Create Project read token')[0]
+    .split('\n').map((line) => line.slice(10)).join('\n')
+  assert.deepEqual(JSON.parse(readFileSync(new URL('./selective-preflight.json', import.meta.url), 'utf8')), { version: 1 })
+  const cases = [
+    { name: 'legacy human draft', type: 'User', draft: true, marker: false, expected: 'legacy-automated=false\nneeds-project=true\n' },
+    { name: 'legacy human ready', type: 'User', draft: false, marker: false, expected: 'legacy-automated=false\nneeds-project=true\n' },
+    { name: 'legacy bot', type: 'Bot', marker: false, expected: 'legacy-automated=true\nneeds-project=false\n' },
+    { name: 'legacy app', type: 'App', marker: false, expected: 'legacy-automated=true\nneeds-project=false\n' },
+    { name: 'modern exempt', type: 'Bot', marker: true, expected: 'exempt=true\nneeds-project=false\n' },
+    { name: 'modern failure', type: 'User', marker: true, failure: true, expected: '' },
+  ]
+  for (const [index, fixture] of cases.entries()) {
+    const cwd = join(directory, String(index))
+    const policyDirectory = join(cwd, '.github', 'issue-management')
+    mkdirSync(policyDirectory, { recursive: true })
+    const eventPath = join(cwd, 'event.json')
+    const outputPath = join(cwd, 'output')
+    writeFileSync(eventPath, JSON.stringify({ pull_request: { user: { type: fixture.type }, draft: fixture.draft } }))
+    writeFileSync(outputPath, '')
+    if (fixture.marker) writeFileSync(join(policyDirectory, 'selective-preflight.json'), '{"version":1}\n')
+    writeFileSync(join(policyDirectory, 'policy.mjs'), fixture.marker && !fixture.failure
+      ? "import fs from 'node:fs'; if (process.argv[2] !== 'pr-preflight') throw Error('wrong command'); fs.appendFileSync(process.env.GITHUB_OUTPUT, 'exempt=true\\nneeds-project=false\\n')\n"
+      : "throw new Error('preflight unavailable or failed')\n")
+    const result = spawnSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], {
+      cwd,
+      env: { PATH: process.env.PATH, GITHUB_EVENT_PATH: eventPath, GITHUB_OUTPUT: outputPath },
+      encoding: 'utf8',
+      timeout: 30_000,
+    })
+    assert.equal(result.error, undefined, fixture.name)
+    assert.equal(result.signal, null, fixture.name)
+    assert.equal(result.status, fixture.failure ? 1 : 0, fixture.name + ': ' + result.stderr)
+    assert.equal(readFileSync(outputPath, 'utf8'), fixture.expected, fixture.name)
+    if (fixture.marker) assert.doesNotMatch(result.stdout, /preserving legacy/)
+    else assert.match(result.stdout, /preserving legacy policy enforcement/)
+  }
+})
+
+test('allocates lifecycle runners only for relevant reviews and PR body edits', () => {
+  const source = readFileSync(new URL('../workflows/issue-lifecycle.yml', import.meta.url), 'utf8')
+  const issues = source.split('  issues:')[1].split('  pull_request:')[0]
+  const pulls = source.split('  pull_request:')[1].split('  pull_request_review:')[0]
+  const actions = (block) => [...block.matchAll(/^      - (\w+)$/gm)].map((match) => match[1])
+  assert.deepEqual(actions(issues), ['opened', 'edited', 'labeled', 'unlabeled', 'closed', 'reopened', 'typed', 'untyped', 'field_added', 'field_removed'])
+  assert.deepEqual(actions(pulls), ['opened', 'edited', 'reopened', 'review_requested'])
+  const job = source.slice(source.indexOf('  lifecycle:'))
+  const beforeSteps = job.slice(0, job.indexOf('    steps:'))
+  assert.ok(beforeSteps.includes('    if: >-'))
+  assert.ok(beforeSteps.includes("(github.event_name != 'pull_request_review' || github.event.review.state == 'changes_requested') &&"))
+  assert.ok(beforeSteps.includes("(github.event_name != 'pull_request' || github.event.action != 'edited' || github.event.changes.body != null)"))
+  assert.ok(source.includes('ref: ${{ github.event.repository.default_branch }}'))
+  assert.ok(source.includes('persist-credentials: false'))
+})
+
+test('keeps REST headers, null responses, and transport errors unchanged', async (t) => {
+  mockPolicyApi(t)
+  process.env.GH_TOKEN = 'preferred-token'
+  process.env.PROJECT_TOKEN = 'project-token'
+  process.env.GITHUB_API_URL = 'https://github.example/api/v3'
+  const requests = []
+  const responses = [
+    Response.json({ ok: true }),
+    new Response(null, { status: 204 }),
+    new Response('missing', { status: 404 }),
+    new Response('denied', { status: 403 }),
+    Response.json({ errors: [{ message: 'first' }, { message: 'second' }] }),
+  ]
+  t.mock.method(globalThis, 'fetch', async (url, options) => {
+    requests.push({ url, options })
+    return responses.shift()
+  })
+  assert.deepEqual(await api('/example'), { ok: true })
+  assert.deepEqual(requests[0], {
+    url: 'https://github.example/api/v3/example',
+    options: { headers: {
+      Accept: 'application/vnd.github+json',
+      Authorization: 'Bearer preferred-token',
+      'X-GitHub-Api-Version': '2026-03-10',
+      'User-Agent': 'dsh-issue-policy',
+    } },
+  })
+  assert.equal(await api('/empty'), null)
+  assert.equal(await api('/missing', { allow404: true }), null)
+  await assert.rejects(api('/denied', { method: 'PATCH' }), { message: 'PATCH /denied: 403 denied' })
+  await assert.rejects(graphql('query { viewer { login } }', {}), { message: 'first; second' })
+  assert.equal(requests[4].options.headers.Authorization, 'Bearer project-token')
+  assert.equal(requests[4].options.method, 'POST')
+  assert.equal(requests[4].options.body, JSON.stringify({ query: 'query { viewer { login } }', variables: {} }))
+  assert.equal(requests.length, 5)
+})
+
+test('reads policy snapshots in reference order and only resolving Project priorities', async (t) => {
+  const fixture = mockPolicyApi(t, {
+    pull: { body: 'Refs #4; Fixes #3; Fixes #2' },
+    issues: { 2: {}, 3: { pull_request: {} }, 4: {} },
+  })
+  assert.deepEqual(await pullRequestSnapshot(10), {
+    number: 10,
+    isDraft: false,
+    authorType: 'User',
+    reviewRequestCount: 1,
+    reviewCount: 0,
+    labels: ['kind/cleanup', 'area/infra'],
+    references: { all: [2, 4], resolving: [2], related: [4] },
+    issues: new Map([[2, { priority: 'P1' }], [4, { priority: null }]]),
+  })
+  const repo = '/repos/deepseek-harness/deepseek-harness'
+  assert.deepEqual(fixture.requests, [
+    repo + '/pulls/10',
+    repo + '/pulls/10/requested_reviewers',
+    repo + '/pulls/10/reviews?per_page=100',
+    repo + '/issues/2',
+    repo + '/issues/3',
+    repo + '/issues/4',
+    '/graphql',
+  ])
+  assert.deepEqual(fixture.output, [])
+})
+
+test('reads lifecycle references for draft Bot PRs without review or Project requests', async (t) => {
+  const fixture = mockPolicyApi(t, {
+    pull: { draft: true, user: { type: 'Bot' }, body: 'Fixes #2; Refs #4', created_at: '2026-08-27T16:00:00Z' },
+    issues: { 2: {}, 4: {} },
+  })
+  assert.deepEqual(await lifecyclePullRequestSnapshot(10), {
+    number: 10,
+    references: { all: [2, 4], resolving: [2], related: [4] },
+    issues: new Map([[2, { priority: null }], [4, { priority: null }]]),
+    createdAt: '2026-08-27T16:00:00Z',
+  })
+  const repo = '/repos/deepseek-harness/deepseek-harness'
+  assert.deepEqual(fixture.requests, [repo + '/pulls/10', repo + '/issues/2', repo + '/issues/4'])
+  assert.deepEqual(fixture.output, [])
+})
+
 test('allows missing Priority only when resolving Issues are also unprioritized', () => {
   const pull = {
     isDraft: false,

+ 126 - 0
.github/issue-management/pull-request.mjs

@@ -0,0 +1,126 @@
+/** Read-only PR snapshots and policy preflight/check workflow entry points. */
+
+import fs from 'node:fs'
+import process from 'node:process'
+
+import config from './config.json' with { type: 'json' }
+import { api, projectContext } from './github.mjs'
+import {
+  parseReferences,
+  retainIssueReferences,
+  requiresPullRequestPolicy,
+  validatePullRequest,
+} from './rules.mjs'
+
+/**
+ * Resolve all same-repository body references with REST, excluding pull-request numbers.
+ * @param {number} number Pull-request number.
+ * @param {{body?: string}} pull Current REST pull-request data.
+ * @returns {Promise<object>} Issue-only references and placeholder priorities; never reads Project data.
+ */
+export async function resolvingReferencesSnapshot(number, pull) {
+  const references = parseReferences({
+    body: pull.body ?? '',
+    repository: `${config.organization}/${config.repository}`,
+  })
+  const issues = new Map()
+  for (const issueNumber of references.all) {
+    const issue = await api(
+      `/repos/${config.organization}/${config.repository}/issues/${issueNumber}`,
+    )
+    if (!issue.pull_request) issues.set(issueNumber, { priority: null })
+  }
+  return {
+    number,
+    references: retainIssueReferences(references, issues),
+    issues,
+  }
+}
+
+/**
+ * Read current PR policy inputs, skipping references for exempt PRs.
+ * @param {number} number Pull-request number.
+ * @param {boolean} includeProject Read Project Priority for resolving Issues when true.
+ * @returns {Promise<object>} Policy snapshot; rejects any failed read and performs no writes.
+ */
+export async function pullRequestSnapshot(number, includeProject = true) {
+  const pull = await api(`/repos/${config.organization}/${config.repository}/pulls/${number}`)
+  const snapshot = {
+    number,
+    isDraft: pull.draft,
+    authorType: pull.user?.type ?? 'User',
+    reviewRequestCount: 0,
+    reviewCount: 0,
+    labels: pull.labels.map((label) => label.name),
+    references: { all: [], resolving: [], related: [] },
+    issues: new Map(),
+  }
+  if (snapshot.isDraft || ['Bot', 'App'].includes(snapshot.authorType)) return snapshot
+  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`),
+  ])
+  snapshot.reviewRequestCount = reviewRequests.users.length + reviewRequests.teams.length
+  snapshot.reviewCount = reviews.length
+  if (!requiresPullRequestPolicy(snapshot)) return snapshot
+  Object.assign(snapshot, await resolvingReferencesSnapshot(number, pull))
+  if (includeProject) {
+    for (const issueNumber of snapshot.references.resolving) {
+      const context = await projectContext(issueNumber)
+      snapshot.issues.get(issueNumber).priority = context.item?.priorityValue?.name ?? null
+    }
+  }
+  return snapshot
+}
+
+/**
+ * Read PR creation time and Issue references without review eligibility gating.
+ * @param {number} number Pull-request number.
+ * @returns {Promise<object>} Lifecycle snapshot without Project reads or writes.
+ */
+export async function lifecyclePullRequestSnapshot(number) {
+  const pull = await api(`/repos/${config.organization}/${config.repository}/pulls/${number}`)
+  return {
+    ...(await resolvingReferencesSnapshot(number, pull)),
+    createdAt: pull.created_at,
+  }
+}
+
+const EXEMPT_MESSAGE =
+  'Issue policy exempt:当前 PR 不在强制范围(Draft、Bot/App 或尚无 review request/review)。\n'
+
+/**
+ * Determine current policy eligibility and Project access needs without Project credentials.
+ * @param {{pull_request: {number: number}}} event GitHub event identifying the PR.
+ * @returns {Promise<{eligible: boolean, needsProject: boolean}>} Trusted workflow decisions.
+ */
+export async function runPullRequestPreflight(event) {
+  const pull = await pullRequestSnapshot(event.pull_request.number, false)
+  const eligible = requiresPullRequestPolicy(pull)
+  const needsProject = eligible && pull.references.resolving.length > 0
+  if (process.env.GITHUB_OUTPUT) {
+    fs.appendFileSync(
+      process.env.GITHUB_OUTPUT,
+      `eligible=${eligible}\nexempt=${!eligible}\nneeds-project=${needsProject}\n`,
+    )
+  }
+  process.stdout.write(eligible ? 'Issue policy applicable;执行完整校验。\n' : EXEMPT_MESSAGE)
+  return { eligible, needsProject }
+}
+
+/**
+ * Enforce all PR rules against current GitHub state, independently of preflight.
+ * @param {{pull_request: {number: number}}} event GitHub event identifying the PR.
+ * @returns {Promise<void>} Resolves on success or exemption; rejects policy failures.
+ */
+export async function runPullRequestCheck(event) {
+  const pull = await pullRequestSnapshot(event.pull_request.number)
+  const errors = validatePullRequest(pull)
+  if (errors.length > 0) {
+    for (const error of errors) process.stdout.write(`::error::${error}\n`)
+    throw new Error(`Issue policy 未通过,共 ${errors.length} 项`)
+  }
+  process.stdout.write(
+    requiresPullRequestPolicy(pull) ? 'Issue policy 通过。\n' : EXEMPT_MESSAGE,
+  )
+}

+ 303 - 0
.github/issue-management/rules.mjs

@@ -0,0 +1,303 @@
+/** Pure Issue/PR validation, reference parsing, lifecycle decisions, and Project dates. */
+
+import config from './config.json' with { type: 'json' }
+
+const TYPES = new Set(['Idea', 'Feature', 'Bug', 'Research', 'Task'])
+const PRIORITIES = ['p0', 'p1', 'p2', 'p3']
+const PR_KINDS = new Set([
+  'kind/feature',
+  'kind/bug-fix',
+  'kind/doc',
+  'kind/testing',
+  'kind/cleanup',
+  'kind/dependency',
+])
+// Retired label aliases stay reserved so they cannot be recreated.
+const LEGACY_LABELS = new Set([
+  'kind/bug',
+  'kind/documentation',
+  'feature',
+  'bug-fix',
+  'doc',
+  'cleanup',
+  'testing',
+  'dependencies',
+  'ci',
+  'cli',
+  'llm',
+  'web-search',
+])
+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',
+  'reopened',
+])
+
+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 未设置')
+}
+if (typeof config.priorityField !== 'string' || !config.priorityField) {
+  throw new Error('config.priorityField 未设置')
+}
+if (typeof config.startDateField !== 'string' || !config.startDateField) {
+  throw new Error('config.startDateField 未设置')
+}
+if (typeof config.projectTimeZone !== 'string' || !config.projectTimeZone) {
+  throw new Error('config.projectTimeZone 未设置')
+}
+Intl.DateTimeFormat('en-US', { timeZone: config.projectTimeZone })
+
+/**
+ * Decide whether the human-review policy applies to a PR.
+ * @param {{isDraft: boolean, authorType: string, reviewRequestCount: number, reviewCount: number}} input PR state.
+ * @returns {boolean} Whether the PR policy is mandatory.
+ */
+export function requiresPullRequestPolicy({
+  isDraft,
+  authorType,
+  reviewRequestCount,
+  reviewCount,
+}) {
+  const automated = authorType === 'Bot' || authorType === 'App'
+  return !isDraft && !automated && (reviewRequestCount > 0 || reviewCount > 0)
+}
+
+/**
+ * Translate a repository event into one resolving-Issue lifecycle command.
+ * @param {string} eventName GitHub event name.
+ * @param {{action?: string, changes?: {body?: object}, 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 === 'edited' && !event.changes?.body) return null
+    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 {'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, 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
+}
+
+/**
+ * Convert a GitHub timestamp to a Project date in one configured time zone.
+ * @param {string} timestamp ISO timestamp.
+ * @param {string} timeZone IANA time-zone name.
+ * @returns {string} Calendar date in YYYY-MM-DD form.
+ */
+export function projectDate(timestamp, timeZone = config.projectTimeZone) {
+  const instant = new Date(timestamp)
+  if (Number.isNaN(instant.getTime())) throw new Error(`无效的 PR 创建时间:${timestamp}`)
+  const parts = Object.fromEntries(
+    new Intl.DateTimeFormat('en-US', {
+      timeZone,
+      year: 'numeric',
+      month: '2-digit',
+      day: '2-digit',
+    })
+      .formatToParts(instant)
+      .filter((part) => part.type !== 'literal')
+      .map((part) => [part.type, part.value]),
+  )
+  return `${parts.year}-${parts.month}-${parts.day}`
+}
+
+function stripIgnoredMarkdown(body) {
+  const lines = body.replace(/<!--[\s\S]*?-->/g, '').split(/\r?\n/)
+  const kept = []
+  let fence = null
+  for (const line of lines) {
+    const marker = line.match(/^\s*([\u0060~]{3,})/)
+    if (marker) {
+      if (fence === null) fence = marker[1][0]
+      else if (marker[1][0] === fence) fence = null
+      continue
+    }
+    if (fence === null) kept.push(line)
+  }
+  return kept.join('\n').replace(/\u0060[^\u0060]*\u0060/g, ' ')
+}
+
+/**
+ * Parse same-repository resolving and informational references.
+ * @param {{body: string, repository: string}} input PR body and repository.
+ * @returns {{all: number[], resolving: number[], related: number[]}} References.
+ */
+export function parseReferences({ body, repository }) {
+  const source = stripIgnoredMarkdown(body)
+  const expected = repository.toLowerCase()
+  const all = new Set()
+  const resolving = new Set()
+  const reference =
+    /(?:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#|#)(\d+)|https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/issues\/(\d+)/gi
+  const closing =
+    /\b(?:close(?:s|d)?|fix(?:es|ed)?|resolve(?:s|d)?)\s*:?\s+(?:(?:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#|#)(\d+)|https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/issues\/(\d+))/gi
+
+  for (const match of source.matchAll(reference)) {
+    const explicit = (match[1] ?? match[3] ?? '').toLowerCase()
+    const number = Number(match[2] ?? match[4])
+    if (!explicit || explicit === expected) all.add(number)
+  }
+  for (const match of source.matchAll(closing)) {
+    const explicit = (match[1] ?? match[3] ?? '').toLowerCase()
+    const number = Number(match[2] ?? match[4])
+    if (!explicit || explicit === expected) {
+      all.add(number)
+      resolving.add(number)
+    }
+  }
+  return {
+    all: [...all].sort((left, right) => left - right),
+    resolving: [...resolving].sort((left, right) => left - right),
+    related: [...all].filter((number) => !resolving.has(number)).sort((a, b) => a - b),
+  }
+}
+
+/**
+ * Retain only references that resolve to Issues rather than pull requests.
+ * @param {{all: number[], resolving: number[], related: number[]}} references Parsed references.
+ * @param {Map<number, unknown>} issues Resolved same-repository Issues.
+ * @returns {{all: number[], resolving: number[], related: number[]}} Issue-only references.
+ */
+export function retainIssueReferences(references, issues) {
+  return {
+    all: references.all.filter((number) => issues.has(number)),
+    resolving: references.resolving.filter((number) => issues.has(number)),
+    related: references.related.filter((number) => issues.has(number)),
+  }
+}
+
+/**
+ * Validate one Issue with its Project status.
+ * @param {{labels: string[], type: string|null, priority: string|null, status: string|null, state: string, stateReason: string|null}} issue Issue snapshot.
+ * @returns {string[]} Validation errors.
+ */
+export function validateIssue(issue) {
+  const errors = []
+  const status = issue.status
+  const invalidLabels = issue.labels.filter(isInvalidIssueLabel)
+
+  if (invalidLabels.length > 0) {
+    errors.push(`Issue 不得使用 PR kind 或旧版标签:${invalidLabels.join(', ')}`)
+  }
+  if (!TYPES.has(issue.type ?? '')) errors.push('Type 必须是五种原生英文 Type 之一')
+  if (!status || !config.statuses.includes(status)) errors.push('Issue 必须在 Project 中且具有合法 Status')
+  if (issue.priority !== null && !PRIORITIES.includes(issue.priority.toLowerCase())) {
+    errors.push('Priority 必须为空或为 P0–P3')
+  }
+  if (status === 'Done' && (issue.state !== 'closed' || issue.stateReason !== 'completed')) {
+    errors.push('Done 必须对应 Completed 关闭原因')
+  }
+  if (
+    status === 'No action' &&
+    (issue.state !== 'closed' || issue.stateReason !== 'not_planned')
+  ) {
+    errors.push('No action 必须对应 Not planned 关闭原因')
+  }
+  if (!['Done', 'No action'].includes(status ?? '') && issue.state !== 'open') {
+    errors.push(`${status} 必须对应开放 Issue`)
+  }
+  return errors
+}
+
+/**
+ * Identify PR kinds and retired aliases that cannot label an Issue.
+ * @param {string} label Label name.
+ * @returns {boolean} Whether the label is invalid for Issues.
+ */
+export function isInvalidIssueLabel(label) {
+  return label.startsWith('kind/') || LEGACY_LABELS.has(label)
+}
+
+/**
+ * Validate PR metadata and its referenced Issues.
+ * @param {{authorType: string, labels: string[], references: ReturnType<typeof parseReferences>, issues: Map<number, {priority: string|null}>}} input PR snapshot.
+ * @returns {string[]} Validation errors.
+ */
+export function validatePullRequest(input) {
+  if (!requiresPullRequestPolicy(input)) return []
+  const errors = []
+  const kinds = input.labels.filter((label) => PR_KINDS.has(label))
+  const unknownKinds = input.labels.filter(
+    (label) => label.startsWith('kind/') && !PR_KINDS.has(label) && !LEGACY_LABELS.has(label),
+  )
+  const legacyLabels = input.labels.filter((label) => LEGACY_LABELS.has(label))
+  const sourceLabels = input.labels.filter((label) => label.startsWith('source/'))
+  const priorities = input.labels.filter((label) => PRIORITIES.includes(label))
+  const areas = input.labels.filter((label) => label.startsWith('area/'))
+
+  if (input.references.all.length === 0) {
+    errors.push('PR 正文必须引用至少一个同仓库 Issue;PR 编号(包括堆叠依赖 PR)不算 Issue 引用')
+  }
+  if (kinds.length !== 1) {
+    errors.push(`PR 必须恰好有一个允许的 kind/*,当前为 ${kinds.length}`)
+  }
+  if (unknownKinds.length > 0) {
+    errors.push(`PR 含不支持的 kind/*:${unknownKinds.join(', ')}`)
+  }
+  if (legacyLabels.length > 0) errors.push(`PR 含旧版标签:${legacyLabels.join(', ')}`)
+  if (sourceLabels.length > 0) errors.push(`source/* 仅用于 Issue:${sourceLabels.join(', ')}`)
+  if (priorities.length > 1) errors.push(`PR 最多有一个 p0–p3,当前为 ${priorities.length}`)
+  if (areas.length === 0) errors.push('PR 必须至少有一个 area/*')
+  for (const number of input.references.all) {
+    if (!input.issues.has(number)) errors.push(`#${number} 不是同仓库 Issue`)
+  }
+
+  const resolving = input.references.resolving
+    .map((number) => [number, input.issues.get(number)])
+    .filter((entry) => entry[1])
+  if (resolving.length === 0) return errors
+
+  const issuePriorities = resolving
+    .map(([, issue]) => issue.priority?.toLowerCase())
+    .filter((priority) => PRIORITIES.includes(priority))
+  if (priorities.length === 0 && issuePriorities.length > 0) {
+    const highest = issuePriorities.sort(
+      (left, right) => PRIORITIES.indexOf(left) - PRIORITIES.indexOf(right),
+    )[0]
+    errors.push(`PR Priority 应为 ${highest}`)
+  } else if (priorities.length === 1 && issuePriorities.length !== resolving.length) {
+    errors.push('有 Priority 的解决型 PR 要求每个被解决 Issue 都设置 Priority')
+  } else if (priorities.length === 1) {
+    const highest = issuePriorities.sort(
+      (left, right) => PRIORITIES.indexOf(left) - PRIORITIES.indexOf(right),
+    )[0]
+    if (priorities[0] !== highest) errors.push(`PR Priority 应为 ${highest}`)
+  }
+  return errors
+}

+ 1 - 0
.github/issue-management/selective-preflight.json

@@ -0,0 +1 @@
+{ "version": 1 }

+ 5 - 12
.github/workflows/issue-lifecycle.yml

@@ -5,22 +5,19 @@ on:
     types:
       - opened
       - edited
-      - assigned
-      - unassigned
       - labeled
       - unlabeled
       - closed
       - reopened
+      - typed
+      - untyped
       - field_added
       - field_removed
   pull_request:
     types:
       - opened
       - edited
-      - synchronize
       - reopened
-      - labeled
-      - unlabeled
       - review_requested
   pull_request_review:
     types:
@@ -36,11 +33,9 @@ concurrency:
 jobs:
   lifecycle:
     name: Issue lifecycle
-    # Runs on every pull_request_review event so the check reports success rather
-    # than a gray "skipped" segment. The token-creating and board-mutating steps
-    # are gated at step level (a skipped step does not gray the job): only a
-    # changes_requested review drives the Project board; approved/commented
-    # reviews never mint a write-capable App token.
+    if: >-
+      (github.event_name != 'pull_request_review' || github.event.review.state == 'changes_requested') &&
+      (github.event_name != 'pull_request' || github.event.action != 'edited' || github.event.changes.body != null)
     runs-on: ubuntu-latest
     steps:
       - name: Check out trusted policy
@@ -50,7 +45,6 @@ jobs:
           persist-credentials: false
       - name: Create project token
         id: app-token
-        if: ${{ github.event_name != 'pull_request_review' || github.event.review.state == 'changes_requested' }}
         uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
         with:
           client-id: ${{ vars.DSH_ISSUE_APP_CLIENT_ID }}
@@ -58,7 +52,6 @@ jobs:
           owner: deepseek-harness
           repositories: deepseek-harness
       - name: Handle repository event
-        if: ${{ github.event_name != 'pull_request_review' || github.event.review.state == 'changes_requested' }}
         env:
           GH_TOKEN: ${{ steps.app-token.outputs.token }}
         run: node .github/issue-management/policy.mjs lifecycle

+ 20 - 2
.github/workflows/issue-policy.yml

@@ -21,9 +21,26 @@ jobs:
         with:
           ref: ${{ github.event.repository.default_branch }}
           persist-credentials: false
+      - name: Determine policy eligibility
+        id: preflight
+        env:
+          GITHUB_TOKEN: ${{ github.token }}
+        shell: bash
+        run: |
+          if [ -f .github/issue-management/selective-preflight.json ]; then
+            node .github/issue-management/policy.mjs pr-preflight
+          else
+            node --input-type=module <<'NODE'
+          import fs from 'node:fs'
+          const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'))
+          const automated = ['Bot', 'App'].includes(event.pull_request.user.type)
+          fs.appendFileSync(process.env.GITHUB_OUTPUT, `legacy-automated=${automated}\nneeds-project=${!automated}\n`)
+          console.log('Trusted policy has no selective preflight; preserving legacy policy enforcement.')
+          NODE
+          fi
       - name: Create Project read token
         id: app-token
-        if: ${{ github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.type != 'App' }}
+        if: ${{ steps.preflight.outputs.needs-project == 'true' }}
         uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
         with:
           client-id: ${{ vars.DSH_ISSUE_APP_CLIENT_ID }}
@@ -32,8 +49,9 @@ jobs:
           repositories: deepseek-harness
           permission-issues: read
           permission-organization-projects: read
+      # Re-read current state even after an exempt preflight; never replace a failure with an edit skip.
       - name: Validate pull request
-        if: ${{ github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.type != 'App' }}
+        if: ${{ steps.preflight.outputs.legacy-automated != 'true' }}
         env:
           GITHUB_TOKEN: ${{ github.token }}
           PROJECT_TOKEN: ${{ steps.app-token.outputs.token }}

+ 22 - 14
scripts/ci-workflow.spec.ts

@@ -956,19 +956,16 @@ describe('Weighted approval workflow', () => {
 })
 
 describe('Issue lifecycle workflow', () => {
-  it('runs the lifecycle job on every PR/review event but gates token and board steps', () => {
+  it('allocates lifecycle runners only for events that can change the board', () => {
     const lifecycle = loadWorkflow('.github/workflows/issue-lifecycle.yml')
     const policy = loadWorkflow('.github/workflows/issue-policy.yml')
     const lifecycleJob = workflowJob(lifecycle, 'lifecycle')
     if (!Array.isArray(lifecycleJob.steps)) throw new TypeError('Issue lifecycle job must define steps')
 
-    // The job has no job-level `if`, so it is listed on every pull_request /
-    // pull_request_review event and reports success instead of a gray skip. The
-    // write-capable steps are gated at step level so approved/commented reviews
-    // never mint a Project/Issue App token nor touch the board.
     expect(lifecycle.on).toHaveProperty('pull_request')
     expect(lifecycle.on).toHaveProperty('pull_request_review')
-    expect(lifecycleJob.if).toBeUndefined()
+    expect(lifecycleJob.if).toContain("github.event.review.state == 'changes_requested'")
+    expect(lifecycleJob.if).toContain('github.event.changes.body != null')
     // Keep the subscription-type gates: issue-lifecycle does not re-subscribe
     // ready_for_review (issue-policy owns that) and only reacts to submitted
     // review events.
@@ -978,31 +975,43 @@ describe('Issue lifecycle workflow', () => {
     expect(lifecyclePullRequest.types).not.toContain('ready_for_review')
     expect(lifecyclePullRequest.types).toContain('review_requested')
     expect(lifecycleReview.types).toEqual(['submitted'])
-    const gated = "${{ github.event_name != 'pull_request_review' || github.event.review.state == 'changes_requested' }}"
+    expect(lifecyclePullRequest.types).not.toContain('synchronize')
+    expect(lifecyclePullRequest.types).not.toContain('labeled')
+    expect(lifecyclePullRequest.types).not.toContain('unlabeled')
+    const issueEvents = workflowEvent(lifecycle, 'issues')
+    expect(issueEvents.types).not.toContain('assigned')
+    expect(issueEvents.types).not.toContain('unassigned')
+    expect(issueEvents.types).toContain('typed')
+    expect(issueEvents.types).toContain('untyped')
     const steps = lifecycleJob.steps.filter(isRecord)
     const tokenStep = steps.find(s => s.name === 'Create project token')
     const handleStep = steps.find(s => s.name === 'Handle repository event')
-    expect(tokenStep).toMatchObject({ if: gated })
-    expect(handleStep).toMatchObject({ if: gated })
+    expect(tokenStep?.if).toBeUndefined()
+    expect(handleStep?.if).toBeUndefined()
 
     // issue-policy owns PR validation; it is read-only and a real gate.
     const policyPullRequest = workflowEvent(policy, 'pull_request')
     expect(policyPullRequest.types).toContain('ready_for_review')
   })
 
-  it('uses a read-only Project token only for human pull request policy metadata', () => {
+  it('mints Project credentials only after preflight and always revalidates current metadata', () => {
     const policy = loadWorkflow('.github/workflows/issue-policy.yml')
     const policyJob = workflowJob(policy, 'policy')
     if (!Array.isArray(policyJob.steps)) throw new TypeError('Issue policy job must define steps')
     const steps = policyJob.steps.filter(isRecord)
     const tokenStep = steps.find(step => step.name === 'Create Project read token')
     const validateStep = steps.find(step => step.name === 'Validate pull request')
-    const humanPullRequest =
-      "${{ github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.type != 'App' }}"
+    const preflightStep = steps.find(step => step.id === 'preflight')
+    expect(preflightStep).toMatchObject({ shell: 'bash' })
+    expect(preflightStep?.run).toContain('if [ -f .github/issue-management/selective-preflight.json ]; then')
+    expect(preflightStep?.run).toContain('node .github/issue-management/policy.mjs pr-preflight')
+    expect(preflightStep?.if).toBeUndefined()
+    expect(policyJob.if).toBeUndefined()
+    expect(validateStep?.if).toBe("${{ steps.preflight.outputs.legacy-automated != 'true' }}")
 
     expect(tokenStep).toMatchObject({
       id: 'app-token',
-      if: humanPullRequest,
+      if: "${{ steps.preflight.outputs.needs-project == 'true' }}",
       uses: 'actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1',
       with: {
         'client-id': '${{ vars.DSH_ISSUE_APP_CLIENT_ID }}',
@@ -1014,7 +1023,6 @@ describe('Issue lifecycle workflow', () => {
       },
     })
     expect(validateStep).toMatchObject({
-      if: humanPullRequest,
       env: {
         GITHUB_TOKEN: '${{ github.token }}',
         PROJECT_TOKEN: '${{ steps.app-token.outputs.token }}',