diff --git a/.gitignore b/.gitignore index e5ef09a..7f75359 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,4 @@ PR_BODY.md -.coding_workflow/diffs/ -.coding_workflow/skill_results/ -.coding_workflow/skill_runtime/ .DS_Store __pycache__/ diff --git a/PR_BODY.md b/PR_BODY.md deleted file mode 100644 index e5e7875..0000000 --- a/PR_BODY.md +++ /dev/null @@ -1,89 +0,0 @@ -## 1. 背景与目标 - -PR #7 已合并后,用户指出 `scripts/OPERATIONS.md` 中的 PR 提交 agent 信息不够充分。 - -本 PR 的目标是只补强 PR 提交 agent 的可执行信息,让它能按同一个 prompt 完成读取、核对、final gate、单 commit 发布、PR body 更新和结果回报,不再依赖临场推导 `PR_Checklist.md` 的发布细节。 - ---- - -## 2. 实现方案 - -- 在 `scripts/OPERATIONS.md` 的 `## 3. PR 提交 Agent` 中新增明确任务边界:只执行 PR 提交,不补写 PASS 1/2/3/4 语义内容。 -- 补充 PR 提交 agent 必须读取的文件:`PR_Checklist.md`、`TESTING.md`、`.github/pull_request_template.md`、`PR_BODY.md`、`.coding_workflow/diffs/sync_state.json`。 -- 补充提交前核对:分支、工作区、`git diff --name-only ...HEAD`、`PR_BODY.md` 变更范围、`Full Document Reconcile` / `Remaining Human Decisions` 在 `PR_BODY.md` 中的具体读取位置、FDR 完整性、语义风险、测试记录和 final gate。 -- 补充提交范围和单 commit 发布命令:区分更新既有 PR 与新建 PR,明确 `git commit --amend --no-edit`、`git push --force-with-lease`、`gh pr edit --body-file`、`gh pr create --draft --body-file`。 -- 回归测试锁住 PR 提交 agent 的关键命令、禁止项和回报项,避免 prompt 再退回抽象描述。 - ---- - -## 3. 变更范围 - -来自 `git diff --name-only origin/main...HEAD`: - -| 文件 / 目录 | 变更类型 | 说明 | -|---|---|---| -| `scripts/OPERATIONS.md` | 修改 | 补强 PR 提交 agent prompt,加入必须读取、提交前核对、提交范围、单 commit 发布和完成回报要求。 | -| `tests/test_sync_coding_workflow.py` | 修改 | 为 PR 提交 agent prompt 增加 literal 回归测试,锁住关键命令与禁止项。 | - ---- - -## 4. 文档影响 - -受影响文档: - -- `scripts/OPERATIONS.md` - -说明: - -- 本 PR 只调整 sync 工具操作手册中的 PR 提交流程 prompt。 -- 不改变下游项目能力边界、用户可观察行为或 sync 脚本运行行为。 -- 不需要更新 `capability_contract.json`、`interact.md` 或 `docs/business_user_guide.md`。 - ---- - -## 5. 用户与架构影响 - -用户可见变化: - -- No -- 说明:不改变应用运行行为;只影响执行 workflow docs sync PR 提交的 agent prompt。 - -架构变化: - -- No -- 说明:未修改 sync 脚本、状态模型、数据流或外部依赖。 - ---- - -## 6. Review / 修复记录 - -| 轮次 | 来源 | 问题摘要 | 判断 | 处理结果 | 证据 | -|---|---|---|---|---|---| -| R0 | 用户反馈 | PR 提交 agent 信息不够充分,缺少可直接执行的提交 / 更新 PR 细节。 | 真实存在 | 补充必须读取、提交前核对、提交范围、单 commit 发布命令和完成回报要求。 | `scripts/OPERATIONS.md`, `tests/test_sync_coding_workflow.py` | -| R1 | 自审 | PR #7 已经合并,无法继续提交到同一个 PR。 | 真实存在 | 从最新 `main` 新建后续分支,只提交 PR 提交 agent 的增量修改。 | `git status`, `gh pr view 7` | -| R2 | 用户反馈 | PR 提交 agent 读不懂 `Full Document Reconcile` / `Remaining Human Decisions`,因为 prompt 没告诉它去哪里读。 | 真实存在 | 在必须读取和提交前核对中明确指向 `PR_BODY.md` 的对应 heading,以及 agent-owned sentinel 区间。 | `scripts/OPERATIONS.md`, `tests/test_sync_coding_workflow.py` | - ---- - -## 7. 已知限制与回滚 - -已知限制: - -- PR 提交 agent prompt 比原来更长,但内容集中在发布阶段,不增加 PASS 1/2/3/4 的上下文负担。 - -回滚方式: - -- 回滚本 PR 即可恢复到 PR #7 合并后的 PR 提交 agent prompt。 - ---- - -## 8. 最终自检 - -- [x] 当前分支不是主干:`codex/pr-submit-agent-prompt` -- [x] 已执行 `git diff --name-only origin/main...HEAD` -- [x] “变更范围”与实际 diff 一致 -- [x] PR body 不包含历史草稿、旧分支名、未落地计划 -- [x] 已按 `TESTING.md` 完成测试与测试记录 -- [x] 用户可见变化已对照 `interact.md` -- [x] 架构变化已对照 `architecture.md` -- [x] 每轮 review / 修复都已写入“Review / 修复记录” diff --git a/README.md b/README.md index 2d1ac2f..ca71862 100644 --- a/README.md +++ b/README.md @@ -2,59 +2,35 @@ [中文](zh/README.md) | English -This repository root is the English public entrypoint for workflow templates, -user-facing docs, and operations runbooks. After choosing English, stay inside -the `en/` tree except for the shared implementation at -`scripts/sync_coding_workflow.py` and repository-level tests. +This repository publishes bilingual workflow-document templates and the +`workflow-docs-sync` Skill that specializes them against a real target repository. ## Quick Start -Run English workflow docs sync from the target repository root: +Invoke the Skill once. Provide only the target repository, optional language +(`zh` or `en`), and whether to create a draft PR after successful validation. -```bash -curl -fsSL https://raw.githubusercontent.com/wlvh/coding-workflow/main/en/scripts/sync.sh | bash +```text +Use $workflow-docs-sync for /absolute/path/to/repository in English. +Do not create a draft PR. ``` -Then follow PASS 1 through PASS 4 in -[en/scripts/OPERATIONS.md](en/scripts/OPERATIONS.md). English sync uses source -templates under `en/`, but installs them into the target repository's canonical -root paths. +The main agent is the only workspace writer. Four domain analyses and the +adversarial audit are read-only; the final checker validates repository state, +not execution history. Upstream checkout and commit resolution are internal. ## Directory Map -- [en/AGENTS.md](en/AGENTS.md): agent entrypoint, file map, coding rules, and document relationships. -- [en/architecture.md](en/architecture.md): architecture, module boundaries, data flow, and invariants. -- [en/capability_contract.json](en/capability_contract.json): machine-readable capability and behavior contract. -- [en/interact.md](en/interact.md): user-visible behavior and acceptance invariants. -- [en/TESTING.md](en/TESTING.md): testing strategy and evidence rules. -- [en/PR_Checklist.md](en/PR_Checklist.md): PR submission, commit, push, and PR body rules. -- [en/SOP.md](en/SOP.md): standard process entrypoints. -- [en/.github/pull_request_template.md](en/.github/pull_request_template.md): English downstream PR body template. -- [en/docs/business_user_guide.md](en/docs/business_user_guide.md): business-user guide. -- [en/docs/development_workflow/README.md](en/docs/development_workflow/README.md): English workflow overview. -- [en/prompts/](en/prompts/): English prompt catalog status. -- [en/scripts/OPERATIONS.md](en/scripts/OPERATIONS.md): English workflow docs sync runbook. -- [en/scripts/sync.sh](en/scripts/sync.sh): English template sync launcher. -- [en/scripts/sync_pr_review_system.md](en/scripts/sync_pr_review_system.md): English independent reviewer prompt. +- [en/](en/): English templates and workflow documentation. +- [zh/](zh/): Chinese source templates, prompts, decisions, and Skill implementation. +- [zh/skills/workflow-docs-sync/](zh/skills/workflow-docs-sync/): single-session orchestration, read-only analysis references, and the deterministic checker. +- [tests/test_workflow_docs_sync.py](tests/test_workflow_docs_sync.py): sync and installer regression tests. ## Install Path Rule -`en/` is an upstream source prefix; it is not installed into the target project. -Sync strips only the leading `en/` and writes the remaining path as-is: +The `zh/` and `en/` directories are upstream source prefixes. The Skill strips +only the selected leading language directory and installs the remaining paths at +the target repository root. Inner paths such as `.github/` remain unchanged. -- `en/AGENTS.md` -> `/AGENTS.md` -- `en/docs/business_user_guide.md` -> `/docs/business_user_guide.md` -- `en/.github/pull_request_template.md` -> `/.github/pull_request_template.md` - -The inner `.github` layout is preserved. The rule is prefix stripping only, not -special-case path rewriting. - -## Language Policy - -Chinese remains the source of truth. English is derived from the Chinese -workflow. When English coverage is pending, mark `en-pending` in the PR body and -avoid exposing untranslated flows as English-ready. - -Root `README.md` intentionally mirrors the English entrypoint with -root-relative links, so the repository homepage stays useful without depending -on symlink rendering behavior. +Chinese remains the source-of-truth workflow. English is its derived language +path; unfinished English coverage must be identified explicitly as `en-pending`. diff --git a/en/README.md b/en/README.md index 1940c88..a8dce5d 100644 --- a/en/README.md +++ b/en/README.md @@ -2,57 +2,45 @@ [中文](../zh/README.md) | English -This directory is the English entrypoint for workflow templates, user-facing -docs, and operations runbooks. After choosing English, stay inside the `en/` -tree except for the shared implementation at `../scripts/sync_coding_workflow.py` -and repository-level tests. +This directory contains the English workflow-document templates. The canonical +single-session Skill is shared from `zh/skills/workflow-docs-sync/` and reads the +selected English templates from the pinned upstream commit. ## Quick Start -Run English workflow docs sync from the target repository root: +Invoke the Skill once and provide only the target repository, optional language, +and optional draft-PR intent: -```bash -curl -fsSL https://raw.githubusercontent.com/wlvh/coding-workflow/main/en/scripts/sync.sh | bash +```text +Use $workflow-docs-sync for `/absolute/path/to/repository` in English. +Do not create a draft PR. ``` -Then follow PASS 1 through PASS 4 in -[en/scripts/OPERATIONS.md](scripts/OPERATIONS.md). English sync uses source -templates under `en/`, but installs them into the target repository's canonical -root paths. +The main agent is the only target-workspace writer. Architecture, capability and +user behavior, testing, governance, and adversarial audit analysis are read-only. ## Directory Map -- [en/AGENTS.md](AGENTS.md): agent entrypoint, file map, coding rules, and document relationships. -- [en/architecture.md](architecture.md): architecture, module boundaries, data flow, and invariants. -- [en/capability_contract.json](capability_contract.json): machine-readable capability and behavior contract. -- [en/interact.md](interact.md): user-visible behavior and acceptance invariants. -- [en/TESTING.md](TESTING.md): testing strategy and evidence rules. -- [en/PR_Checklist.md](PR_Checklist.md): PR submission, commit, push, and PR body rules. -- [en/SOP.md](SOP.md): standard process entrypoints. -- [en/.github/pull_request_template.md](.github/pull_request_template.md): English downstream PR body template. -- [en/docs/business_user_guide.md](docs/business_user_guide.md): business-user guide. -- [en/docs/development_workflow/README.md](docs/development_workflow/README.md): English workflow overview. -- [en/prompts/](prompts/): English prompt catalog status. -- [en/scripts/OPERATIONS.md](scripts/OPERATIONS.md): English workflow docs sync runbook. -- [en/scripts/sync.sh](scripts/sync.sh): English template sync launcher. -- [en/scripts/sync_pr_review_system.md](scripts/sync_pr_review_system.md): English independent reviewer prompt. +- [AGENTS.md](AGENTS.md): agent entrypoint, rules, and document relationships. +- [architecture.md](architecture.md): architecture and system-boundary template. +- [capability_contract.json](capability_contract.json): machine-readable capability contract. +- [interact.md](interact.md): user-visible behavior and acceptance template. +- [TESTING.md](TESTING.md): testing strategy and evidence template. +- [PR_Checklist.md](PR_Checklist.md): general PR submission template. +- [SOP.md](SOP.md): standard-process entrypoint template. +- [.github/pull_request_template.md](.github/pull_request_template.md): downstream PR body template. +- [docs/business_user_guide.md](docs/business_user_guide.md): business-user teaching template. +- [docs/development_workflow/README.md](docs/development_workflow/README.md): English workflow overview. +- [../zh/skills/workflow-docs-sync/](../zh/skills/workflow-docs-sync/): canonical Skill implementation. ## Install Path Rule -`en/` is an upstream source prefix; it is not installed into the target project. -Sync strips only the leading `en/` and writes the remaining path as-is: +`en/` is an upstream source prefix, not a target directory. Sync strips only the +leading `en/` and preserves the rest of each path: - `en/AGENTS.md` -> `/AGENTS.md` - `en/docs/business_user_guide.md` -> `/docs/business_user_guide.md` - `en/.github/pull_request_template.md` -> `/.github/pull_request_template.md` -The inner `.github` layout is preserved. The rule is prefix stripping only, not -special-case path rewriting. - -## Language Policy - -Chinese remains the source of truth. English is derived from the Chinese -workflow. When English coverage is pending, mark `en-pending` in the PR body and -avoid exposing untranslated flows as English-ready. - - +Chinese remains the workflow source of truth. English is derived; unfinished +coverage must be marked `en-pending` instead of being presented as ready. diff --git a/en/docs/development_workflow/README.md b/en/docs/development_workflow/README.md index 6452c8d..4135af9 100644 --- a/en/docs/development_workflow/README.md +++ b/en/docs/development_workflow/README.md @@ -44,8 +44,9 @@ only exposes the English path and the current English coverage boundary. method. - `Issue`: executable development contract. - `PR_BODY.md`: local PR body draft generated from `.github/pull_request_template.md`. -- `Workflow Docs Sync`: full reconcile evidence, pass workorder, and sync PR - body skeleton generated by `en/scripts/sync.sh`. +- `Workflow Docs Sync`: one invocation that maps current code, performs four + read-only domain analyses, lets the main agent update documents, runs a + read-only adversarial audit, executes tests, and checks final repository state. ## English Coverage Boundary @@ -56,14 +57,23 @@ semantics without updating English, mark `en-pending` in the PR body. ## Workflow Docs Sync -Use [en/scripts/OPERATIONS.md](../../scripts/OPERATIONS.md) for the full sync -runbook. The English launcher uses source files under `en/` and installs them -into canonical target paths by stripping only the leading `en/` prefix. +Invoke `$workflow-docs-sync` once with the target repository, optional `en` +language, and optional draft-PR intent. Upstream checkout and commit resolution +are internal. The main agent is the only workspace writer; domain agents and +the adversarial auditor return read-only findings in the current session. + +The deterministic script has only two internal commands: `prepare` installs +missing templates without replacing existing documents, and `check` validates +the final repository state. The entire sync flow neither creates, reads, rewrites, +nor deletes repository-local `PR_BODY.md`. It creates no run records, runs no +target test command, and performs no commit, push, or GitHub operation. If the +user asks for a draft PR, the post-check publishing step uses a temporary +Markdown body outside the repository and the general GitHub publishing ability. Key implementation files: -- `en/scripts/sync.sh`: English one-shot launcher. -- `en/scripts/OPERATIONS.md`: English runbook and pass prompts. -- `en/scripts/sync_pr_review_system.md`: English reviewer prompt. -- `../../../scripts/sync_coding_workflow.py`: shared implementation. -- `../../../tests/test_sync_coding_workflow.py`: shared regression tests. +- `../../../zh/skills/workflow-docs-sync/SKILL.md`: orchestration contract. +- `../../../zh/skills/workflow-docs-sync/references/sections.md`: four domain analyses. +- `../../../zh/skills/workflow-docs-sync/references/audit.md`: adversarial audit. +- `../../../zh/skills/workflow-docs-sync/scripts/sync_docs.py`: mechanical preparation and check. +- `../../../tests/test_workflow_docs_sync.py`: regression tests. diff --git a/en/scripts/OPERATIONS.md b/en/scripts/OPERATIONS.md deleted file mode 100644 index e882b62..0000000 --- a/en/scripts/OPERATIONS.md +++ /dev/null @@ -1,305 +0,0 @@ -# Workflow Docs Sync - English Runbook - -[中文](../../zh/scripts/OPERATIONS.md) | English - -This runbook is the English-derived operations entrypoint. Chinese documents -remain the anchor. The English sync path reads source templates under `en/` -and installs them into canonical target paths such as `AGENTS.md`, -`TESTING.md`, and `.github/pull_request_template.md`. - -Each run writes machine evidence to `.coding_workflow/diffs/agent_workorder.md`, -`sync_state.json`, and `PR_BODY.md`. Treat those generated files as the current -run's evidence. - ---- - -## 1. Quick Start - -Run English workflow docs sync from the target repository root: - -```bash -curl -fsSL https://raw.githubusercontent.com/wlvh/coding-workflow/main/en/scripts/sync.sh | bash -``` - -Before running: - -- Do not mix non-sync code, config, or test dirty changes into the target repo. -- If rerunning within the same sync cycle, only core docs, `.gitignore`, - `PR_BODY.md`, and `.coding_workflow/diffs/` may be dirty. -- If `PR_BODY.md` is not a sync sentinel body, ordinary sync fails fast. Move it, - delete it, or manually migrate useful text into the agent-owned sync sections. -- If sync warns that `PR_BODY.md` is tracked, do not fix that inside the sync - flow. Use a separate cleanup PR if the target project wants it untracked. - -Ordinary sync outputs: - -- `.coding_workflow/diffs/agent_workorder.md`: run-specific machine facts and this runbook's pinned raw URL. -- `.coding_workflow/diffs/pr_body_skeleton.md`: initial sync PR body skeleton when `PR_BODY.md` does not exist. -- `.coding_workflow/diffs/sync_state.json`: machine state used by final gate. -- `.coding_workflow/diffs/upstream_full/`: local mirror of upstream English templates and prompts. - -After ordinary sync succeeds, copy PASS 1 through PASS 4 into new chats one at a -time. The executing agent reads `.coding_workflow/diffs/agent_workorder.md`; the -user does not need to read it before starting the next pass. - ---- - -## 2. Sync Agent Passes - -Each pass must only edit its owned files and its agent-owned PR body sections. -Never edit `` to ``. - -Common rules for every pass: - -- Use `.coding_workflow/diffs/pr_body_skeleton.md` or current `PR_BODY.md` as the PR body structure authority. -- Do not modify sync sentinels or content outside sentinel sections. -- A literal `|` inside a table cell must be written as `\|` or replaced with `
`; only an unescaped `|` separates cells. -- Do not fill `pr_test_evidence`; only the PR submission agent owns it. -- `agent_execution_evidence` is self-reported read coverage for reviewer spot checks only. -- `Full Document Reconcile` must include upstream semantic delta, adopted where, not adopted because, evidence, and downstream impact. -- Evidence must explicitly cover `class-1 template/missing`, `class-2 upstream`, and `class-3 code/test/behavior drift`. -- Do not quote upstream template markers, sample anchors, angle-bracket placeholders, or TODO sentinels as semantic deltas. Rewrite them descriptively and use upstream raw URLs as evidence. -- At pass end, rerun ordinary English sync: - -```bash -curl -fsSL https://raw.githubusercontent.com/wlvh/coding-workflow/main/en/scripts/sync.sh | bash -``` - -### 2.1 PASS 1 - Code Facts / Architecture - -```text -Overall goal: complete this workflow docs sync by using ordinary sync outputs -and code evidence to update this pass's owned docs and PR_BODY.md agent-owned -sections. Current task: execute only PASS 1 - Code Facts / Architecture. - -Must read: -1. `.coding_workflow/diffs/agent_workorder.md` -2. `.coding_workflow/diffs/upstream_full/architecture.md` -3. `PR_BODY.md` must be supplied as the formal handoff artifact before this - PASS starts; if it is missing, stop and report a workflow-preparation - artifact failure. Do not create it from the skeleton during the PASS. -4. `PR_BODY.md` repo_facts_map and the `architecture.md` row in - full_document_reconcile. -5. Current repo entrypoints, module boundaries, data flow, state model, - external dependencies, and code-level invariants. - -May modify only: -- `architecture.md` -- `PR_BODY.md` repo_facts_map -- `PR_BODY.md` full_document_reconcile row for `architecture.md` -- `PR_BODY.md` agent_execution_evidence row for PASS 1 - -Must fill: -- Repo Facts Map with concrete code, document, or command evidence. -- Code facts in `architecture.md` when evidence is sufficient. -- Full Document Reconcile row for `architecture.md`. -- Agent Execution Evidence row for PASS 1. - -Finish by rerunning ordinary English sync. If it fails, stop and report the -error; do not hand-edit the auto section. -``` - -### 2.2 PASS 2 - Capability / User Behavior - -```text -Overall goal: complete this workflow docs sync by using ordinary sync outputs -and code evidence to update this pass's owned docs and PR_BODY.md agent-owned -sections. Current task: execute only PASS 2 - Capability / User Behavior. - -Precondition: -- In `.coding_workflow/diffs/agent_workorder.md`, PASS 1 owned docs should have - marker / TODO hits as `none`; otherwise return to PASS 1. - -Must read: -1. `.coding_workflow/diffs/agent_workorder.md` -2. `.coding_workflow/diffs/upstream_full/capability_contract.json` -3. `.coding_workflow/diffs/upstream_full/interact.md` -4. `.coding_workflow/diffs/upstream_full/docs/business_user_guide.md` -5. `PR_BODY.md` -6. Capability-related Repo Facts Map entries and this pass's rows in - full_document_reconcile. -7. `architecture.md`, `capability_contract.json`, `interact.md`, and - `docs/business_user_guide.md`. - -May modify only: -- `capability_contract.json` -- `interact.md` -- `docs/business_user_guide.md` -- Capability-related Repo Facts Map entries -- Full Document Reconcile rows for this pass -- Agent Execution Evidence row for PASS 2 - -Must fill: -- Machine-readable capability, boundary, responsibility, and behavior contracts. -- User-visible behavior and acceptance invariants. -- Business-user guide explanations that derive only from the contract and - interaction docs. -- Full Document Reconcile rows for all three owned docs. -- Agent Execution Evidence row for PASS 2. - -Finish by rerunning ordinary English sync. If it fails, stop and report the -error; do not hand-edit the auto section. -``` - -### 2.3 PASS 3 - TESTING Independent Review - -```text -Overall goal: complete this workflow docs sync by using ordinary sync outputs -and code evidence to update this pass's owned docs and PR_BODY.md agent-owned -sections. Current task: execute only PASS 3 - TESTING Independent Review. - -Precondition: -- PASS 1 and PASS 2 owned docs should have marker / TODO hits as `none`; - otherwise return to the owning pass. - -Must read: -1. `.coding_workflow/diffs/agent_workorder.md` -2. `.coding_workflow/diffs/upstream_full/TESTING.md` -3. `PR_BODY.md` -4. Testing-related Repo Facts Map entries and the `TESTING.md` row in - full_document_reconcile. -5. `TESTING.md`, target project test entrypoints, test inventory, and - mechanical signal command output. -6. PASS 1/2 owned docs only as needed for test strategy anchors. - -May modify only: -- `TESTING.md` -- Testing-related Repo Facts Map entries -- Full Document Reconcile row for `TESTING.md` -- Agent Execution Evidence row for PASS 3 - -Suggested mechanical signals: -- `find tests -type f -name 'test_*.py' -exec wc -l {} + | sort -n` -- `grep -rh '^def test_\|^class Test' tests/` -- `git log --since='3 months ago' --name-only -- tests/` - -Must fill: -- `## TESTING_REVIEW_PACKET` at the top of `TESTING.md`, creating it if - missing, with existing_test_inventory, redundant_tests, - missing_high_value_tests, tests_not_worth_adding, - unit_vs_contract_vs_scenario_vs_e2e_decision, real_failure_modes_covered, - mock_only_risks, recommended_gate, and - downstream_requirements_for_PR_Checklist. -- Full Document Reconcile row for `TESTING.md`. -- Agent Execution Evidence row for PASS 3. - -Finish by rerunning ordinary English sync. If it fails, stop and report the -error; do not hand-edit the auto section. -``` - -### 2.4 PASS 4 - Governance / Reverse Closure - -```text -Overall goal: complete this workflow docs sync by using ordinary sync outputs -and code evidence to update this pass's owned docs and PR_BODY.md agent-owned -sections. Current task: execute only PASS 4 - Governance / Reverse Closure. - -Precondition: -- PASS 1/2/3 owned docs in `.coding_workflow/diffs/agent_workorder.md` should - have marker / TODO hits as `none`; otherwise return to the owning pass. - -Must read: -1. `.coding_workflow/diffs/agent_workorder.md` -2. `.coding_workflow/diffs/upstream_full/PR_Checklist.md` -3. `.coding_workflow/diffs/upstream_full/SOP.md` -4. `.coding_workflow/diffs/upstream_full/AGENTS.md` -5. `.coding_workflow/diffs/upstream_full/.github/pull_request_template.md` -6. `PR_BODY.md` -7. The whole Full Document Reconcile table and Remaining Human Decisions. -8. `PR_Checklist.md`, `SOP.md`, `AGENTS.md`, and - `.github/pull_request_template.md`. -9. PASS 1/2/3 owned docs only where downstream impact closure requires it. - -May modify only: -- `PR_Checklist.md` -- `SOP.md` -- `AGENTS.md` -- `.github/pull_request_template.md` -- Full Document Reconcile rows for this pass -- Remaining Human Decisions -- Agent Execution Evidence row for PASS 4 - -Must fill: -- How downstream impacts from PASS 1/2/3 are consumed by governance docs. -- PR template inherit / override decision. -- Remaining Human Decisions, or `none` when no unresolved decision remains. -- Agent Execution Evidence row for PASS 4. - -Finish by rerunning ordinary English sync. If it fails, stop and report the -error; do not hand-edit the auto section. -``` - ---- - -## 3. PR Submission Agent - -After PASS 4, start the PR submission agent. It must rely on `PR_BODY.md` Full -Document Reconcile / Remaining Human Decisions and final gate, not on chat -summaries. - -```text -Current task: execute only the PR Submission Agent. Do not fill PASS 1/2/3/4 -semantic content. - -Must read: -1. `PR_Checklist.md` -2. `TESTING.md` -3. `.github/pull_request_template.md` -4. `PR_BODY.md` -5. `.coding_workflow/diffs/sync_state.json` -6. `PR_BODY.md` Full Document Reconcile table -7. `PR_BODY.md` PR Test Evidence -8. `PR_BODY.md` Upstream Drift Log -9. `PR_BODY.md` Agent Execution Evidence -10. `PR_BODY.md` Remaining Human Decisions - -Before submission: -- Confirm current branch is not main / master / default branch. -- Use `git status --short` to separate sync files, PR_BODY.md, tests, and - unrelated changes. Do not submit unrelated changes. -- Use `git diff --name-only ...HEAD` and `git diff --name-only` to keep - PR_BODY.md change scope accurate. -- Ensure Full Document Reconcile covers all core docs and contains no - `待补充`. -- Ensure four Agent Execution Evidence PASS rows are filled. -- If Upstream Drift Log is not `none`, tell the reviewer to re-check current - upstream raw URLs. -- Run required tests per `TESTING.md` and PR_BODY.md, then write commands, - results, and N/A reasons into PR Test Evidence. -- Before submit, run: - `curl -fsSL https://raw.githubusercontent.com/wlvh/coding-workflow/main/en/scripts/sync.sh | bash -s -- --final` - -Submission scope: -- Submit only this sync's core docs, `.gitignore`, necessary tests, and - target-project-allowed PR_BODY.md. -- Do not submit `.coding_workflow/diffs/`, temporary clones, unrelated code, - unrelated config, or stale drafts. -- `PR_BODY.md` is local PR body scratch by default; do not commit it unless the - target project explicitly allows that. -``` - ---- - -## 4. Sync PR Review - -After the PR submission agent provides a PR URL, start the independent reviewer: - -```text -You are the PR reviewer for a workflow-docs sync PR. Review PR using the -PR body's Sync Review Contract and the commit-pinned English reviewer prompt raw -URL. For the English flow, that reviewer prompt should point to -`en/scripts/sync_pr_review_system.md`. - -Open every raw URL listed in the PR body upstream sections and compare them -against the PR head's core docs. Also inspect PR Test Evidence, Upstream -Drift Log, and Agent Execution Evidence. Agent Execution Evidence is self-report -only and does not replace reviewer spot-checks of PR body evidence paths. -``` - -Outcomes: - -- PASS: proceed to user-view acceptance. -- WARN: explain why the risk is acceptable in PR body, then proceed. -- BLOCKER: mechanical problems return to sync pass and ordinary sync; semantic - problems require evidence or user judgment, then rerun PR submission and - review. diff --git a/en/scripts/sync.sh b/en/scripts/sync.sh deleted file mode 100755 index 797a866..0000000 --- a/en/scripts/sync.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env bash -# One-shot launcher for English workflow docs sync. -# -# Usage from a target project: -# curl -fsSL https://raw.githubusercontent.com/wlvh/coding-workflow/main/en/scripts/sync.sh | bash -# -# This launcher uses the English template source family under `en/` and installs -# selected templates into canonical target paths such as `AGENTS.md`, -# `TESTING.md`, and `.github/pull_request_template.md`. - -set -euo pipefail - -FINAL_CHECK=0 -if [ $# -eq 1 ] && [ "$1" = "--final" ]; then - FINAL_CHECK=1 -elif [ $# -ne 0 ]; then - echo "Unknown arg: $1" - exit 2 -fi - -if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then - echo "Error: $(pwd) is not inside a git worktree." - exit 1 -fi -REPO_ROOT="$(git rev-parse --show-toplevel)" - -UPSTREAM_PARENT="" -cleanup() { - if [ -n "$UPSTREAM_PARENT" ]; then - rm -rf "$UPSTREAM_PARENT" - fi -} -trap cleanup EXIT - -if [ -n "${CODING_WORKFLOW_UPSTREAM_DIR:-}" ]; then - UPSTREAM_DIR="$(cd "$CODING_WORKFLOW_UPSTREAM_DIR" && pwd)" -else - UPSTREAM_PARENT="$(mktemp -d "${TMPDIR:-/tmp}/coding-workflow-XXXXXX")" - echo "Cloning wlvh/coding-workflow..." - # Shallow clone keeps the one-shot launcher fast; the Python script pins the - # resolved HEAD before reading every upstream template. - git clone --quiet --depth=1 --single-branch \ - https://github.com/wlvh/coding-workflow.git "$UPSTREAM_PARENT/cw" - UPSTREAM_DIR="$UPSTREAM_PARENT/cw" -fi - -export REPO_ROOT -export UPSTREAM_DIR -export CODING_WORKFLOW_LANGUAGE=en -python3 "$UPSTREAM_DIR/scripts/sync_coding_workflow.py" - -if [ "$FINAL_CHECK" -eq 1 ]; then - python3 "$UPSTREAM_DIR/scripts/sync_coding_workflow.py" \ - --check-final PR_BODY.md -elif [ -f "$REPO_ROOT/PR_BODY.md" ]; then - python3 "$UPSTREAM_DIR/scripts/sync_coding_workflow.py" \ - --update-pr-body PR_BODY.md -else - echo "PR_BODY.md not found; use .coding_workflow/diffs/pr_body_skeleton.md when drafting the sync PR body." -fi diff --git a/en/scripts/sync_pr_review_system.md b/en/scripts/sync_pr_review_system.md deleted file mode 100644 index c9e826f..0000000 --- a/en/scripts/sync_pr_review_system.md +++ /dev/null @@ -1,131 +0,0 @@ -# Sync PR Review System Prompt - -## Who You Are - -You are the sync PR reviewer. Your task is not ordinary code review; your task is -to decide whether a `full_reconcile` PR truly aligns current project facts with -current upstream workflow rules. - -This prompt starts the independent review. The PR body's `Sync Review Contract` -lists run-specific required inputs and the final-gate split of responsibility. -The final gate owns sentinels, auto-section freshness, placeholder residue, and -template residue. This prompt owns semantic review: pass closure, evidence -truth, upstream rule absorption, and operability. - ---- - -## Inputs - -You must read: - -- PR body, especially `Sync Review Contract`, `Full Document Reconcile`, and final-gate evidence. -- PR body's `PR Test Evidence`, `Upstream Drift Log`, and `Agent Execution Evidence`. -- PR changed files. -- Core documents listed by `Sync Review Contract` on PR head. -- Every raw URL listed in the PR body's upstream sections. -- Code, document, or command evidence referenced by PR body project facts. - -If PR body lacks `Sync Review Contract`, or listed required inputs are -inaccessible, do not give PASS. - ---- - -## Review Method - -Audit the PR body `Sync Review Contract` and agent-owned sections item by item. -Focus on: - -- Whether PR body includes final-gate pass evidence. -- Whether Repo Facts have real evidence and do not present unsupported judgment as fact. -- Whether every pass-owned doc has Full Document Reconcile evidence that traces to a core document. -- Whether the TESTING pass independently reviewed test redundancy, necessity, real failure coverage, mock-only risk, and E2E/scenario value. -- Whether full reconcile covers this run's core docs and explains upstream semantic delta, adopted where, not adopted because, evidence, and downstream impact. -- Whether PR Test Evidence records actual test commands, results, and N/A reasons. -- Whether Upstream Drift Log exposes upstream commit drift during PR body refresh. -- Whether Agent Execution Evidence is framed as self-report for spot checks, not authenticated tool telemetry. -- Whether Remaining Human Decisions exposes unresolved semantic decisions for user or reviewer judgment. -- Whether downstream impact is consumed by later passes or governance docs. -- Whether PR head truly absorbs or reasonably rejects upstream rules. -- Whether the next agent or developer can use the docs to enter the project, test, submit, understand architecture, and validate user-facing behavior. -- Spot-check several PR body evidence paths or command claims directly against PR head or reproducible commands. - -If you cannot see the target project's scratch directory, do not assume scratch -evidence is valid. PR body must carry the key evidence. - ---- - -## Output Format - -```markdown -## Sync PR Review - -### Verdict -PASS / WARN / BLOCKER - -### 1. Contract Compliance -PASS/WARN/BLOCKER + evidence - -### 2. Evidence Quality -PASS/WARN/BLOCKER + evidence - -### 3. Full Reconcile Closure -PASS/WARN/BLOCKER + evidence - -### 4. Per-Pass Evidence and Propagation -PASS/WARN/BLOCKER + Full Document Reconcile, TESTING pass, and downstream impact closure evidence - -### 5. Test, Drift, and Execution Evidence -PASS/WARN/BLOCKER + PR Test Evidence, Upstream Drift Log, Agent Execution Evidence, and spot-check evidence - -### 6. Upstream Cross-check -PASS/WARN/BLOCKER + raw URLs actually opened - -### 7. Operability -PASS/WARN/BLOCKER + evidence - -### 8. Top Issues -Only highest-impact issues, with file paths and lines / PR body references - -### 9. Evidence Index -List PR body sections, core docs, raw URLs, and verification commands actually read or run -``` - ---- - -## Verdict Rules - -BLOCKER: - -- Final gate or `Sync Review Contract` required inputs / evidence requirements are violated. -- PR body lacks `Sync Review Contract` or required materials. -- Raw URLs are wrong, inaccessible, or do not prove corresponding upstream content. -- Repo facts, document judgments, or upstream absorption claims lack evidence. -- `specialized` is treated as "no update needed" without upstream semantic delta and adopted/not adopted evidence. -- Any pass-owned doc lacks Full Document Reconcile evidence, or evidence / downstream impact cannot support closure. -- TESTING pass did not independently review redundancy, necessity, real failure coverage, or E2E/scenario value. -- PR Test Evidence lacks required commands, results, or N/A reasons. -- Upstream Drift Log is not `none` but reviewer did not re-check current upstream raw URLs. -- Agent Execution Evidence claims tool-level read proof or contradicts PR body evidence. -- Remaining Human Decisions hides actual unresolved decisions or contradicts PR head / PR body. -- Downstream impact is not closed in final files or later passes. -- Final gate failed, or PR body does not state final-gate result. -- PR head and PR body contradict each other. - -WARN: - -- Evidence is thin but does not affect the main judgment. -- Minor explanation lacks detail, but final gate and required inputs are closed. -- Raw URL cannot be verified because of a temporary network issue. - -PASS: - -- PASS only when final gate, evidence, upstream cross-check, document operability, and remaining human-decision risk are explicit and acceptable. - ---- - -## What You Do Not Do - -- Do not maintain or require an old incremental baseline manifest. -- Do not infer drift from commit history. -- Do not replace user-view acceptance. -- Do not treat this prompt as the mechanical checklist source of truth; final gate owns mechanical details. diff --git a/scripts/sync.sh b/scripts/sync.sh deleted file mode 100755 index 5d29d1b..0000000 --- a/scripts/sync.sh +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env bash -# Backward-compatible one-shot launcher for Chinese workflow docs sync. -# -# Usage from a target project: -# curl -fsSL https://raw.githubusercontent.com/wlvh/coding-workflow/main/scripts/sync.sh | bash -# -# What this does: -# 1. Verifies current dir is a git worktree; Python rejects unmanaged dirty paths -# 2. Shallow-clones wlvh/coding-workflow at the current default-branch HEAD -# 3. Runs sync_coding_workflow.py with `zh/` template source paths -# 4. Without --final, refreshes PR_BODY.md when it exists -# 5. With --final, verifies existing PR_BODY.md against current sync evidence -# 6. Cleans up the clone on exit - -set -euo pipefail - -FINAL_CHECK=0 -if [ $# -eq 1 ] && [ "$1" = "--final" ]; then - FINAL_CHECK=1 -elif [ $# -ne 0 ]; then - echo "Unknown arg: $1" - exit 2 -fi - -if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then - echo "Error: $(pwd) is not inside a git worktree." - exit 1 -fi -REPO_ROOT="$(git rev-parse --show-toplevel)" - -UPSTREAM_PARENT="" -cleanup() { - if [ -n "$UPSTREAM_PARENT" ]; then - rm -rf "$UPSTREAM_PARENT" - fi -} -trap cleanup EXIT - -if [ -n "${CODING_WORKFLOW_UPSTREAM_DIR:-}" ]; then - UPSTREAM_DIR="$(cd "$CODING_WORKFLOW_UPSTREAM_DIR" && pwd)" -else - UPSTREAM_PARENT="$(mktemp -d "${TMPDIR:-/tmp}/coding-workflow-XXXXXX")" - echo "Cloning wlvh/coding-workflow..." - # Shallow clone keeps the one-shot launcher fast; the Python script pins the - # resolved HEAD before reading every upstream template. - git clone --quiet --depth=1 --single-branch \ - https://github.com/wlvh/coding-workflow.git "$UPSTREAM_PARENT/cw" - UPSTREAM_DIR="$UPSTREAM_PARENT/cw" -fi - -export REPO_ROOT -export UPSTREAM_DIR -export CODING_WORKFLOW_LANGUAGE=zh -python3 "$UPSTREAM_DIR/scripts/sync_coding_workflow.py" - -if [ "$FINAL_CHECK" -eq 1 ]; then - python3 "$UPSTREAM_DIR/scripts/sync_coding_workflow.py" \ - --check-final PR_BODY.md -elif [ -f "$REPO_ROOT/PR_BODY.md" ]; then - python3 "$UPSTREAM_DIR/scripts/sync_coding_workflow.py" \ - --update-pr-body PR_BODY.md -else - echo "PR_BODY.md not found; use .coding_workflow/diffs/pr_body_skeleton.md when drafting the sync PR body." -fi diff --git a/scripts/sync_coding_workflow.py b/scripts/sync_coding_workflow.py deleted file mode 100644 index bc8197d..0000000 --- a/scripts/sync_coding_workflow.py +++ /dev/null @@ -1,1987 +0,0 @@ -#!/usr/bin/env python3 -""" -Workflow Docs Full Reconcile. - -Purpose: - Generate one-run evidence for reconciling a target repository's workflow - documents against the latest `wlvh/coding-workflow` upstream templates. - -Call flow: - main() - -> assert_git_repo() - -> assert_upstream_repo() - -> assert_no_unmanaged_dirty() - -> ensure_gitignore() - -> warn_if_pr_body_tracked() - -> stage_full_reconcile_outputs() - -> render_pr_body_skeleton() - -> print_summary() - -This script intentionally has no committed baseline manifest. Each run is a -full review of current project facts plus the latest upstream rules. Scratch -evidence is written under `.coding_workflow/diffs/` and is not committed. -""" - -from __future__ import annotations - -import json -import os -import shutil -import subprocess -import sys -from datetime import datetime, timezone -from pathlib import Path - -LANGUAGE_ZH = "zh" -LANGUAGE_EN = "en" - - -def resolve_workflow_language() -> str: - """Return the workflow template language selected by the launcher. - - Parameters: - None. `CODING_WORKFLOW_LANGUAGE` is set by `scripts/sync.sh`, - `zh/scripts/sync.sh`, or `en/scripts/sync.sh`. - - Expected output: - `zh` or `en`. Unknown values fail fast so a caller cannot silently sync - the wrong template path family. - """ - language = ( - os.environ["CODING_WORKFLOW_LANGUAGE"] - if "CODING_WORKFLOW_LANGUAGE" in os.environ - else LANGUAGE_ZH - ) - if language in (LANGUAGE_ZH, LANGUAGE_EN): - return language - sys.exit( - "FATAL: CODING_WORKFLOW_LANGUAGE must be `zh` or `en`; " - f"got: {language}" - ) - - -WORKFLOW_LANGUAGE = resolve_workflow_language() - -ZH_CORE_SOURCE_FILES = ( - "zh/architecture.md", - "zh/capability_contract.json", - "zh/interact.md", - "zh/docs/business_user_guide.md", - "zh/TESTING.md", - "zh/PR_Checklist.md", - "zh/SOP.md", - "zh/AGENTS.md", - "zh/.github/pull_request_template.md", -) - -EN_CORE_SOURCE_FILES = ( - "en/architecture.md", - "en/capability_contract.json", - "en/interact.md", - "en/docs/business_user_guide.md", - "en/TESTING.md", - "en/PR_Checklist.md", - "en/SOP.md", - "en/AGENTS.md", - "en/.github/pull_request_template.md", -) - - -def strip_language_prefix(path: str) -> str: - """Return the target path for a language-scoped upstream template. - - Parameters: - path: Upstream source path under `zh/` or `en/`. - - Expected output: - Path written to the target project after stripping exactly the leading - language directory. Inner directories such as `.github/` are preserved. - """ - for prefix in ("zh/", "en/"): - if path.startswith(prefix): - return path[len(prefix):] - sys.exit(f"FATAL: language template path must start with zh/ or en/: {path}") - - -CORE_SOURCE_FILES_BY_LANGUAGE = { - LANGUAGE_ZH: ZH_CORE_SOURCE_FILES, - LANGUAGE_EN: EN_CORE_SOURCE_FILES, -} - -ZH_CORE_FILES = tuple(strip_language_prefix(path) for path in ZH_CORE_SOURCE_FILES) -EN_CORE_FILES = tuple(strip_language_prefix(path) for path in EN_CORE_SOURCE_FILES) -CORE_SOURCE_FILES = CORE_SOURCE_FILES_BY_LANGUAGE[WORKFLOW_LANGUAGE] -CORE_FILES = tuple(strip_language_prefix(path) for path in CORE_SOURCE_FILES) -CORE_SOURCE_BY_TARGET = dict(zip(CORE_FILES, CORE_SOURCE_FILES)) - -SYNC_PASSES = ( - { - "id": "code_architecture", - "title": "PASS 1 - Code Facts / Architecture", - "files": ("architecture.md",), - }, - { - "id": "capability_behavior", - "title": "PASS 2 - Capability / User Behavior", - "files": ( - "capability_contract.json", - "interact.md", - "docs/business_user_guide.md", - ), - }, - { - "id": "testing_quality", - "title": "PASS 3 - TESTING Independent Review", - "files": ("TESTING.md",), - }, - { - "id": "governance_closure", - "title": "PASS 4 - Governance / Reverse Closure", - "files": ( - "PR_Checklist.md", - "SOP.md", - "AGENTS.md", - ".github/pull_request_template.md", - ), - }, -) - -FULL_RECONCILE_COLUMNS = ( - "pass", - "文件", - "当前脚本信号", - "upstream semantic delta", - "adopted where", - "not adopted because", - "evidence", - "downstream impact", -) - -OPERATIONS_PROMPT_FILE_BY_LANGUAGE = { - LANGUAGE_ZH: "zh/scripts/OPERATIONS.md", - LANGUAGE_EN: "en/scripts/OPERATIONS.md", -} -REVIEWER_PROMPT_FILE_BY_LANGUAGE = { - LANGUAGE_ZH: "zh/scripts/sync_pr_review_system.md", - LANGUAGE_EN: "en/scripts/sync_pr_review_system.md", -} - -OPERATIONS_PROMPT_FILE = OPERATIONS_PROMPT_FILE_BY_LANGUAGE[WORKFLOW_LANGUAGE] -SYNC_PROMPT_FILES = ( - OPERATIONS_PROMPT_FILE, - REVIEWER_PROMPT_FILE_BY_LANGUAGE[WORKFLOW_LANGUAGE], -) - -PERMITTED_INHERIT_FILES = frozenset({".github/pull_request_template.md"}) - -TEMPLATE_MARKERS = ( - "<项目名>", - "<项目 / agent / app 名称>", - "<对象>", - "<指标 / 结果>", - "", - "", - "", - "", - "Case 1:确认一个对象最近是否异常", - "Case 1: Check whether one object is abnormal", - "待项目负责人补充", - "project owner must replace this", - "sample_supported_question", - "sample_multi_object_comparison_not_supported", - "sample_no_final_business_decision", - "sample_requires_context_before_answer", - "CAPABILITY.sample_", - "BOUNDARY.sample_", - "RESPONSIBILITY.sample_", - "BEHAVIOR.sample_", -) - -TODO_ANCHOR_COMMENTS = ( - "", - "", -) - -SYNC_MANAGED_DIRTY_PATHS = frozenset(CORE_FILES) | frozenset({ - ".gitignore", - "PR_BODY.md", -}) - -GITIGNORE_LINES = ( - "PR_BODY.md", - ".coding_workflow/diffs/", - ".coding_workflow/skill_results/", - ".coding_workflow/skill_runtime/", -) - -DIFFS_DIR = ".coding_workflow/diffs" -SKILL_RESULTS_DIR = ".coding_workflow/skill_results" -SKILL_RUNTIME_DIR = ".coding_workflow/skill_runtime" -STATE_FILE = "sync_state.json" -WORKORDER_FILE = "agent_workorder.md" -PR_BODY_SKELETON_FILE = "pr_body_skeleton.md" -UPSTREAM_FULL_DIR = "upstream_full" - -SYNC_AUTO_START = "" -SYNC_AUTO_END = "" -SYNC_PR_BODY_MARKER = "" -AGENT_SECTIONS = ( - "repo_facts_map", - "full_document_reconcile", - "pr_test_evidence", - "upstream_drift_log", - "agent_execution_evidence", - "remaining_human_decisions", -) -PR_BODY_REQUIRED_SECTIONS = ( - "Repo Facts Map", - "Sync Summary", - "Working Tree State at Sync Time", - "Sync Review Contract", - "Upstream Templates at Sync Time", - "Upstream Instructions at Sync Time", - "Installation Status", - "Full Document Reconcile", - "PR Test Evidence", - "Upstream Drift Log", - "Agent Execution Evidence", - "Remaining Human Decisions", -) -REPO_FACTS_HEADINGS = ( - "### 1. 项目类型", - "### 2. 系统输入", - "### 3. 系统输出", - "### 4. 用户身份", - "### 5. 核心模块清单", - "### 6. 主要数据流", - "### 7. 关键不变量", - "### 8. 当前能力清单", - "### 9. 测试现状", - "### 10. 不确定项", -) -BLOCKING_FINAL_STATUSES = frozenset({ - "installed_template", - "template_copy_requires_specialization", -}) -UNFILLED_AGENT_PLACEHOLDERS = ("待补充",) - - -def git(*args: str, cwd: Path) -> str: - """Run git and return stdout. - - Parameters: - *args: Git subcommand and flags, already split by argument. - cwd: Repository or upstream clone path. - - Expected output: - Captured stdout as a string. Non-zero git exits raise immediately. - """ - return subprocess.run( - ["git", *args], - cwd=cwd, - check=True, - capture_output=True, - encoding="utf-8", - text=True, - ).stdout - - -def git_optional(*args: str, cwd: Path) -> str | None: - """Run git and return stdout when the command succeeds. - - Parameters: - *args: Git subcommand and flags, already split by argument. - cwd: Repository or upstream clone path. - - Expected output: - Captured stdout, or None when git exits non-zero. This is used only for - existence checks where failing fast would hide a clearer message. - """ - result = subprocess.run( - ["git", *args], - cwd=cwd, - check=False, - capture_output=True, - encoding="utf-8", - text=True, - ) - return result.stdout if result.returncode == 0 else None - - -def file_at_ref(upstream_dir: Path, ref: str, path: str) -> str | None: - """Return one upstream template file at a git ref. - - Parameters: - upstream_dir: Local clone of `wlvh/coding-workflow`. - ref: Commit SHA or ref name. - path: Repository-relative file path. - - Expected output: - UTF-8 text at `ref:path`, or None if upstream lacks the file. - """ - return git_optional("show", f"{ref}:{path}", cwd=upstream_dir) - - -def upstream_raw_url(upstream_sha: str, rel_path: str) -> str: - """Return a commit-pinned raw GitHub URL for one upstream file. - - Parameters: - upstream_sha: Resolved upstream commit SHA. - rel_path: Repository-relative upstream file path. - - Expected output: - Raw GitHub URL pinned to `upstream_sha`, safe to read after the - temporary upstream clone has been removed. - """ - return ( - "https://raw.githubusercontent.com/wlvh/coding-workflow/" - f"{upstream_sha}/{rel_path}" - ) - - -def agent_section_start(section_name: str) -> str: - """Return the start sentinel for an agent-preserved PR body section. - - Parameters: - section_name: Stable section key from `AGENT_SECTIONS`. - - Expected output: - HTML comment sentinel that survives normal Markdown rendering. - """ - return f"" - - -def agent_section_end(section_name: str) -> str: - """Return the end sentinel for an agent-preserved PR body section. - - Parameters: - section_name: Stable section key from `AGENT_SECTIONS`. - - Expected output: - HTML comment sentinel that lets the script preserve semantic work while - refreshing deterministic sync evidence. - """ - return f"" - - -def diffs_root_for(repo_root: Path) -> Path: - """Return the scratch evidence directory for one sync epoch. - - Parameters: - repo_root: Target project path. - - Expected output: - `.coding_workflow/diffs` path under the target repository. - """ - return repo_root / DIFFS_DIR - - -def state_path_for(repo_root: Path) -> Path: - """Return the current sync state path. - - Parameters: - repo_root: Target project path. - - Expected output: - Path to `.coding_workflow/diffs/sync_state.json`, which is rebuilt on - every full reconcile run and never acts as a persistent baseline. - """ - return diffs_root_for(repo_root=repo_root) / STATE_FILE - - -def assert_git_repo(repo_root: Path) -> None: - """Fail fast when REPO_ROOT is not inside a git worktree. - - Parameters: - repo_root: Target project path. - - Expected output: - None. The process exits with a clear error when sync cannot inspect git - state. This accepts normal clones and `git worktree` checkouts. - """ - inside = git_optional("rev-parse", "--is-inside-work-tree", cwd=repo_root) - if inside is None or inside.strip() != "true": - sys.exit(f"FATAL: {repo_root} is not inside a git worktree.") - - -def assert_upstream_repo(upstream_dir: Path) -> None: - """Fail fast when UPSTREAM_DIR is missing or not a git worktree. - - Parameters: - upstream_dir: Local clone of `wlvh/coding-workflow`. - - Expected output: - None. The process exits with a clear error when a direct script caller - provides an invalid upstream path. - """ - if not upstream_dir.is_dir(): - sys.exit( - f"FATAL: UPSTREAM_DIR does not exist or is not a directory: " - f"{upstream_dir}" - ) - inside = git_optional( - "rev-parse", - "--is-inside-work-tree", - cwd=upstream_dir, - ) - if inside is None or inside.strip() != "true": - sys.exit(f"FATAL: UPSTREAM_DIR is not a git worktree: {upstream_dir}") - - -def is_sync_managed_dirty_path(path: str) -> bool: - """Return whether an uncommitted path may be dirty during full reconcile. - - Parameters: - path: Repository-relative path parsed from `git status --porcelain`. - - Expected output: - True for sync/skill runtime、core workflow docs、`.gitignore` and - local `PR_BODY.md`; False for project code/config/test changes. - """ - return ( - path.startswith(".coding_workflow/diffs/") - or path.startswith(f"{SKILL_RESULTS_DIR}/") - or path.startswith(f"{SKILL_RUNTIME_DIR}/") - or path in SYNC_MANAGED_DIRTY_PATHS - ) - - -def parse_dirty_status_paths(status_output: str) -> list[str]: - """Parse NUL-delimited git porcelain rows into repository paths. - - Parameters: - status_output: Output from `git status --porcelain=v1 -z -uall`. - - Expected output: - Repository-relative dirty paths, including rename/copy sources and - destinations. NUL parsing avoids quoted path edge cases in - sync-managed directories. - """ - paths: list[str] = [] - fields = status_output.split("\0") - index = 0 - while index < len(fields): - row = fields[index] - index += 1 - if not row: - continue - paths.append(row[3:]) - if "R" in row[:2] or "C" in row[:2]: - if index >= len(fields) or not fields[index]: - sys.exit("FATAL: malformed git status rename/copy row.") - paths.append(fields[index]) - index += 1 - return paths - - -def assert_no_unmanaged_dirty(repo_root: Path) -> None: - """Refuse to run with uncommitted project code changes. - - Parameters: - repo_root: Target project path. - - Expected output: - None. Dirty project files make the full review ambiguous because the - resulting PR evidence would mix committed facts with local scratch. - """ - paths = parse_dirty_status_paths( - status_output=git( - "status", - "--porcelain=v1", - "-z", - "-uall", - cwd=repo_root, - ) - ) - unmanaged: list[str] = [] - for path in paths: - if not is_sync_managed_dirty_path(path): - unmanaged.append(path) - if unmanaged: - sys.exit( - "FATAL: uncommitted changes outside sync-managed files.\n" - "Commit, stash, or discard project code/config/test changes before " - "running full workflow-docs reconcile.\n\n" - "Unmanaged dirty:\n " + "\n ".join(unmanaged) - ) - - -def collect_dirty_core_files(repo_root: Path) -> list[str]: - """Return core documents whose working tree content differs from HEAD. - - Parameters: - repo_root: Target project path. - - Expected output: - Ordered repository-relative core document paths. This is report metadata - only: full reconcile intentionally reads working tree content so agents - can modify docs and rerun sync before committing. - """ - dirty_paths = set(parse_dirty_status_paths( - status_output=git( - "status", - "--porcelain=v1", - "-z", - "-uall", - "--", - *CORE_FILES, - cwd=repo_root, - ) - )) - return [path for path in CORE_FILES if path in dirty_paths] - - -def ensure_gitignore(repo_root: Path) -> None: - """Ensure sync scratch files are ignored by git. - - Parameters: - repo_root: Target project path. - - Expected output: - `.gitignore` contains every local sync/skill runtime path. - The change is deliberately made after the dirty check so a refused run - does not leave surprise edits behind. - """ - gitignore_path = repo_root / ".gitignore" - existing = ( - gitignore_path.read_text(encoding="utf-8") - if gitignore_path.exists() - else "" - ) - existing_lines = set(existing.splitlines()) - missing = [line for line in GITIGNORE_LINES if line not in existing_lines] - if not missing: - return - prefix = existing.rstrip("\n") - addition = "\n".join(( - "# Workflow docs sync scratch and PR draft", - *missing, - )) - content = f"{prefix}\n\n{addition}\n" if prefix else f"{addition}\n" - gitignore_path.write_text(content, encoding="utf-8") - - -def warn_if_pr_body_tracked(repo_root: Path) -> None: - """Warn when PR_BODY.md is already tracked in the target repo. - - Parameters: - repo_root: Target project path. - - Expected output: - A warning on stdout when `PR_BODY.md` is tracked. Sync treats the file - as local PR-body scratch, but automatically untracking it would mutate - the target repo index and should be an explicit cleanup PR decision. - """ - tracked = git_optional( - "ls-files", - "--error-unmatch", - "PR_BODY.md", - cwd=repo_root, - ) - if tracked is None: - return - print( - "WARN: PR_BODY.md is tracked in this repo. Workflow docs sync treats " - "PR_BODY.md as a local PR body scratch file; run " - "`git rm --cached PR_BODY.md` in a cleanup PR if this project wants " - "PR_BODY.md to stay untracked." - ) - - -def normalize_text(text: str) -> str: - """Normalize line endings without changing semantic document content. - - Parameters: - text: UTF-8 text read from a local file or upstream git object. - - Expected output: - Text with CRLF and CR line endings normalized to LF, so template-copy - detection does not depend on platform checkout settings. - """ - return text.replace("\r\n", "\n").replace("\r", "\n") - - -def marker_hits(text: str) -> list[str]: - """Return line-level template marker hits in one text blob. - - Parameters: - text: UTF-8 Markdown, JSON, or template text from a core file. - - Expected output: - Human-readable `line: marker` entries for the agent workorder. These - hits are mechanical signals only; semantic alignment remains reviewer - work. - """ - hits: list[str] = [] - markers = (*TEMPLATE_MARKERS, *TODO_ANCHOR_COMMENTS) - for line_number, line in enumerate(text.splitlines(), start=1): - for marker in markers: - if marker in line: - hits.append(f"line {line_number}: {marker}") - return hits - - -def sync_pass_for_file(rel_path: str) -> dict[str, object]: - """Return the sync pass that owns one core document. - - Parameters: - rel_path: Repository-relative core document path. - - Expected output: - Pass metadata from `SYNC_PASSES`. Missing ownership exits because every - core document must have an explicit evidence-domain owner. - """ - for sync_pass in SYNC_PASSES: - if rel_path in sync_pass["files"]: - return sync_pass - sys.exit(f"FATAL: core file has no sync pass owner: {rel_path}") - - -def sync_pass_id_for_file(rel_path: str) -> str: - """Return the pass id that owns one core document. - - Parameters: - rel_path: Repository-relative core document path. - - Expected output: - Stable pass id for PR body tables and sync state records. - """ - sync_pass = sync_pass_for_file(rel_path=rel_path) - return str(sync_pass["id"]) - - -def sync_pass_title_for_id(pass_id: str) -> str: - """Return a human-readable title for one sync pass id. - - Parameters: - pass_id: Stable id from `SYNC_PASSES`. - - Expected output: - Title used in generated status tables. Unknown ids fail fast to avoid - silently producing an unowned pass row. - """ - for sync_pass in SYNC_PASSES: - if str(sync_pass["id"]) == pass_id: - return str(sync_pass["title"]) - sys.exit(f"FATAL: unknown sync pass id: {pass_id}") - - -def prompt_raw_url(state: dict[str, object], rel_path: str) -> str: - """Return the commit-pinned raw URL for one sync instruction file. - - Parameters: - state: Current sync state loaded from `.coding_workflow/diffs`. - rel_path: Repository-relative instruction file path. - - Expected output: - Raw GitHub URL pinned to the same upstream commit as the core evidence. - Missing records fail fast because the workorder must not point agents to - moving or unverified instructions. - """ - for record in state["sync_prompt_files"]: - if str(record["path"]) == rel_path: - return str(record["upstream_raw_url"]) - sys.exit(f"FATAL: sync state missing instruction file: {rel_path}") - - -def mechanical_action_for_status(status: str) -> str: - """Translate a script status into a bounded agent action. - - Parameters: - status: One status returned by `classify_core_file`. - - Expected output: - A workorder sentence. `specialized` is deliberately weak: it only means - the script found no mechanical required edit, not that the file is - semantically aligned. - """ - if status == "installed_template": - return ( - "必须基于 Repo Facts Map 项目化;" - "不能把 upstream 模板原样提交。" - ) - if status == "template_copy_requires_specialization": - return ( - "必须解释为什么可继承,或基于当前项目事实项目化。" - ) - if status == "inherited_upstream_allowed": - return "脚本允许继承 upstream;仍需在 PR body 写清证据。" - if status == "specialized": - return ( - "脚本未发现机械必改项;是否修改以对应 PASS prompt 为准。" - ) - sys.exit(f"FATAL: unknown sync status: {status}") - - -def classify_core_file( - rel_path: str, - exists: bool, - local_text: str, - upstream_text: str, -) -> tuple[str, str]: - """Classify one core document for full reconcile review. - - Parameters: - rel_path: Repository-relative core file path. - exists: Whether the target project had the file before this run. - local_text: Current target file text, or empty string when missing. - upstream_text: Latest upstream template text. - - Expected output: - `(status, note)` for the Installation Status table. - """ - normalized_local_text = normalize_text(text=local_text) - normalized_upstream_text = normalize_text(text=upstream_text) - if not exists: - if rel_path in PERMITTED_INHERIT_FILES: - return ( - "inherited_upstream_allowed", - "File was missing; sync installed upstream template. " - "This file is permitted to inherit upstream content.", - ) - return ( - "installed_template", - "File was missing; sync installed upstream template. " - "Agent must project-specialize before commit.", - ) - if normalized_local_text == normalized_upstream_text: - if rel_path in PERMITTED_INHERIT_FILES: - return ( - "inherited_upstream_allowed", - "File matches upstream verbatim; this file may inherit upstream.", - ) - return ( - "template_copy_requires_specialization", - "File matches upstream verbatim; project-specific review is required.", - ) - return ( - "specialized", - "File appears project-specific; reviewer must still cross-check latest " - "upstream rules.", - ) - - -def build_sync_state( - upstream_sha: str, - project_sha: str, - dirty_core_files: list[str], - core_records: list[dict[str, object]], - sync_prompt_records: list[dict[str, object]], -) -> dict[str, object]: - """Build the per-run state consumed by PR-body helpers. - - Parameters: - upstream_sha: Latest upstream commit used by this run. - project_sha: Target project base commit at sync time. - dirty_core_files: Core documents whose working tree differs from HEAD. - core_records: Per-core-file state rows for this sync epoch. - sync_prompt_records: Prompt file rows verified at the same upstream ref. - - Expected output: - JSON-serializable state. It is valid only inside the current - `.coding_workflow/diffs/` evidence epoch. - """ - return { - "schema_version": "0.5.0", - "sync_mode": "full_reconcile", - "workflow_language": WORKFLOW_LANGUAGE, - "upstream_resolved_commit": upstream_sha, - "project_head_commit": project_sha, - "evidence_source": "working_tree", - "core_files_checked": len(CORE_FILES), - "dirty_core_files": dirty_core_files, - "sync_passes": SYNC_PASSES, - "sync_prompt_files": sync_prompt_records, - "core_files": core_records, - } - - -def write_sync_state(repo_root: Path, state: dict[str, object]) -> None: - """Write current sync state under the scratch evidence directory. - - Parameters: - repo_root: Target project path. - state: JSON-serializable state from `build_sync_state`. - - Expected output: - `.coding_workflow/diffs/sync_state.json` is replaced for this run. - """ - state_path_for(repo_root=repo_root).write_text( - json.dumps( - state, - ensure_ascii=False, - indent=2, - sort_keys=True, - ) + "\n", - encoding="utf-8", - ) - - -def render_review_contract(state: dict[str, object]) -> str: - """Render the compact review contract for one sync run. - - Parameters: - state: Current sync state loaded from `.coding_workflow/diffs`. - - Expected output: - Markdown contract containing run-specific review inputs. The final gate - owns mechanical constants, so this section stays small. - """ - core_paths = [str(record["path"]) for record in state["core_files"]] - prompt_paths = [str(record["path"]) for record in state["sync_prompt_files"]] - lines = [ - "## Sync Review Contract", - "", - ( - "Final gate owns sentinels, stale auto checks, blocking statuses, " - "`待补充` residue, and template residue. The independent reviewer " - "owns pass closure, evidence truth, table quality, upstream " - "cross-check, and operability." - ), - "", - "Reviewer must cross-check:", - "", - ] - lines.extend(f"- `{path}` against its upstream raw URL" for path in core_paths) - lines.extend(( - "", - "Reviewer instruction files:", - "", - )) - lines.extend(f"- `{path}`" for path in prompt_paths) - lines.extend(( - "", - "Required evidence focus:", - "", - "- Repo Facts Map has concrete code, document, or command evidence.", - "- Full Document Reconcile has per-document evidence and downstream closure.", - "- PR Test Evidence records submission-time test commands, results, and N/A reasons.", - ( - "- Upstream Drift Log exposes upstream commit changes that occurred " - "while the same PR body was being refreshed." - ), - ( - "- Agent Execution Evidence is self-reported read coverage for " - "reviewer spot-checks; it is not a harness-authenticated read log." - ), - ( - "- Remaining Human Decisions exposes unresolved semantic decisions " - "for reviewer judgment." - ), - )) - return "\n".join(lines) - - -def render_sync_auto_section(state: dict[str, object]) -> str: - """Render deterministic PR body sections from current sync state. - - Parameters: - state: Current sync state loaded from `.coding_workflow/diffs`. - - Expected output: - Markdown containing only script-owned sections. `--check-final` - compares this block byte-for-byte against `PR_BODY.md`. - """ - dirty_core_files = state["dirty_core_files"] - core_files = state["core_files"] - lines = [ - SYNC_AUTO_START, - "## Sync Summary", - "", - "- sync mode: full_reconcile", - f"- upstream_resolved_commit: {state['upstream_resolved_commit']}", - f"- project_head_commit: {state['project_head_commit']}", - "- evidence_source: working_tree", - f"- core files checked: {state['core_files_checked']}", - "", - "## Working Tree State at Sync Time", - "", - ( - f"- project_head_commit: {state['project_head_commit']} " - "(base commit; evidence content is read from the working tree)" - ), - "- evidence_source: working_tree", - "- dirty core files (working tree differs from HEAD):", - ] - if dirty_core_files: - for path in dirty_core_files: - lines.append(f" - `{path}`") - if not dirty_core_files: - lines.append(" - none") - lines.extend(("", render_review_contract(state=state), "")) - lines.extend(( - "## Upstream Templates at Sync Time", - "", - )) - for record in core_files: - lines.append( - f"- `{record['path']}`: {record['upstream_raw_url']}" - ) - lines.extend(( - "", - "## Upstream Instructions at Sync Time", - "", - )) - for record in state["sync_prompt_files"]: - lines.append( - f"- `{record['path']}`: {record['upstream_raw_url']}" - ) - lines.extend(( - "", - "## Installation Status", - "", - "| File | Action | Note |", - "|---|---|---|", - )) - for record in core_files: - lines.append( - f"| `{record['path']}` | `{record['status']}` | " - f"{record['note']} |" - ) - lines.append(SYNC_AUTO_END) - return "\n".join(lines) - - -def render_repo_facts_template() -> str: - """Render the agent-owned Repo Facts Map template. - - Parameters: - None. - - Expected output: - Markdown with 10 required headings. The script validates structure, but - independent review remains responsible for evidence quality. - """ - lines = [ - "## Repo Facts Map", - "", - ( - "每项必须有代码路径 / 文档路径 / 命令输出等证据;" - "不能编。" - ), - "", - ] - for heading in REPO_FACTS_HEADINGS: - lines.extend((heading, "证据: 待补充", "")) - return "\n".join(lines).rstrip() - - -def render_full_document_reconcile_template( - state: dict[str, object], -) -> str: - """Render the agent-owned full reconcile evidence template. - - Parameters: - state: Current sync state loaded from `.coding_workflow/diffs`. - - Expected output: - Markdown table containing all 9 core documents. The script gives - mechanical signals; the agent fills semantic judgment and evidence. - """ - lines = [ - "## Full Document Reconcile", - "", - "| " + " | ".join(FULL_RECONCILE_COLUMNS) + " |", - "| " + " | ".join("---" for _ in FULL_RECONCILE_COLUMNS) + " |", - ] - for record in state["core_files"]: - pass_title = sync_pass_title_for_id( - pass_id=str(record["sync_pass_id"]), - ) - lines.append( - f"| {pass_title} | `{record['path']}` | `{record['status']}` | " - "待补充 | 待补充 | 待补充 | 待补充 | 待补充 |" - ) - return "\n".join(lines) - - -def render_remaining_human_decisions_template() -> str: - """Render the agent-owned human decision placeholder. - - Parameters: - None. - - Expected output: - A minimal Markdown section. Agents keep `none` when no unresolved - project decision remains, or list explicit semantic decisions for - reviewer and user judgment. - """ - return "\n".join(( - "## Remaining Human Decisions", - "", - "- none", - )) - - -def render_pr_test_evidence_template() -> str: - """Render the PR-submission test evidence placeholder. - - Parameters: - None. - - Expected output: - Markdown section that the PR submission agent must fill before final - gate. PASS 1-4 agents must not invent submission-time test results. - """ - return "\n".join(( - "## PR Test Evidence", - "", - "- commands: 待补充", - "- result: 待补充", - "- not run / N/A: 待补充", - )) - - -def render_upstream_drift_log_template() -> str: - """Render the upstream drift log placeholder. - - Parameters: - None. - - Expected output: - Markdown section preserved across refreshes. Ordinary sync appends to - it when a sentinel PR body is refreshed across upstream commits. - """ - return "\n".join(( - "## Upstream Drift Log", - "", - "- none", - )) - - -def render_agent_execution_evidence_template() -> str: - """Render the per-pass self-reported execution evidence placeholder. - - Parameters: - None. - - Expected output: - Markdown table that each PASS agent fills with actual files read, - observed facts, and skipped files. This is reviewer-facing discipline, - not authenticated tool telemetry. - """ - lines = [ - "## Agent Execution Evidence", - "", - ( - "Self-report for reviewer spot-checks only; this does not prove " - "tool-level reads without external harness logs." - ), - "", - "| pass | required files read | key facts observed | skipped files / reason |", - "| --- | --- | --- | --- |", - ] - for sync_pass in SYNC_PASSES: - lines.append( - f"| {sync_pass['title']} | 待补充 | 待补充 | 待补充 |" - ) - return "\n".join(lines) - - -def wrap_agent_section(section_name: str, content: str) -> str: - """Wrap one agent-owned section with stable sentinels. - - Parameters: - section_name: Stable section key from `AGENT_SECTIONS`. - content: Markdown content to preserve across script refreshes. - - Expected output: - Sentinel-wrapped Markdown section. - """ - return "\n".join(( - agent_section_start(section_name=section_name), - content.rstrip(), - agent_section_end(section_name=section_name), - )) - - -def render_pr_body_from_sections( - state: dict[str, object], - sections: dict[str, str], -) -> str: - """Render a complete sentinel PR body from agent-owned sections. - - Parameters: - state: Current sync state used for the script-owned auto block. - sections: Mapping for every key in `AGENT_SECTIONS`. - - Expected output: - Complete PR body text with the auto block in the same stable position. - Missing section keys raise immediately instead of producing partial body. - """ - body_sections = [ - SYNC_PR_BODY_MARKER, - wrap_agent_section( - section_name="repo_facts_map", - content=sections["repo_facts_map"], - ), - render_sync_auto_section(state=state), - wrap_agent_section( - section_name="full_document_reconcile", - content=sections["full_document_reconcile"], - ), - wrap_agent_section( - section_name="pr_test_evidence", - content=sections["pr_test_evidence"], - ), - wrap_agent_section( - section_name="upstream_drift_log", - content=sections["upstream_drift_log"], - ), - wrap_agent_section( - section_name="agent_execution_evidence", - content=sections["agent_execution_evidence"], - ), - wrap_agent_section( - section_name="remaining_human_decisions", - content=sections["remaining_human_decisions"], - ), - ] - return "\n\n".join(body_sections) + "\n" - - -def render_pr_body_skeleton(state: dict[str, object]) -> str: - """Render a complete sentinel-based PR body skeleton. - - Parameters: - state: Current sync state loaded from `.coding_workflow/diffs`. - - Expected output: - Markdown body whose auto block can be replaced deterministically while - preserving agent-owned semantic sections. - """ - sections = { - "repo_facts_map": render_repo_facts_template(), - "full_document_reconcile": render_full_document_reconcile_template( - state=state, - ), - "pr_test_evidence": render_pr_test_evidence_template(), - "upstream_drift_log": render_upstream_drift_log_template(), - "agent_execution_evidence": render_agent_execution_evidence_template(), - "remaining_human_decisions": render_remaining_human_decisions_template(), - } - return render_pr_body_from_sections(state=state, sections=sections) - - -def write_pr_body_skeleton( - diffs_root: Path, - state: dict[str, object], -) -> None: - """Write the current PR body skeleton into sync evidence. - - Parameters: - diffs_root: `.coding_workflow/diffs` path. - state: Current sync state. - - Expected output: - `.coding_workflow/diffs/pr_body_skeleton.md` is ready for agents to - copy or for `--update-pr-body` to apply. - """ - (diffs_root / PR_BODY_SKELETON_FILE).write_text( - render_pr_body_skeleton(state=state), - encoding="utf-8", - ) - - -def write_agent_workorder( - diffs_root: Path, - state: dict[str, object], -) -> None: - """Write this run's dynamic file-processing facts for the next agent. - - Parameters: - diffs_root: `.coding_workflow/diffs` path. - state: Current sync state. - - Expected output: - `.coding_workflow/diffs/agent_workorder.md` contains only this run's - machine facts and the commit-pinned operations URL. - """ - operations_url = prompt_raw_url( - state=state, - rel_path=OPERATIONS_PROMPT_FILE, - ) - lines = [ - "# Agent Workorder", - "", - ( - "本工单只表达脚本发现的机械信号;" - "语义是否对齐仍由 agent 和 reviewer 判断。" - ), - "", - "操作手册:", - operations_url, - "", - "## 文件处理清单", - "", - "| Pass | 文件 | 脚本信号 | 机械动作 | marker / TODO 命中 |", - "|---|---|---|---|---|", - ] - for record in state["core_files"]: - hits = record["marker_hits"] - hit_text = "
".join(str(hit) for hit in hits) if hits else "none" - pass_title = sync_pass_title_for_id( - pass_id=str(record["sync_pass_id"]), - ) - lines.append( - f"| {pass_title} | `{record['path']}` | `{record['status']}` | " - f"{record['mechanical_action']} | {hit_text} |" - ) - (diffs_root / WORKORDER_FILE).write_text( - "\n".join(lines) + "\n", - encoding="utf-8", - ) - - -def collect_sync_prompt_records( - upstream_dir: Path, - upstream_sha: str, - upstream_full_root: Path, -) -> list[dict[str, object]]: - """Verify and stage upstream instruction prompts for PR review. - - Parameters: - upstream_dir: Local upstream clone. - upstream_sha: Latest upstream commit. - upstream_full_root: Root of the staged upstream text mirror. - - Expected output: - Prompt records with live commit-pinned raw URLs and local mirror paths. - A missing prompt fails fast before PR body automation can publish a - dead review-instruction link. - """ - prompt_records: list[dict[str, object]] = [] - for rel_path in SYNC_PROMPT_FILES: - upstream_text = file_at_ref( - upstream_dir=upstream_dir, - ref=upstream_sha, - path=rel_path, - ) - if upstream_text is None: - sys.exit( - f"FATAL: upstream is missing required sync prompt file at " - f"{upstream_sha[:12]}: {rel_path}" - ) - - upstream_full_path = upstream_full_root / rel_path - upstream_full_path.parent.mkdir(parents=True, exist_ok=True) - upstream_full_path.write_text(upstream_text, encoding="utf-8") - prompt_records.append({ - "path": rel_path, - "upstream_raw_url": upstream_raw_url( - upstream_sha=upstream_sha, - rel_path=rel_path, - ), - "upstream_full_path": ( - f"{DIFFS_DIR}/{UPSTREAM_FULL_DIR}/{rel_path}" - ), - }) - return prompt_records - - -def stage_full_reconcile_outputs( - repo_root: Path, - upstream_dir: Path, - upstream_sha: str, - project_sha: str, -) -> dict[str, object]: - """Generate scratch evidence and install missing core templates. - - Parameters: - repo_root: Target project path. - upstream_dir: Local upstream clone. - upstream_sha: Latest upstream commit. - project_sha: Target project base commit at sync time. - - Expected output: - Sync state. Scratch evidence is regenerated under - `.coding_workflow/diffs/` on every run. - """ - diffs_root = diffs_root_for(repo_root=repo_root) - if diffs_root.exists(): - shutil.rmtree(diffs_root) - upstream_full_root = diffs_root / UPSTREAM_FULL_DIR - upstream_full_root.mkdir(parents=True, exist_ok=True) - - core_records: list[dict[str, object]] = [] - for rel_path in CORE_FILES: - source_path = CORE_SOURCE_BY_TARGET[rel_path] - upstream_text = file_at_ref( - upstream_dir=upstream_dir, - ref=upstream_sha, - path=source_path, - ) - if upstream_text is None: - sys.exit( - f"FATAL: upstream is missing required core file at " - f"{upstream_sha[:12]}: {source_path}" - ) - - local_path = repo_root / rel_path - exists = local_path.exists() - local_text = local_path.read_text(encoding="utf-8") if exists else "" - status, note = classify_core_file( - rel_path=rel_path, - exists=exists, - local_text=local_text, - upstream_text=upstream_text, - ) - - upstream_full_path = upstream_full_root / rel_path - upstream_full_path.parent.mkdir(parents=True, exist_ok=True) - upstream_full_path.write_text(upstream_text, encoding="utf-8") - - upstream_full_rel_path = f"{DIFFS_DIR}/{UPSTREAM_FULL_DIR}/{rel_path}" - marker_source_text = upstream_text if not exists else local_text - core_records.append({ - "path": rel_path, - "sync_pass_id": sync_pass_id_for_file(rel_path=rel_path), - "status": status, - "note": note, - "mechanical_action": mechanical_action_for_status(status=status), - "marker_hits": marker_hits(text=marker_source_text), - "upstream_raw_url": upstream_raw_url( - upstream_sha=upstream_sha, - rel_path=source_path, - ), - "upstream_full_path": upstream_full_rel_path, - }) - - if not exists: - local_path.parent.mkdir(parents=True, exist_ok=True) - local_path.write_text(upstream_text, encoding="utf-8") - - sync_prompt_records = collect_sync_prompt_records( - upstream_dir=upstream_dir, - upstream_sha=upstream_sha, - upstream_full_root=upstream_full_root, - ) - dirty_core_files = collect_dirty_core_files(repo_root=repo_root) - state = build_sync_state( - upstream_sha=upstream_sha, - project_sha=project_sha, - dirty_core_files=dirty_core_files, - core_records=core_records, - sync_prompt_records=sync_prompt_records, - ) - write_sync_state(repo_root=repo_root, state=state) - write_pr_body_skeleton(diffs_root=diffs_root, state=state) - write_agent_workorder(diffs_root=diffs_root, state=state) - return state - - -def print_summary( - upstream_sha: str, - project_sha: str, -) -> None: - """Print user-facing sync output. - - Parameters: - upstream_sha: Latest upstream commit. - project_sha: Target project base commit at sync time. - - Expected output: - Compact next-step hint for the user who ran sync. - """ - operations_url = upstream_raw_url( - upstream_sha=upstream_sha, - rel_path=OPERATIONS_PROMPT_FILE, - ) - print("=" * 70) - print("sync OK: full_reconcile") - print(f"upstream: {upstream_sha[:12]}") - print(f"project: {project_sha[:12]}") - print(f"runbook: {operations_url}") - print("next: copy the relevant PASS prompt into a new chat.") - print(f"agent workorder: {DIFFS_DIR}/{WORKORDER_FILE}") - print("=" * 70) - - -def load_sync_state(repo_root: Path) -> dict[str, object]: - """Load the current sync state from the scratch evidence directory. - - Parameters: - repo_root: Target project path. - - Expected output: - Parsed JSON object for the current evidence epoch. Missing or stale - state fails fast because PR body automation must not infer values. - """ - state_path = state_path_for(repo_root=repo_root) - if not state_path.exists(): - sys.exit( - f"FATAL: missing {DIFFS_DIR}/{STATE_FILE}; run sync before " - "updating or checking PR_BODY.md." - ) - state = json.loads(state_path.read_text(encoding="utf-8")) - required_keys = ( - "schema_version", - "sync_mode", - "upstream_resolved_commit", - "project_head_commit", - "evidence_source", - "core_files_checked", - "dirty_core_files", - "sync_passes", - "sync_prompt_files", - "core_files", - ) - for key in required_keys: - if key not in state: - sys.exit(f"FATAL: sync state missing required key: {key}") - return state - - -def extract_block(text: str, start: str, end: str) -> str: - """Extract one sentinel-delimited block. - - Parameters: - text: Full Markdown document. - start: Exact start sentinel. - end: Exact end sentinel. - - Expected output: - Raw content between sentinels. Duplicate or malformed sentinels fail - fast so script-owned and agent-owned regions cannot drift silently. - """ - start_index, end_index = sentinel_span( - text=text, - start=start, - end=end, - ) - return text[start_index + len(start):end_index - len(end)].strip("\n") - - -def sentinel_span(text: str, start: str, end: str) -> tuple[int, int]: - """Return one sentinel-delimited span including both sentinels. - - Parameters: - text: Full Markdown document. - start: Exact start sentinel. - end: Exact end sentinel. - - Expected output: - `(start_index, end_index)` slice bounds for the full block. Duplicate - or malformed sentinels fail fast before a refresh can lose content. - """ - if text.count(start) != 1: - sys.exit(f"FATAL: expected exactly one sentinel: {start}") - if text.count(end) != 1: - sys.exit(f"FATAL: expected exactly one sentinel: {end}") - start_index = text.index(start) - end_index = text.index(end) + len(end) - if end_index < start_index: - sys.exit(f"FATAL: malformed sentinel order: {start} before {end}") - return start_index, end_index - - -def assert_no_content_outside_sync_sections( - text: str, - require_all_agent_sections: bool, -) -> None: - """Fail when a sentinel PR body contains refresh-unsafe outer content. - - Parameters: - text: Existing PR body text with sync sentinels. - require_all_agent_sections: Whether every current agent section must - already exist. Refresh mode allows older sentinel PR bodies to miss - newly introduced agent sections so the script can insert them. - - Expected output: - None. Human-authored content must live inside a known agent-owned - section so script refreshes cannot silently drop work. - """ - if text.count(SYNC_PR_BODY_MARKER) != 1: - sys.exit(f"FATAL: expected exactly one marker: {SYNC_PR_BODY_MARKER}") - marker_start = text.index(SYNC_PR_BODY_MARKER) - marker_end = marker_start + len(SYNC_PR_BODY_MARKER) - spans = [(marker_start, marker_end)] - spans.append(sentinel_span( - text=text, - start=SYNC_AUTO_START, - end=SYNC_AUTO_END, - )) - for section_name in AGENT_SECTIONS: - start = agent_section_start(section_name=section_name) - end = agent_section_end(section_name=section_name) - start_count = text.count(start) - end_count = text.count(end) - if start_count == 0 and end_count == 0 and not require_all_agent_sections: - continue - spans.append(sentinel_span(text=text, start=start, end=end)) - spans.sort() - - outside_parts: list[str] = [] - cursor = 0 - for start_index, end_index in spans: - if start_index < cursor: - sys.exit("FATAL: malformed overlapping sync sentinel sections.") - outside_parts.append(text[cursor:start_index]) - cursor = end_index - outside_parts.append(text[cursor:]) - - if "".join(outside_parts).strip(): - sys.exit( - "FATAL: PR_BODY.md contains content outside sync sentinel " - "sections. Move human-authored text into a sync agent-owned " - "section before rerunning sync." - ) - - -def pr_body_has_any_sync_sentinel(text: str) -> bool: - """Return whether a PR body already uses sync sentinels. - - Parameters: - text: Existing PR body text. - - Expected output: - True when any known sync sentinel appears. This distinguishes a prior - sync PR body from an unrelated local PR draft. - """ - if SYNC_PR_BODY_MARKER in text: - return True - if SYNC_AUTO_START in text or SYNC_AUTO_END in text: - return True - for section_name in AGENT_SECTIONS: - if agent_section_start(section_name=section_name) in text: - return True - if agent_section_end(section_name=section_name) in text: - return True - return False - - -def render_agent_section_template( - section_name: str, - state: dict[str, object], -) -> str: - """Render the default content for one agent-owned section. - - Parameters: - section_name: Stable section key from `AGENT_SECTIONS`. - state: Current sync state, required for reconcile tables. - - Expected output: - Default Markdown section content. Unknown section names fail fast so - schema migrations cannot silently drop an agent-owned region. - """ - if section_name == "repo_facts_map": - return render_repo_facts_template() - if section_name == "full_document_reconcile": - return render_full_document_reconcile_template(state=state) - if section_name == "pr_test_evidence": - return render_pr_test_evidence_template() - if section_name == "upstream_drift_log": - return render_upstream_drift_log_template() - if section_name == "agent_execution_evidence": - return render_agent_execution_evidence_template() - if section_name == "remaining_human_decisions": - return render_remaining_human_decisions_template() - sys.exit(f"FATAL: unknown agent section: {section_name}") - - -def current_utc_timestamp() -> str: - """Return a second-precision UTC timestamp for sync log entries. - - Parameters: - None. - - Expected output: - ISO-like UTC timestamp ending with `Z`. - """ - return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - - -def upstream_commit_from_auto_section(text: str) -> str | None: - """Extract the upstream commit from an existing PR body auto section. - - Parameters: - text: Existing sync PR body. - - Expected output: - Commit SHA string when the auto section contains one, otherwise None. - Missing values are non-fatal because older damaged drafts should still - reach the normal sentinel validation path. - """ - if SYNC_AUTO_START not in text or SYNC_AUTO_END not in text: - return None - auto_text = extract_block(text=text, start=SYNC_AUTO_START, end=SYNC_AUTO_END) - prefix = "- upstream_resolved_commit: " - for line in auto_text.splitlines(): - if line.startswith(prefix): - return line.removeprefix(prefix).strip() - return None - - -def append_upstream_drift_log( - section_text: str, - previous_upstream: str | None, - current_upstream: str, -) -> str: - """Append an upstream drift entry when a refresh crosses commits. - - Parameters: - section_text: Current `upstream_drift_log` section content. - previous_upstream: Upstream commit recorded in the existing auto block. - current_upstream: Upstream commit in current sync state. - - Expected output: - Updated section content. No entry is added when commits match or the - existing draft has no parseable previous upstream commit. - """ - if previous_upstream is None or previous_upstream == current_upstream: - return section_text - entry = ( - f"- {current_utc_timestamp()}: {previous_upstream} -> " - f"{current_upstream}; reviewer must re-check agent-owned reconcile " - "rows against the current upstream raw URLs." - ) - lines = section_text.rstrip().splitlines() - if not lines: - lines = render_upstream_drift_log_template().splitlines() - lines = [line for line in lines if line.strip() != "- none"] - lines.append(entry) - return "\n".join(lines) - - -def preserved_agent_sections( - text: str, - state: dict[str, object], -) -> dict[str, str]: - """Return agent-owned sections from an existing sentinel PR body. - - Parameters: - text: Existing PR body text with sync sentinels. - state: Current sync state used to render defaults for newly introduced - sections that older PR bodies do not yet contain. - - Expected output: - Mapping from section name to existing or default content. The script - preserves old regions across deterministic auto-section refreshes and - inserts new schema sections during ordinary sync. - """ - sections: dict[str, str] = {} - for section_name in AGENT_SECTIONS: - start = agent_section_start(section_name=section_name) - end = agent_section_end(section_name=section_name) - if start not in text and end not in text: - sections[section_name] = render_agent_section_template( - section_name=section_name, - state=state, - ) - continue - sections[section_name] = extract_block(text=text, start=start, end=end) - return sections - - -def update_pr_body(repo_root: Path, pr_body_path: Path) -> None: - """Create or refresh a sentinel-based sync PR body. - - Parameters: - repo_root: Target project path. - pr_body_path: PR body file to create or update. - - Expected output: - `PR_BODY.md` contains current deterministic sync sections. A non-sync - draft fails fast because the script cannot migrate semantic content. - """ - state = load_sync_state(repo_root=repo_root) - skeleton = render_pr_body_skeleton(state=state) - if not pr_body_path.exists(): - pr_body_path.write_text(skeleton, encoding="utf-8") - print(f"Created {pr_body_path} from current sync skeleton.") - return - - existing = pr_body_path.read_text(encoding="utf-8") - if not pr_body_has_any_sync_sentinel(text=existing): - sys.exit( - f"FATAL: {pr_body_path} exists but is not a sync PR body. Move or " - "delete the draft, or manually migrate required content into a " - "sync agent-owned section before rerunning sync." - ) - - assert_no_content_outside_sync_sections( - text=existing, - require_all_agent_sections=False, - ) - previous_upstream = upstream_commit_from_auto_section(text=existing) - sections = preserved_agent_sections(text=existing, state=state) - sections["upstream_drift_log"] = append_upstream_drift_log( - section_text=sections["upstream_drift_log"], - previous_upstream=previous_upstream, - current_upstream=str(state["upstream_resolved_commit"]), - ) - refreshed = render_pr_body_from_sections( - state=state, - sections=sections, - ) - pr_body_path.write_text(refreshed, encoding="utf-8") - print(f"Updated script-owned sync sections in {pr_body_path}.") - - -def check_no_template_residue(path: Path, text: str) -> list[str]: - """Return template residue failures for one text file. - - Parameters: - path: File path used in diagnostics. - text: UTF-8 text to scan. - - Expected output: - Failure messages. The caller decides whether to exit so multiple - issues can be reported together. - """ - failures: list[str] = [] - markers = (*TEMPLATE_MARKERS, *TODO_ANCHOR_COMMENTS) - for line_number, line in enumerate(text.splitlines(), start=1): - for marker in markers: - if marker in line: - failures.append(f"{path}:{line_number}: template residue {marker}") - return failures - - -def check_no_unfilled_agent_placeholders(text: str) -> list[str]: - """Return failures for script-generated agent placeholders. - - Parameters: - text: Existing PR body text. - - Expected output: - Failure messages for placeholders that mean the agent never completed a - semantic section. `待判断` is allowed because unresolved semantic - decisions should stay visible for reviewer and user judgment. - """ - failures: list[str] = [] - for section_name in AGENT_SECTIONS: - section_text = extract_block( - text=text, - start=agent_section_start(section_name=section_name), - end=agent_section_end(section_name=section_name), - ) - for placeholder in UNFILLED_AGENT_PLACEHOLDERS: - if placeholder in section_text: - failures.append( - f"PR_BODY.md {section_name} still contains placeholder: " - f"{placeholder}. Replace script placeholders or expose " - "unresolved decisions as `待判断` for reviewer judgment." - ) - return failures - - -def check_blocking_statuses(state: dict[str, object]) -> list[str]: - """Return failures for mechanical statuses that cannot pass final check. - - Parameters: - state: Current sync state. - - Expected output: - Failure messages for unresolved template installation/copy/residue - states. Explicit human exceptions belong in reviewer judgment, not in - this final mechanical gate. - """ - failures: list[str] = [] - for record in state["core_files"]: - status = str(record["status"]) - path = str(record["path"]) - if status in BLOCKING_FINAL_STATUSES: - failures.append(f"{path} still has blocking sync status: {status}") - return failures - - -def check_sync_state_shape(state: dict[str, object]) -> list[str]: - """Return failures when current state lacks required file coverage. - - Parameters: - state: Current sync state. - - Expected output: - Failure messages for missing core files or prompt files. This keeps the - PR body review inputs complete without making semantic claims. - """ - failures: list[str] = [] - core_paths = [str(record["path"]) for record in state["core_files"]] - prompt_paths = [str(record["path"]) for record in state["sync_prompt_files"]] - for rel_path in CORE_FILES: - if rel_path not in core_paths: - failures.append(f"sync_state missing core file: {rel_path}") - for rel_path in SYNC_PROMPT_FILES: - if rel_path not in prompt_paths: - failures.append(f"sync_state missing prompt file: {rel_path}") - return failures - - -def check_pr_body_auto_section( - state: dict[str, object], - text: str, -) -> list[str]: - """Return failures when PR body auto section is stale or edited. - - Parameters: - state: Current sync state. - text: Existing PR body text. - - Expected output: - Failure messages. A mismatch means the PR body no longer describes the - current `.coding_workflow/diffs/sync_state.json` evidence epoch. - """ - actual = "\n".join(( - SYNC_AUTO_START, - extract_block(text=text, start=SYNC_AUTO_START, end=SYNC_AUTO_END), - SYNC_AUTO_END, - )) - expected = render_sync_auto_section(state=state) - if actual != expected: - return [ - "PR_BODY.md sync auto section differs from current sync_state.json; " - "rerun sync or refresh PR_BODY.md." - ] - return [] - - -def check_final_pr_body(repo_root: Path, pr_body_path: Path) -> None: - """Run the final mechanical sync PR body gate. - - Parameters: - repo_root: Target project path. - pr_body_path: PR body file to validate. - - Expected output: - Exit 0 when sentinels, auto state, blocking statuses, placeholder - residue, and template residue checks pass. This does not certify - semantic evidence quality. - """ - if not pr_body_path.exists(): - sys.exit(f"FATAL: missing PR body file: {pr_body_path}") - state = load_sync_state(repo_root=repo_root) - text = pr_body_path.read_text(encoding="utf-8") - failures: list[str] = [] - if SYNC_PR_BODY_MARKER not in text: - failures.append("PR_BODY.md missing sync PR body marker.") - assert_no_content_outside_sync_sections( - text=text, - require_all_agent_sections=True, - ) - failures.extend(check_sync_state_shape(state=state)) - failures.extend(check_pr_body_auto_section(state=state, text=text)) - failures.extend(check_blocking_statuses(state=state)) - failures.extend(check_no_template_residue( - path=pr_body_path, - text=text, - )) - failures.extend(check_no_unfilled_agent_placeholders(text=text)) - for rel_path in CORE_FILES: - core_path = repo_root / rel_path - if core_path.exists(): - failures.extend(check_no_template_residue( - path=core_path, - text=core_path.read_text(encoding="utf-8"), - )) - if failures: - sys.exit("FATAL: final sync check failed:\n " + "\n ".join(failures)) - print( - "Final sync check passed: PR_BODY.md matches current sync_state.json " - "and mechanical sync checks passed." - ) - - -def resolve_repo_root() -> Path: - """Resolve the target repository root for every script mode. - - Parameters: - None. The optional `REPO_ROOT` environment variable is honored when set - by `sync.sh`; otherwise the current directory is used. - - Expected output: - Absolute target repository path. - """ - repo_root = ( - Path(os.environ["REPO_ROOT"]).resolve() - if "REPO_ROOT" in os.environ - else Path(".").resolve() - ) - assert_git_repo(repo_root=repo_root) - return repo_root - - -def run_full_reconcile() -> int: - """Run full workflow-docs reconcile. - - Parameters: - None. Inputs are environment variables set by `sync.sh`: - `REPO_ROOT`, `UPSTREAM_DIR`, and `CODING_WORKFLOW_LANGUAGE`. - - Expected output: - Exit 0 after writing scratch evidence and printing PR-body fields. - """ - upstream_dir_value = ( - os.environ["UPSTREAM_DIR"] - if "UPSTREAM_DIR" in os.environ - else "" - ) - if not upstream_dir_value: - sys.exit( - "FATAL: UPSTREAM_DIR is not set; invoke sync through scripts/sync.sh." - ) - - repo_root = resolve_repo_root() - upstream_dir = Path(upstream_dir_value).resolve() - - assert_upstream_repo(upstream_dir=upstream_dir) - assert_no_unmanaged_dirty(repo_root=repo_root) - ensure_gitignore(repo_root=repo_root) - warn_if_pr_body_tracked(repo_root=repo_root) - - upstream_sha = git("rev-parse", "HEAD", cwd=upstream_dir).strip() - project_sha = git_optional("rev-parse", "HEAD", cwd=repo_root) - if project_sha is None: - sys.exit( - "FATAL: target repository has no commits; make an initial commit " - "before running full workflow-docs reconcile." - ) - project_sha = project_sha.strip() - stage_full_reconcile_outputs( - repo_root=repo_root, - upstream_dir=upstream_dir, - upstream_sha=upstream_sha, - project_sha=project_sha, - ) - print_summary( - upstream_sha=upstream_sha, - project_sha=project_sha, - ) - return 0 - - -def main() -> int: - """Dispatch full reconcile and internal PR body helper modes. - - Parameters: - None. CLI arguments are intentionally minimal: normal sync, internal - `--update-pr-body `, or internal `--check-final `. - - Expected output: - Exit code for the selected mode. - """ - args = sys.argv[1:] - if not args: - return run_full_reconcile() - if len(args) == 2 and args[0] == "--update-pr-body": - repo_root = resolve_repo_root() - update_pr_body( - repo_root=repo_root, - pr_body_path=repo_root / args[1], - ) - return 0 - if len(args) == 2 and args[0] == "--check-final": - repo_root = resolve_repo_root() - check_final_pr_body( - repo_root=repo_root, - pr_body_path=repo_root / args[1], - ) - return 0 - sys.exit( - "FATAL: usage: sync_coding_workflow.py " - "[--update-pr-body PR_BODY.md | --check-final PR_BODY.md]" - ) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/test_sync_coding_workflow.py b/tests/test_sync_coding_workflow.py deleted file mode 100644 index 19c1b3d..0000000 --- a/tests/test_sync_coding_workflow.py +++ /dev/null @@ -1,1156 +0,0 @@ -"""Regression tests for workflow-docs sync behavior. - -Call flow: - python -m unittest discover -s tests - -> SyncWorkflowTests creates a temporary target git repository - -> scripts/sync.sh runs against this checkout as the upstream template - -> assertions verify final-gate and PR body refresh contracts -""" - -from __future__ import annotations - -import contextlib -import importlib.util -import io -import json -import os -import shutil -import subprocess -import tempfile -import unittest -from pathlib import Path - - -REPO_ROOT = Path(__file__).resolve().parents[1] -SYNC_SH = REPO_ROOT / "scripts" / "sync.sh" - - -def load_sync_module() -> object: - """Load the sync script under test without executing its CLI. - - Parameters: - None. - - Expected output: - Imported module object exposing the same constants used by sync. - """ - module_path = REPO_ROOT / "scripts" / "sync_coding_workflow.py" - previous_language = ( - os.environ["CODING_WORKFLOW_LANGUAGE"] - if "CODING_WORKFLOW_LANGUAGE" in os.environ - else None - ) - if "CODING_WORKFLOW_LANGUAGE" in os.environ: - del os.environ["CODING_WORKFLOW_LANGUAGE"] - spec = importlib.util.spec_from_file_location( - "sync_coding_workflow_under_test", - module_path, - ) - if spec is None or spec.loader is None: - raise RuntimeError(f"cannot import sync script: {module_path}") - module = importlib.util.module_from_spec(spec) - try: - spec.loader.exec_module(module) - finally: - if previous_language is not None: - os.environ["CODING_WORKFLOW_LANGUAGE"] = previous_language - return module - - -SYNC_MODULE = load_sync_module() -CORE_FILES = tuple(SYNC_MODULE.CORE_FILES) - - -def run_command( - args: list[str], - cwd: Path, - env: dict[str, str], - check: bool, -) -> subprocess.CompletedProcess[str]: - """Run one command with UTF-8 capture for diagnosis. - - Parameters: - args: Executable and split arguments. - cwd: Working directory. - env: Environment variables for the child process. - check: Whether a non-zero return code should fail the test helper. - - Expected output: - CompletedProcess with stdout, stderr, and return code. When `check` is - true, failures raise AssertionError with captured output. - """ - result = subprocess.run( - args=args, - cwd=cwd, - env=env, - check=False, - capture_output=True, - encoding="utf-8", - text=True, - ) - if check and result.returncode != 0: - raise AssertionError( - f"command failed: {args}\nSTDOUT:\n{result.stdout}\n" - f"STDERR:\n{result.stderr}" - ) - return result - - -def sync_env(upstream_dir: Path) -> dict[str, str]: - """Return an environment that uses the checkout as upstream. - - Parameters: - upstream_dir: Upstream coding-workflow checkout to exercise. - - Expected output: - Environment dictionary. The override keeps tests offline and ensures - they exercise the script version under review. - """ - env = os.environ.copy() - env["CODING_WORKFLOW_UPSTREAM_DIR"] = str(upstream_dir) - return env - - -def write_core_files(repo_root: Path, omitted_paths: set[str]) -> None: - """Write project-specific core files into a temporary target repo. - - Parameters: - repo_root: Temporary target repository root. - omitted_paths: Repository-relative core files to leave missing. - - Expected output: - All non-omitted core files exist with marker-free project text. - """ - for rel_path in CORE_FILES: - if rel_path in omitted_paths: - continue - path = repo_root / rel_path - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - f"project specific content for {rel_path}\n", - encoding="utf-8", - ) - - -def commit_initial_repo(repo_root: Path) -> None: - """Create the first git commit for a temporary target repo. - - Parameters: - repo_root: Temporary target repository root. - - Expected output: - A clean git repository with one commit, so sync can resolve HEAD. - """ - env = os.environ.copy() - run_command(args=["git", "init", "-q"], cwd=repo_root, env=env, check=True) - run_command( - args=["git", "config", "user.email", "review@example.com"], - cwd=repo_root, - env=env, - check=True, - ) - run_command( - args=["git", "config", "user.name", "review"], - cwd=repo_root, - env=env, - check=True, - ) - run_command(args=["git", "add", "."], cwd=repo_root, env=env, check=True) - run_command( - args=["git", "commit", "-q", "-m", "init"], - cwd=repo_root, - env=env, - check=True, - ) - - -def create_target_repo(repo_root: Path, omitted_paths: set[str]) -> None: - """Create a temporary target repo ready for sync. - - Parameters: - repo_root: Temporary target repository root. - omitted_paths: Core files intentionally absent before sync. - - Expected output: - A committed repo whose files are safe for sync to inspect. - """ - write_core_files(repo_root=repo_root, omitted_paths=omitted_paths) - commit_initial_repo(repo_root=repo_root) - - -def run_sync(repo_root: Path, check: bool) -> subprocess.CompletedProcess[str]: - """Run the public sync launcher against the checkout under test. - - Parameters: - repo_root: Temporary target repository root. - check: Whether a non-zero result should fail immediately. - - Expected output: - Completed sync process using this repository as the upstream template. - """ - return run_command( - args=["bash", str(SYNC_SH)], - cwd=repo_root, - env=sync_env(upstream_dir=REPO_ROOT), - check=check, - ) - - -def create_sync_pr_body(repo_root: Path) -> Path: - """Run ordinary sync and create a sentinel PR body for final-gate tests. - - Parameters: - repo_root: Temporary target repository root. - - Expected output: - Path to `PR_BODY.md` created from the current sync skeleton. - """ - run_sync(repo_root=repo_root, check=True) - run_command( - args=[ - "python3", - str(REPO_ROOT / "scripts" / "sync_coding_workflow.py"), - "--update-pr-body", - "PR_BODY.md", - ], - cwd=repo_root, - env=sync_env(upstream_dir=REPO_ROOT), - check=True, - ) - return repo_root / "PR_BODY.md" - - -def create_upstream_without_prompt( - upstream_root: Path, - missing_prompt_path: str, -) -> None: - """Create an upstream checkout whose HEAD lacks one prompt file. - - Parameters: - upstream_root: Destination directory for a committed upstream copy. - missing_prompt_path: Repository-relative prompt file to delete. - - Expected output: - A git worktree using the current source under review, with HEAD missing - the selected prompt so sync must reject its raw URL. - """ - shutil.copytree( - src=REPO_ROOT, - dst=upstream_root, - ignore=shutil.ignore_patterns( - ".git", - ".coding_workflow", - "__pycache__", - "*.pyc", - ), - ) - commit_initial_repo(repo_root=upstream_root) - (upstream_root / missing_prompt_path).unlink() - env = os.environ.copy() - run_command( - args=["git", "add", "-A"], - cwd=upstream_root, - env=env, - check=True, - ) - run_command( - args=["git", "commit", "-q", "-m", "remove prompt"], - cwd=upstream_root, - env=env, - check=True, - ) - - -def create_committed_upstream_copy(upstream_root: Path) -> None: - """Create an upstream copy whose HEAD includes uncommitted test fixtures. - - Parameters: - upstream_root: Destination directory for a committed upstream copy. - - Expected output: - A git worktree containing the current checkout files, including new - English templates added by the patch under test. - """ - shutil.copytree( - src=REPO_ROOT, - dst=upstream_root, - ignore=shutil.ignore_patterns( - ".git", - ".coding_workflow", - "__pycache__", - "*.pyc", - ), - ) - commit_initial_repo(repo_root=upstream_root) - - -def fill_agent_placeholders(pr_body_path: Path) -> None: - """Replace script placeholders with review-safe test content. - - Parameters: - pr_body_path: PR body file generated by sync. - - Expected output: - Agent-owned sections no longer contain final-gate placeholder words. - """ - text = pr_body_path.read_text(encoding="utf-8") - if "待补充" not in text: - raise AssertionError("expected script placeholders before filling") - for section_name in SYNC_MODULE.AGENT_SECTIONS: - start = SYNC_MODULE.agent_section_start(section_name=section_name) - end = SYNC_MODULE.agent_section_end(section_name=section_name) - start_index, end_index = SYNC_MODULE.sentinel_span( - text=text, - start=start, - end=end, - ) - section = text[start_index:end_index] - text = ( - text[:start_index] - + section.replace("待补充", "已填写") - + text[end_index:] - ) - pr_body_path.write_text(text, encoding="utf-8") - - -def extract_review_contract(pr_body_text: str) -> str: - """Extract the compact review contract from a generated PR body. - - Parameters: - pr_body_text: Full PR body skeleton or draft. - - Expected output: - Markdown for `Sync Review Contract`. Missing boundaries fail the test - because reviewers must not infer contract values from any prompt. - """ - start = "## Sync Review Contract" - end = "## Upstream Templates at Sync Time" - if start not in pr_body_text: - raise AssertionError("missing Sync Review Contract section") - if end not in pr_body_text: - raise AssertionError("missing upstream template section after contract") - start_index = pr_body_text.index(start) - end_index = pr_body_text.index(end) - if end_index <= start_index: - raise AssertionError("Sync Review Contract appears out of order") - return pr_body_text[start_index:end_index] - - -def sample_sync_state() -> dict[str, object]: - """Build a minimal state for render-only PR body tests. - - Parameters: - None. - - Expected output: - State dictionary with all core and prompt records needed by - `render_pr_body_skeleton`. URLs are inert because the test only verifies - local rendering from constants. - """ - core_records = [] - for rel_path in CORE_FILES: - core_records.append({ - "path": rel_path, - "sync_pass_id": SYNC_MODULE.sync_pass_id_for_file( - rel_path=rel_path, - ), - "status": "specialized", - "note": "ok", - "upstream_raw_url": f"https://example.invalid/{rel_path}", - }) - prompt_records = [ - { - "path": rel_path, - "upstream_raw_url": f"https://example.invalid/{rel_path}", - } - for rel_path in SYNC_MODULE.SYNC_PROMPT_FILES - ] - return SYNC_MODULE.build_sync_state( - upstream_sha="a" * 40, - project_sha="b" * 40, - dirty_core_files=[], - core_records=core_records, - sync_prompt_records=prompt_records, - ) - - -class SyncWorkflowTests(unittest.TestCase): - """Behavioral regressions for sync shell and PR body helpers.""" - - def test_pr_body_skeleton_renders_sync_constants(self) -> None: - """Generated skeleton should own PR body structure literals.""" - state = sample_sync_state() - skeleton = SYNC_MODULE.render_pr_body_skeleton( - state=state, - ) - expected_literals = [ - SYNC_MODULE.SYNC_PR_BODY_MARKER, - SYNC_MODULE.SYNC_AUTO_START, - SYNC_MODULE.SYNC_AUTO_END, - "| " + " | ".join(SYNC_MODULE.FULL_RECONCILE_COLUMNS) + " |", - "## PR Test Evidence", - "## Upstream Drift Log", - "## Agent Execution Evidence", - ] - for section_name in SYNC_MODULE.AGENT_SECTIONS: - expected_literals.extend(( - SYNC_MODULE.agent_section_start(section_name=section_name), - SYNC_MODULE.agent_section_end(section_name=section_name), - )) - expected_literals.extend(SYNC_MODULE.REPO_FACTS_HEADINGS) - for sync_pass in SYNC_MODULE.SYNC_PASSES: - expected_literals.append(str(sync_pass["title"])) - - for literal in expected_literals: - self.assertIn( - literal, - skeleton, - msg=f"PR body skeleton missing sync literal: {literal}", - ) - self.assertNotIn( - "待补充 | 待补充 | none | 待补充 | 待判断", - skeleton, - ) - for record in state["core_files"]: - pass_title = SYNC_MODULE.sync_pass_title_for_id( - pass_id=str(record["sync_pass_id"]), - ) - expected_row = ( - f"| {pass_title} | `{record['path']}` | " - f"`{record['status']}` | " - "待补充 | 待补充 | 待补充 | 待补充 | 待补充 |" - ) - self.assertIn(expected_row, skeleton) - - def test_each_pass_prompt_contains_common_execution_rules(self) -> None: - """Every copyable pass prompt should carry its own guardrails.""" - operations_text = ( - REPO_ROOT / "zh/scripts/OPERATIONS.md" - ).read_text(encoding="utf-8") - self.assertNotIn("### 2.0 共用执行契约", operations_text) - self.assertNotIn("ready_for_next_pass", operations_text) - self.assertNotIn("Sync Pass Status", operations_text) - self.assertNotIn("pass_handoffs", operations_text) - self.assertNotIn( - "PASS 4 回报普通 sync 已重跑、全部 pass ready", - operations_text, - ) - self.assertNotIn("整体任务:更新本地代码", operations_text) - self.assertIn( - "每次新开对话,只复制并执行对应 PASS 的 code block", - operations_text, - ) - self.assertIn( - "不以\nPASS 4 的聊天摘要作为事实源", - operations_text, - ) - self.assertNotIn("第一步打开", operations_text) - self.assertIn( - "用户只需要从 PASS 1 开始复制对应 PASS 的 code block 到新对话", - operations_text, - ) - self.assertIn( - "给执行 agent 的本轮工单和机器信号", - operations_text, - ) - self.assertIn( - "用户不阅读也不影响启动下一步", - operations_text, - ) - self.assertIn("执行 agent 会按 prompt 读取它", operations_text) - reconcile_agent_columns = [ - column - for column in SYNC_MODULE.FULL_RECONCILE_COLUMNS - if column not in {"pass", "文件", "当前脚本信号"} - ] - common_literals = [ - "整体目标:完成本轮 workflow docs sync", - "owned docs,并把结论写入 `PR_BODY.md` 的 agent-owned 区", - "当前任务:只执行", - "不要执行其他 PASS", - "共用执行规则", - ".coding_workflow/diffs/agent_workorder.md", - ".coding_workflow/diffs/pr_body_skeleton.md", - "PR_BODY.md", - "应由 PREPARE 机械创建", - "不得自行复制 skeleton", - "表格 cell 中的字面 `|` 必须写为 `\\|`", - SYNC_MODULE.SYNC_AUTO_START, - SYNC_MODULE.SYNC_AUTO_END, - "任何 sync sentinel、sentinel 外内容", - "agent-owned 内容不能保留 `待补充`", - "`pr_test_evidence` 区只由 PR 提交 Agent 填写", - "`agent_execution_evidence` 是执行自报清单", - "`待判断` 留给 reviewer 和用户", - "文档语义对账表", - "没有拒绝项或下游影响时写", - "evidence 列必须显式覆盖三类漂移", - "class-1 template/missing", - "class-2 upstream", - "class-3 code/test/behavior drift", - "不得字面引用上游模板 marker", - "本 pass owned docs 的漂移触发器", - "本 pass owned docs 的闭合规则", - ( - "curl -fsSL https://raw.githubusercontent.com/" - "wlvh/coding-workflow/main/zh/scripts/sync.sh | bash" - ), - "不要手修 auto 区", - "人工 code-block 模式", - "Skill 模式", - "不要执行本 block 末尾的 curl", - "pinned `finish-pass`", - ] - common_literals.extend(str(column) for column in reconcile_agent_columns) - sync_passes = list(SYNC_MODULE.SYNC_PASSES) - for index, sync_pass in enumerate(sync_passes, start=1): - title = str(sync_pass["title"]) - start = f"### 2.{index} {title}" - if index < len(sync_passes): - next_title = str(sync_passes[index]["title"]) - end = f"### 2.{index + 1} {next_title}" - else: - end = "---\n\n## 3. PR 提交 Agent" - self.assertIn(start, operations_text) - self.assertIn(end, operations_text) - pass_text = operations_text.split(start, maxsplit=1)[1].split( - end, - maxsplit=1, - )[0] - self.assertNotIn("本文档", pass_text) - self.assertEqual( - pass_text.count("应由 PREPARE 机械创建"), - 1, - msg=f"{title} prompt should bind PR_BODY to PREPARE once", - ) - self.assertNotIn("如果不存在,先用", pass_text) - self.assertNotIn("回报修改了哪些文件", pass_text) - self.assertNotIn("回报是否写入 downstream impact", pass_text) - self.assertNotIn("PR body sections", pass_text) - self.assertNotIn("是否全部 pass ready", pass_text) - self.assertNotIn("读取收口", pass_text) - self.assertNotIn("停止条件", pass_text) - pass_specific_literals = [] - if title != "PASS 4 - Governance / Reverse Closure": - self.assertNotIn("remaining_human_decisions", pass_text) - if title == "PASS 1 - Code Facts / Architecture": - pass_specific_literals.extend(( - "系统目的、模块表、数据流", - "upstream architecture 新增或调整架构章节", - "代码新增入口、模块、数据流、状态模型", - "架构事实写回 `architecture.md`", - "能力、用户行为、测试或治理影响只写 downstream impact", - "本 pass 负责的 `Full Document Reconcile` 行", - )) - if title == "PASS 2 - Capability / User Behavior": - pass_specific_literals.extend(( - "`sample_*` anchor", - "代码或测试显示新增能力、拒绝、必须追问", - "`capability_contract.json`:class-1/2/3 修成真实能力边界和 stable anchor", - "用户可观察行为、不变量或验收口径", - "新增声明必须锚到 contract 或测试证据", - "怎么问、怎么看、何时找人", - "只解释 contract / interact 已确认且业务可感知的能力", - "本 pass 三个 owned docs 行", - )) - if title == "PASS 3 - TESTING Independent Review": - pass_specific_literals.extend(( - "`TESTING.md`:class-1/2 更新测试原则、证据记录和 gate", - "class-3 只写 `TESTING_REVIEW_PACKET`", - "机械信号收集", - "find tests -type f -name 'test_*.py'", - "grep -rh", - "git log --since='3 months ago'", - "按项目等价工具改写并在 evidence 写明实际命令", - "本 pass 只写 review packet,不改测试代码", - "只打开机械信号", - "不通读全文", - )) - if title == "PASS 4 - Governance / Reverse Closure": - pass_specific_literals.extend(( - "downstream impact 列必须逐 pass 显式列出", - "PASS 1 找到 N 条 class-3 漂移", - "PASS 2 ... 闭合于 capability_contract.json", - "PASS 3 ... 闭合于 TESTING_REVIEW_PACKET", - "需要提交期强制执行的事项收进 checklist", - "只为项目固定流程新增或更新流程入口", - "需要新的项目代码事实时写 downstream impact", - "缺失或 upstream 更新默认 inherit / adopt", - "唯一允许直接继承 upstream", - "不默认通读前置 pass 全文", - )) - expected_literals = [*common_literals] - expected_literals.extend(pass_specific_literals) - expected_literals.extend( - f"`{rel_path}`" for rel_path in sync_pass["files"] - ) - for literal in expected_literals: - self.assertIn( - literal, - pass_text, - msg=f"{title} prompt missing self-contained rule: {literal}", - ) - submit_text = operations_text.split( - "## 3. PR 提交 Agent", - maxsplit=1, - )[1].split("---\n\n## 4. Sync PR Review", maxsplit=1)[0] - submit_literals = [ - "当前任务:只执行 PR 提交 Agent", - "`PR_Checklist.md`", - "`TESTING.md`", - "`.github/pull_request_template.md`", - "`.coding_workflow/diffs/sync_state.json`", - "`PR_BODY.md` 的 `Full Document Reconcile` 表", - "`` 到", - "`PR_BODY.md` 的 `PR Test Evidence` 段", - "`PR_BODY.md` 的 `Upstream Drift Log` 段", - "`PR_BODY.md` 的 `Agent Execution Evidence` 表", - "`PR_BODY.md` 的 `Remaining Human Decisions` 段", - "`` 到", - "`git branch --show-current`", - "`git status --short`", - "`git diff --name-only ...HEAD`", - "PR body 中列了但 diff", - "`PR_BODY.md` 的 `Full Document Reconcile` 表覆盖本轮核心文档", - "`Agent Execution Evidence` 四个 PASS 行都已填写", - "`Upstream Drift Log`", - "`PR Test Evidence` 区", - "不得把本 PR 一次性测试证据写入 `Repo Facts Map`", - "`PR_BODY.md` 默认只用于更新 GitHub PR body,不提交仓库", - "不得提交 `.coding_workflow/diffs/`", - "`gh pr list --state open --head `", - "`git commit --amend --no-edit`", - "`git push --force-with-lease origin HEAD:`", - "`gh pr edit --body-file PR_BODY.md`", - "`gh pr create --draft --title --body-file PR_BODY.md --base <base> --head <branch>`", - "禁止使用裸 `git push --force`", - "不要手修 PR body auto 区", - "`PR Test Evidence` 是否已记录测试命令、结果和 N/A 原因", - "`Upstream Drift Log` 是否为 `none`", - "`PR_BODY.md` 是已提交,还是仅用于更新 GitHub PR body", - "`Remaining Human Decisions` 是否为 `none`", - ] - for literal in submit_literals: - self.assertIn( - literal, - submit_text, - msg=f"PR submit prompt missing execution detail: {literal}", - ) - - def test_final_mode_rejects_stale_auto_without_refresh(self) -> None: - """`--final` must not repair stale auto content before checking it.""" - with tempfile.TemporaryDirectory() as temp_dir: - target_root = Path(temp_dir) - create_target_repo(repo_root=target_root, omitted_paths=set()) - pr_body_path = create_sync_pr_body(repo_root=target_root) - fill_agent_placeholders(pr_body_path=pr_body_path) - pr_body_text = pr_body_path.read_text(encoding="utf-8") - stale_pr_body = pr_body_text.replace( - "Final gate owns sentinels", - "Stale gate owns sentinels", - ) - pr_body_path.write_text( - stale_pr_body, - encoding="utf-8", - ) - - result = run_command( - args=["bash", str(SYNC_SH), "--final"], - cwd=target_root, - env=sync_env(upstream_dir=REPO_ROOT), - check=False, - ) - - self.assertNotEqual(result.returncode, 0) - self.assertIn( - "sync auto section differs", - f"{result.stdout}\n{result.stderr}", - ) - self.assertIn( - "Stale gate", - pr_body_path.read_text(encoding="utf-8"), - ) - - def test_installed_template_reports_upstream_marker_hits(self) -> None: - """Installed templates should report markers agents must remove.""" - with tempfile.TemporaryDirectory() as temp_dir: - target_root = Path(temp_dir) - create_target_repo( - repo_root=target_root, - omitted_paths={"docs/business_user_guide.md"}, - ) - - run_sync(repo_root=target_root, check=True) - - state_path = target_root / ".coding_workflow/diffs/sync_state.json" - state = json.loads(state_path.read_text(encoding="utf-8")) - records = [ - record - for record in state["core_files"] - if record["path"] == "docs/business_user_guide.md" - ] - - self.assertEqual(len(records), 1) - self.assertEqual(records[0]["status"], "installed_template") - self.assertTrue(records[0]["marker_hits"]) - self.assertIn("<项目名>", "\n".join(records[0]["marker_hits"])) - - def test_refresh_rejects_content_outside_sync_sentinels(self) -> None: - """Refresh must fail before dropping content outside sentinel blocks.""" - with tempfile.TemporaryDirectory() as temp_dir: - target_root = Path(temp_dir) - create_target_repo(repo_root=target_root, omitted_paths=set()) - pr_body_path = create_sync_pr_body(repo_root=target_root) - fill_agent_placeholders(pr_body_path=pr_body_path) - with pr_body_path.open(mode="a", encoding="utf-8") as pr_body: - pr_body.write("\n## 验证\noutside sentinel\n") - - result = run_command( - args=["bash", str(SYNC_SH)], - cwd=target_root, - env=sync_env(upstream_dir=REPO_ROOT), - check=False, - ) - - self.assertNotEqual(result.returncode, 0) - self.assertIn( - "content outside sync sentinel sections", - f"{result.stdout}\n{result.stderr}", - ) - self.assertIn( - "outside sentinel", - pr_body_path.read_text(encoding="utf-8"), - ) - - def test_refresh_rejects_non_sync_pr_body(self) -> None: - """Ordinary sync should fail fast instead of migrating old drafts.""" - with tempfile.TemporaryDirectory() as temp_dir: - target_root = Path(temp_dir) - create_target_repo(repo_root=target_root, omitted_paths=set()) - (target_root / "PR_BODY.md").write_text( - "ordinary draft\n", - encoding="utf-8", - ) - - result = run_command( - args=["bash", str(SYNC_SH)], - cwd=target_root, - env=sync_env(upstream_dir=REPO_ROOT), - check=False, - ) - - self.assertNotEqual(result.returncode, 0) - self.assertIn( - "exists but is not a sync PR body", - f"{result.stdout}\n{result.stderr}", - ) - self.assertEqual( - "ordinary draft\n", - (target_root / "PR_BODY.md").read_text(encoding="utf-8"), - ) - - def test_final_mode_passes_with_current_complete_pr_body(self) -> None: - """`--final` should pass when auto and agent sections are complete.""" - with tempfile.TemporaryDirectory() as temp_dir: - target_root = Path(temp_dir) - create_target_repo(repo_root=target_root, omitted_paths=set()) - pr_body_path = create_sync_pr_body(repo_root=target_root) - fill_agent_placeholders(pr_body_path=pr_body_path) - - result = run_command( - args=["bash", str(SYNC_SH), "--final"], - cwd=target_root, - env=sync_env(upstream_dir=REPO_ROOT), - check=True, - ) - - self.assertIn("Final sync check passed", result.stdout) - - def test_final_mode_rejects_unfilled_placeholder(self) -> None: - """Final gate should reject agent-owned script placeholders.""" - with tempfile.TemporaryDirectory() as temp_dir: - target_root = Path(temp_dir) - create_target_repo(repo_root=target_root, omitted_paths=set()) - pr_body_path = create_sync_pr_body(repo_root=target_root) - fill_agent_placeholders(pr_body_path=pr_body_path) - pr_body_path.write_text( - pr_body_path.read_text(encoding="utf-8").replace( - "已填写", - "待补充", - 1, - ), - encoding="utf-8", - ) - - result = run_command( - args=["bash", str(SYNC_SH), "--final"], - cwd=target_root, - env=sync_env(upstream_dir=REPO_ROOT), - check=False, - ) - - self.assertNotEqual(result.returncode, 0) - self.assertIn( - "still contains placeholder: 待补充", - f"{result.stdout}\n{result.stderr}", - ) - - def test_final_mode_allows_visible_semantic_pending_decisions(self) -> None: - """Final gate should leave semantic uncertainty to independent review.""" - with tempfile.TemporaryDirectory() as temp_dir: - target_root = Path(temp_dir) - create_target_repo(repo_root=target_root, omitted_paths=set()) - pr_body_path = create_sync_pr_body(repo_root=target_root) - fill_agent_placeholders(pr_body_path=pr_body_path) - pr_body_path.write_text( - pr_body_path.read_text(encoding="utf-8").replace( - "已填写", - "待判断", - 1, - ), - encoding="utf-8", - ) - - result = run_command( - args=["bash", str(SYNC_SH), "--final"], - cwd=target_root, - env=sync_env(upstream_dir=REPO_ROOT), - check=True, - ) - - self.assertIn("Final sync check passed", result.stdout) - self.assertIn("待判断", pr_body_path.read_text(encoding="utf-8")) - - def test_generated_workorder_stays_thin_and_points_to_runbook(self) -> None: - """Sync workorder should list machine facts, not duplicate prompts.""" - with tempfile.TemporaryDirectory() as temp_dir: - target_root = Path(temp_dir) - create_target_repo(repo_root=target_root, omitted_paths=set()) - - result = run_sync(repo_root=target_root, check=True) - output = f"{result.stdout}\n{result.stderr}" - self.assertIn("sync OK: full_reconcile", output) - self.assertIn("runbook:", output) - self.assertIn("raw.githubusercontent.com", output) - self.assertIn("agent workorder:", output) - self.assertNotIn("Read these in order", output) - self.assertNotIn("First action", output) - self.assertNotIn("installation_status.md", output) - self.assertNotIn("full_reconcile_report.md", output) - - workorder_path = ( - target_root - / ".coding_workflow/diffs/agent_workorder.md" - ) - workorder = workorder_path.read_text(encoding="utf-8") - self.assertIn("zh/scripts/OPERATIONS.md", workorder) - self.assertIn("## 文件处理清单", workorder) - self.assertNotIn("## 入口", workorder) - self.assertNotIn("## 角色边界", workorder) - self.assertNotIn("## Sync Pass Plan", workorder) - self.assertNotIn("## 本地读取优先级", workorder) - self.assertNotIn("Sync Pass Status", workorder) - self.assertIn("PASS 3 - TESTING Independent Review", workorder) - workorder_lines = workorder.splitlines() - file_header_index = workorder_lines.index( - "| Pass | 文件 | 脚本信号 | 机械动作 | marker / TODO 命中 |" - ) - self.assertTrue( - workorder_lines[file_header_index + 2].startswith( - "| PASS 1 - Code Facts / Architecture | `architecture.md` |" - ), - ) - self.assertNotIn("## Copyable Pass Prompts", workorder) - self.assertEqual(0, workorder.count("```text")) - self.assertNotIn("tests_not_worth_adding", workorder) - self.assertNotIn("propagation_targets", workorder) - self.assertNotIn("kick_back_to", workorder) - self.assertNotIn("AGENTS.md ## 文件简介", workorder) - self.assertNotIn("Repo Facts Map", workorder) - self.assertNotIn("upstream_vs_local", workorder) - self.assertFalse( - (target_root / ".coding_workflow/diffs/upstream_vs_local").exists() - ) - self.assertFalse( - (target_root / ".coding_workflow/diffs/installation_status.md").exists() - ) - self.assertFalse( - (target_root / ".coding_workflow/diffs/full_reconcile_report.md").exists() - ) - - def test_english_sync_uses_english_template_paths(self) -> None: - """English launcher should sync the English path family.""" - with tempfile.TemporaryDirectory() as temp_dir: - root = Path(temp_dir) - upstream_root = root / "upstream" - target_root = root / "target" - target_root.mkdir() - create_committed_upstream_copy(upstream_root=upstream_root) - omitted_paths = { - "AGENTS.md", - "TESTING.md", - ".github/pull_request_template.md", - } - create_target_repo( - repo_root=target_root, - omitted_paths=omitted_paths, - ) - - result = run_command( - args=["bash", str(upstream_root / "en/scripts/sync.sh")], - cwd=target_root, - env=sync_env(upstream_dir=upstream_root), - check=True, - ) - - self.assertIn("en/scripts/OPERATIONS.md", result.stdout) - self.assertTrue((target_root / "AGENTS.md").exists()) - self.assertTrue((target_root / "TESTING.md").exists()) - self.assertTrue( - (target_root / ".github/pull_request_template.md").exists() - ) - self.assertEqual( - (upstream_root / "en/AGENTS.md").read_text(encoding="utf-8"), - (target_root / "AGENTS.md").read_text(encoding="utf-8"), - ) - state_path = ( - target_root / ".coding_workflow/diffs/sync_state.json" - ) - state = json.loads(state_path.read_text(encoding="utf-8")) - self.assertEqual("en", state["workflow_language"]) - core_paths = [record["path"] for record in state["core_files"]] - prompt_paths = [ - record["path"] for record in state["sync_prompt_files"] - ] - self.assertIn("AGENTS.md", core_paths) - self.assertIn("capability_contract.json", core_paths) - self.assertIn("en/scripts/OPERATIONS.md", prompt_paths) - self.assertIn("en/scripts/sync_pr_review_system.md", prompt_paths) - self.assertNotIn("en/AGENTS.md", core_paths) - - def test_language_core_files_pair_one_to_one(self) -> None: - """Language source files should install to the same target paths.""" - self.assertEqual( - len(SYNC_MODULE.ZH_CORE_SOURCE_FILES), - len(SYNC_MODULE.EN_CORE_SOURCE_FILES), - ) - for zh_path, en_path, zh_target, en_target in zip( - SYNC_MODULE.ZH_CORE_SOURCE_FILES, - SYNC_MODULE.EN_CORE_SOURCE_FILES, - SYNC_MODULE.ZH_CORE_FILES, - SYNC_MODULE.EN_CORE_FILES, - ): - self.assertTrue(zh_path.startswith("zh/")) - self.assertTrue(en_path.startswith("en/")) - self.assertEqual(zh_target, en_target) - self.assertEqual( - zh_target, - SYNC_MODULE.strip_language_prefix(path=zh_path), - ) - self.assertEqual( - en_target, - SYNC_MODULE.strip_language_prefix(path=en_path), - ) - - def test_language_prefix_strip_preserves_inner_github_path(self) -> None: - """Only the leading language prefix is stripped from install paths.""" - self.assertEqual( - ".github/pull_request_template.md", - SYNC_MODULE.strip_language_prefix( - path="zh/.github/pull_request_template.md", - ), - ) - self.assertEqual( - ".github/pull_request_template.md", - SYNC_MODULE.strip_language_prefix( - path="en/.github/pull_request_template.md", - ), - ) - - def test_final_mode_delegates_semantic_table_to_review(self) -> None: - """Final gate should not deep-parse reviewer-owned evidence table.""" - with tempfile.TemporaryDirectory() as temp_dir: - target_root = Path(temp_dir) - create_target_repo(repo_root=target_root, omitted_paths=set()) - pr_body_path = create_sync_pr_body(repo_root=target_root) - fill_agent_placeholders(pr_body_path=pr_body_path) - text = pr_body_path.read_text(encoding="utf-8") - text = text.replace( - ( - "| pass | 文件 | 当前脚本信号 | upstream semantic delta | " - "adopted where | not adopted because | evidence | " - "downstream impact |" - ), - "| reviewer owned malformed full reconcile table |", - 1, - ) - pr_body_path.write_text(text, encoding="utf-8") - - result = run_command( - args=["bash", str(SYNC_SH), "--final"], - cwd=target_root, - env=sync_env(upstream_dir=REPO_ROOT), - check=True, - ) - - self.assertIn("Final sync check passed", result.stdout) - - def test_pr_body_review_contract_stays_compact(self) -> None: - """Generated review contract should not dump script constants.""" - with tempfile.TemporaryDirectory() as temp_dir: - target_root = Path(temp_dir) - create_target_repo(repo_root=target_root, omitted_paths=set()) - - run_command( - args=["bash", str(SYNC_SH)], - cwd=target_root, - env=sync_env(upstream_dir=REPO_ROOT), - check=True, - ) - - skeleton_path = ( - target_root - / ".coding_workflow/diffs/pr_body_skeleton.md" - ) - contract = extract_review_contract( - pr_body_text=skeleton_path.read_text(encoding="utf-8"), - ) - self.assertIn("Final gate owns sentinels", contract) - self.assertIn("Reviewer must cross-check", contract) - self.assertIn("architecture.md", contract) - self.assertIn("zh/scripts/OPERATIONS.md", contract) - self.assertIn("zh/scripts/sync_pr_review_system.md", contract) - self.assertNotIn("Required sync sentinels", contract) - self.assertNotIn("pass_id | pass | status | evidence", contract) - self.assertNotIn("Full Document Reconcile columns", contract) - self.assertNotIn("<!-- sync:", contract) - - def test_reviewer_prompt_does_not_copy_contract_literals(self) -> None: - """Thin reviewer prompt must not become a second mechanical contract.""" - prompt_path = REPO_ROOT / "zh/scripts/sync_pr_review_system.md" - prompt_text = prompt_path.read_text(encoding="utf-8") - sentinel_literals = [ - SYNC_MODULE.SYNC_PR_BODY_MARKER, - SYNC_MODULE.SYNC_AUTO_START, - SYNC_MODULE.SYNC_AUTO_END, - ] - for section_name in SYNC_MODULE.AGENT_SECTIONS: - sentinel_literals.append( - SYNC_MODULE.agent_section_start(section_name=section_name) - ) - sentinel_literals.append( - SYNC_MODULE.agent_section_end(section_name=section_name) - ) - forbidden_literals = [ - *CORE_FILES, - *SYNC_MODULE.SYNC_PROMPT_FILES, - *sentinel_literals, - *SYNC_MODULE.TEMPLATE_MARKERS, - *SYNC_MODULE.TODO_ANCHOR_COMMENTS, - *sorted(SYNC_MODULE.BLOCKING_FINAL_STATUSES), - ] - forbidden_phrases = ( - "9 个", - "2 个", - "10 项", - "core files checked: 9", - ) - for literal in forbidden_literals: - self.assertNotIn( - literal, - prompt_text, - msg=f"review prompt copied contract literal: {literal}", - ) - for phrase in forbidden_phrases: - self.assertNotIn( - phrase, - prompt_text, - msg=f"review prompt copied fixed count: {phrase}", - ) - - def test_sync_rejects_missing_upstream_prompt_file(self) -> None: - """Sync must not publish raw URLs for missing review prompts.""" - with tempfile.TemporaryDirectory() as temp_dir: - root = Path(temp_dir) - upstream_root = root / "upstream" - target_root = root / "target" - target_root.mkdir() - create_upstream_without_prompt( - upstream_root=upstream_root, - missing_prompt_path="zh/scripts/sync_pr_review_system.md", - ) - create_target_repo(repo_root=target_root, omitted_paths=set()) - - result = run_command( - args=["bash", str(upstream_root / "scripts" / "sync.sh")], - cwd=target_root, - env=sync_env(upstream_dir=upstream_root), - check=False, - ) - - self.assertNotEqual(result.returncode, 0) - self.assertIn( - "missing required sync prompt file", - f"{result.stdout}\n{result.stderr}", - ) - - def test_pr_body_marker_counts_as_sync_sentinel(self) -> None: - """A marker-only damaged sync draft should not look like plain text.""" - self.assertTrue( - SYNC_MODULE.pr_body_has_any_sync_sentinel( - text=f"{SYNC_MODULE.SYNC_PR_BODY_MARKER}\n" - ) - ) - - def test_warns_when_pr_body_is_tracked(self) -> None: - """Sync should warn instead of silently accepting tracked PR_BODY.md.""" - with tempfile.TemporaryDirectory() as temp_dir: - target_root = Path(temp_dir) - (target_root / "PR_BODY.md").write_text( - "tracked draft\n", - encoding="utf-8", - ) - commit_initial_repo(repo_root=target_root) - - output = io.StringIO() - with contextlib.redirect_stdout(output): - SYNC_MODULE.warn_if_pr_body_tracked(repo_root=target_root) - - self.assertIn("WARN: PR_BODY.md is tracked", output.getvalue()) - self.assertIn("git rm --cached PR_BODY.md", output.getvalue()) - - def test_refresh_appends_upstream_drift_log(self) -> None: - """Refreshing across upstream commits should preserve a drift warning.""" - with tempfile.TemporaryDirectory() as temp_dir: - target_root = Path(temp_dir) - old_state = sample_sync_state() - new_state = sample_sync_state() - new_state["upstream_resolved_commit"] = "c" * 40 - state_path = SYNC_MODULE.state_path_for(repo_root=target_root) - state_path.parent.mkdir(parents=True, exist_ok=True) - state_path.write_text( - json.dumps(new_state, ensure_ascii=False), - encoding="utf-8", - ) - pr_body_path = target_root / "PR_BODY.md" - pr_body_path.write_text( - SYNC_MODULE.render_pr_body_skeleton(state=old_state), - encoding="utf-8", - ) - - SYNC_MODULE.update_pr_body( - repo_root=target_root, - pr_body_path=pr_body_path, - ) - - refreshed = pr_body_path.read_text(encoding="utf-8") - self.assertIn("## Upstream Drift Log", refreshed) - self.assertIn(f"{'a' * 40} -> {'c' * 40}", refreshed) - self.assertIn( - "- upstream_resolved_commit: " + "c" * 40, - refreshed, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_workflow_docs_sync.py b/tests/test_workflow_docs_sync.py new file mode 100644 index 0000000..d4bb82a --- /dev/null +++ b/tests/test_workflow_docs_sync.py @@ -0,0 +1,999 @@ +"""验证单会话 Workflow Docs Sync 的 prepare、check、安装器和结构边界。""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import Any + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SKILL_ROOT = REPO_ROOT / "zh/skills/workflow-docs-sync" +SYNC_SCRIPT = SKILL_ROOT / "scripts/sync_docs.py" +INSTALLER = REPO_ROOT / "zh/scripts/install_skills.py" +PLATFORM_ROOTS = (Path(".agents/skills"), Path(".claude/skills")) +OBSOLETE_SKILL = "workflow-docs-sync-review" +CORE_FILES = ( + "architecture.md", + "capability_contract.json", + "interact.md", + "docs/business_user_guide.md", + "TESTING.md", + "PR_Checklist.md", + "SOP.md", + "AGENTS.md", + ".github/pull_request_template.md", +) +NON_PR_FILES = tuple( + path for path in CORE_FILES if path != ".github/pull_request_template.md" +) +TEMPLATE_TOKENS = ( + "<项目名>", + "<项目 / agent / app 名称>", + "<对象>", + "<指标 / 结果>", + "<project name>", + "<project / agent / app name>", + "<object>", + "<metric / result>", + "Case 1:确认一个对象最近是否异常", + "Case 1: Check whether one object is abnormal", + "待项目负责人补充", + "project owner must replace this", + "sample_supported_question", + "sample_multi_object_comparison_not_supported", + "sample_no_final_business_decision", + "sample_requires_context_before_answer", + "CAPABILITY.sample_", + "BOUNDARY.sample_", + "RESPONSIBILITY.sample_", + "BEHAVIOR.sample_", + "<!-- capability-anchor: TODO -->", + "<!-- test-anchor: TODO -->", + "待补充", +) +FORBIDDEN_CONTROL_WORDS = ( + "start-pass", + "finish-pass", + "prepare-submit", + "seal-submit", + "finish-submit", + "active_mode", + "completed_modes", + "submit_ready", + "run.json", + "baseline", + "skill_runtime", + "skill_results", + "SYNC_PR_BODY_MARKER", + "sync:agent:start", + "headRefOid", +) + + +def run( + *, + args: list[str], + cwd: Path, + check: bool = False, + environment: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + """运行测试子进程并稳定捕获 UTF-8 输出。""" + merged_environment = os.environ.copy() + merged_environment["LC_ALL"] = "C" + merged_environment["PYTHONDONTWRITEBYTECODE"] = "1" + if environment is not None: + merged_environment.update(environment) + return subprocess.run( + args=args, + cwd=cwd, + env=merged_environment, + check=check, + capture_output=True, + encoding="utf-8", + text=True, + ) + + +def git(*, repo: Path, args: list[str], check: bool = True) -> str: + """运行 Git 并返回去掉末尾换行的 stdout。""" + result = run(args=["git", "-C", str(repo), *args], cwd=repo, check=False) + if check and result.returncode != 0: + pytest.fail( + f"git {' '.join(args)} failed ({result.returncode}):\n" + f"stdout={result.stdout}\nstderr={result.stderr}" + ) + return result.stdout.strip() + + +def init_repo(*, path: Path) -> Path: + """创建带固定身份和初始提交的临时 Git 仓库。""" + path.mkdir(parents=True) + git(repo=path, args=["init", "-q"]) + git(repo=path, args=["config", "user.email", "tests@example.com"]) + git(repo=path, args=["config", "user.name", "Workflow Tests"]) + (path / "README.md").write_text("# Temporary repository\n", encoding="utf-8") + git(repo=path, args=["add", "README.md"]) + git(repo=path, args=["commit", "-q", "-m", "initial"]) + return path + + +def commit_all(*, repo: Path, message: str) -> str: + """提交临时仓库全部文件并返回完整 HEAD。""" + git(repo=repo, args=["add", "-A"]) + git(repo=repo, args=["commit", "-q", "-m", message]) + return git(repo=repo, args=["rev-parse", "HEAD"]) + + +def template_text(*, language: str, relative_path: str, version: str) -> str: + """生成无占位、可用于 pin 与 equality 测试的最小模板。""" + if relative_path == "capability_contract.json": + return json.dumps( + { + "schema_version": "1.0", + "contracts": { + "documents": [ + { + "anchor_id": f"DOC.{language}_{version}", + "statement": f"{language} {version} contract", + } + ] + }, + }, + ensure_ascii=False, + indent=2, + sort_keys=True, + ) + "\n" + title = relative_path.replace("/", " ") + return f"# {language.upper()} {title} {version}\n\nUpstream {version} text.\n" + + +def write_templates(*, upstream: Path, version: str) -> None: + """向临时上游写入两种语言的九份模板。""" + for language in ("zh", "en"): + for relative_path in CORE_FILES: + path = upstream / language / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + template_text( + language=language, + relative_path=relative_path, + version=version, + ), + encoding="utf-8", + ) + + +def create_upstream(*, root: Path, version: str = "v1") -> tuple[Path, str]: + """创建并提交双语模板上游。""" + upstream = init_repo(path=root) + write_templates(upstream=upstream, version=version) + return upstream, commit_all(repo=upstream, message=f"templates {version}") + + +def create_target(*, root: Path) -> Path: + """创建没有核心文档的 clean 目标仓库。""" + return init_repo(path=root) + + +def parse_single_json(*, result: subprocess.CompletedProcess[str]) -> dict[str, Any]: + """断言脚本 stdout 恰好包含一行 JSON 并返回对象。""" + lines = result.stdout.splitlines() + assert len(lines) == 1, result.stdout + payload = json.loads(lines[0]) + assert isinstance(payload, dict) + return payload + + +def run_sync( + *, + command: str, + target: Path, + upstream: Path, + language: str = "zh", + upstream_sha: str | None = None, + expected_target_head: str | None = None, +) -> tuple[subprocess.CompletedProcess[str], dict[str, Any]]: + """调用同步器内部 CLI 并解析单行结果。""" + args = [ + sys.executable, + str(SYNC_SCRIPT), + command, + "--target-repo", + str(target), + "--upstream-dir", + str(upstream), + ] + if command == "check": + assert upstream_sha is not None + assert expected_target_head is not None + args.extend( + [ + "--upstream-sha", + upstream_sha, + "--expected-target-head", + expected_target_head, + ] + ) + args.extend(["--language", language]) + result = run(args=args, cwd=target) + return result, parse_single_json(result=result) + + +def prepare( + *, target: Path, upstream: Path, language: str = "zh" +) -> dict[str, Any]: + """运行成功的 prepare 并返回会话事实。""" + result, payload = run_sync( + command="prepare", + target=target, + upstream=upstream, + language=language, + ) + assert result.returncode == 0, payload + assert payload["status"] == "prepared" + return payload + + +def specialize(*, target: Path, label: str = "target") -> None: + """把八份非 PR 模板改成项目内容,保留 PR template 直接继承。""" + for relative_path in NON_PR_FILES: + path = target / relative_path + if relative_path == "capability_contract.json": + payload = json.loads(path.read_text(encoding="utf-8")) + payload["project"] = label + path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + else: + path.write_text( + path.read_text(encoding="utf-8") + + f"\nProject-specific evidence for {label}.\n", + encoding="utf-8", + ) + + +def run_check( + *, + target: Path, + upstream: Path, + prepared: dict[str, Any], + language: str = "zh", + upstream_sha: str | None = None, +) -> tuple[subprocess.CompletedProcess[str], dict[str, Any]]: + """使用 prepare 返回的 SHA 和目标 HEAD 调用 check。""" + return run_sync( + command="check", + target=target, + upstream=upstream, + language=language, + upstream_sha=upstream_sha or prepared["upstream_sha"], + expected_target_head=prepared["target_head"], + ) + + +def ready_case( + *, tmp_path: Path, language: str = "zh" +) -> tuple[Path, Path, dict[str, Any]]: + """创建已 prepare 且八份文档项目化的常用测试场景。""" + upstream, _ = create_upstream(root=tmp_path / "upstream") + target = create_target(root=tmp_path / "target") + prepared = prepare(target=target, upstream=upstream, language=language) + specialize(target=target) + return upstream, target, prepared + + +def assert_check_failed( + *, + target: Path, + upstream: Path, + prepared: dict[str, Any], + expected_text: str, + language: str = "zh", +) -> dict[str, Any]: + """断言最终检查失败且诊断包含指定文本。""" + result, payload = run_check( + target=target, + upstream=upstream, + prepared=prepared, + language=language, + ) + assert result.returncode == 1 + assert payload["status"] == "failed" + assert expected_text in payload["detail"] + return payload + + +def snapshot(*, repo: Path) -> tuple[dict[str, bytes], str]: + """读取除 .git 外的文件 bytes 与 porcelain 状态。""" + files = { + path.relative_to(repo).as_posix(): path.read_bytes() + for path in repo.rglob("*") + if path.is_file() and ".git" not in path.relative_to(repo).parts + } + status = git( + repo=repo, + args=["status", "--porcelain=v1", "--untracked-files=all"], + ) + return files, status + + +@pytest.mark.parametrize("language", ["zh", "en"]) +def test_prepare_installs_selected_language_templates( + tmp_path: Path, language: str +) -> None: + """prepare 应从所选语言的固定 Git 对象安装九份模板。""" + upstream, upstream_sha = create_upstream(root=tmp_path / "upstream") + target = create_target(root=tmp_path / "target") + payload = prepare(target=target, upstream=upstream, language=language) + assert payload["upstream_sha"] == upstream_sha + assert payload["installed"] == list(CORE_FILES) + assert payload["existing"] == [] + for relative_path in CORE_FILES: + assert (target / relative_path).read_text(encoding="utf-8") == ( + upstream / language / relative_path + ).read_text(encoding="utf-8") + + +def test_prepare_is_idempotent(tmp_path: Path) -> None: + """第二次 prepare 不得重写第一次安装的任何文件。""" + upstream, _ = create_upstream(root=tmp_path / "upstream") + target = create_target(root=tmp_path / "target") + first = prepare(target=target, upstream=upstream) + before = snapshot(repo=target) + second = prepare(target=target, upstream=upstream) + assert second["target_head"] == first["target_head"] + assert second["installed"] == [] + assert second["existing"] == list(CORE_FILES) + assert snapshot(repo=target) == before + + +def test_prepare_never_overwrites_existing_document(tmp_path: Path) -> None: + """已有核心文档即使 dirty 也必须原样保留。""" + upstream, _ = create_upstream(root=tmp_path / "upstream") + target = create_target(root=tmp_path / "target") + custom = "# Existing architecture\n\nProject truth.\n" + (target / "architecture.md").write_text(custom, encoding="utf-8") + payload = prepare(target=target, upstream=upstream) + assert payload["installed"] == list(CORE_FILES[1:]) + assert payload["existing"] == ["architecture.md"] + assert (target / "architecture.md").read_text(encoding="utf-8") == custom + + +def test_prepare_reads_committed_template_not_upstream_worktree(tmp_path: Path) -> None: + """upstream dirty 内容不得影响 git-show 读取的 HEAD 模板。""" + upstream, _ = create_upstream(root=tmp_path / "upstream") + target = create_target(root=tmp_path / "target") + committed = (upstream / "zh/architecture.md").read_text(encoding="utf-8") + (upstream / "zh/architecture.md").write_text( + "# Dirty upstream worktree\n", + encoding="utf-8", + ) + prepare(target=target, upstream=upstream) + assert (target / "architecture.md").read_text(encoding="utf-8") == committed + + +def test_check_uses_prepare_sha_after_upstream_head_advances(tmp_path: Path) -> None: + """check 必须复用 prepare SHA,而不能偷偷读取新的 upstream HEAD。""" + upstream, old_sha = create_upstream(root=tmp_path / "upstream", version="v1") + target = create_target(root=tmp_path / "target") + prepared = prepare(target=target, upstream=upstream) + specialize(target=target) + write_templates(upstream=upstream, version="v2") + new_sha = commit_all(repo=upstream, message="templates v2") + (target / "architecture.md").write_text( + (upstream / "zh/architecture.md").read_text(encoding="utf-8"), + encoding="utf-8", + ) + old_result, old_payload = run_check( + target=target, + upstream=upstream, + prepared=prepared, + upstream_sha=old_sha, + ) + new_result, new_payload = run_check( + target=target, + upstream=upstream, + prepared=prepared, + upstream_sha=new_sha, + ) + assert old_result.returncode == 0, old_payload + assert old_payload["upstream_sha"] == old_sha + assert new_result.returncode == 1 + assert "architecture.md" in new_payload["detail"] + assert "完全相同" in new_payload["detail"] + + +@pytest.mark.parametrize("dirty_kind", ["untracked", "tracked"]) +def test_prepare_rejects_outside_dirty_before_any_write( + tmp_path: Path, dirty_kind: str +) -> None: + """allowlist 外 dirty path 必须在九份模板落盘前失败。""" + upstream, _ = create_upstream(root=tmp_path / "upstream") + target = create_target(root=tmp_path / "target") + if dirty_kind == "untracked": + (target / "rogue.txt").write_text("rogue\n", encoding="utf-8") + else: + (target / "README.md").write_text("# Changed\n", encoding="utf-8") + result, payload = run_sync( + command="prepare", + target=target, + upstream=upstream, + ) + assert result.returncode == 1 + assert "dirty path" in payload["error"] + assert not any((target / path).exists() for path in CORE_FILES) + + +def test_prepare_rejects_rename_with_outside_endpoint(tmp_path: Path) -> None: + """rename 的来源和目标都必须属于允许范围。""" + upstream, _ = create_upstream(root=tmp_path / "upstream") + target = create_target(root=tmp_path / "target") + (target / "architecture.md").write_text("# Architecture\n", encoding="utf-8") + commit_all(repo=target, message="add allowed doc") + git(repo=target, args=["mv", "architecture.md", "rogue.md"]) + result, payload = run_sync( + command="prepare", + target=target, + upstream=upstream, + ) + assert result.returncode == 1 + assert "rogue.md" in payload["detail"] + assert not (target / "capability_contract.json").exists() + + +def test_prepare_allows_core_and_gitignore_dirty(tmp_path: Path) -> None: + """核心文档与辅助 .gitignore 是唯一允许的 dirty 范围。""" + upstream, _ = create_upstream(root=tmp_path / "upstream") + target = create_target(root=tmp_path / "target") + (target / ".gitignore").write_text("*.local\n", encoding="utf-8") + (target / "architecture.md").write_text( + "# Project architecture\n", + encoding="utf-8", + ) + payload = prepare(target=target, upstream=upstream) + assert "architecture.md" in payload["existing"] + assert len(payload["installed"]) == 8 + + +def test_prepare_preflights_parent_collision(tmp_path: Path) -> None: + """父路径冲突必须在安装任意模板前失败。""" + upstream, _ = create_upstream(root=tmp_path / "upstream") + target = create_target(root=tmp_path / "target") + (target / "docs").write_text("not a directory\n", encoding="utf-8") + commit_all(repo=target, message="parent collision") + result, payload = run_sync( + command="prepare", + target=target, + upstream=upstream, + ) + assert result.returncode == 1 + assert "父路径不是目录" in payload["error"] + assert not (target / "architecture.md").exists() + + +def test_prepare_rejects_symlink_destination_before_write(tmp_path: Path) -> None: + """核心文档 symlink 不得被当成已有普通文档。""" + upstream, _ = create_upstream(root=tmp_path / "upstream") + target = create_target(root=tmp_path / "target") + outside = tmp_path / "outside.md" + outside.write_text("# Outside\n", encoding="utf-8") + (target / "architecture.md").symlink_to(outside) + result, payload = run_sync( + command="prepare", + target=target, + upstream=upstream, + ) + assert result.returncode == 1 + assert "符号链接" in payload["error"] + assert not (target / "capability_contract.json").exists() + assert outside.read_text(encoding="utf-8") == "# Outside\n" + + +@pytest.mark.parametrize("missing_path", CORE_FILES) +def test_check_requires_all_nine_files(tmp_path: Path, missing_path: str) -> None: + """缺少任意核心文件都必须失败。""" + upstream, target, prepared = ready_case(tmp_path=tmp_path) + (target / missing_path).unlink() + assert_check_failed( + target=target, + upstream=upstream, + prepared=prepared, + expected_text=f"缺少必需文件: {missing_path}", + ) + + +def test_check_rejects_invalid_utf8(tmp_path: Path) -> None: + """九份文件必须能严格解码为 UTF-8。""" + upstream, target, prepared = ready_case(tmp_path=tmp_path) + (target / "architecture.md").write_bytes(b"# valid\n\xff\n") + assert_check_failed( + target=target, + upstream=upstream, + prepared=prepared, + expected_text="不是有效 UTF-8", + ) + + +@pytest.mark.parametrize( + ("content", "expected"), + [ + ("{\n", "JSON 无效"), + ("[]\n", "顶层必须是 JSON object"), + ], +) +def test_check_validates_capability_contract( + tmp_path: Path, content: str, expected: str +) -> None: + """capability contract 应验证合法 JSON object。""" + upstream, target, prepared = ready_case(tmp_path=tmp_path) + (target / "capability_contract.json").write_text(content, encoding="utf-8") + assert_check_failed( + target=target, + upstream=upstream, + prepared=prepared, + expected_text=expected, + ) + + +def test_capability_contract_accepts_project_specific_object(tmp_path: Path) -> None: + """机械层不强制项目合同 schema 或虚构语义锚点。""" + upstream, target, prepared = ready_case(tmp_path=tmp_path) + (target / "capability_contract.json").write_text( + '{"project":"schema-owned-by-target"}\n', + encoding="utf-8", + ) + result, payload = run_check( + target=target, + upstream=upstream, + prepared=prepared, + ) + assert result.returncode == 0, payload + + +@pytest.mark.parametrize( + ("content", "expected"), + [ + ("", "文件为空"), + ("plain text\n", "缺少非空 Markdown 标题"), + ("#\nbody\n", "Markdown 标题为空"), + ("# #\nbody\n", "Markdown 标题为空"), + ("# ###\nbody\n", "Markdown 标题为空"), + ("```text\n# code only\n```\n", "缺少非空 Markdown 标题"), + ], +) +def test_check_rejects_empty_file_or_markdown_title( + tmp_path: Path, content: str, expected: str +) -> None: + """空内容、空标题和 code fence 内伪标题都不能通过。""" + upstream, target, prepared = ready_case(tmp_path=tmp_path) + (target / "architecture.md").write_text(content, encoding="utf-8") + assert_check_failed( + target=target, + upstream=upstream, + prepared=prepared, + expected_text=expected, + ) + + +@pytest.mark.parametrize("token", TEMPLATE_TOKENS) +def test_check_rejects_explicit_template_tokens(tmp_path: Path, token: str) -> None: + """明确列出的模板 token 和待填写值必须逐项失败。""" + upstream, target, prepared = ready_case(tmp_path=tmp_path) + (target / "architecture.md").write_text( + f"# Project architecture\n\nResidue: {token}\n", + encoding="utf-8", + ) + assert_check_failed( + target=target, + upstream=upstream, + prepared=prepared, + expected_text="检测到未项目化内容", + ) + + +def test_check_does_not_generalize_angle_bracket_tokens(tmp_path: Path) -> None: + """合法命令占位不得被泛化的尖括号规则误杀。""" + upstream, target, prepared = ready_case(tmp_path=tmp_path) + (target / "architecture.md").write_text( + "# Project architecture\n\n" + "Compare <base>...HEAD and publish <feature-branch> for <time range>.\n", + encoding="utf-8", + ) + result, payload = run_check( + target=target, + upstream=upstream, + prepared=prepared, + ) + assert result.returncode == 0, payload + + +@pytest.mark.parametrize("relative_path", NON_PR_FILES) +def test_check_rejects_non_pr_file_equal_to_template( + tmp_path: Path, relative_path: str +) -> None: + """除 PR template 外的八份文档都必须体现项目事实。""" + upstream, target, prepared = ready_case(tmp_path=tmp_path) + (target / relative_path).write_text( + (upstream / "zh" / relative_path).read_text(encoding="utf-8"), + encoding="utf-8", + ) + assert_check_failed( + target=target, + upstream=upstream, + prepared=prepared, + expected_text=f"{relative_path}: 不允许与固定上游模板完全相同", + ) + + +def test_check_treats_crlf_template_copy_as_equal(tmp_path: Path) -> None: + """模板 equality 只应忽略平台换行差异。""" + upstream, target, prepared = ready_case(tmp_path=tmp_path) + text = (upstream / "zh/architecture.md").read_text(encoding="utf-8") + (target / "architecture.md").write_bytes(text.replace("\n", "\r\n").encode()) + assert_check_failed( + target=target, + upstream=upstream, + prepared=prepared, + expected_text="architecture.md: 不允许与固定上游模板完全相同", + ) + + +def test_pr_template_exact_inheritance_is_allowed(tmp_path: Path) -> None: + """PR template 是唯一允许与固定上游完全相同的核心文件。""" + upstream, target, prepared = ready_case(tmp_path=tmp_path) + result, payload = run_check( + target=target, + upstream=upstream, + prepared=prepared, + ) + assert result.returncode == 0, payload + assert payload["files_checked"] == 9 + + +def tracked_ready_case(*, tmp_path: Path) -> tuple[Path, Path, dict[str, Any]]: + """创建核心文档已提交、随后重新 prepare 的 whitespace 场景。""" + upstream, target, _ = ready_case(tmp_path=tmp_path) + commit_all(repo=target, message="project docs") + prepared = prepare(target=target, upstream=upstream) + return upstream, target, prepared + + +@pytest.mark.parametrize("state", ["unstaged", "staged", "committed"]) +def test_check_rejects_tracked_whitespace(tmp_path: Path, state: str) -> None: + """working tree、index 与已提交基线的 whitespace 都必须被发现。""" + upstream, target, prepared = tracked_ready_case(tmp_path=tmp_path) + with (target / "architecture.md").open("a", encoding="utf-8") as stream: + stream.write("bad trailing whitespace \n") + if state == "staged": + git(repo=target, args=["add", "architecture.md"]) + elif state == "committed": + commit_all(repo=target, message="bad baseline") + prepared = prepare(target=target, upstream=upstream) + assert_check_failed( + target=target, + upstream=upstream, + prepared=prepared, + expected_text="trailing whitespace", + ) + + +@pytest.mark.parametrize("bad_line,ignored", [ + ("bad trailing whitespace \n", False), ("<<<<<<< ours\n", False), + ("bad trailing whitespace \n", True), +]) +def test_untracked_whitespace(tmp_path: Path, bad_line: str, ignored: bool) -> None: + """untracked 或 ignored 核心文档必须接受同等检查。""" + upstream, target, prepared = ready_case(tmp_path=tmp_path) + if ignored: + (target / ".gitignore").write_text("architecture.md\n", encoding="utf-8") + with (target / "architecture.md").open("a", encoding="utf-8") as stream: + stream.write(bad_line) + expected = "trailing whitespace" if bad_line.startswith("bad") else "conflict marker" + assert_check_failed( + target=target, + upstream=upstream, + prepared=prepared, + expected_text=expected, + ) + + +@pytest.mark.parametrize( + ("content", "expected"), + [(b"*.local\n", None), (b"*.local \n", "trailing whitespace"), + (b"*.local\n\xff", "不是有效 UTF-8")], +) +def test_check_validates_optional_gitignore( + tmp_path: Path, content: bytes, expected: str | None) -> None: + """可选 gitignore 必须是 UTF-8,且继续使用 Git whitespace 规则。""" + upstream, target, prepared = ready_case(tmp_path=tmp_path) + (target / ".gitignore").write_bytes(content) + if expected is not None: + assert_check_failed( + target=target, upstream=upstream, prepared=prepared, expected_text=expected + ) + else: + result, payload = run_check(target=target, upstream=upstream, prepared=prepared) + assert result.returncode == 0, payload + + +@pytest.mark.parametrize("kind", ["symlink", "directory"]) +def test_check_rejects_non_regular_gitignore(tmp_path: Path, kind: str) -> None: + """可选 gitignore 不得是 symlink 或其他非普通文件。""" + upstream, target, prepared = ready_case(tmp_path=tmp_path) + outside = tmp_path / "outside.gitignore" + outside.write_text("external target\n", encoding="utf-8") + if kind == "symlink": + (target / ".gitignore").symlink_to(outside) + else: + (target / ".gitignore").mkdir() + expected = "符号链接" if kind == "symlink" else "不是普通文件" + assert_check_failed( + target=target, upstream=upstream, prepared=prepared, expected_text=expected + ) + assert outside.read_text(encoding="utf-8") == "external target\n" + + +def test_check_rejects_changed_target_head(tmp_path: Path) -> None: + """prepare 后目标 HEAD 变化必须先于内容检查失败。""" + upstream, target, prepared = ready_case(tmp_path=tmp_path) + commit_all(repo=target, message="unexpected commit") + result, payload = run_check( + target=target, + upstream=upstream, + prepared=prepared, + ) + assert result.returncode == 1 + assert payload["error"] == "目标 HEAD 已变化" + + +def test_check_is_idempotent_and_read_only(tmp_path: Path) -> None: + """连续 check 应返回同一结果且不改变文件或 Git 状态。""" + upstream, target, prepared = ready_case(tmp_path=tmp_path) + before = snapshot(repo=target) + first_result, first = run_check( + target=target, + upstream=upstream, + prepared=prepared, + ) + middle = snapshot(repo=target) + second_result, second = run_check( + target=target, + upstream=upstream, + prepared=prepared, + ) + assert first_result.returncode == second_result.returncode == 0 + assert first == second + assert before == middle == snapshot(repo=target) + assert not (target / ".coding_workflow").exists() + assert not (target / "PR_BODY.md").exists() + + +def test_existing_ignored_pr_body_is_not_sync_input(tmp_path: Path) -> None: + """被目标仓库忽略的通用 PR 草稿不得被读取、重写或删除。""" + upstream, _ = create_upstream(root=tmp_path / "upstream") + target = create_target(root=tmp_path / "target") + (target / ".gitignore").write_text("PR_BODY.md\n", encoding="utf-8") + body = "# Unrelated PR draft\n" + (target / "PR_BODY.md").write_text(body, encoding="utf-8") + prepared = prepare(target=target, upstream=upstream) + specialize(target=target) + result, payload = run_check( + target=target, + upstream=upstream, + prepared=prepared, + ) + assert result.returncode == 0, payload + assert (target / "PR_BODY.md").read_text(encoding="utf-8") == body + assert not (target / ".coding_workflow").exists() + + +def create_installer_upstream(*, root: Path) -> Path: + """创建同时含 canonical Skill 和应被忽略的第二 Skill 的 clean 上游。""" + upstream = init_repo(path=root) + canonical = upstream / "zh/skills/workflow-docs-sync" + canonical.mkdir(parents=True) + (canonical / "SKILL.md").write_text( + "---\nname: workflow-docs-sync\n" + "description: Test canonical skill.\n---\n\n# Skill\n", + encoding="utf-8", + ) + (canonical / "scripts").mkdir() + (canonical / "scripts/helper.py").write_text("VALUE = 1\n", encoding="utf-8") + obsolete = upstream / "zh/skills/workflow-docs-sync-review" + obsolete.mkdir(parents=True) + (obsolete / "SKILL.md").write_text( + "---\nname: workflow-docs-sync-review\n" + "description: Must not be copied.\n---\n", + encoding="utf-8", + ) + commit_all(repo=upstream, message="skills") + return upstream + + +def run_installer( + *, upstream: Path, target: Path | None = None, home: Path | None = None +) -> tuple[subprocess.CompletedProcess[str], dict[str, Any]]: + """以 repo 或临时 HOME user scope 运行安装器并解析 JSON。""" + scope = "repo" if target is not None else "user" + args = [sys.executable, str(INSTALLER), "--scope", scope, "--upstream-dir", str(upstream)] + if target is not None: + args.extend(["--target-repo", str(target)]) + result = run( + args=args, + cwd=REPO_ROOT, + environment={"HOME": str(home)} if home is not None else None, + ) + return result, parse_single_json(result=result) + + +def test_installer_repo_upgrade_removes_only_obsolete_directories(tmp_path: Path) -> None: + """repo 升级应精确删除两份旧 reviewer,并保持其他 Skill 字节不变。""" + upstream = create_installer_upstream(root=tmp_path / "upstream") + target = create_target(root=tmp_path / "target") + for platform_root in PLATFORM_ROOTS: + obsolete = target / platform_root / OBSOLETE_SKILL + obsolete.mkdir(parents=True) + (obsolete / "legacy.bin").write_bytes(b"legacy reviewer\x00") + unrelated = target / platform_root / "unrelated-skill" + unrelated.mkdir() + (unrelated / "keep.bin").write_bytes(b"keep unrelated\x00") + commit_all(repo=target, message="legacy installed skills") + result, payload = run_installer(upstream=upstream, target=target) + assert result.returncode == 0, payload + assert payload["skill"] == "workflow-docs-sync" + expected = [str(target / root / OBSOLETE_SKILL) for root in PLATFORM_ROOTS] + assert payload["removed_obsolete"] == expected + for platform_root in PLATFORM_ROOTS: + installed = target / platform_root / "workflow-docs-sync" + assert (installed / "SKILL.md").is_file() + assert (installed / "scripts/helper.py").is_file() + assert not (target / platform_root / OBSOLETE_SKILL).exists() + unrelated = target / platform_root / "unrelated-skill/keep.bin" + assert unrelated.read_bytes() == b"keep unrelated\x00" + assert not list(target.rglob(".source.json")) + commit_all(repo=target, message="upgrade installed skills") + repeated, repeated_payload = run_installer(upstream=upstream, target=target) + assert repeated.returncode == 0, repeated_payload + assert repeated_payload["removed_obsolete"] == [] + assert git(repo=target, args=["status", "--short"]) == "" + + +def test_installer_removes_obsolete_symlink_and_file(tmp_path: Path) -> None: + """旧路径为 symlink 或普通文件时应 unlink,且不得跟随 symlink。""" + upstream = create_installer_upstream(root=tmp_path / "upstream") + target = create_target(root=tmp_path / "target") + outside = tmp_path / "outside-skill" + outside.mkdir() + (outside / "keep.bin").write_bytes(b"external\x00") + paths = [target / root / OBSOLETE_SKILL for root in PLATFORM_ROOTS] + paths[0].parent.mkdir(parents=True) + paths[0].symlink_to(outside, target_is_directory=True) + paths[1].parent.mkdir(parents=True) + paths[1].write_bytes(b"legacy file\x00") + commit_all(repo=target, message="legacy path types") + result, payload = run_installer(upstream=upstream, target=target) + assert result.returncode == 0, payload + assert payload["removed_obsolete"] == [str(path) for path in paths] + assert all(not path.exists() and not path.is_symlink() for path in paths) + assert (outside / "keep.bin").read_bytes() == b"external\x00" + + +def test_installer_user_upgrade_cleans_temporary_home(tmp_path: Path) -> None: + """user scope 应在临时 HOME 执行同样的精确清理。""" + upstream = create_installer_upstream(root=tmp_path / "upstream") + home = tmp_path / "home" + for platform_root in PLATFORM_ROOTS: + obsolete = home / platform_root / OBSOLETE_SKILL + obsolete.mkdir(parents=True) + (obsolete / "legacy.bin").write_bytes(b"legacy\x00") + unrelated = home / platform_root / "unrelated-skill" + unrelated.mkdir() + (unrelated / "keep.bin").write_bytes(b"keep\x00") + result, payload = run_installer(upstream=upstream, home=home) + assert result.returncode == 0, payload + expected = [str(home / root / OBSOLETE_SKILL) for root in PLATFORM_ROOTS] + assert payload["removed_obsolete"] == expected + for platform_root in PLATFORM_ROOTS: + assert not (home / platform_root / OBSOLETE_SKILL).exists() + assert (home / platform_root / "workflow-docs-sync/SKILL.md").is_file() + unrelated = home / platform_root / "unrelated-skill/keep.bin" + assert unrelated.read_bytes() == b"keep\x00" + assert not list(home.rglob(".source.json")) + + +def test_sync_cli_exposes_only_prepare_and_check() -> None: + """内部 CLI 不得重新长出其他控制入口。""" + result = run( + args=[sys.executable, str(SYNC_SCRIPT), "--help"], + cwd=REPO_ROOT, + ) + assert result.returncode == 0 + assert "{prepare,check}" in result.stdout + assert "status" not in result.stdout + + +def test_deleted_control_plane_files_do_not_exist() -> None: + """旧 launcher、review Skill、runbook 和测试不能作为兼容实现保留。""" + deleted = ( + "PR_BODY.md", + "scripts/sync_coding_workflow.py", + "scripts/sync.sh", + "zh/scripts/sync.sh", + "en/scripts/sync.sh", + "zh/scripts/OPERATIONS.md", + "en/scripts/OPERATIONS.md", + "zh/scripts/sync_pr_review_system.md", + "en/scripts/sync_pr_review_system.md", + "zh/skills/workflow-docs-sync/scripts/harness.py", + "zh/skills/workflow-docs-sync/references/modes.md", + "zh/skills/workflow-docs-sync/references/pass_ownership.json", + "zh/skills/workflow-docs-sync-review", + "tests/test_workflow_sync_skill.py", + "tests/test_sync_coding_workflow.py", + ) + assert [path for path in deleted if (REPO_ROOT / path).exists()] == [] + assert git(repo=REPO_ROOT, args=["ls-files", "--", "PR_BODY.md"]) == "" + + +def production_text_files() -> list[Path]: + """列出结构性负向测试覆盖的生产与用户文档文件。""" + suffixes = {".md", ".py", ".yaml", ".yml", ".json", ".sh"} + excluded = { + REPO_ROOT / "zh/docs/development_workflow/decisions.md", + Path(__file__).resolve(), + } + return sorted( + path + for path in REPO_ROOT.rglob("*") + if path.is_file() + and ".git" not in path.relative_to(REPO_ROOT).parts + and "__pycache__" not in path.parts + and path.suffix in suffixes + and path not in excluded + ) + + +def test_production_files_have_no_forbidden_control_plane_words() -> None: + """除 DEC 历史外,生产面不得残留旧控制面状态或远端绑定词。""" + hits: list[str] = [] + for path in production_text_files(): + text = path.read_text(encoding="utf-8") + for word in FORBIDDEN_CONTROL_WORDS: + if word in text: + hits.append(f"{path.relative_to(REPO_ROOT)}: {word}") + assert hits == [] + + +def test_source_line_budgets() -> None: + """保持测试可审查,并限制生产 Python 总体积。""" + test_lines = Path(__file__).read_text(encoding="utf-8").count("\n") + 1 + production_lines = sum( + path.read_text(encoding="utf-8").count("\n") + 1 + for path in (SYNC_SCRIPT, INSTALLER) + ) + assert 600 <= test_lines <= 1000 + assert 500 <= production_lines <= 700 + + +def test_skill_has_exact_minimal_structure() -> None: + """canonical Skill 只保留合同指定的最小文件结构。""" + actual = sorted( + path.relative_to(SKILL_ROOT).as_posix() + for path in SKILL_ROOT.rglob("*") + if path.is_file() and "__pycache__" not in path.parts + ) + assert actual == [ + "SKILL.md", + "agents/openai.yaml", + "evals/README.md", + "references/audit.md", + "references/sections.md", + "scripts/sync_docs.py", + ] diff --git a/tests/test_workflow_sync_skill.py b/tests/test_workflow_sync_skill.py deleted file mode 100644 index ca72293..0000000 --- a/tests/test_workflow_sync_skill.py +++ /dev/null @@ -1,2150 +0,0 @@ -"""验证轻量 Workflow Docs Sync harness、reviewer 和 installer。 - -调用关系: - pytest / unittest -> WorkflowSyncSkillTests - 测试方法 -> 临时 clean upstream / target Git 仓库 - 临时仓库 -> harness / pinned sync / fake gh / review validator -""" - -from __future__ import annotations - -import importlib.util -import json -import os -import shutil -import subprocess -import sys -import tempfile -import unittest -from pathlib import Path -from typing import Any - - -REPO_ROOT = Path(__file__).resolve().parents[1] -EXEC_SKILL = REPO_ROOT / "zh/skills/workflow-docs-sync" -REVIEW_SKILL = REPO_ROOT / "zh/skills/workflow-docs-sync-review" -HARNESS = EXEC_SKILL / "scripts/harness.py" -VALIDATE_REVIEW = REVIEW_SKILL / "scripts/validate_review.py" -INSTALL_SKILLS = REPO_ROOT / "zh/scripts/install_skills.py" -OPERATIONS = REPO_ROOT / "zh/scripts/OPERATIONS.md" -REVIEW_SYSTEM = REPO_ROOT / "zh/scripts/sync_pr_review_system.md" -OWNERSHIP = EXEC_SKILL / "references/pass_ownership.json" -MODES = ("PREPARE", "PASS_1", "PASS_2", "PASS_3", "PASS_4", "SUBMIT") -PASS_MODES = ("PASS_1", "PASS_2", "PASS_3", "PASS_4") -SKILL_NAMES = ("workflow-docs-sync", "workflow-docs-sync-review") -CORE_SOURCES = ( - "zh/architecture.md", - "zh/capability_contract.json", - "zh/interact.md", - "zh/docs/business_user_guide.md", - "zh/TESTING.md", - "zh/PR_Checklist.md", - "zh/SOP.md", - "zh/AGENTS.md", - "zh/.github/pull_request_template.md", -) -UPSTREAM_SOURCES = ( - "scripts/sync_coding_workflow.py", - "zh/scripts/sync.sh", - "zh/scripts/OPERATIONS.md", - "zh/scripts/sync_pr_review_system.md", - "zh/scripts/install_skills.py", -) -AGENT_SECTIONS = ( - "repo_facts_map", - "full_document_reconcile", - "pr_test_evidence", - "upstream_drift_log", - "agent_execution_evidence", - "remaining_human_decisions", -) - - -def load_module(*, module_path: Path, module_name: str) -> Any: - """加载指定 Python 文件,返回测试所需的真实生产模块。""" - spec = importlib.util.spec_from_file_location(module_name, module_path) - if spec is None or spec.loader is None: - raise AssertionError(f"无法加载 {module_path}") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -SYNC_MODULE = load_module( - module_path=REPO_ROOT / "scripts/sync_coding_workflow.py", - module_name="sync_test", -) -HARNESS_MODULE = load_module(module_path=HARNESS, module_name="harness_test") - - -def run_command( - *, - args: list[str], - cwd: Path, - env: dict[str, str] | None = None, -) -> subprocess.CompletedProcess[str]: - """运行测试命令,返回捕获 UTF-8 输出的 CompletedProcess。""" - return subprocess.run( - args=args, - cwd=cwd, - env=os.environ.copy() if env is None else env, - check=False, - capture_output=True, - encoding="utf-8", - text=True, - ) - - -def git( - *, - repo_root: Path, - args: list[str], -) -> subprocess.CompletedProcess[str]: - """在临时仓库用固定测试身份运行 Git;调用方判断返回码。""" - return run_command( - args=[ - "git", - "-c", "user.email=skill-test@example.com", - "-c", "user.name=skill-test", - *args, - ], - cwd=repo_root, - ) - - -def git_head(*, repo_root: Path) -> str: - """返回临时仓库完整 HEAD SHA。""" - result = git(repo_root=repo_root, args=["rev-parse", "HEAD"]) - if result.returncode != 0: - raise AssertionError(result.stderr) - return result.stdout.strip() - - -def commit_paths( - *, - repo_root: Path, - paths: list[str], - message: str, -) -> None: - """提交显式路径,预期 HEAD 前进。""" - add = git(repo_root=repo_root, args=["add", "--", *paths]) - commit = git( - repo_root=repo_root, - args=["commit", "-q", "-m", message], - ) - if add.returncode != 0 or commit.returncode != 0: - raise AssertionError(add.stderr + commit.stderr) - - -def commit_all(*, repo_root: Path, message: str) -> None: - """提交临时仓库全部非 ignored 文件。""" - add = git(repo_root=repo_root, args=["add", "."]) - commit = git( - repo_root=repo_root, - args=["commit", "-q", "-m", message], - ) - if add.returncode != 0 or commit.returncode != 0: - raise AssertionError(add.stderr + commit.stderr) - - -def initialize_repo(*, repo_root: Path) -> None: - """创建带固定身份和一个基线提交的临时仓库。""" - repo_root.mkdir(parents=True, exist_ok=True) - result = git(repo_root=repo_root, args=["init", "-q"]) - if result.returncode != 0: - raise AssertionError(result.stderr) - (repo_root / "seed.txt").write_text("基线\n", encoding="utf-8") - commit_all(repo_root=repo_root, message="初始化") - - -def copy_upstream_sources(*, upstream: Path) -> None: - """复制真实 sync、模板、prompt、installer 和两个 canonical Skill。""" - for relative_path in (*UPSTREAM_SOURCES, *CORE_SOURCES): - source = REPO_ROOT / relative_path - destination = upstream / relative_path - destination.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(source, destination) - shutil.copytree( - src=REPO_ROOT / "zh/skills", - dst=upstream / "zh/skills", - ) - - -def create_upstream( - *, - repo_root: Path, - fake_sync: bool, - final_passes: bool = True, -) -> str: - """创建 clean upstream;可用快速 fake sync 替代真实实现。""" - copy_upstream_sources(upstream=repo_root) - if fake_sync: - final_lines = ( - " echo 'final complete'\n" - if final_passes - else ( - " echo 'FATAL: final sync check failed:'\n" - " echo ' template residue'\n" - " exit 1\n" - ) - ) - script = repo_root / "zh/scripts/sync.sh" - script.write_text( - "#!/usr/bin/env bash\n" - "set -euo pipefail\n" - "mkdir -p .coding_workflow/diffs\n" - "printf 'workorder\\n' > " - ".coding_workflow/diffs/agent_workorder.md\n" - "printf '{}\\n' > .coding_workflow/diffs/sync_state.json\n" - "[ ! -f PR_BODY.md ] || " - "[ -f .coding_workflow/diffs/pr_body_skeleton.md ] || " - "cp PR_BODY.md .coding_workflow/diffs/pr_body_skeleton.md\n" - "if [ \"$#\" -eq 1 ] && [ \"$1\" = \"--final\" ]; then\n" - f"{final_lines}" - "fi\n" - "echo 'sync complete'\n", - encoding="utf-8", - ) - initialize_repo(repo_root=repo_root) - return git_head(repo_root=repo_root) - - -def pr_body_text() -> str: - """返回保留真实 heading、表头和全部逐 PASS 行的 sentinel body。""" - reconcile = [ - "## Full Document Reconcile", - "", - "| " + " | ".join(SYNC_MODULE.FULL_RECONCILE_COLUMNS) + " |", - "| " - + " | ".join("---" for _ in SYNC_MODULE.FULL_RECONCILE_COLUMNS) - + " |", - ] - for sync_pass in SYNC_MODULE.SYNC_PASSES: - for relative_path in sync_pass["files"]: - reconcile.append( - f"| {sync_pass['title']} | `{relative_path}` | `fixture` | " - "待补充 | 待补充 | 待补充 | 待补充 | 待补充 |" - ) - contents = ( - SYNC_MODULE.render_repo_facts_template(), - "\n".join(reconcile), - SYNC_MODULE.render_pr_test_evidence_template(), - SYNC_MODULE.render_upstream_drift_log_template(), - SYNC_MODULE.render_agent_execution_evidence_template(), - SYNC_MODULE.render_remaining_human_decisions_template().replace( - "- none", "- 待判断: fixture\n- concrete fixture decision"), - ) - body = [ - SYNC_MODULE.SYNC_PR_BODY_MARKER, - SYNC_MODULE.wrap_agent_section( - section_name="repo_facts_map", - content=contents[0], - ), - "<!-- sync:auto:start -->\nfixture\n<!-- sync:auto:end -->", - ] - body.extend( - SYNC_MODULE.wrap_agent_section( - section_name=section_name, - content=content, - ) - for section_name, content in zip(AGENT_SECTIONS[1:], contents[1:]) - ) - return "\n\n".join(body) + "\n" - - -def initialize_core_target( - *, - target: Path, - upstream: Path, - include_pr_body: bool = True, -) -> None: - """创建已提交核心模板、runtime ignore 和可选 ignored PR body 的目标仓库。""" - initialize_repo(repo_root=target) - for source_path in CORE_SOURCES: - destination = target / source_path.removeprefix("zh/") - destination.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(upstream / source_path, destination) - (target / ".gitignore").write_text( - "PR_BODY.md\n" - ".coding_workflow/diffs/\n" - ".coding_workflow/skill_results/\n" - ".coding_workflow/skill_runtime/\n", - encoding="utf-8", - ) - commit_all(repo_root=target, message="准备核心文档") - switch = git( - repo_root=target, - args=["switch", "-q", "-c", "workflow-docs"], - ) - if switch.returncode != 0: - raise AssertionError(switch.stderr) - if include_pr_body: - (target / "PR_BODY.md").write_text( - pr_body_text(), - encoding="utf-8", - ) - - -def initialize_specialized_target(*, target: Path) -> None: - """创建所有核心文档已项目化、但尚未运行真实 sync 的目标仓库。""" - initialize_repo(repo_root=target) - for source_path in CORE_SOURCES: - relative_path = source_path.removeprefix("zh/") - destination = target / relative_path - destination.parent.mkdir(parents=True, exist_ok=True) - content = ( - '{"fixture": "specialized"}\n' - if relative_path.endswith(".json") - else f"# {relative_path}\n\nproject-specific fixture\n" - ) - destination.write_text(content, encoding="utf-8") - commit_all(repo_root=target, message="准备项目化核心文档") - switch = git( - repo_root=target, - args=["switch", "-q", "-c", "workflow-docs"], - ) - if switch.returncode != 0: - raise AssertionError(switch.stderr) - - -def parse_json(*, result: subprocess.CompletedProcess[str]) -> Any: - """解析脚本唯一一行 stdout JSON。""" - lines = [line for line in result.stdout.splitlines() if line] - if len(lines) != 1: - raise AssertionError( - f"stdout={result.stdout!r}, stderr={result.stderr!r}" - ) - return json.loads(lines[0]) - - -def fake_gh_env( - *, - bin_dir: Path, - payload: dict[str, Any], - no_real_gh: bool = False, -) -> dict[str, str]: - """创建读取环境 JSON 的 fake gh,并返回密闭 PATH。""" - bin_dir.mkdir(parents=True, exist_ok=True) - fake_gh = bin_dir / "gh" - fake_gh.write_text( - f"#!{sys.executable}\n" - "import os\n" - "print(os.environ['FAKE_GH_PAYLOAD'])\n", - encoding="utf-8", - ) - fake_gh.chmod(0o755) - env = os.environ.copy() - suffix = "/usr/bin:/bin:/usr/sbin:/sbin" if no_real_gh else env["PATH"] - env["PATH"] = f"{bin_dir}{os.pathsep}{suffix}" - env["FAKE_GH_PAYLOAD"] = json.dumps( - payload, - ensure_ascii=False, - separators=(",", ":"), - ) - return env - - -def run_harness( - *, - command: str, - target: Path, - upstream: Path | None = None, - upstream_sha: str | None = None, - mode: str | None = None, - pr_number: int | None = None, - base: str | None = None, - head: str | None = None, - env: dict[str, str] | None = None, - harness_path: Path = HARNESS, -) -> subprocess.CompletedProcess[str]: - """运行 harness subcommand,并显式拼入适用参数。""" - args = [ - sys.executable, - str(harness_path), - command, - "--target-repo", - str(target), - ] - if command != "status": - if upstream is None or upstream_sha is None: - raise AssertionError("修改命令必须提供 upstream 和 SHA") - args.extend( - [ - "--upstream-dir", - str(upstream), - "--upstream-sha", - upstream_sha, - ] - ) - if mode is not None: - args.extend(["--mode", mode]) - if pr_number is not None: - args.extend(["--pr-number", str(pr_number)]) - if base is not None: - args.extend(["--base", base]) - if head is not None: - args.extend(["--head", head]) - return run_command( - args=args, - cwd=target, - env=env, - ) - - -def finish_one(*, target: Path, upstream: Path, sha: str) -> Any: - """运行 PASS_1 finish,返回可供连续失败重试断言的结果。""" - return run_harness( - command="finish-pass", - target=target, - upstream=upstream, - upstream_sha=sha, - mode="PASS_1", - ) - - -def append_text(*, path: Path, text: str) -> None: - """向 UTF-8 文件末尾追加测试内容。""" - path.write_text( - path.read_text(encoding="utf-8") + text, - encoding="utf-8", - ) - - -def replace_section( - *, - path: Path, - section_name: str, - text: str, -) -> None: - """只替换 PR body 指定 sentinel section 的内容。""" - content = path.read_text(encoding="utf-8") - start = f"<!-- sync:agent:start {section_name} -->" - end = f"<!-- sync:agent:end {section_name} -->" - start_index = content.index(start) + len(start) - end_index = content.index(end, start_index) - path.write_text( - content[:start_index] - + "\n" - + text - + "\n" - + content[end_index:], - encoding="utf-8", - ) - - -def section_text(*, path: Path, section_name: str) -> str: - """读取指定 sentinel section,返回不含边界标记的原始文本。""" - content = path.read_text(encoding="utf-8") - start = f"<!-- sync:agent:start {section_name} -->" - end = f"<!-- sync:agent:end {section_name} -->" - return content[content.index(start) + len(start):content.index(end)] - - -def complete_pass_body(*, path: Path, mode: str) -> None: - """只完成当前 PASS 的既有 heading/owned rows/evidence row。""" - ownership = json.loads(OWNERSHIP.read_text(encoding="utf-8")) - entry = ownership[mode] - if mode == "PASS_1": - repo_facts = section_text( - path=path, - section_name="repo_facts_map", - ) - replace_section( - path=path, - section_name="repo_facts_map", - text=repo_facts.replace("证据: 待补充", "证据: fixture evidence"), - ) - - # 逐行替换能保留表头、未来 PASS 行和完整 handoff 结构。 - reconcile = section_text( - path=path, - section_name="full_document_reconcile", - ).splitlines() - for relative_path in entry["owned"]: - matches = [ - index - for index, line in enumerate(reconcile) - if f"`{relative_path}`" in line - ] - if len(matches) != 1: - raise AssertionError(f"reconcile row mismatch: {relative_path}") - values = ( - "## Full Document Reconcile", "none", "待判断", r"literal \| pipe", - "none", - ) - for value in values: - reconcile[matches[0]] = reconcile[matches[0]].replace( - "待补充", value, 1, - ) - replace_section( - path=path, - section_name="full_document_reconcile", - text="\n".join(reconcile), - ) - title = entry["title"].split(maxsplit=1)[1] - evidence = section_text( - path=path, - section_name="agent_execution_evidence", - ).splitlines() - matches = [ - index for index, line in enumerate(evidence) - if f"| {title} |" in line - ] - if len(matches) != 1: - raise AssertionError(f"execution row mismatch: {mode}") - for value in ("## Agent Execution Evidence", "none", r"x \| y"): - evidence[matches[0]] = evidence[matches[0]].replace( - "待补充", value, 1, - ) - replace_section( - path=path, - section_name="agent_execution_evidence", - text="\n".join(evidence), - ) - - -def complete_prepare( - *, - target: Path, - upstream: Path, - upstream_sha: str, -) -> None: - """执行 PREPARE,失败时立即抛出测试错误。""" - result = run_harness( - command="prepare", - target=target, - upstream=upstream, - upstream_sha=upstream_sha, - ) - if result.returncode != 0: - raise AssertionError(result.stdout + result.stderr) - - -def complete_pass( - *, - target: Path, - upstream: Path, - upstream_sha: str, - mode: str, - relative_paths: list[str], -) -> None: - """按 start、逐行 body/owned docs 修改、finish 完成一个 PASS。""" - start = run_harness( - command="start-pass", - target=target, - upstream=upstream, - upstream_sha=upstream_sha, - mode=mode, - ) - if start.returncode != 0: - raise AssertionError(start.stdout + start.stderr) - complete_pass_body(path=target / "PR_BODY.md", mode=mode) - for relative_path in relative_paths: - append_text( - path=target / relative_path, - text=f"\n{mode} semantic edit\n", - ) - finish = run_harness( - command="finish-pass", - target=target, - upstream=upstream, - upstream_sha=upstream_sha, - mode=mode, - ) - if finish.returncode != 0: - raise AssertionError(finish.stdout + finish.stderr) - - -def complete_prepare_and_passes( - *, - target: Path, - upstream: Path, - upstream_sha: str, -) -> None: - """完成 PREPARE 与 PASS_1–PASS_4,保留累计 dirty 文档。""" - complete_prepare( - target=target, - upstream=upstream, - upstream_sha=upstream_sha, - ) - edits = { - "PASS_1": ["architecture.md"], - "PASS_2": [ - "capability_contract.json", - "interact.md", - "docs/business_user_guide.md", - ], - "PASS_3": ["TESTING.md"], - "PASS_4": ["PR_Checklist.md", "SOP.md", "AGENTS.md"], - } - for mode, relative_paths in edits.items(): - complete_pass( - target=target, - upstream=upstream, - upstream_sha=upstream_sha, - mode=mode, - relative_paths=relative_paths, - ) - - -def complete_real_prepare_and_passes( - *, - target: Path, - upstream: Path, - upstream_sha: str, -) -> None: - """用正式 body 保留全表结构并逐 PASS 完成已有责任行。""" - complete_prepare( - target=target, - upstream=upstream, - upstream_sha=upstream_sha, - ) - for mode, relative_paths in { - "PASS_1": ["architecture.md"], - "PASS_2": ["interact.md"], - "PASS_3": ["TESTING.md"], - "PASS_4": ["AGENTS.md"], - }.items(): - complete_pass( - target=target, - upstream=upstream, - upstream_sha=upstream_sha, - mode=mode, - relative_paths=relative_paths, - ) - - -def prepare_and_seal_real_submit( - *, - target: Path, - upstream: Path, - upstream_sha: str, -) -> list[str]: - """建立真实 SUBMIT baseline,填 evidence 后 seal 并返回精确提交集合。""" - prepared = run_harness( - command="prepare-submit", - target=target, - upstream=upstream, - upstream_sha=upstream_sha, - ) - if prepared.returncode != 0: - raise AssertionError(prepared.stdout + prepared.stderr) - replace_section( - path=target / "PR_BODY.md", - section_name="pr_test_evidence", - text=( - "## PR Test Evidence\n\n" - "- commands: python -m pytest -q\n" - "- result: passed\n" - "- not run / N/A: none" - ), - ) - sealed = run_harness( - command="seal-submit", - target=target, - upstream=upstream, - upstream_sha=upstream_sha, - ) - if sealed.returncode != 0: - raise AssertionError(sealed.stdout + sealed.stderr) - return parse_json(result=sealed)["allowed_commit_paths"] - - -def pull_request_payload( - *, - head_sha: str, - body: str, - pr_number: int = 12, - base: str = "main", - head: str = "workflow-docs", -) -> dict[str, Any]: - """返回 finish-submit fake gh 所需完整 PR JSON。""" - return { - "number": pr_number, - "state": "OPEN", - "headRefOid": head_sha, - "headRefName": head, - "baseRefName": base, - "body": body, - "url": f"https://example.test/pull/{pr_number}", - } - - -def valid_review( - *, - verdict: str, - head_sha: str, - upstream_sha: str, - pr_number: int = 12, - blocker_section: bool = False, -) -> dict[str, Any]: - """构造完整 review fixture,可选制造 section BLOCKER。""" - return { - "verdict": verdict, - "findings": [], - "pr": pr_number, - "head_sha": head_sha, - "upstream_sha": upstream_sha, - "review_sections": { - section: { - "verdict": ( - "BLOCKER" - if blocker_section and section == "contract_compliance" - else "PASS" - ), - "evidence": f"{section} evidence", - } - for section in ( - "contract_compliance", - "evidence_quality", - "full_reconcile_closure", - "per_pass_evidence_and_propagation", - "test_drift_and_execution_evidence", - "upstream_cross_check", - "operability", - ) - }, - "evidence_index": ["PR body", "architecture.md", "pytest"], - } - - -def review_pr_payload(*, head_sha: str, upstream_sha: str) -> dict[str, Any]: - """返回 reviewer validator 所需真实 identity 与 auto SHA。""" - return { - "number": 12, - "state": "OPEN", - "headRefOid": head_sha, - "body": ( - "<!-- sync:auto:start -->\n" - f"- upstream_resolved_commit: {upstream_sha}\n" - "<!-- sync:auto:end -->\n" - ), - } - - -class WorkflowSyncSkillTests(unittest.TestCase): - """覆盖 prompt、mode handoff、提交、review、安装和模板合同。""" - - def test_titles_ownership_and_transport_alignment(self) -> None: - """PASS 标题/ownership 双向对账,人工与 Skill transport 不冲突。""" - ownership = json.loads(OWNERSHIP.read_text(encoding="utf-8")) - expected = { - f"PASS_{index}": { - "title": f"2.{index} {sync_pass['title']}", - "owned": list(sync_pass["files"]), - } - for index, sync_pass in enumerate( - SYNC_MODULE.SYNC_PASSES, - start=1, - ) - } - self.assertEqual(set(ownership), set(expected)) - self.assertEqual( - HARNESS_MODULE.PERMITTED_INHERIT_PATHS, - set(SYNC_MODULE.PERMITTED_INHERIT_FILES), - ) - operations = OPERATIONS.read_text(encoding="utf-8") - for mode in PASS_MODES: - self.assertEqual(ownership[mode]["title"], expected[mode]["title"]) - self.assertEqual(ownership[mode]["owned"], expected[mode]["owned"]) - heading = f"### {ownership[mode]['title']}" - start = operations.index(heading) - candidates = [ - position - for marker in ("\n### ", "\n## ") - if (position := operations.find(marker, start + 1)) >= 0 - ] - section = operations[start:min(candidates)] - opening = section.index("```") - body_start = section.index("\n", opening) - body_end = section.index("```", body_start + 1) - block = section[body_start + 1:body_end] - self.assertIn("人工 code-block 模式", block) - self.assertIn("Skill 模式", block) - self.assertIn("不要执行本 block 末尾的 curl", block) - - def test_skills_do_not_copy_semantic_prompts(self) -> None: - """两个 SKILL.md 不得复制 PASS/reviewer 任意连续 40 字符。""" - source_texts = [ - OPERATIONS.read_text(encoding="utf-8"), - REVIEW_SYSTEM.read_text(encoding="utf-8"), - ] - skill_texts = [ - (EXEC_SKILL / "SKILL.md").read_text(encoding="utf-8"), - (REVIEW_SKILL / "SKILL.md").read_text(encoding="utf-8"), - ] - for source_text in source_texts: - for index in range(len(source_text) - 39): - fragment = source_text[index:index + 40] - if fragment.isspace(): - continue - for skill_text in skill_texts: - self.assertNotIn(fragment, skill_text) - - def test_template_headings_and_pollution_regression(self) -> None: - """上游空标题必须保留,且不能登记上游专属实现路径。""" - agents = (REPO_ROOT / "zh/AGENTS.md").read_text(encoding="utf-8") - testing = (REPO_ROOT / "zh/TESTING.md").read_text(encoding="utf-8") - self.assertIn("### 核心模块", agents) - self.assertIn("## 测试文件简介", testing) - agents_inventory = agents.split("### 业务逻辑", 1)[0] - for path in ( - "skills/workflow-docs-sync/", - "skills/workflow-docs-sync-review/", - "scripts/install_skills.py", - ): - self.assertNotIn(path, agents_inventory) - self.assertNotIn("tests/test_workflow_sync_skill.py", testing) - - def test_prepare_requires_clean_pinned_upstream(self) -> None: - """PREPARE 拒绝业务 dirty、错误 SHA 和 dirty upstream。""" - with tempfile.TemporaryDirectory() as temp_dir: - root = Path(temp_dir) - upstream = root / "upstream" - target = root / "target" - sha = create_upstream( - repo_root=upstream, - fake_sync=True, - ) - initialize_core_target(target=target, upstream=upstream) - append_text(path=target / "seed.txt", text="dirty\n") - dirty = run_harness( - command="prepare", - target=target, - upstream=upstream, - upstream_sha=sha, - ) - self.assertNotEqual(dirty.returncode, 0) - git(repo_root=target, args=["restore", "seed.txt"]) - wrong = run_harness( - command="prepare", - target=target, - upstream=upstream, - upstream_sha="0" * 40, - ) - self.assertNotEqual(wrong.returncode, 0) - append_text( - path=upstream / "zh/scripts/OPERATIONS.md", - text="\ndirty\n", - ) - dirty_upstream = run_harness( - command="prepare", - target=target, - upstream=upstream, - upstream_sha=sha, - ) - self.assertNotEqual(dirty_upstream.returncode, 0) - - def test_prepare_creates_body_and_pass_one_retries_in_place(self) -> None: - """Case A 各失败点保留 active PASS,补全责任项后原地成功。""" - with tempfile.TemporaryDirectory() as temp_dir: - root = Path(temp_dir) - upstream = root / "upstream" - target = root / "target" - sha = create_upstream(repo_root=upstream, fake_sync=False) - initialize_repo(repo_root=target) - result = run_harness( - command="prepare", - target=target, - upstream=upstream, - upstream_sha=sha, - ) - self.assertEqual(result.returncode, 0, msg=result.stdout) - payload = parse_json(result=result) - self.assertIn("architecture.md", payload["changed_paths"]) - self.assertTrue( - ( - target - / ".coding_workflow/skill_results/PREPARE.json" - ).is_file() - ) - body_path = target / "PR_BODY.md" - skeleton = target / ".coding_workflow/diffs/pr_body_skeleton.md" - self.assertTrue(body_path.is_file()) - self.assertEqual( - body_path.read_text(encoding="utf-8"), - skeleton.read_text(encoding="utf-8"), - ) - body = body_path.read_text(encoding="utf-8") - self.assertTrue(all( - body.count(f"<!-- sync:agent:start {name} -->") == 1 - for name in AGENT_SECTIONS - )) - start = run_harness( - command="start-pass", - target=target, - upstream=upstream, - upstream_sha=sha, - mode="PASS_1", - ) - self.assertEqual(start.returncode, 0, msg=start.stdout) - append_text( - path=target / "architecture.md", - text="\nPASS_1 semantic edit\n", - ) - incomplete = finish_one(target=target, upstream=upstream, sha=sha) - self.assertNotEqual(incomplete.returncode, 0) - self.assertEqual( - parse_json(result=incomplete)["error"], - "PASS PR_BODY 未完成", - ) - run_path = target / ".coding_workflow/skill_runtime/run.json" - state = json.loads(run_path.read_text(encoding="utf-8")) - self.assertEqual(state["active_mode"], "PASS_1") - body_path.unlink() - deleted = finish_one(target=target, upstream=upstream, sha=sha) - self.assertNotEqual(deleted.returncode, 0) - self.assertEqual( - parse_json(result=deleted)["error"], - "PR_BODY scope 无法校验", - ) - baseline = json.loads( - ( - target - / ".coding_workflow/skill_runtime/baselines/PASS_1.json" - ).read_text(encoding="utf-8") - ) - body_path.write_text(baseline["pr_body_text"], encoding="utf-8") - pending_execution = section_text( - path=body_path, - section_name="agent_execution_evidence", - ) - complete_pass_body(path=body_path, mode="PASS_1") - replace_section( - path=body_path, - section_name="agent_execution_evidence", - text=pending_execution, - ) - pending = finish_one(target=target, upstream=upstream, sha=sha) - self.assertNotEqual(pending.returncode, 0) - self.assertIn("PASS 1", parse_json(result=pending)["detail"]) - current_state = json.loads(run_path.read_text(encoding="utf-8")) - self.assertEqual(current_state["active_mode"], "PASS_1") - self.assertFalse( - ( - target / ".coding_workflow/skill_results/PASS_1.json" - ).exists() - ) - - complete_pass_body(path=body_path, mode="PASS_1") - done = body_path.read_text(encoding="utf-8") - fact_evidence = "证据: fixture evidence" - reconcile_values = ( - r"## Full Document Reconcile | none | 待判断 | " - r"literal \| pipe | none" - ) - execution_values = r"## Agent Execution Evidence | none | x \| y" - execution_header = ( - "| pass | required files read | key facts observed | " - "skipped files / reason |" - ) - invalid_bodies = { - "repo_empty": done.replace(f"{fact_evidence}\n", "", 1), - "evidence_empty": done.replace(fact_evidence, "证据:", 1), - "reconcile_empty": done.replace( - reconcile_values, " | | | | ", 1, - ), - "execution_empty": done.replace( - execution_values, " | | ", 1, - ), - "literal_pipe": done.replace( - r"literal \| pipe", "literal | pipe", 1, - ), - "execution_header": done.replace( - execution_header, "| bad | bad | bad | bad | bad |", 1, - ), - } - for scenario, invalid_body in invalid_bodies.items(): - with self.subTest(completion=scenario): - body_path.write_text(invalid_body, encoding="utf-8") - invalid = finish_one( - target=target, upstream=upstream, sha=sha, - ) - self.assertNotEqual(invalid.returncode, 0) - payload = parse_json(result=invalid) - self.assertEqual(payload["error"], "PASS PR_BODY 未完成") - if scenario == "literal_pipe": - self.assertIn( - "表格 cell 中的字面 | 必须写为 \\| 或改用 <br>", - payload["detail"], - ) - with self.assertRaises(HARNESS_MODULE.HarnessError): - HARNESS_MODULE.assert_remaining_decisions_complete( - section_text="## Remaining Human Decisions\n", - ) - body_path.write_text(done, encoding="utf-8") - upstream_architecture = ( - upstream / "zh/architecture.md" - ).read_text(encoding="utf-8") - (target / "architecture.md").write_bytes( - upstream_architecture.replace("\n", "\r\n").encode("utf-8") - ) - template_copy = finish_one( - target=target, upstream=upstream, sha=sha - ) - self.assertNotEqual(template_copy.returncode, 0) - self.assertEqual( - parse_json(result=template_copy)["error"], - "PASS owned 文档未项目化", - ) - self.assertIn( - "architecture.md", - parse_json(result=template_copy)["detail"], - ) - append_text( - path=target / "architecture.md", - text="\nproject architecture evidence\n", - ) - self.assertIn( - "| PASS 2 - Capability / User Behavior | " - "`capability_contract.json` | `installed_template` | 待补充", - body_path.read_text(encoding="utf-8"), - ) - finish = finish_one(target=target, upstream=upstream, sha=sha) - self.assertEqual(finish.returncode, 0, msg=finish.stdout) - state = json.loads(run_path.read_text(encoding="utf-8")) - self.assertEqual(state["completed_modes"], ["PREPARE", "PASS_1"]) - self.assertIsNone(state["active_mode"]) - pass_result = json.loads( - ( - target / ".coding_workflow/skill_results/PASS_1.json" - ).read_text(encoding="utf-8") - ) - self.assertEqual(pass_result["status"], "passed") - self.assertEqual( - pass_result["changed_paths"], - ["PR_BODY.md", "architecture.md"], - ) - - def test_prepare_preserves_valid_body_and_rejects_damage(self) -> None: - """PREPARE 迁移合法旧 schema,并在 sync 前拒绝损坏 body。""" - with tempfile.TemporaryDirectory() as temp_dir: - root = Path(temp_dir) - upstream = root / "upstream" - sha = create_upstream(repo_root=upstream, fake_sync=False) - valid = pr_body_text().replace( - "证据: 待补充", "证据: legacy retained", 1, - ) - sections = SYNC_MODULE.preserved_agent_sections( - text=valid, state={}, - ) - blocks = { - name: SYNC_MODULE.wrap_agent_section( - section_name=name, content=sections[name], - ) - for name in AGENT_SECTIONS - } - reordered = valid.replace( - blocks["repo_facts_map"], "<swap>", 1, - ).replace( - blocks["full_document_reconcile"], - blocks["repo_facts_map"], 1, - ).replace("<swap>", blocks["full_document_reconcile"], 1) - last_heading = SYNC_MODULE.REPO_FACTS_HEADINGS[-1] - old_repo = valid.replace( - f"{last_heading}\n证据: 待补充", "", 1, - ) - successes = { - "valid": valid, - "missing_one": old_repo.replace( - blocks["pr_test_evidence"], "", - ), - "missing_two": valid.replace( - blocks["pr_test_evidence"], "", - ).replace(blocks["upstream_drift_log"], ""), - "reordered": reordered, - } - for scenario, old_body in successes.items(): - with self.subTest(migration=scenario): - target = root / scenario - initialize_core_target(target=target, upstream=upstream) - body_path = target / "PR_BODY.md" - body_path.write_text(old_body, encoding="utf-8") - result = run_harness( - command="prepare", target=target, - upstream=upstream, upstream_sha=sha, - ) - self.assertEqual(result.returncode, 0, msg=result.stdout) - actual = body_path.read_text(encoding="utf-8") - HARNESS_MODULE.parse_agent_sections(text=actual) - preserve = SYNC_MODULE.preserved_agent_sections - self.assertEqual( - preserve(text=old_body, state={}), - preserve(text=actual, state={}), - ) - - heading_target = root / "missing_one" - body_path = heading_target / "PR_BODY.md" - heading_body = body_path.read_text(encoding="utf-8") - self.assertNotIn(last_heading, heading_body) - started = run_harness( - command="start-pass", target=heading_target, - upstream=upstream, upstream_sha=sha, mode="PASS_1", - ) - self.assertEqual(started.returncode, 0, msg=started.stdout) - diffs = heading_target / ".coding_workflow/diffs" - schema = section_text( - path=diffs / "pr_body_skeleton.md", - section_name="repo_facts_map", - ) - repo_facts = section_text( - path=body_path, section_name="repo_facts_map", - ) - addition = schema[schema.index(last_heading):] - replace_section( - path=body_path, section_name="repo_facts_map", - text=f"{repo_facts.rstrip()}\n\n{addition}", - ) - complete_pass_body(path=body_path, mode="PASS_1") - append_text( - path=heading_target / "architecture.md", - text="\nproject architecture evidence\n", - ) - finished = finish_one( - target=heading_target, upstream=upstream, sha=sha, - ) - self.assertEqual(finished.returncode, 0, msg=finished.stdout) - repo_start = "<!-- sync:agent:start repo_facts_map -->" - repo_end = "<!-- sync:agent:end repo_facts_map -->" - full_start = "<!-- sync:agent:start full_document_reconcile -->" - full_end = "<!-- sync:agent:end full_document_reconcile -->" - missing = valid.replace(blocks["pr_test_evidence"], "", 1) - marker = SYNC_MODULE.SYNC_PR_BODY_MARKER - auto_end = SYNC_MODULE.SYNC_AUTO_END - damage = { - "non_sentinel": "legacy PR body\n", - "marker_duplicate": valid.replace(marker, marker * 2, 1), - "auto_partial": valid.replace(auto_end, "", 1), - "only_start": valid.replace(repo_end, "", 1), - "only_end": valid.replace(repo_start, "", 1), - "missing_duplicate": missing.replace( - blocks["full_document_reconcile"], - blocks["full_document_reconcile"] * 2, 1, - ), - "missing_partial": missing.replace(full_end, "", 1), - "overlap": valid.replace(repo_end, "", 1).replace( - full_start, f"{full_start}\n{repo_end}", 1, - ), - "outside": f"outside\n{valid}", - } - for scenario, damaged_body in damage.items(): - with self.subTest(damage=scenario): - target = root / f"damage_{scenario}" - initialize_core_target(target=target, upstream=upstream) - body_path = target / "PR_BODY.md" - body_path.write_text(damaged_body, encoding="utf-8") - result = run_harness( - command="prepare", target=target, - upstream=upstream, upstream_sha=sha, - ) - self.assertNotEqual(result.returncode, 0) - self.assertEqual( - parse_json(result=result)["error"], "已有 PR_BODY.md 无效", - ) - self.assertEqual( - body_path.read_text(encoding="utf-8"), damaged_body, - ) - run_path = target / HARNESS_MODULE.RUN_PATH - self.assertFalse(run_path.exists()) - - def test_complete_mode_sequence_handoff(self) -> None: - """PREPARE 至 SUBMIT 起点必须允许合法累计 dirty handoff。""" - with tempfile.TemporaryDirectory() as temp_dir: - root = Path(temp_dir) - upstream = root / "upstream" - target = root / "target" - sha = create_upstream(repo_root=upstream, fake_sync=True) - initialize_core_target(target=target, upstream=upstream) - complete_prepare_and_passes( - target=target, - upstream=upstream, - upstream_sha=sha, - ) - submit = run_harness( - command="prepare-submit", - target=target, - upstream=upstream, - upstream_sha=sha, - ) - self.assertEqual(submit.returncode, 0, msg=submit.stdout) - payload = parse_json(result=submit) - self.assertEqual( - payload["allowed_commit_paths"], - [ - "AGENTS.md", "PR_Checklist.md", "SOP.md", "TESTING.md", - "architecture.md", "capability_contract.json", - "docs/business_user_guide.md", "interact.md", - ], - ) - - def test_start_pass_rejects_inter_mode_edit(self) -> None: - """后续 PASS 文件在 PASS_1 开始前被修改时必须失败。""" - with tempfile.TemporaryDirectory() as temp_dir: - root = Path(temp_dir) - upstream = root / "upstream" - target = root / "target" - sha = create_upstream(repo_root=upstream, fake_sync=True) - initialize_core_target(target=target, upstream=upstream) - complete_prepare( - target=target, - upstream=upstream, - upstream_sha=sha, - ) - append_text(path=target / "TESTING.md", text="\ninter-mode edit\n") - result = run_harness( - command="start-pass", - target=target, - upstream=upstream, - upstream_sha=sha, - mode="PASS_1", - ) - self.assertNotEqual(result.returncode, 0) - self.assertEqual( - parse_json(result=result)["error"], - "mode 间发现未归属改动", - ) - - def test_finish_pass_enforces_path_and_pr_body_sections(self) -> None: - """当前 PASS 只能修改 owned 文档和允许的 PR body section。""" - for scenario in ("path", "rename", "section"): - with self.subTest(scenario=scenario): - with tempfile.TemporaryDirectory() as temp_dir: - root = Path(temp_dir) - upstream = root / "upstream" - target = root / "target" - sha = create_upstream(repo_root=upstream, fake_sync=True) - initialize_core_target(target=target, upstream=upstream) - if scenario == "rename": - source = target / "?? architecture.md" - source.touch() - (target / "architecture.md").unlink() - commit_paths( - repo_root=target, - paths=["architecture.md", "?? architecture.md"], - message="prepare rename source", - ) - complete_prepare( - target=target, - upstream=upstream, - upstream_sha=sha, - ) - start = run_harness( - command="start-pass", - target=target, - upstream=upstream, - upstream_sha=sha, - mode="PASS_1", - ) - self.assertEqual(start.returncode, 0) - if scenario == "rename": - source.replace(target / "architecture.md") - git( - repo_root=target, - args=["add", "-N", "architecture.md"], - ).check_returncode() - else: - append_text( - path=target / "architecture.md", - text="\nPASS_1 edit\n", - ) - if scenario == "path": - append_text( - path=target / "TESTING.md", - text="\nunauthorized\n", - ) - elif scenario == "section": - replace_section( - path=target / "PR_BODY.md", - section_name="pr_test_evidence", - text="unauthorized section", - ) - finish = run_harness( - command="finish-pass", - target=target, - upstream=upstream, - upstream_sha=sha, - mode="PASS_1", - ) - self.assertNotEqual(finish.returncode, 0) - if scenario == "rename": - self.assertEqual( - parse_json(result=finish)["error"], - "PASS 产生越权路径", - ) - - def test_full_sequence_completes_submit_with_body_binding(self) -> None: - """真实 sync 完成 seal 失败、修复、提交和无真实 gh 的全序列。""" - with tempfile.TemporaryDirectory() as temp_dir: - root = Path(temp_dir) - upstream = root / "upstream" - target = root / "target" - fake_bin = root / "bin" - sha = create_upstream(repo_root=upstream, fake_sync=False) - initialize_specialized_target(target=target) - complete_real_prepare_and_passes( - target=target, - upstream=upstream, - upstream_sha=sha, - ) - prepared = run_harness( - command="prepare-submit", - target=target, - upstream=upstream, - upstream_sha=sha, - ) - self.assertEqual(prepared.returncode, 0, msg=prepared.stdout) - run_path = target / ".coding_workflow/skill_runtime/run.json" - before = json.loads(run_path.read_text(encoding="utf-8")) - self.assertEqual(before["active_mode"], "SUBMIT") - self.assertFalse(before["submit_ready"]) - failed = run_harness( - command="seal-submit", - target=target, - upstream=upstream, - upstream_sha=sha, - ) - self.assertNotEqual(failed.returncode, 0) - self.assertEqual( - parse_json(result=failed)["error"], - "pinned final gate 失败", - ) - after = json.loads(run_path.read_text(encoding="utf-8")) - self.assertEqual(after["run_id"], before["run_id"]) - self.assertEqual(after["active_mode"], "SUBMIT") - self.assertFalse(after["submit_ready"]) - self.assertTrue( - ( - target - / ".coding_workflow/skill_runtime/baselines/SUBMIT.json" - ).is_file() - ) - replace_section( - path=target / "PR_BODY.md", - section_name="pr_test_evidence", - text=( - "## PR Test Evidence\n\n" - "- commands: python -m pytest -q\n" - "- result: passed\n" - "- not run / N/A: none" - ), - ) - sealed = run_harness( - command="seal-submit", - target=target, - upstream=upstream, - upstream_sha=sha, - ) - self.assertEqual(sealed.returncode, 0, msg=sealed.stdout) - allowed = parse_json(result=sealed)["allowed_commit_paths"] - commit_paths( - repo_root=target, - paths=allowed, - message="workflow docs", - ) - head_sha = git_head(repo_root=target) - body = (target / "PR_BODY.md").read_text(encoding="utf-8") - env = fake_gh_env( - bin_dir=fake_bin, - payload=pull_request_payload( - head_sha=head_sha, - body=body.rstrip("\n") + "\r\n", - ), - no_real_gh=True, - ) - finish = run_harness( - command="finish-submit", - target=target, - upstream=upstream, - upstream_sha=sha, - pr_number=12, - base="main", - head="workflow-docs", - env=env, - ) - self.assertEqual(finish.returncode, 0, msg=finish.stdout) - state = json.loads( - ( - target / ".coding_workflow/skill_runtime/run.json" - ).read_text(encoding="utf-8") - ) - self.assertEqual(state["completed_modes"], list(MODES)) - repeated = run_harness( - command="start-pass", - target=target, - upstream=upstream, - upstream_sha=sha, - mode="PASS_1", - ) - self.assertNotEqual(repeated.returncode, 0) - self.assertEqual( - parse_json(result=repeated)["error"], - "workflow 已完成", - ) - - def test_failed_gate_restart_uses_local_start_head(self) -> None: - """未发布分支也能保留旧 runtime 并从 start_head 整轮重启。""" - with tempfile.TemporaryDirectory() as temp_dir: - root = Path(temp_dir) - upstream = root / "upstream" - target = root / "target" - restart = root / "restart" - publish = root / "publish.git" - fake_bin = root / "bin" - sha = create_upstream(repo_root=upstream, fake_sync=False) - initialize_specialized_target(target=target) - bare = run_command( - args=["git", "init", "-q", "--bare", str(publish)], - cwd=root, - ) - self.assertEqual(bare.returncode, 0, msg=bare.stderr) - add_origin = git( - repo_root=target, - args=["remote", "add", "origin", str(publish)], - ) - self.assertEqual(add_origin.returncode, 0, msg=add_origin.stderr) - remote_head = git( - repo_root=target, - args=["ls-remote", "--heads", "origin", "workflow-docs"], - ) - self.assertEqual(remote_head.stdout, "") - complete_real_prepare_and_passes( - target=target, - upstream=upstream, - upstream_sha=sha, - ) - prepared = run_harness( - command="prepare-submit", - target=target, - upstream=upstream, - upstream_sha=sha, - ) - self.assertEqual(prepared.returncode, 0, msg=prepared.stdout) - failed = run_harness( - command="seal-submit", - target=target, - upstream=upstream, - upstream_sha=sha, - ) - self.assertNotEqual(failed.returncode, 0) - old_run_path = ( - target / ".coding_workflow/skill_runtime/run.json" - ) - old_state = json.loads(old_run_path.read_text(encoding="utf-8")) - clone = run_command( - args=[ - "git", "clone", "--no-local", "--no-checkout", - "--origin", "failed-source", - str(target), str(restart), - ], - cwd=root, - ) - self.assertEqual(clone.returncode, 0, msg=clone.stderr) - checkout = git( - repo_root=restart, - args=[ - "switch", "-C", "workflow-docs", - old_state["project_head"], - ], - ) - self.assertEqual(checkout.returncode, 0, msg=checkout.stderr) - restore_origin = git( - repo_root=restart, - args=["remote", "add", "origin", str(publish)], - ) - self.assertEqual( - restore_origin.returncode, - 0, - msg=restore_origin.stderr, - ) - self.assertEqual( - git_head(repo_root=restart), - old_state["project_head"], - ) - complete_real_prepare_and_passes( - target=restart, - upstream=upstream, - upstream_sha=sha, - ) - allowed = prepare_and_seal_real_submit( - target=restart, - upstream=upstream, - upstream_sha=sha, - ) - commit_paths( - repo_root=restart, - paths=allowed, - message="restarted workflow docs", - ) - push = git( - repo_root=restart, - args=[ - "push", "-q", "origin", - "HEAD:refs/heads/workflow-docs", - ], - ) - self.assertEqual(push.returncode, 0, msg=push.stderr) - restarted_head = git_head(repo_root=restart) - env = fake_gh_env( - bin_dir=fake_bin, - payload=pull_request_payload( - head_sha=restarted_head, - body=(restart / "PR_BODY.md").read_text( - encoding="utf-8" - ), - ), - no_real_gh=True, - ) - finish = run_harness( - command="finish-submit", - target=restart, - upstream=upstream, - upstream_sha=sha, - pr_number=12, - base="main", - head="workflow-docs", - env=env, - ) - self.assertEqual(finish.returncode, 0, msg=finish.stdout) - preserved = json.loads(old_run_path.read_text(encoding="utf-8")) - self.assertEqual(preserved["run_id"], old_state["run_id"]) - self.assertEqual(preserved["active_mode"], "SUBMIT") - self.assertFalse(preserved["submit_ready"]) - - def test_submit_rejects_extra_or_missing_committed_paths(self) -> None: - """真实 final 后 committed set 必须精确等于 sealed allowed set。""" - for scenario in ("extra", "missing"): - with self.subTest(scenario=scenario): - with tempfile.TemporaryDirectory() as temp_dir: - root = Path(temp_dir) - upstream = root / "upstream" - target = root / "target" - fake_bin = root / "bin" - sha = create_upstream(repo_root=upstream, fake_sync=False) - initialize_specialized_target(target=target) - complete_real_prepare_and_passes( - target=target, - upstream=upstream, - upstream_sha=sha, - ) - allowed = prepare_and_seal_real_submit( - target=target, - upstream=upstream, - upstream_sha=sha, - ) - if scenario == "extra": - append_text( - path=target / "seed.txt", - text="business\n", - ) - commit_paths( - repo_root=target, - paths=[*allowed, "seed.txt"], - message="extra path", - ) - else: - commit_paths( - repo_root=target, - paths=[allowed[0]], - message="partial docs", - ) - head_sha = git_head(repo_root=target) - env = fake_gh_env( - bin_dir=fake_bin, - payload=pull_request_payload( - head_sha=head_sha, - body=( - target / "PR_BODY.md" - ).read_text(encoding="utf-8"), - ), - ) - finish = run_harness( - command="finish-submit", - target=target, - upstream=upstream, - upstream_sha=sha, - pr_number=12, - base="main", - head="workflow-docs", - env=env, - ) - self.assertNotEqual(finish.returncode, 0) - self.assertEqual( - parse_json(result=finish)["error"], - "SUBMIT committed scope 不匹配", - ) - - def test_submit_rejects_multiple_commits(self) -> None: - """sealed 路径即使精确,SUBMIT 也只能新增一个 commit。""" - with tempfile.TemporaryDirectory() as temp_dir: - root = Path(temp_dir) - upstream = root / "upstream" - target = root / "target" - fake_bin = root / "bin" - sha = create_upstream(repo_root=upstream, fake_sync=False) - initialize_specialized_target(target=target) - complete_real_prepare_and_passes( - target=target, - upstream=upstream, - upstream_sha=sha, - ) - allowed = prepare_and_seal_real_submit( - target=target, - upstream=upstream, - upstream_sha=sha, - ) - commit_paths( - repo_root=target, - paths=allowed[:-1], - message="first docs commit", - ) - commit_paths( - repo_root=target, - paths=allowed[-1:], - message="second docs commit", - ) - env = fake_gh_env( - bin_dir=fake_bin, - payload=pull_request_payload( - head_sha=git_head(repo_root=target), - body=(target / "PR_BODY.md").read_text(encoding="utf-8"), - ), - ) - finish = run_harness( - command="finish-submit", - target=target, - upstream=upstream, - upstream_sha=sha, - pr_number=12, - base="main", - head="workflow-docs", - env=env, - ) - self.assertNotEqual(finish.returncode, 0) - self.assertEqual( - parse_json(result=finish)["error"], - "SUBMIT commit 数不匹配", - ) - - def test_submit_rejects_stale_remote_body(self) -> None: - """真实 seal 后远端 body 与 sealed body 不同必须失败。""" - with tempfile.TemporaryDirectory() as temp_dir: - root = Path(temp_dir) - upstream = root / "upstream" - target = root / "target" - fake_bin = root / "bin" - sha = create_upstream(repo_root=upstream, fake_sync=False) - initialize_specialized_target(target=target) - complete_real_prepare_and_passes( - target=target, - upstream=upstream, - upstream_sha=sha, - ) - allowed = prepare_and_seal_real_submit( - target=target, - upstream=upstream, - upstream_sha=sha, - ) - commit_paths( - repo_root=target, - paths=allowed, - message="workflow docs", - ) - env = fake_gh_env( - bin_dir=fake_bin, - payload=pull_request_payload( - head_sha=git_head(repo_root=target), - body="stale body", - ), - ) - finish = run_harness( - command="finish-submit", - target=target, - upstream=upstream, - upstream_sha=sha, - pr_number=12, - base="main", - head="workflow-docs", - env=env, - ) - self.assertNotEqual(finish.returncode, 0) - self.assertEqual( - parse_json(result=finish)["error"], - "远端 PR 与本地提交不匹配", - ) - - def test_finish_submit_rejects_post_seal_workflow_change(self) -> None: - """seal 后即使提交路径集合正确,workflow 内容篡改仍失败。""" - with tempfile.TemporaryDirectory() as temp_dir: - root = Path(temp_dir) - upstream = root / "upstream" - target = root / "target" - fake_bin = root / "bin" - sha = create_upstream(repo_root=upstream, fake_sync=False) - initialize_specialized_target(target=target) - complete_real_prepare_and_passes( - target=target, - upstream=upstream, - upstream_sha=sha, - ) - allowed = prepare_and_seal_real_submit( - target=target, - upstream=upstream, - upstream_sha=sha, - ) - append_text(path=target / "AGENTS.md", text="\npost-seal tamper\n") - commit_paths( - repo_root=target, - paths=allowed, - message="tampered docs", - ) - head_sha = git_head(repo_root=target) - env = fake_gh_env( - bin_dir=fake_bin, - payload=pull_request_payload( - head_sha=head_sha, - body=(target / "PR_BODY.md").read_text(encoding="utf-8"), - ), - ) - finish = run_harness( - command="finish-submit", - target=target, - upstream=upstream, - upstream_sha=sha, - pr_number=12, - base="main", - head="workflow-docs", - env=env, - ) - self.assertNotEqual(finish.returncode, 0) - self.assertEqual( - parse_json(result=finish)["error"], - "sealed workflow 内容已变化", - ) - - def test_finish_submit_rejects_post_seal_file_mode_change(self) -> None: - """seal 后 workflow executable bit 变化也必须失败。""" - with tempfile.TemporaryDirectory() as temp_dir: - root = Path(temp_dir) - upstream = root / "upstream" - target = root / "target" - fake_bin = root / "bin" - sha = create_upstream(repo_root=upstream, fake_sync=False) - initialize_specialized_target(target=target) - complete_real_prepare_and_passes( - target=target, - upstream=upstream, - upstream_sha=sha, - ) - allowed = prepare_and_seal_real_submit( - target=target, - upstream=upstream, - upstream_sha=sha, - ) - (target / "AGENTS.md").chmod(0o755) - commit_paths( - repo_root=target, - paths=allowed, - message="changed file mode", - ) - env = fake_gh_env( - bin_dir=fake_bin, - payload=pull_request_payload( - head_sha=git_head(repo_root=target), - body=(target / "PR_BODY.md").read_text(encoding="utf-8"), - ), - ) - finish = run_harness( - command="finish-submit", - target=target, - upstream=upstream, - upstream_sha=sha, - pr_number=12, - base="main", - head="workflow-docs", - env=env, - ) - self.assertNotEqual(finish.returncode, 0) - self.assertEqual( - parse_json(result=finish)["error"], - "sealed workflow 内容已变化", - ) - - def test_status_and_result_schema_survive_restart(self) -> None: - """新进程可读取 run.json,PREPARE result 字段保持精确。""" - with tempfile.TemporaryDirectory() as temp_dir: - root = Path(temp_dir) - upstream = root / "upstream" - target = root / "target" - sha = create_upstream(repo_root=upstream, fake_sync=True) - initialize_core_target(target=target, upstream=upstream) - complete_prepare( - target=target, - upstream=upstream, - upstream_sha=sha, - ) - status = run_harness(command="status", target=target) - self.assertEqual(status.returncode, 0) - self.assertEqual( - parse_json(result=status)["completed_modes"], - ["PREPARE"], - ) - result = json.loads( - ( - target / ".coding_workflow/skill_results/PREPARE.json" - ).read_text(encoding="utf-8") - ) - self.assertEqual( - set(result), - { - "schema_version", - "run_id", - "mode", - "status", - "upstream_sha", - "project_head", - "created_at_utc", - "changed_paths", - "details", - "error", - }, - ) - - def test_review_validator_derives_verdict_and_binds_real_pr(self) -> None: - """review section 最高等级、PR number/head 和 target HEAD 必须一致。""" - self.assertFalse((REVIEW_SKILL / ".source.json").exists()) - with tempfile.TemporaryDirectory() as temp_dir: - root = Path(temp_dir) - upstream = root / "upstream" - target = root / "target" - fake_bin = root / "bin" - sha = create_upstream(repo_root=upstream, fake_sync=True) - initialize_repo(repo_root=target) - (target / ".gitignore").write_text( - ".coding_workflow/skill_results/\n", - encoding="utf-8", - ) - commit_all(repo_root=target, message="ignore review results") - head_sha = git_head(repo_root=target) - review_file = ( - target / ".coding_workflow/skill_results/review.json" - ) - review_file.parent.mkdir(parents=True, exist_ok=True) - review_file.write_text( - json.dumps( - valid_review( - verdict="PASS", - head_sha=head_sha, - upstream_sha=sha, - blocker_section=True, - ), - ensure_ascii=False, - ), - encoding="utf-8", - ) - env = fake_gh_env( - bin_dir=fake_bin, - payload=review_pr_payload( - head_sha=head_sha, - upstream_sha=sha, - ), - no_real_gh=True, - ) - args = [ - sys.executable, - str(VALIDATE_REVIEW), - "--target-repo", - str(target), - "--upstream-dir", - str(upstream), - "--upstream-sha", - sha, - "--review-file", - str(review_file), - "--pr-number", - "12", - ] - conflict = run_command(args=args, cwd=target, env=env) - self.assertNotEqual(conflict.returncode, 0) - review_file.write_text( - json.dumps( - valid_review( - verdict="BLOCKER", - head_sha=head_sha, - upstream_sha=sha, - blocker_section=True, - ), - ensure_ascii=False, - ), - encoding="utf-8", - ) - valid = run_command(args=args, cwd=target, env=env) - self.assertEqual(valid.returncode, 0, msg=valid.stdout) - artifact = target / "untracked-review-artifact.txt" - artifact.write_text("unexpected\n", encoding="utf-8") - dirty = run_command(args=args, cwd=target, env=env) - self.assertNotEqual(dirty.returncode, 0) - self.assertEqual( - parse_json(result=dirty)["error"], - "目标仓库工作区不干净", - ) - artifact.unlink() - wrong_sha_env = fake_gh_env( - bin_dir=fake_bin, - payload=review_pr_payload( - head_sha=head_sha, - upstream_sha="0" * 40, - ), - no_real_gh=True, - ) - wrong_sha = run_command( - args=args, - cwd=target, - env=wrong_sha_env, - ) - self.assertNotEqual(wrong_sha.returncode, 0) - self.assertEqual( - parse_json(result=wrong_sha)["error"], - "PR body upstream SHA 不匹配", - ) - for duplicate_sha in (sha, "0" * 40): - payload = review_pr_payload( - head_sha=head_sha, - upstream_sha=sha, - ) - payload["body"] = payload["body"].replace( - "<!-- sync:auto:end -->", - f"- upstream_resolved_commit: {duplicate_sha}\n" - "<!-- sync:auto:end -->", - ) - duplicate_env = fake_gh_env( - bin_dir=fake_bin, - payload=payload, - no_real_gh=True, - ) - duplicate = run_command( - args=args, - cwd=target, - env=duplicate_env, - ) - self.assertNotEqual(duplicate.returncode, 0) - self.assertEqual( - parse_json(result=duplicate)["error"], - "PR body upstream SHA 不匹配", - ) - - def test_installer_user_repo_and_source_metadata(self) -> None: - """哑安装器覆盖复制双平台,Claude 禁止隐式调用。""" - with tempfile.TemporaryDirectory() as temp_dir: - root = Path(temp_dir) - upstream = root / "upstream" - home = root / "home" - target = root / "target" - home.mkdir() - sha = create_upstream(repo_root=upstream, fake_sync=True) - initialize_repo(repo_root=target) - user_env = os.environ.copy() - user_env["HOME"] = str(home) - user = run_command( - args=[ - sys.executable, - str(INSTALL_SKILLS), - "--upstream-dir", - str(upstream), - "--upstream-sha", - sha, - ], - cwd=REPO_ROOT, - env=user_env, - ) - self.assertEqual(user.returncode, 0, msg=user.stdout) - source = json.loads( - ( - home - / ".agents/skills/workflow-docs-sync/.source.json" - ).read_text(encoding="utf-8") - ) - self.assertEqual( - source, - { - "upstream_sha": sha, - "canonical_relative_path": "zh/skills/workflow-docs-sync", - "platform": "codex", - }, - ) - review_source = json.loads( - ( - home - / ".agents/skills/workflow-docs-sync-review/.source.json" - ).read_text(encoding="utf-8") - ) - self.assertEqual( - review_source["canonical_relative_path"], - "zh/skills/workflow-docs-sync-review", - ) - claude_skill = ( - home / ".claude/skills/workflow-docs-sync/SKILL.md" - ).read_text(encoding="utf-8") - self.assertIn("disable-model-invocation: true", claude_skill) - repo = run_command( - args=[ - sys.executable, - str(INSTALL_SKILLS), - "--scope", - "repo", - "--target-repo", - str(target), - "--upstream-dir", - str(upstream), - "--upstream-sha", - sha, - ], - cwd=REPO_ROOT, - ) - self.assertEqual(repo.returncode, 0, msg=repo.stdout) - for skill_name in SKILL_NAMES: - self.assertTrue( - ( - target - / f".agents/skills/{skill_name}/SKILL.md" - ).is_file() - ) - - def test_installed_skill_requires_reinstall_for_new_sha(self) -> None: - """SHA A 安装副本用 SHA B 调用时必须给出重装错误。""" - with tempfile.TemporaryDirectory() as temp_dir: - root = Path(temp_dir) - upstream = root / "upstream" - home = root / "home" - target = root / "target" - home.mkdir() - sha_a = create_upstream(repo_root=upstream, fake_sync=True) - env = os.environ.copy() - env["HOME"] = str(home) - install = run_command( - args=[ - sys.executable, - str(INSTALL_SKILLS), - "--upstream-dir", - str(upstream), - "--upstream-sha", - sha_a, - ], - cwd=REPO_ROOT, - env=env, - ) - self.assertEqual(install.returncode, 0) - append_text( - path=upstream / "zh/scripts/OPERATIONS.md", - text="\nnew SHA\n", - ) - commit_all(repo_root=upstream, message="new upstream") - sha_b = git_head(repo_root=upstream) - initialize_core_target(target=target, upstream=upstream) - installed_harness = ( - home - / ".agents/skills/workflow-docs-sync/scripts/harness.py" - ) - result = run_harness( - command="prepare", - target=target, - upstream=upstream, - upstream_sha=sha_b, - harness_path=installed_harness, - ) - self.assertNotEqual(result.returncode, 0) - self.assertEqual( - parse_json(result=result)["error"], - "Skill 来源 SHA 不匹配", - ) - installed_reviewer = ( - home - / ".agents/skills/workflow-docs-sync-review" - / "scripts/validate_review.py" - ) - review = run_command( - args=[ - sys.executable, - str(installed_reviewer), - "--target-repo", - str(target), - "--upstream-dir", - str(upstream), - "--upstream-sha", - sha_b, - "--review-file", - ".coding_workflow/skill_results/review.json", - "--pr-number", - "12", - ], - cwd=target, - ) - self.assertNotEqual(review.returncode, 0) - self.assertEqual( - parse_json(result=review)["error"], - "reviewer Skill 来源 SHA 不匹配", - ) - - def test_invocation_metadata_and_ci(self) -> None: - """Codex/Claude 必须显式调用,最小 CI 必须运行 pytest。""" - for skill_dir in (EXEC_SKILL, REVIEW_SKILL): - frontmatter = ( - skill_dir / "SKILL.md" - ).read_text(encoding="utf-8").split("---", 2)[1] - keys = { - line.split(":", 1)[0].strip() - for line in frontmatter.splitlines() - if line.strip() - } - self.assertEqual(keys, {"name", "description"}) - openai = ( - skill_dir / "agents/openai.yaml" - ).read_text(encoding="utf-8") - self.assertIn("allow_implicit_invocation: false", openai) - modes = ( - EXEC_SKILL / "references/modes.md" - ).read_text(encoding="utf-8") - self.assertIn("$workflow-docs-sync", modes) - self.assertIn("/workflow-docs-sync", modes) - self.assertIn( - "<workflow-docs-sync-root>/scripts/harness.py", - modes, - ) - self.assertNotIn("python3 scripts/harness.py", modes) - review_skill = REVIEW_SKILL.joinpath("SKILL.md").read_text( - encoding="utf-8" - ) - self.assertIn( - "<workflow-docs-sync-review-root>/scripts/validate_review.py", - review_skill, - ) - operations = (REPO_ROOT / "zh/scripts/OPERATIONS.md").read_text( - encoding="utf-8" - ) - self.assertIn( - "`PR_BODY.md` 默认只用于更新 GitHub PR body,不提交仓库", - operations, - ) - workflow = ( - REPO_ROOT / ".github/workflows/test.yml" - ).read_text(encoding="utf-8") - self.assertIn("actions/setup-python", workflow) - self.assertIn("python -m pytest -q", workflow) - - def test_reviewer_handoff_preserves_finding_union(self) -> None: - """长期流程必须保留来源 ID 并禁止 finding 静默消失。""" - workflow = ( - REPO_ROOT / "zh/docs/development_workflow/README.md" - ).read_text(encoding="utf-8") - for disposition in ( - "confirmed", - "rejected", - "merged_as_duplicate:<ID>", - "downgraded:<新严重度>", - "needs_human", - ): - self.assertIn(disposition, workflow) - self.assertIn("静默消失", workflow) - - -if __name__ == "__main__": - unittest.main() diff --git a/zh/.github/pull_request_template.md b/zh/.github/pull_request_template.md index 270cb0e..80221bf 100644 --- a/zh/.github/pull_request_template.md +++ b/zh/.github/pull_request_template.md @@ -93,11 +93,11 @@ git diff --name-only <base>...HEAD 已知限制: -- +- 回滚方式: -- +- --- diff --git a/zh/README.md b/zh/README.md index da9456f..135d7ec 100644 --- a/zh/README.md +++ b/zh/README.md @@ -2,69 +2,54 @@ 中文 | [English](../en/README.md) -本目录是中文模板、文档、prompt 和操作手册的入口。选择中文后,除共享实现 -`../scripts/sync_coding_workflow.py`、兼容入口 `../scripts/sync.sh`、测试和本仓库 -GitHub workflow 外,后续路径都应留在 `zh/` 目录树内。 +本目录提供中文核心模板、开发工作流和单会话 `workflow-docs-sync` Skill。 ## Quick Start -在目标项目仓库根目录运行中文 workflow docs sync: +用户只调用一次 Skill,只提供目标仓库、可选语言和可选 draft PR 意图: -```bash -curl -fsSL https://raw.githubusercontent.com/wlvh/coding-workflow/main/zh/scripts/sync.sh | bash +```text +使用 $workflow-docs-sync 同步 `/目标仓库绝对路径`,语言 zh,结束后不要创建 draft PR。 ``` -兼容旧入口仍可用: +主 Agent 是目标工作区唯一写入者;四领域分析和内部对抗性审计都只读。上游 checkout +和 SHA 由 Skill 内部解析,最终检查只验证仓库状态,不证明执行历史。 -```bash -curl -fsSL https://raw.githubusercontent.com/wlvh/coding-workflow/main/scripts/sync.sh | bash -``` +## Skill 安装 -普通 sync 完成后,按 [zh/scripts/OPERATIONS.md](scripts/OPERATIONS.md) 的 PASS 1 到 -PASS 4 执行;PR 提交 agent 先补测试证据,经 seal 的 final gate 封存后再发布 PR。 +Studio 可直接加载 canonical `zh/skills/workflow-docs-sync/`。个人或团队安装只复制这一个 +Skill,不保存来源状态: -Skill 使用分三类:Studio 从 clean pinned upstream 直接加载 canonical source; -个人安装默认写用户级目录;团队仓库安装必须显式使用 `--scope repo` 并先提交独立 -安装 PR。安装副本用简单 `.source.json` 记录 upstream SHA,切换 SHA 前重新安装; -mode 边界由单一 `harness.py` 检查。双平台调用和完整命令见 -[modes.md](skills/workflow-docs-sync/references/modes.md)。 +```bash +python3 zh/scripts/install_skills.py --upstream-dir <clean-canonical-checkout> +python3 zh/scripts/install_skills.py --scope repo \ + --target-repo <目标仓库> --upstream-dir <clean-canonical-checkout> +``` ## 目录地图 -- [zh/AGENTS.md](AGENTS.md):agent 工作入口、文件简介、代码规范与文档关系。 -- [zh/architecture.md](architecture.md):系统架构、模块边界、数据流和架构不变量。 -- [zh/capability_contract.json](capability_contract.json):能力、边界和 agent 行为承诺的机器可读契约。 -- [zh/interact.md](interact.md):用户可观察行为与验收不变量。 -- [zh/TESTING.md](TESTING.md):测试策略、测试证据和 contract alignment 测试原则。 -- [zh/PR_Checklist.md](PR_Checklist.md):PR 提交、commit、push 和 PR body 使用规则。 -- [zh/SOP.md](SOP.md):标准流程入口。 -- [zh/.github/pull_request_template.md](.github/pull_request_template.md):下游项目继承的中文 PR body 模板。 -- [zh/docs/business_user_guide.md](docs/business_user_guide.md):面向首次接触业务人员的教学文档。 -- [zh/docs/development_workflow/README.md](docs/development_workflow/README.md):完整开发工作流。 -- [zh/prompts/](prompts/):FSD、issue、review 和验收相关长 prompt。 -- [zh/scripts/OPERATIONS.md](scripts/OPERATIONS.md):workflow docs sync 操作手册和 pass prompt 真相源。 -- [zh/scripts/sync.sh](scripts/sync.sh):中文模板 sync 启动入口。 -- [zh/scripts/sync_pr_review_system.md](scripts/sync_pr_review_system.md):中文独立 reviewer 启动 prompt。 -- [zh/scripts/install_skills.py](scripts/install_skills.py):按 user/repo scope 覆盖复制双平台 Skill,并写简单来源记录。 -- [zh/docs/development_workflow/decisions.md](docs/development_workflow/decisions.md):记录 Skill 威胁模型和 AI / 机械事实边界。 -- [zh/skills/workflow-docs-sync/](skills/workflow-docs-sync/):按独立会话执行语义 PASS,由薄 harness 负责边界检查、pinned sync、SUBMIT seal 和 PR 核验。 -- [zh/skills/workflow-docs-sync-review/](skills/workflow-docs-sync-review/):不修改 tracked 项目文件、只写 ignored result 的独立审查 Skill。 - -## 路径落盘规则 - -`zh/` 是上游源码前缀,不写入目标项目。sync 安装模板时只剥离开头的 `zh/`, -剥完后按原路径落盘: - -- `zh/AGENTS.md` -> `<target>/AGENTS.md` -- `zh/docs/business_user_guide.md` -> `<target>/docs/business_user_guide.md` -- `zh/.github/pull_request_template.md` -> `<target>/.github/pull_request_template.md` - -`.github` 的内层结构保持不变;只剥语言前缀,不做其他路径重写。 - -## 维护边界 - -中文是锚点,英文是派生层。修改中文模板、prompt、runbook 或开发流程后,应同步检查 -英文目录是否需要更新;如果英文暂不覆盖,必须在 PR body 标记 `en-pending`。 - -根目录 `.github/` 只代表本仓库自己的 GitHub infra,不是下游模板源。下游 PR -模板源在 `zh/.github/` 与 `en/.github/`。 +- [AGENTS.md](AGENTS.md):agent 工作入口、代码规范与文档关系。 +- [architecture.md](architecture.md):系统架构、模块边界、数据流和架构不变量模板。 +- [capability_contract.json](capability_contract.json):能力、边界和 agent 行为承诺契约。 +- [interact.md](interact.md):用户可观察行为与验收不变量模板。 +- [TESTING.md](TESTING.md):测试策略和证据规则模板。 +- [PR_Checklist.md](PR_Checklist.md):通用 PR 提交规则模板。 +- [SOP.md](SOP.md):标准流程入口模板。 +- [.github/pull_request_template.md](.github/pull_request_template.md):下游 PR body 模板。 +- [docs/business_user_guide.md](docs/business_user_guide.md):业务人员教学模板。 +- [docs/development_workflow/README.md](docs/development_workflow/README.md):完整开发工作流。 +- [docs/development_workflow/decisions.md](docs/development_workflow/decisions.md):产品实现决策。 +- [skills/workflow-docs-sync/](skills/workflow-docs-sync/):单会话同步 Skill。 +- [skills/workflow-docs-sync/references/sections.md](skills/workflow-docs-sync/references/sections.md):四领域只读分析语义。 +- [skills/workflow-docs-sync/references/audit.md](skills/workflow-docs-sync/references/audit.md):只读对抗性审计语义。 +- [skills/workflow-docs-sync/scripts/sync_docs.py](skills/workflow-docs-sync/scripts/sync_docs.py):内部 `prepare` / `check` 机械接口。 +- [scripts/install_skills.py](scripts/install_skills.py):单 Skill 双平台薄复制器。 + +## 路径与维护边界 + +`zh/` 是上游源码前缀,不写入目标项目。安装模板时只剥离开头的 `zh/`;例如 +`zh/docs/business_user_guide.md` 落到 `<target>/docs/business_user_guide.md`, +`zh/.github/pull_request_template.md` 落到 `<target>/.github/pull_request_template.md`。 + +中文是锚点,英文是派生层。修改中文模板、Skill 或开发流程后,必须同步检查英文路径; +暂未覆盖时明确标记 `en-pending`。根目录 `.github/` 是本仓库基础设施,不是下游模板源。 diff --git a/zh/docs/development_workflow/README.md b/zh/docs/development_workflow/README.md index a9ac7b1..c676dbe 100644 --- a/zh/docs/development_workflow/README.md +++ b/zh/docs/development_workflow/README.md @@ -158,11 +158,16 @@ E. 输出风格约束 - `PR_BODY.md`:本地临时 PR body 草稿,由 `.github/pull_request_template.md` 生成,不提交仓库;是 review 的重要输入材料。 - `Merge Readiness Report`:判断当前 PR 是否具备合并条件。 - `FSD 完备性验收报告`:Issue 关闭前的最后一道契约核查。 -- `Workflow Docs Sync`:用 `zh/scripts/sync.sh` 生成 full reconcile 证据、薄工单和带紧凑 `Sync Review Contract` 的 sync PR body 骨架,4 个 pass prompt 由 `zh/scripts/OPERATIONS.md` 承载,最终由独立 reviewer 守语义质量门。 +- `Workflow Docs Sync`:用户一次调用完成代码地图、四领域只读分析、主 Agent 统一改写、 + 内部只读审计、测试和最终仓库检查。 ## 代码项目核心文档 -本仓库中的这些文件是给目标项目继承和项目化的 upstream 模板 / 样本文档。开发 sync 工具时,不因为工具实现细节去改写 `AGENTS.md`、`TESTING.md`、`PR_Checklist.md`、`architecture.md` 这类模板;sync 工具自身的操作说明、实现文件清单和回归测试说明落在 `zh/README.md` 和 `scripts/` 下。例外是 `.github/pull_request_template.md`:它是长期 PR body 模板,可以直接继承 upstream。 +本仓库中的这些文件是给目标项目继承和项目化的 upstream 模板 / 样本文档。开发 sync +工具时,不因为工具实现细节去改写 `AGENTS.md`、`TESTING.md`、`PR_Checklist.md`、 +`architecture.md` 这类模板;sync 工具自身说明和实现放在 +`zh/skills/workflow-docs-sync/`。例外是 `.github/pull_request_template.md`:它是长期 +PR body 模板,可以直接继承 upstream。 - `AGENTS.md`:agent 工作入口、文件简介、代码规范与文档关系。 - `architecture.md`:系统架构、模块边界、数据流、架构不变量与扩展点。 @@ -176,39 +181,32 @@ E. 输出风格约束 ## Workflow Docs Sync -完整操作入口见 `zh/scripts/OPERATIONS.md`。该手册说明如何运行 `zh/scripts/sync.sh`、 -用四个专用 prompt / 新对话按 pass 接力执行 sync、由 PR 提交 agent 先完成证据并在 -commit 前 seal,再用 `zh/scripts/sync_pr_review_system.md` 启动独立 review。`agent_workorder.md` 只列本轮机器信号和 -`OPERATIONS.md` 的 commit-pinned URL,不复制四段长 prompt。 - -- 机械合同:`sync.sh --final`;`PR_BODY.md` auto 区的 `Sync Review Contract` 只保留本轮 reviewer 输入和分工边界 -- 语义交接:`zh/scripts/OPERATIONS.md` 承载 4 个 pass prompt;`PR_BODY.md` agent 区的 `Full Document Reconcile` 记录每个核心文档的 upstream semantic delta、采纳 / 拒绝位置、证据和 downstream impact,`PR Test Evidence` 记录 PR 提交阶段的一次性测试证据,`Agent Execution Evidence` 记录 pass agent 的自报读取清单供 reviewer 抽查,`Upstream Drift Log` 暴露 PR body 刷新期间的 upstream commit 漂移,`Remaining Human Decisions` 暴露仍需判断的语义事项 -- 独立 reviewer 是必经语义质量门;final gate 只证明机械一致性,不能替代证据真实性和 upstream 规则吸收审查 -- 如果已有 `PR_BODY.md` 不是 sync sentinel body,普通 sync 会 fail-fast;先移走、删除,或手动迁入 sync PR body 的 agent-owned 区后再运行 -- 如果目标仓库历史上已把 `PR_BODY.md` 提交入库,sync 只打印 warning,不会自动 `git rm --cached`;是否解除跟踪应由目标项目单独 cleanup PR 决定 -- 本轮证据目录:`.coding_workflow/diffs/` -- 工具实现:`scripts/sync_coding_workflow.py` -- 一次性启动入口:`zh/scripts/sync.sh` -- 操作手册:`zh/scripts/OPERATIONS.md` -- reviewer 启动 prompt:`zh/scripts/sync_pr_review_system.md` -- 回归测试:`tests/test_sync_coding_workflow.py` - -开发 sync 工具时,若改动工单、PR body、final gate、reviewer prompt 或 pass 交接合同,必须同步检查: - -- `scripts/sync_coding_workflow.py` -- `zh/scripts/OPERATIONS.md` -- `en/scripts/OPERATIONS.md` -- `zh/scripts/sync_pr_review_system.md` -- `en/scripts/sync_pr_review_system.md` -- `zh/scripts/sync.sh` -- `en/scripts/sync.sh` -- `zh/README.md` -- `en/README.md` -- `tests/test_sync_coding_workflow.py` - -这些要求属于本仓库 sync 工具维护规则,不写入下游项目会继承的 `AGENTS.md` / `TESTING.md` / `PR_Checklist.md` 模板。 - -当前 sync 工具的 `TESTING.md` 独立 pass 是生成工单和 PR body 的合同,不是 `TESTING.md` 模板正文。该 pass 要求 sync agent 单独检查测试冗余、必要性、真实失败覆盖、mock-only 风险、E2E/scenario 价值和不值得新增的测试。 +用户只调用一次 `$workflow-docs-sync`,只提供目标仓库、可选 `zh` / `en` 和可选 draft +PR 意图。Skill 内部解析 canonical upstream checkout;无法定位时使用仓库外临时 shallow +clone,并在整轮固定同一上游提交。 + +- 主 Agent 是目标工作区唯一写入者,先建立真实代码地图。 +- Architecture、Capability / User Behavior、Testing、Governance 四领域分析只读并只在 + 当前会话返回发现;无 subagent 平台由主 Agent 按四个隔离章节顺序执行。 +- 主 Agent 统一修改九份核心文档,再由内部只读对抗性审计检查事实、跨文档闭合和验证 + 层级;BLOCKER 与可行动 WARN 修复后进行轻量复审。 +- 主 Agent 实际运行目标项目必要测试并记录命令与结果。 +- `sync_docs.py prepare` 只补齐缺失模板;`check` 只读验证最终 HEAD、dirty 范围、九份 + 文件、编码、JSON、标题、模板残留、固定上游差异和 whitespace。 +- 同步过程不读取、创建、改写或删除仓库内 `PR_BODY.md`,也不创建工单、模板镜像或运行状态。 + commit、push 和 draft PR 创建由通用 GitHub 发布能力在检查成功后完成。 +- 最终机械检查只证明最终仓库状态,不证明四领域分析、审计或测试曾运行。 + +维护入口: + +- `zh/skills/workflow-docs-sync/SKILL.md` +- `zh/skills/workflow-docs-sync/references/sections.md` +- `zh/skills/workflow-docs-sync/references/audit.md` +- `zh/skills/workflow-docs-sync/scripts/sync_docs.py` +- `zh/scripts/install_skills.py` +- `tests/test_workflow_docs_sync.py` + +这些规则属于上游 sync 工具,不写入下游项目继承的核心模板。 ## 上游双语语义等价审核 SOP @@ -217,13 +215,16 @@ commit 前 seal,再用 `zh/scripts/sync_pr_review_system.md` 启动独立 revi 目标是定期确认中文锚点文档的语义变化已经被英文派生路径吸收,或已经显式记录 `en-pending` follow-up。 -触发节奏:每月最后一个工作日执行一次;如果本月修改过任意中文锚点核心文档、`zh/README.md`、`zh/scripts/OPERATIONS.md` 或 `zh/scripts/sync_pr_review_system.md`,则必须在下一次主线 PR 合并前执行。 +触发节奏:每月最后一个工作日执行一次;如果本月修改过任意中文锚点核心文档、 +`zh/README.md` 或 `zh/skills/workflow-docs-sync/`,则必须在下一次主线 PR 合并前执行。 ### Step 1:确认审核范围 - 做什么:列出本轮要审核的中英配对文件,只覆盖本仓库声明的双语入口、模板和 sync 工具文档。 -- 去哪看:`zh/README.md` 的“目录地图”、`scripts/sync_coding_workflow.py` 的 `ZH_CORE_SOURCE_FILES` / `EN_CORE_SOURCE_FILES` / prompt file mapping、`zh/scripts/OPERATIONS.md` 和 `en/scripts/OPERATIONS.md` 的 pass 路径。 -- 做完如何验收:每一项必须是 `zh/路径 -> en/同名路径`,共享实现例外为 `scripts/sync_coding_workflow.py`;不得出现 `.en.md`、`.en.json`、`.en.sh` 文件路径。 +- 去哪看:`zh/README.md` 的“目录地图”、`sync_docs.py` 的 `CORE_FILES` 和中英模板目录。 +- 做完如何验收:每一项必须是 `zh/路径 -> en/同名路径`;Skill 实现只在 canonical + `zh/skills/workflow-docs-sync/` 维护,不创建英文实现副本,也不得出现 `.en.md`、 + `.en.json`、`.en.sh` 文件路径。 ### Step 2:收集中英文变更证据 @@ -234,7 +235,8 @@ commit 前 seal,再用 `zh/scripts/sync_pr_review_system.md` 启动独立 revi ### Step 3:逐项判断语义等价 - 做什么:以中文 diff 为锚,判断英文是否覆盖同一流程、能力边界、验收不变量、路径和拒绝 / 追问规则;`both` 状态重点判断英文是否是中文语义派生,而不是独立创作。 -- 去哪看:中文文件当前内容、英文文件当前内容、`zh/README.md` 的中文锚点规则、`zh/scripts/OPERATIONS.md` 和 `en/scripts/OPERATIONS.md` 的对应 pass 规则。 +- 去哪看:中文文件当前内容、英文文件当前内容、`zh/README.md` 的中文锚点规则和 + `zh/skills/workflow-docs-sync/references/` 的领域语义。 - 做完如何验收:每个配对文件得到一个结论:`ok`、`missing translation`、`stale en`、`contradiction`、`intentionally pending`;除 `ok` 外都必须记录具体文件、段落和建议处理方式。 ### Step 4:生成审核结论 @@ -249,6 +251,11 @@ commit 前 seal,再用 `zh/scripts/sync_pr_review_system.md` 启动独立 revi - 去哪看:本轮审核结论、相关 PR body 或 issue。 - 做完如何验收:所有非 `ok` 项都有明确 follow-up;如果本轮完成修复,重新执行 Step 2 和 Step 3,直到结论为 `ok` 或 `intentionally pending`。 -反向规则:任何对 `AGENTS.md`、`TESTING.md`、`PR_Checklist.md`、`SOP.md`、`architecture.md`、`interact.md`、`capability_contract.json` 或 `docs/business_user_guide.md` 的修改,如果只对本仓库 sync 工具特殊场景有用、对下游继承项目无意义,必须迁移到 `zh/README.md`、`zh/docs/development_workflow/` 或 `zh/scripts/` 后再合入。 +反向规则:任何对 `AGENTS.md`、`TESTING.md`、`PR_Checklist.md`、`SOP.md`、 +`architecture.md`、`interact.md`、`capability_contract.json` 或 +`docs/business_user_guide.md` 的修改,如果只对本仓库 sync 工具特殊场景有用、对下游 +继承项目无意义,必须迁移到 `zh/README.md`、`zh/docs/development_workflow/` 或 +`zh/skills/workflow-docs-sync/` 后再合入。 -配套边界:`en-pending` 只属于维护 `wlvh/coding-workflow` 上游仓库时的双语治理语境,不得作为通用 sync PR reviewer BLOCKER 写入 `zh/scripts/sync_pr_review_system.md`,避免下游中文或英文单一路径项目被误拦截。 +配套边界:`en-pending` 只属于维护 `wlvh/coding-workflow` 上游仓库时的双语治理语境, +不得作为通用目标仓库审计 BLOCKER,避免中文或英文单一路径项目被误拦截。 diff --git a/zh/docs/development_workflow/decisions.md b/zh/docs/development_workflow/decisions.md index b3a3ef3..cf6bdd8 100644 --- a/zh/docs/development_workflow/decisions.md +++ b/zh/docs/development_workflow/decisions.md @@ -23,6 +23,8 @@ pinned final gate 和真实 GitHub PR;不使用执行过程中的链式自证。 - 独立 reviewer:在执行 Skill 之外复核语义,并由 validator 派生 verdict。 - 用户边界:Gate W0 始终由用户负责,不移入 Skill。 +- 后续:`workflow-docs-sync` 范围内的上述实现已由 DEC-005 替代;语义与 + 机械事实的责任边界仍保留。 - 英文状态:`en-pending`。 ## DEC-004:SUBMIT 使用 evidence / seal / publish 三阶段 @@ -38,4 +40,27 @@ 与四个 PASS;不要求中间 commit,也不把当前 worktree rebaseline。 - 排除项:不增加 receipt、hash chain、generic rebaseline、rollback framework 或 Threat Model B 的 index / whole-tree 对抗机制。 +- 后续:`workflow-docs-sync` 范围内的三阶段实现已由 DEC-005 替代。 +- 英文状态:`en-pending`。 + +## DEC-005:Workflow Docs Sync 采用单会话内建编排 + +- 状态:accepted +- 日期:2026-07-20 UTC +- 决策:用户每轮只调用一次 `$workflow-docs-sync`。主 Agent 是目标工作区的唯一 + 写入者;Architecture、Capability / User Behavior、Testing、Governance 四个领域 + Agent 只读分析,内部对抗性审计 Agent 也只读。平台不支持 subagent 时,由主 Agent + 在同一会话按四个隔离章节顺序完成同样的语义检查。 +- 数据边界:子 Agent 发现只通过当前会话返回,不写运行状态、result receipt、工单、 + 模板镜像或 PR body。上游 checkout 和 SHA 由 Skill 内部解析并在同一轮固定复用; + 用户只提供目标仓库、可选语言和可选 draft PR 意图。 +- 机械边界:`prepare` 只解析 Git 根目录与 SHA、在任何写入前检查 dirty allowlist, + 并补齐缺失模板;`check` 只读验证最终仓库状态。检查器不证明 Agent、审计或测试曾经 + 执行,测试由主 Agent 实际运行并在最终报告记录。 +- 发布边界:PR body、commit、push、远端绑定和 draft PR 创建不属于同步器。用户要求 + draft PR 时,由通用 GitHub 发布能力使用仓库外临时 Markdown body 完成。 +- 替代关系:本决策在 `workflow-docs-sync` 范围内 supersede DEC-003 和 DEC-004 的旧 + 实现;不保留旧 launcher、mode、harness、缓存模板或控制面兼容 fallback,Git 历史 + 承担回滚。无 subagent 时的顺序执行是当前正式路径,不是旧实现 fallback。仍保留 + DEC-003 的原则:AI 负责项目语义与文档改写,机械层只判断可确定事实。 - 英文状态:`en-pending`。 diff --git a/zh/scripts/OPERATIONS.md b/zh/scripts/OPERATIONS.md deleted file mode 100644 index a1db771..0000000 --- a/zh/scripts/OPERATIONS.md +++ /dev/null @@ -1,555 +0,0 @@ -# Workflow Docs Sync - 操作手册 - -中文 | [English](../../en/scripts/OPERATIONS.md) - -本文档只说明 workflow docs sync 的人工入口、4 个 sync pass prompt 和角色边界。 -每次运行后的文件清单、脚本信号、上游模板路径和本轮状态,以 -`.coding_workflow/diffs/agent_workorder.md`、`sync_state.json` 和 `PR_BODY.md` 为准。 - -中文操作手册使用 `zh/` 上游模板源,并把模板安装到目标项目 canonical 路径, -例如 `AGENTS.md`、`TESTING.md` 和 `.github/pull_request_template.md`。 -英文模板 sync 请使用 `en/scripts/OPERATIONS.md` 和 `en/scripts/sync.sh`。 - -流程摘要:普通 sync 生成本轮证据和薄工单;4 个 sync pass 在新对话中按 -本文档的专用 prompt 接力补核心文档和 PR body agent-owned 区;PR 提交 agent -先完成测试证据,再由 final gate seal 内容,seal 后才发布;独立 reviewer 做语义审查。 - ---- - -## 1. Quick Start - -在目标项目现有仓库目录运行普通 sync: - -```bash -curl -fsSL https://raw.githubusercontent.com/wlvh/coding-workflow/main/zh/scripts/sync.sh | bash -``` - -运行前要求: - -- 不要混入非 sync 的代码、配置或测试 dirty 改动;脚本会 fail-fast 并列出路径。 -- 根据本轮工单重跑普通 sync 时,只允许累计核心文档、`.gitignore`、 - `PR_BODY.md`、`.coding_workflow/diffs/`、`.coding_workflow/skill_results/` - 和 `.coding_workflow/skill_runtime/` 处于 dirty 状态;后两类是 ignored - Skill 本地状态,不能提交。 -- 如果已有 `PR_BODY.md` 不是 sync sentinel body,普通 sync 会 fail-fast; - 先移走、删除,或手动迁入 sync PR body 的 agent-owned 区后再运行。 -- 如果脚本警告 `PR_BODY.md` 已被 git 跟踪,不要在 sync 流程里自动修; - 由目标项目单独 cleanup PR 决定是否执行 `git rm --cached PR_BODY.md`。 - -普通 sync 输出: - -- `.coding_workflow/diffs/agent_workorder.md`:本轮机器信号和本文档的 pinned URL。 -- `.coding_workflow/diffs/pr_body_skeleton.md`:没有 `PR_BODY.md` 时的初始化骨架。 -- `.coding_workflow/diffs/sync_state.json`:final gate 使用的机器状态。 -- `.coding_workflow/diffs/upstream_full/`:本轮上游模板本地副本。 - -普通 sync 完成后,用户只需要从 PASS 1 开始复制对应 PASS 的 code block 到新对话。 -`.coding_workflow/diffs/agent_workorder.md` 是给执行 agent 的本轮工单和机器信号; -用户不阅读也不影响启动下一步,执行 agent 会按 prompt 读取它。 - -### 1.1 Skill 执行方式 - -本手册的可执行孪生体位于 `zh/skills/`。 -人工复制 code block 的方式继续有效,并且仍是语义真相源。 -两种执行方式的一致性由 ownership 双向对账、prompt 反复制和 transport alignment -测试保证。 - -Studio / 自动编排直接从 clean pinned upstream 加载 canonical skill,不写目标仓库。 -个人安装默认写用户级目录;团队安装必须显式使用 `install_skills.py --scope repo` -并先通过独立安装 PR 提交产物。Codex 使用 `$workflow-docs-sync`,Claude Code -使用 `/workflow-docs-sync`;两个平台都禁止模型自动调用。安装产物只用 -`.source.json` 记录 upstream SHA、canonical 相对路径和平台,切换 SHA 前重新安装。 - -Skill 模式由调用方运行单一 `harness.py`: - -- `prepare`:校验仓库和 clean pinned upstream,运行普通 sync;若根 - `PR_BODY.md` 缺失,则从本轮 skeleton 机械复制正式 body,再初始化 run。已有合法 - sentinel body 保留;旧草稿或损坏 sentinel body 会失败且不会被覆盖。 -- `start-pass`:校验 mode 间没有新编辑,保存本 PASS baseline,输出 prompt 位置。 -- `finish-pass`:先检查 owned 路径、PR body 授权范围、当前 PASS 既有责任行和文档 - readiness,再运行 pinned 普通 sync 并推进 mode。 -- `prepare-submit`:建立 active、unsealed SUBMIT baseline,不运行 final gate。 -- `seal-submit`:限制本阶段只能填写提交测试证据,运行 pinned final gate并封存内容。 -- `finish-submit`:把 sealed snapshot/body/path set 与 commit、工作区和远端 PR 绑定。 -- `status`:输出 completed modes、active mode 和结果路径。 - -PREPARE 成功后,根 `PR_BODY.md` 始终是正式交接产物; -`.coding_workflow/diffs/pr_body_skeleton.md` 只是它的机械来源,不能替代正式 body。 -PASS Agent 只能填写 ownership 声明的 section,并且只能更新匹配当前 owned path 和 -当前 PASS 的既有表格行。不得用一段摘要替换整个表格,也不得删除、改写其他 PASS 行。 - -机械 PASS completion 只证明:1. 强制产物存在;2. sentinel / heading / row 结构 -存在;3. 当前责任字段非空;4. owned 文档达到 readiness。内容真实性与证据质量仍由 -独立 reviewer 根据仓库事实判断。 - -该 harness 防善意执行者漏步骤和顺手越权,不试图抵御与它拥有相同 shell、Git 和 -工作区写权限的主动恶意执行者。 - ---- - -## 2. Sync Agent Pass - -每次新开对话,只复制并执行对应 PASS 的 code block;每个 PASS prompt 已内置必要共用规则。 - -### 2.1 PASS 1 - Code Facts / Architecture - -```text -整体目标:完成本轮 workflow docs sync;用普通 sync 产物和代码证据更新本 pass -owned docs,并把结论写入 `PR_BODY.md` 的 agent-owned 区。 -当前任务:只执行 PASS 1 - Code Facts / Architecture。不要执行其他 PASS。 - -执行模式: -- 人工 code-block 模式:执行本 block 的语义任务,并在完成后运行末尾普通 sync curl。 -- Skill 模式:只执行语义读取、判断和改写;不要执行本 block 末尾的 curl,也不要自行 - 运行 harness。调用方会在本 mode 前后运行 `start-pass` / `finish-pass`。 - -前置条件: -- 当前仓库必须已经运行过普通 sync。 - -共用执行规则: -- 完整 PR body 结构、sync sentinel、Repo Facts heading 和表头,以 - `.coding_workflow/diffs/pr_body_skeleton.md` 或当前 `PR_BODY.md` 为准。 -- 表格 cell 中的字面 `|` 必须写为 `\|` 或改用 `<br>`;只有未转义 `|` 才作为列分隔符。 -- 不得手改 `<!-- sync:auto:start -->` 到 `<!-- sync:auto:end -->` 区域、 - 任何 sync sentinel、sentinel 外内容。 -- 只修改本 pass 允许的文件和 agent-owned section 内容;本 pass 负责的 - agent-owned 内容不能保留 `待补充`。 -- `pr_test_evidence` 区只由 PR 提交 Agent 填写;PASS 1-4 不得修改、不得为它消去 `待补充`。 -- `agent_execution_evidence` 是执行自报清单,只用于 reviewer 抽查;本 pass 只填写自己的行,不能把它表述为 harness 级读取证明。 -- `Full Document Reconcile` 是 `PR_BODY.md` 的文档语义对账表;必须填写 - upstream semantic delta、adopted where、not adopted because、evidence、 - downstream impact;没有拒绝项或下游影响时写 `none`,无法判断时写 - `待判断` 留给 reviewer 和用户。 -- `Full Document Reconcile` 的 evidence 列必须显式覆盖三类漂移;未发现写 `none`: - `class-1 template/missing: ...<br>class-2 upstream: ...<br>class-3 code/test/behavior drift: ...` -- 描述 upstream semantic delta 时,不得字面引用上游模板 marker、sample anchor、尖括号占位符或 TODO sentinel;必须改写为描述性表述,并使用 upstream raw URL 作为证据来源。 -- 本 pass owned docs 的漂移触发器: - - `architecture.md` - - class-1 template/missing:系统目的、模块表、数据流、状态 / 错误 / 外部依赖 / 不变量仍是模板或缺失。 - - class-2 upstream:upstream architecture 新增或调整架构章节 / 表达要求,本地未采纳也未说明原因。 - - class-3 code/test/behavior drift:代码新增入口、模块、数据流、状态模型、错误模型、外部依赖、扩展点或架构不变量,而 `architecture.md` 未跟上。 -- 本 pass owned docs 的闭合规则: - - class-1:用代码证据去模板化 `architecture.md`;证据不足写 evidence,不补虚假架构。 - - class-2:适用则 adopt 到 `architecture.md`;不适用写 not adopted because。 - - class-3:架构事实写回 `architecture.md`;能力、用户行为、测试或治理影响只写 downstream impact,不越权改后续 pass 文件。 - -必须读取: -1. `.coding_workflow/diffs/agent_workorder.md` -2. `.coding_workflow/diffs/upstream_full/architecture.md` -3. `PR_BODY.md` 应由 PREPARE 机械创建;如缺失立即停止并回报 PREPARE 产物异常,不得自行复制 skeleton。 -4. `PR_BODY.md` 的 `repo_facts_map` 和 `full_document_reconcile` 中 `architecture.md` 行 -5. 当前仓库的代码入口、模块边界、数据流、状态模型、外部依赖和代码层不变量 - -只允许修改: -- `architecture.md` -- `PR_BODY.md` 的 `repo_facts_map` -- `PR_BODY.md` 的 `full_document_reconcile` 中 `architecture.md` 行 -- `PR_BODY.md` 的 `agent_execution_evidence` 中 PASS 1 行 - -必须填写: -- `Repo Facts Map` 的 10 个固定子项;每项必须有代码路径、文档路径或命令输出证据。 -- `architecture.md` 中证据足够的代码事实;无代码证据的段落不得凭空写满。 -- `Full Document Reconcile` 的 `architecture.md` 行。 -- `Agent Execution Evidence` 的 PASS 1 行:实际读取文件、关键事实摘要、未读文件及原因。 - -完成后: -1. 人工 code-block 模式运行并确认普通 sync 成功: - `curl -fsSL https://raw.githubusercontent.com/wlvh/coding-workflow/main/zh/scripts/sync.sh | bash`。 -2. Skill 模式不要执行本 block 末尾的 curl;直接回报语义任务完成,调用方随后运行 - pinned `finish-pass`。 -3. 任一模式的普通 sync / finish-pass 失败时停止;不要手修 auto 区。 -4. 回报本 pass 负责的 `Full Document Reconcile` 行是否留下 `待判断`。 -``` - -### 2.2 PASS 2 - Capability / User Behavior - -```text -整体目标:完成本轮 workflow docs sync;用普通 sync 产物和代码证据更新本 pass -owned docs,并把结论写入 `PR_BODY.md` 的 agent-owned 区。 -当前任务:只执行 PASS 2 - Capability / User Behavior。不要执行其他 PASS。 - -执行模式: -- 人工 code-block 模式:执行本 block 的语义任务,并在完成后运行末尾普通 sync curl。 -- Skill 模式:只执行语义读取、判断和改写;不要执行本 block 末尾的 curl,也不要自行 - 运行 harness。调用方会在本 mode 前后运行 `start-pass` / `finish-pass`。 - -前置条件: -- `.coding_workflow/diffs/agent_workorder.md` 的 `## 文件处理清单` 中 - `architecture.md` 的 `marker / TODO 命中` 应为 `none`;否则停止并回报需要先完成 - PASS 1。 - -共用执行规则: -- 完整 PR body 结构、sync sentinel、Repo Facts heading 和表头,以 - `.coding_workflow/diffs/pr_body_skeleton.md` 或当前 `PR_BODY.md` 为准。 -- 表格 cell 中的字面 `|` 必须写为 `\|` 或改用 `<br>`;只有未转义 `|` 才作为列分隔符。 -- 不得手改 `<!-- sync:auto:start -->` 到 `<!-- sync:auto:end -->` 区域、 - 任何 sync sentinel、sentinel 外内容。 -- 只修改本 pass 允许的文件和 agent-owned section 内容;本 pass 负责的 - agent-owned 内容不能保留 `待补充`。 -- `pr_test_evidence` 区只由 PR 提交 Agent 填写;PASS 1-4 不得修改、不得为它消去 `待补充`。 -- `agent_execution_evidence` 是执行自报清单,只用于 reviewer 抽查;本 pass 只填写自己的行,不能把它表述为 harness 级读取证明。 -- `Full Document Reconcile` 是 `PR_BODY.md` 的文档语义对账表;必须填写 - upstream semantic delta、adopted where、not adopted because、evidence、 - downstream impact;没有拒绝项或下游影响时写 `none`,无法判断时写 - `待判断` 留给 reviewer 和用户。 -- `Full Document Reconcile` 的 evidence 列必须显式覆盖三类漂移;未发现写 `none`: - `class-1 template/missing: ...<br>class-2 upstream: ...<br>class-3 code/test/behavior drift: ...` -- 描述 upstream semantic delta 时,不得字面引用上游模板 marker、sample anchor、尖括号占位符或 TODO sentinel;必须改写为描述性表述,并使用 upstream raw URL 作为证据来源。 -- 本 pass owned docs 的漂移触发器: - - `capability_contract.json` - - class-1 template/missing:`sample_*` anchor、sample status、样本能力桶或占位能力仍未项目化。 - - class-2 upstream:upstream schema 新增能力桶、失败模式、行为承诺字段或锚点规则,本地未采纳也未说明原因。 - - class-3 code/test/behavior drift:代码或测试显示新增能力、拒绝、必须追问、降级或边界行为,但 contract 无对应 anchor。 - - `interact.md` - - class-1 template/missing:用户可观察行为、不变量或验收口径仍是写作骨架或缺失。 - - class-2 upstream:upstream 用户行为写作规则、颗粒度原则或验收表达更新,本地未采纳也未说明原因。 - - class-3 code/test/behavior drift:代码新增可见入口、输出结构、错误提示、排序、默认行为或限制,但 `interact.md` 无对应不变量。 - - `docs/business_user_guide.md` - - class-1 template/missing:尖括号占位、样例 case、泛用问法或模板说明仍未项目化。 - - class-2 upstream:upstream 教学结构、业务解释顺序或风险提示更新,本地未采纳也未说明原因。 - - class-3 code/test/behavior drift:contract / interact 已确认且业务人员会感知的能力,guide 未解释怎么问、怎么看、何时找人。 -- 本 pass owned docs 的闭合规则: - - `capability_contract.json`:class-1/2/3 修成真实能力边界和 stable anchor;证据不足写 downstream impact,不凭空声明能力。 - - `interact.md`:只闭合用户可观察行为和验收不变量;新增声明必须锚到 contract 或测试证据。 - - `docs/business_user_guide.md`:只解释 contract / interact 已确认且业务可感知的能力;能力未确认先回到 contract / interact。 - - class-2 不适用时,各 owned doc 对应写 not adopted because。 - -必须读取: -1. `.coding_workflow/diffs/agent_workorder.md` -2. `.coding_workflow/diffs/upstream_full/capability_contract.json` -3. `.coding_workflow/diffs/upstream_full/interact.md` -4. `.coding_workflow/diffs/upstream_full/docs/business_user_guide.md` -5. `PR_BODY.md` 应由 PREPARE 机械创建;如缺失立即停止并回报 PREPARE 产物异常,不得自行复制 skeleton。 -6. `PR_BODY.md` 的 `repo_facts_map` 中能力相关事实和 `full_document_reconcile` - 中本 pass 三个 owned docs 行 -7. `architecture.md`、`capability_contract.json`、`interact.md`、 - `docs/business_user_guide.md` - -只允许修改: -- `capability_contract.json` -- `interact.md` -- `docs/business_user_guide.md` -- `PR_BODY.md` 的 `repo_facts_map` 中能力相关事实 -- `PR_BODY.md` 的 `full_document_reconcile` 中本 pass 三个 owned docs 行 -- `PR_BODY.md` 的 `agent_execution_evidence` 中 PASS 2 行 - -必须填写: -- `capability_contract.json`:能力边界、职责边界和 agent 行为承诺的机器可读契约。 -- `interact.md`:用户可观察行为和验收不变量。 -- `docs/business_user_guide.md`:业务人员教学文档,只解释已存在能力,不新增能力。 -- 锚点优先级:`capability_contract.json` → `interact.md` → 测试存在性; - 测试只用于证明能力实现存在,不替代 contract / interact,也不为测试设计背书。 -- `Full Document Reconcile` 中 `capability_contract.json`、`interact.md`、 - `docs/business_user_guide.md` 三行。 -- `Agent Execution Evidence` 的 PASS 2 行:实际读取文件、关键事实摘要、未读文件及原因。 - -完成后: -1. 人工 code-block 模式运行并确认普通 sync 成功: - `curl -fsSL https://raw.githubusercontent.com/wlvh/coding-workflow/main/zh/scripts/sync.sh | bash`。 -2. Skill 模式不要执行本 block 末尾的 curl;直接回报语义任务完成,调用方随后运行 - pinned `finish-pass`。 -3. 任一模式的普通 sync / finish-pass 失败时停止;不要手修 auto 区。 -4. 回报本 pass 负责的 `Full Document Reconcile` 行是否留下 `待判断`。 -``` - -### 2.3 PASS 3 - TESTING Independent Review - -```text -整体目标:完成本轮 workflow docs sync;用普通 sync 产物和代码证据更新本 pass -owned docs,并把结论写入 `PR_BODY.md` 的 agent-owned 区。 -当前任务:只执行 PASS 3 - TESTING Independent Review。不要执行其他 PASS。 - -执行模式: -- 人工 code-block 模式:执行本 block 的语义任务,并在完成后运行末尾普通 sync curl。 -- Skill 模式:只执行语义读取、判断和改写;不要执行本 block 末尾的 curl,也不要自行 - 运行 harness。调用方会在本 mode 前后运行 `start-pass` / `finish-pass`。 - -前置条件: -- `.coding_workflow/diffs/agent_workorder.md` 的 `## 文件处理清单` 中 - `architecture.md`、`capability_contract.json`、`interact.md` 和 - `docs/business_user_guide.md` 的 `marker / TODO 命中` 应为 `none`; - 否则停止并回报应该回到 PASS 1 或 PASS 2。 - -共用执行规则: -- 完整 PR body 结构、sync sentinel、Repo Facts heading 和表头,以 - `.coding_workflow/diffs/pr_body_skeleton.md` 或当前 `PR_BODY.md` 为准。 -- 表格 cell 中的字面 `|` 必须写为 `\|` 或改用 `<br>`;只有未转义 `|` 才作为列分隔符。 -- 不得手改 `<!-- sync:auto:start -->` 到 `<!-- sync:auto:end -->` 区域、 - 任何 sync sentinel、sentinel 外内容。 -- 只修改本 pass 允许的文件和 agent-owned section 内容;本 pass 负责的 - agent-owned 内容不能保留 `待补充`。 -- `pr_test_evidence` 区只由 PR 提交 Agent 填写;PASS 1-4 不得修改、不得为它消去 `待补充`。 -- `agent_execution_evidence` 是执行自报清单,只用于 reviewer 抽查;本 pass 只填写自己的行,不能把它表述为 harness 级读取证明。 -- `Full Document Reconcile` 是 `PR_BODY.md` 的文档语义对账表;必须填写 - upstream semantic delta、adopted where、not adopted because、evidence、 - downstream impact;没有拒绝项或下游影响时写 `none`,无法判断时写 - `待判断` 留给 reviewer 和用户。 -- `Full Document Reconcile` 的 evidence 列必须显式覆盖三类漂移;未发现写 `none`: - `class-1 template/missing: ...<br>class-2 upstream: ...<br>class-3 code/test/behavior drift: ...` -- 描述 upstream semantic delta 时,不得字面引用上游模板 marker、sample anchor、尖括号占位符或 TODO sentinel;必须改写为描述性表述,并使用 upstream raw URL 作为证据来源。 -- 本 pass owned docs 的漂移触发器: - - `TESTING.md` - - class-1 template/missing:测试分层、测试证据、推荐 gate 或 `TESTING_REVIEW_PACKET` 仍是上游骨架或缺失。 - - class-2 upstream:upstream 新增测试原则、alignment 测试规则、证据记录方式或 gate 口径,本地未采纳也未说明原因。 - - class-3 code/test/behavior drift:`tests/` 冗长、重复、mock-only、只测实现细节,或没有覆盖新增能力;本 pass 只写 review packet,不改测试代码。 -- 本 pass owned docs 的闭合规则: - - `TESTING.md`:class-1/2 更新测试原则、证据记录和 gate;不适用的 upstream 规则写 not adopted because。 - - class-3 只写 `TESTING_REVIEW_PACKET`,把机械信号和定向证据落入 redundant_tests、missing_high_value_tests、tests_not_worth_adding、mock_only_risks、recommended_gate 和 downstream_requirements_for_PR_Checklist;本 pass 不改 `tests/`。 - -必须读取: -1. `.coding_workflow/diffs/agent_workorder.md` -2. `.coding_workflow/diffs/upstream_full/TESTING.md` -3. `PR_BODY.md` 应由 PREPARE 机械创建;如缺失立即停止并回报 PREPARE 产物异常,不得自行复制 skeleton。 -4. `PR_BODY.md` 的 `repo_facts_map` 中测试现状相关事实和 `full_document_reconcile` - 中 `TESTING.md` 行 -5. `TESTING.md`、目标项目测试入口、`tests/` 清单和机械信号命令输出;只打开机械信号 - 指向的具体测试片段 -6. PASS 1/2 owned docs 的相关锚点或段落;只在测试策略需要时定向读取,不通读全文 - -只允许修改: -- `TESTING.md` -- `PR_BODY.md` 的 `repo_facts_map` 中测试现状相关事实 -- `PR_BODY.md` 的 `full_document_reconcile` 中 `TESTING.md` 行 -- `PR_BODY.md` 的 `agent_execution_evidence` 中 PASS 3 行 - -机械信号收集(示例命令,agent 必须按项目等价工具改写并在 evidence 写明实际命令): -- `find tests -type f -name 'test_*.py' -exec wc -l {} + | sort -n` -- `grep -rh '^def test_\|^class Test' tests/` -- `git log --since='3 months ago' --name-only -- tests/` - -必须填写: -- `TESTING.md` 顶部的 `## TESTING_REVIEW_PACKET` section;如果不存在就新增,包含 9 项: - existing_test_inventory、redundant_tests、missing_high_value_tests、 - tests_not_worth_adding、unit_vs_contract_vs_scenario_vs_e2e_decision、 - real_failure_modes_covered、mock_only_risks、recommended_gate、 - downstream_requirements_for_PR_Checklist。 -- `Full Document Reconcile` 的 `TESTING.md` 行。 -- `Agent Execution Evidence` 的 PASS 3 行:实际读取文件、关键事实摘要、未读文件及原因。 - -完成后: -1. 人工 code-block 模式运行并确认普通 sync 成功: - `curl -fsSL https://raw.githubusercontent.com/wlvh/coding-workflow/main/zh/scripts/sync.sh | bash`。 -2. Skill 模式不要执行本 block 末尾的 curl;直接回报语义任务完成,调用方随后运行 - pinned `finish-pass`。 -3. 任一模式的普通 sync / finish-pass 失败时停止;不要手修 auto 区。 -4. 回报本 pass 负责的 `Full Document Reconcile` 行是否留下 `待判断`。 -``` - -### 2.4 PASS 4 - Governance / Reverse Closure - -```text -整体目标:完成本轮 workflow docs sync;用普通 sync 产物和代码证据更新本 pass -owned docs,并把结论写入 `PR_BODY.md` 的 agent-owned 区。 -当前任务:只执行 PASS 4 - Governance / Reverse Closure。不要执行其他 PASS。 - -执行模式: -- 人工 code-block 模式:执行本 block 的语义任务,并在完成后运行末尾普通 sync curl。 -- Skill 模式:只执行语义读取、判断和改写;不要执行本 block 末尾的 curl,也不要自行 - 运行 harness。调用方会在本 mode 前后运行 `start-pass` / `finish-pass`。 - -前置条件: -- `.coding_workflow/diffs/agent_workorder.md` 的 `## 文件处理清单` 中 PASS 1/2/3 - owned docs 的 `marker / TODO 命中` 应为 `none`;否则停止并回报应该回到哪个 pass。 - -共用执行规则: -- 完整 PR body 结构、sync sentinel、Repo Facts heading 和表头,以 - `.coding_workflow/diffs/pr_body_skeleton.md` 或当前 `PR_BODY.md` 为准。 -- 表格 cell 中的字面 `|` 必须写为 `\|` 或改用 `<br>`;只有未转义 `|` 才作为列分隔符。 -- 不得手改 `<!-- sync:auto:start -->` 到 `<!-- sync:auto:end -->` 区域、 - 任何 sync sentinel、sentinel 外内容。 -- 只修改本 pass 允许的文件和 agent-owned section 内容;本 pass 负责的 - agent-owned 内容不能保留 `待补充`。 -- `pr_test_evidence` 区只由 PR 提交 Agent 填写;PASS 1-4 不得修改、不得为它消去 `待补充`。 -- `agent_execution_evidence` 是执行自报清单,只用于 reviewer 抽查;本 pass 只填写自己的行,不能把它表述为 harness 级读取证明。 -- `Full Document Reconcile` 是 `PR_BODY.md` 的文档语义对账表;必须填写 - upstream semantic delta、adopted where、not adopted because、evidence、 - downstream impact;没有拒绝项或下游影响时写 `none`,无法判断时写 - `待判断` 留给 reviewer 和用户。 -- `Full Document Reconcile` 的 evidence 列必须显式覆盖三类漂移;未发现写 `none`: - `class-1 template/missing: ...<br>class-2 upstream: ...<br>class-3 code/test/behavior drift: ...` -- 描述 upstream semantic delta 时,不得字面引用上游模板 marker、sample anchor、尖括号占位符或 TODO sentinel;必须改写为描述性表述,并使用 upstream raw URL 作为证据来源。 -- 本 pass owned docs 的漂移触发器: - - `PR_Checklist.md` - - class-1 template/missing:提交、PR body、测试证据或文档同步清单仍是骨架或缺失。 - - class-2 upstream:upstream 新增提交检查项、文档同步规则或 review 记录要求,本地未采纳也未说明原因。 - - class-3 code/test/behavior drift:PASS 1/2/3 的 downstream impact 需要提交流程约束,但 checklist 未收。 - - `SOP.md` - - class-1 template/missing:只剩 SOP 原则、空段或样例流程,没有项目固定流程入口。 - - class-2 upstream:upstream SOP 原则或流程入口组织方式更新,本地未采纳也未说明原因。 - - class-3 code/test/behavior drift:代码或交付方式新增固定流程,如迁移、部署、E2E、live ops,但 SOP 无入口。 - - `AGENTS.md` - - class-1 template/missing:`## 文件简介`、文档关系、测试 / PR 入口或业务知识仍是空段或模板。 - - class-2 upstream:upstream agent 工作规则、文档关系或文件简介要求更新,本地未采纳也未说明原因。 - - class-3 code/test/behavior drift:核心模块、业务逻辑、入口脚本或治理文件增删,但 `## 文件简介` 未跟上。 - - `.github/pull_request_template.md` - - class-1 template/missing:文件缺失;这是唯一允许直接继承 upstream 的核心文件。 - - class-2 upstream:upstream PR template 更新时默认 adopt;本地覆盖才需要写清 not adopted because。 - - class-3 code/test/behavior drift:项目需要固定 PR 段落但 upstream 无法覆盖,才保留本地覆盖。 -- 本 pass owned docs 的闭合规则: - - `PR_Checklist.md`:把 PASS 1/2/3 downstream impact 中需要提交期强制执行的事项收进 checklist。 - - `SOP.md`:只为项目固定流程新增或更新流程入口;新增 / 修改 SOP 后同步 `AGENTS.md` 的 SOP 清单。 - - `AGENTS.md`:只按前三个 pass 的证据更新文件简介和治理入口;需要新的项目代码事实时写 downstream impact,不做大范围代码重读。 - - `.github/pull_request_template.md`:缺失或 upstream 更新默认 inherit / adopt;只有项目固定 PR 段落无法由 upstream 覆盖时,才保留最小本地覆盖并写 not adopted because。 - -必须读取: -1. `.coding_workflow/diffs/agent_workorder.md` -2. `.coding_workflow/diffs/upstream_full/PR_Checklist.md` -3. `.coding_workflow/diffs/upstream_full/SOP.md` -4. `.coding_workflow/diffs/upstream_full/AGENTS.md` -5. `.coding_workflow/diffs/upstream_full/.github/pull_request_template.md` -6. `PR_BODY.md` 应由 PREPARE 机械创建;如缺失立即停止并回报 PREPARE 产物异常,不得自行复制 skeleton。 -7. `PR_BODY.md` 的 `full_document_reconcile` 全表和 `remaining_human_decisions` -8. `PR_Checklist.md`、`SOP.md`、`AGENTS.md` 和 `.github/pull_request_template.md` -9. PASS 1/2/3 owned docs 仅在验证 downstream impact 闭合时定向读取相关段落; - 不默认通读前置 pass 全文 - -只允许修改: -- `PR_Checklist.md` -- `SOP.md` -- `AGENTS.md` -- `.github/pull_request_template.md` -- `PR_BODY.md` 的 `full_document_reconcile` 中本 pass 四个 owned docs 行 -- `PR_BODY.md` 的 `remaining_human_decisions` -- `PR_BODY.md` 的 `agent_execution_evidence` 中 PASS 4 行 - -必须填写: -- 前三个 pass 的 downstream impact 是否已被治理文档消费。 -- `Full Document Reconcile` 的 downstream impact 列必须逐 pass 显式列出: - `PASS 1 找到 N 条 class-3 漂移,闭合于 architecture.md §X / 未闭合 K 条 deferred 到 ...`; - `PASS 2 ... 闭合于 capability_contract.json / interact.md / docs/business_user_guide.md ...`; - `PASS 3 ... 闭合于 TESTING_REVIEW_PACKET / 留作 PR_Checklist 待落实 ...`。 -- `PR_Checklist.md`、`SOP.md`、`AGENTS.md` 的同步治理规则。 -- PR template override decision:判断目标项目是否继承上游 PR template、是否有本地覆盖, - 并把决定写入 `Full Document Reconcile` 的 adopted where 或 not adopted because。 -- `Remaining Human Decisions`:没有待判断项保留 `none`;否则列出具体待决事项。 -- `Agent Execution Evidence` 的 PASS 4 行:实际读取文件、关键事实摘要、未读文件及原因。 - -完成后: -1. 人工 code-block 模式运行并确认普通 sync 成功: - `curl -fsSL https://raw.githubusercontent.com/wlvh/coding-workflow/main/zh/scripts/sync.sh | bash`。 -2. Skill 模式不要执行本 block 末尾的 curl;直接回报语义任务完成,调用方随后运行 - pinned `finish-pass`。 -3. 任一模式的普通 sync / finish-pass 失败时停止;不要手修 auto 区。 -4. 回报 `Full Document Reconcile` 或 `Remaining Human Decisions` 是否留下 - `待判断`。 -``` - ---- - -## 3. PR 提交 Agent - -PASS 4 完成后,启动 PR 提交 agent;提交判断以 `PR_BODY.md` 的 -`Full Document Reconcile` / `Remaining Human Decisions` 和 final gate 为准,不以 -PASS 4 的聊天摘要作为事实源。 - -```text -整体目标:完成本轮 workflow docs sync;用普通 sync 产物和代码证据更新本 pass -owned docs,并把结论写入 `PR_BODY.md` 的 agent-owned 区。 -当前任务:只执行 PR 提交 Agent。不要补写 PASS 1/2/3/4 的语义内容。 - -执行模式: -- 人工 code-block 模式:按本 block 运行 final gate、提交、push 和更新 PR body。 -- Skill 模式 evidence 阶段:调用方只运行了 `prepare-submit`;此时 active 但尚未 - seal。运行测试并只填写 `PR Test Evidence`,不要 commit、push 或更新远端 body。 -- Skill 模式发布阶段:调用方成功运行 `seal-submit` 后,严格提交 sealed allowed - paths,push 并更新远端 body;最后由调用方运行 `finish-submit`。 - -必须读取: -1. `PR_Checklist.md` -2. `TESTING.md` -3. `.github/pull_request_template.md` -4. `PR_BODY.md` -5. `.coding_workflow/diffs/sync_state.json` -6. `PR_BODY.md` 的 `Full Document Reconcile` 表;如果找不到该 heading, - 先查 `<!-- sync:agent:start full_document_reconcile -->` 到 - `<!-- sync:agent:end full_document_reconcile -->` agent-owned 区。 -7. `PR_BODY.md` 的 `PR Test Evidence` 段。 -8. `PR_BODY.md` 的 `Upstream Drift Log` 段。 -9. `PR_BODY.md` 的 `Agent Execution Evidence` 表。 -10. `PR_BODY.md` 的 `Remaining Human Decisions` 段;如果找不到该 heading, - 先查 `<!-- sync:agent:start remaining_human_decisions -->` 到 - `<!-- sync:agent:end remaining_human_decisions -->` agent-owned 区。 - -提交前核对: -- 用 `git branch --show-current` 确认当前分支不是 main / master / 默认主干。 -- 用 `git status --short` 区分本轮 sync 文件、`PR_BODY.md`、测试和无关改动;无关改动不得提交。 -- 用 `git diff --name-only <base>...HEAD` 和 `git diff --name-only` 核对 - `PR_BODY.md` 的变更范围,diff 中有但 PR body 未列的补齐,PR body 中列了但 diff - 不存在的删除。 -- 检查 `PR_BODY.md` 的 `Full Document Reconcile` 表覆盖本轮核心文档,且没有 `待补充`。 -- 检查 `Agent Execution Evidence` 四个 PASS 行都已填写;这是 reviewer 抽查入口,不是工具读取证明。 -- 检查 `Upstream Drift Log`;如果不是 `none`,必须在 PR body 或回报中提示 reviewer 重新核对 current upstream raw URL。 -- `Remaining Human Decisions` 是语义风险表达,不是 final gate 硬阻断;如有非 - `none` 项,必须保留在 PR body 交给 reviewer 和用户判断。 -- 按 `TESTING.md` 和 `PR_BODY.md` 记录的测试策略运行必要测试,并把结果写入 - `PR Test Evidence` 区;不得把本 PR 一次性测试证据写入 `Repo Facts Map`。 -- 人工 code-block 模式提交前必须运行: - `curl -fsSL https://raw.githubusercontent.com/wlvh/coding-workflow/main/zh/scripts/sync.sh | bash -s -- --final` -- Skill 模式不得重跑该 curl;填写证据后停止,由调用方执行 `seal-submit`。seal - 成功前不得进入下面的单 commit 发布步骤。 - -提交范围: -- Skill 模式只允许提交 `seal-submit` 返回的精确 `allowed_commit_paths`;该集合来自 - PASS ownership 与 `.gitignore`,不得自行加入测试或其他路径。 -- `PR_BODY.md` 默认只用于更新 GitHub PR body,不提交仓库;在 Skill 模式这是硬约束, - 始终不在 sealed commit set 内。即使目标仓库历史上 tracked,也保留为本地未提交 - body,不得加入本轮 commit。 -- 不得提交 `.coding_workflow/diffs/`、`.coding_workflow/skill_results/`、 - `.coding_workflow/skill_runtime/`、临时 clone 目录、无关代码、无关配置或历史草稿。 - -单 commit 发布: -- 先用 `gh pr list --state open --head <branch>` 判断是更新既有 PR 还是新建 PR。 -- 更新既有 PR:先更新 `PR_BODY.md` 的 Review / 修复记录,再 stage 预期文件, - 运行 `git commit --amend --no-edit`,用 - `git push --force-with-lease origin HEAD:<branch>` 推送,最后运行 - `gh pr edit <number> --body-file PR_BODY.md`。 -- 新建 PR:stage 预期文件,创建一个 commit,运行 `git push -u origin <branch>`, - 再运行 - `gh pr create --draft --title <title> --body-file PR_BODY.md --base <base> --head <branch>`。 -- 禁止使用裸 `git push --force`;禁止把 `.github/pull_request_template.md` - 当作 PR body 直接提交。 -- 如果 seal 只报告 `PR Test Evidence` 占位或 submission evidence 问题,保持当前 - active、unsealed runtime,修正该 section 后重跑 `seal-submit`;不 commit、不删除 - runtime,也不要手修 PR body auto 区。 -- 如果 seal 报告 PASS-owned 核心文档问题或 final reconcile 改写这类内容,保留 - 当前失败 runtime;从失败仓库做不依赖已发布 branch 的本地 clone,把原 head 分支 - 指向失败 SUBMIT baseline 的 `start_head`,恢复真实 GitHub `origin` 并精确校验。在 - 新 clone 依次执行 PREPARE、PASS_1–4 和新的 SUBMIT。不得删除旧 runtime、吸收 - 当前内容为新 baseline 或创建中间 commit。精确命令见 Skill 的 - `references/modes.md`。 -- Skill 模式的 `finish-submit` 会读取远端 body;如果没有成功执行 `gh pr edit - --body-file PR_BODY.md`,SUBMIT 必须失败。 - -完成后回报: -- PR URL、branch、base、commit hash。 -- `git diff --name-only <base>...HEAD` 的实际提交文件。 -- final gate 和测试命令 / 结果。 -- `PR Test Evidence` 是否已记录测试命令、结果和 N/A 原因。 -- `Upstream Drift Log` 是否为 `none`;如非 `none`,说明 reviewer 需要重核的 upstream 漂移。 -- `PR_BODY.md` 是已提交,还是仅用于更新 GitHub PR body。 -- `Remaining Human Decisions` 是否为 `none`。 -``` - ---- - -## 4. Sync PR Review - -PR 提交 agent 给出 PR URL 后,启动独立 reviewer: - -```text -你是PR reviewer,这个pr是负责更新本地文档治理的漂移修复。请按 PR body auto 区的 `Sync Review Contract` -和 commit-pinned reviewer prompt raw URL 审核 PR <URL>;中文流程中该 reviewer prompt -应指向 `zh/scripts/sync_pr_review_system.md`。 - -必须打开 PR body upstream 段落列出的 raw URL,并对照 PR head 上的核心文档做 -full reconcile cross-check。必须同时检查 `PR Test Evidence`、`Upstream Drift Log` -和 `Agent Execution Evidence`;其中 `Agent Execution Evidence` 只是 agent 自报, -不能替代 reviewer 对 PR body 证据路径的抽查。 -``` - -处理结果: - -- PASS:进入用户视角验收。 -- WARN:在 PR body 说明为什么可接受,再进入用户视角验收。 -- BLOCKER:机械问题回到 sync pass 重跑普通 sync;语义问题先补证据或让用户判断, - 再回到 sync pass 写入文档和 PR body,之后重新走 PR 提交和 review。 diff --git a/zh/scripts/install_skills.py b/zh/scripts/install_skills.py index cbb9d0d..cbe5d2d 100644 --- a/zh/scripts/install_skills.py +++ b/zh/scripts/install_skills.py @@ -1,55 +1,33 @@ #!/usr/bin/env python3 -"""从 clean pinned upstream 覆盖安装两个 Workflow Docs Sync Skill。""" +"""安装 canonical workflow-docs-sync,并清理精确废弃的 reviewer Skill。""" from __future__ import annotations import argparse import json import os -import re import shutil import subprocess import sys from pathlib import Path -from typing import Any -SKILL_NAMES = ("workflow-docs-sync", "workflow-docs-sync-review") -PLATFORM_ROOTS = { - "codex": Path(".agents/skills"), - "claude": Path(".claude/skills"), -} +SKILL_NAME = "workflow-docs-sync" +OBSOLETE_SKILL_NAME = "workflow-docs-sync-review" +PLATFORM_ROOTS = {"codex": Path(".agents/skills"), "claude": Path(".claude/skills")} class InstallError(RuntimeError): - """保存必须 fail-fast 的安装错误。""" + """表示应立即停止的本地安装错误。""" - def __init__(self, *, error: str, detail: str) -> None: - """参数为摘要和证据;预期构造结构化异常。""" - super().__init__(error) - self.error = error - self.detail = detail - -class JsonArgumentParser(argparse.ArgumentParser): - """把 argparse 错误转换为 InstallError。""" - - def error(self, message: str) -> None: - """参数为 argparse 消息;预期抛出单行 JSON 错误。""" - raise InstallError(error="参数无效", detail=message) - - -def emit_json(*, payload: dict[str, Any]) -> None: - """参数为结果对象;预期 stdout 写一行 UTF-8 JSON。""" - print(json.dumps(payload, ensure_ascii=False, separators=(",", ":"))) - - -def git(*, repo_root: Path, args: list[str]) -> Any: - """参数为仓库和 Git 参数;预期返回捕获 UTF-8 输出的进程结果。""" +def git(*, repo_root: Path, args: list[str]) -> subprocess.CompletedProcess[str]: + """在指定路径运行 Git 并捕获 UTF-8 输出。""" + environment = {**os.environ, "LC_ALL": "C"} return subprocess.run( args=["git", "-C", str(repo_root), *args], cwd=repo_root, - env=os.environ.copy(), + env=environment, check=False, capture_output=True, encoding="utf-8", @@ -57,186 +35,113 @@ def git(*, repo_root: Path, args: list[str]) -> Any: ) -def require_sha(*, value: str, label: str) -> str: - """参数为候选值和字段名;预期返回完整小写 Git SHA。""" - if re.fullmatch(r"[0-9a-f]{40}", value) is None: - raise InstallError( - error=f"{label}无效", - detail="必须是 40 位小写十六进制 Git SHA", - ) - return value - - -def require_repo(*, value: str, label: str) -> tuple[Path, str]: - """参数为仓库路径和角色;预期返回规范根目录与 HEAD。""" - repo_root = Path(value).expanduser().resolve(strict=True) - result = git( - repo_root=repo_root, - args=["rev-parse", "--show-toplevel", "HEAD"], - ) - lines = result.stdout.splitlines() - if result.returncode != 0 or len(lines) != 2: - raise InstallError( - error=f"{label}无效", - detail=result.stderr.strip() or "无法解析仓库根目录和 HEAD", - ) - actual_root = Path(lines[0]).resolve(strict=True) - if actual_root != repo_root: - raise InstallError( - error=f"{label}必须是仓库根目录", - detail=f"传入 {repo_root},实际 {actual_root}", - ) - return repo_root, require_sha(value=lines[1], label=f"{label} HEAD") - - -def require_clean(*, repo_root: Path, label: str) -> None: - """参数为仓库和角色;预期工作区/暂存区无任何改动。""" - result = git( - repo_root=repo_root, - args=["status", "--porcelain=v1", "--untracked-files=all"], - ) - if result.returncode != 0: - raise InstallError(error="无法读取 Git 状态", detail=result.stderr.strip()) - if result.stdout: - raise InstallError( - error=f"{label}工作区不干净", detail=result.stdout.strip() - ) +def require_clean_repo(*, value: str, label: str) -> Path: + """验证路径恰好是 clean Git 工作树根目录。""" + root = Path(value).expanduser().resolve(strict=True) + top = git(repo_root=root, args=["rev-parse", "--show-toplevel"]) + if top.returncode != 0: + raise InstallError(top.stderr.strip() or f"{label}不是 Git 仓库") + actual = Path(top.stdout.strip()).resolve(strict=True) + if actual != root: + raise InstallError(f"{label}必须是 Git 根目录:{actual}") + status_args = ["status", "--porcelain=v1", "--untracked-files=all"] + status = git(repo_root=root, args=status_args) + if status.returncode != 0: + raise InstallError(status.stderr.strip() or f"无法读取{label}状态") + if status.stdout: + raise InstallError(f"{label}工作区不干净:{status.stdout.strip()}") + return root def claude_text(*, text: str) -> str: - """参数为 canonical SKILL.md;预期禁止模型隐式调用。""" + """为 Claude 副本添加禁止隐式调用的 frontmatter 标记。""" parts = text.split("---", 2) if len(parts) != 3 or parts[0] != "": - raise InstallError( - error="SKILL.md frontmatter 无效", - detail="缺少标准 YAML frontmatter", - ) + raise InstallError("SKILL.md 缺少标准 YAML frontmatter") lines = parts[1].strip("\n").splitlines() - key = "disable-model-invocation" - indexes = [ - index for index, line in enumerate(lines) - if line.split(":", 1)[0].strip() == key - ] - if len(indexes) > 1: - raise InstallError(error="SKILL.md frontmatter 无效", detail=f"{key} 重复") - if indexes: - lines[indexes[0]] = f"{key}: true" - if not indexes: - lines.append(f"{key}: true") + lines.append("disable-model-invocation: true") return "---\n" + "\n".join(lines) + "\n---" + parts[2] -def install( - *, - source: Path, - destination: Path, - platform: str, - upstream_sha: str, -) -> dict[str, str]: - """参数为来源/目标/平台;预期覆盖复制并写三字段来源记录。""" - skill_name = source.name +def remove_path(*, path: Path) -> bool: + """删除一个精确路径,并返回是否实际删除。""" + # 先识别 symlink,避免把外部目标目录当作本地目录递归删除。 + if path.is_symlink() or path.is_file(): + path.unlink() + elif path.is_dir(): + shutil.rmtree(path=path) + else: + return False + return True + + +def copy_skill(*, source: Path, destination: Path, platform: str) -> None: + """覆盖一个明确目录并复制 canonical 单 Skill。""" if not source.is_dir() or not (source / "SKILL.md").is_file(): - raise InstallError(error="canonical Skill 不完整", detail=str(source)) - if destination.is_symlink() or destination.is_file(): - destination.unlink() - if destination.is_dir(): - shutil.rmtree(destination) + raise InstallError(f"canonical Skill 不完整:{source}") + remove_path(path=destination) destination.parent.mkdir(parents=True, exist_ok=True) shutil.copytree( src=source, dst=destination, symlinks=False, - ignore=shutil.ignore_patterns( - "__pycache__", "*.py[cod]", ".DS_Store", ".source.json" - ), + ignore=shutil.ignore_patterns("__pycache__", "*.py[cod]", ".DS_Store"), ) - skill_path = destination / "SKILL.md" if platform == "claude": - skill_path.write_text( - claude_text(text=skill_path.read_text(encoding="utf-8")), - encoding="utf-8", - ) - source_record = { - "upstream_sha": upstream_sha, - "canonical_relative_path": f"zh/skills/{skill_name}", - "platform": platform, - } - (destination / ".source.json").write_text( - json.dumps(source_record, ensure_ascii=False, indent=2, sort_keys=True) - + "\n", - encoding="utf-8", - ) - return { - "skill": skill_name, - "platform": platform, - "destination": str(destination), - "action": "copied", - } + skill_path = destination / "SKILL.md" + text = claude_text(text=skill_path.read_text(encoding="utf-8")) + skill_path.write_text(text, encoding="utf-8") -def parse_args() -> Any: - """无参数;预期返回 scope、目标仓库、upstream 路径和 SHA。""" - parser = JsonArgumentParser(description="复制 Workflow Docs Sync Skill。") +def parse_args() -> argparse.Namespace: + """解析 upstream、user/repo scope 和目标仓库。""" + parser = argparse.ArgumentParser(description="安装 workflow-docs-sync。") parser.add_argument("--scope", choices=("user", "repo"), default="user") parser.add_argument("--target-repo") parser.add_argument("--upstream-dir", required=True) - parser.add_argument("--upstream-sha", required=True) args = parser.parse_args() if (args.scope == "repo") != (args.target_repo is not None): - raise InstallError( - error="参数无效", - detail="只有 repo scope 必须且可以提供 --target-repo", - ) + parser.error("只有 repo scope 必须且可以提供 --target-repo") return args -def execute() -> dict[str, Any]: - """无参数;预期从 clean pinned upstream 覆盖复制四个安装目录。""" +def execute() -> dict[str, object]: + """从 clean checkout 安装一个 Skill 并移除精确废弃路径。""" args = parse_args() - expected_sha = require_sha(value=args.upstream_sha, label="上游 SHA") - upstream, actual_sha = require_repo(value=args.upstream_dir, label="上游目录") - if actual_sha != expected_sha: - raise InstallError( - error="上游 SHA 不匹配", - detail=f"期望 {expected_sha},实际 {actual_sha}", - ) - require_clean(repo_root=upstream, label="上游目录") - if args.scope == "user": - if "HOME" not in os.environ: - raise InstallError(error="缺少用户目录", detail="user scope 需要 HOME") - install_root = Path(os.environ["HOME"]).expanduser().resolve() - if args.scope == "repo": - install_root, _ = require_repo(value=args.target_repo, label="目标仓库") - actions = [ - install( - source=upstream / "zh/skills" / skill_name, - destination=install_root / platform_root / skill_name, - platform=platform, - upstream_sha=actual_sha, - ) - for skill_name in SKILL_NAMES - for platform, platform_root in PLATFORM_ROOTS.items() - ] + upstream = require_clean_repo(value=args.upstream_dir, label="上游目录") + install_root = ( + require_clean_repo(value=args.target_repo, label="目标仓库") + if args.scope == "repo" + else Path.home().resolve() + ) + source = upstream / "zh/skills" / SKILL_NAME + actions, removed_obsolete = [], [] + for platform, platform_root in PLATFORM_ROOTS.items(): + obsolete = install_root / platform_root / OBSOLETE_SKILL_NAME + if remove_path(path=obsolete): + removed_obsolete.append(str(obsolete)) + destination = install_root / platform_root / SKILL_NAME + copy_skill(source=source, destination=destination, platform=platform) + actions.append({"platform": platform, "destination": str(destination)}) return { - "status": "passed", "scope": args.scope, "upstream_sha": actual_sha, - "install_root": str(install_root), "actions": actions, + "status": "passed", + "scope": args.scope, + "skill": SKILL_NAME, + "install_root": str(install_root), + "removed_obsolete": removed_obsolete, + "actions": actions, } def run_cli() -> int: - """无参数;预期成功返回 0,已知本地错误返回 1。""" + """运行安装器并以一行 JSON 报告成功或失败。""" try: payload = execute() - except InstallError as exc: - emit_json(payload={"error": exc.error, "detail": exc.detail}) + except (InstallError, OSError, RuntimeError, UnicodeError) as exc: + payload = {"status": "failed", "error": str(exc)} + print(json.dumps(payload, ensure_ascii=False, separators=(",", ":"))) return 1 - except (KeyError, OSError, TypeError, UnicodeError, ValueError) as exc: - emit_json( - payload={"error": "Skill 安装失败", - "detail": f"{type(exc).__name__}: {exc}"} - ) - return 1 - emit_json(payload=payload) + print(json.dumps(payload, ensure_ascii=False, separators=(",", ":"))) return 0 diff --git a/zh/scripts/sync.sh b/zh/scripts/sync.sh deleted file mode 100755 index d8c17e9..0000000 --- a/zh/scripts/sync.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env bash -# One-shot launcher for Chinese workflow docs sync. -# -# Usage from a target project: -# curl -fsSL https://raw.githubusercontent.com/wlvh/coding-workflow/main/zh/scripts/sync.sh | bash -# -# This launcher uses the Chinese template source family under `zh/` and installs -# selected templates into canonical target paths such as `AGENTS.md`, -# `TESTING.md`, and `.github/pull_request_template.md`. - -set -euo pipefail - -FINAL_CHECK=0 -if [ $# -eq 1 ] && [ "$1" = "--final" ]; then - FINAL_CHECK=1 -elif [ $# -ne 0 ]; then - echo "Unknown arg: $1" - exit 2 -fi - -if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then - echo "Error: $(pwd) is not inside a git worktree." - exit 1 -fi -REPO_ROOT="$(git rev-parse --show-toplevel)" - -UPSTREAM_PARENT="" -cleanup() { - if [ -n "$UPSTREAM_PARENT" ]; then - rm -rf "$UPSTREAM_PARENT" - fi -} -trap cleanup EXIT - -if [ -n "${CODING_WORKFLOW_UPSTREAM_DIR:-}" ]; then - UPSTREAM_DIR="$(cd "$CODING_WORKFLOW_UPSTREAM_DIR" && pwd)" -else - UPSTREAM_PARENT="$(mktemp -d "${TMPDIR:-/tmp}/coding-workflow-XXXXXX")" - echo "Cloning wlvh/coding-workflow..." - # Shallow clone keeps the one-shot launcher fast; the Python script pins the - # resolved HEAD before reading every upstream template. - git clone --quiet --depth=1 --single-branch \ - https://github.com/wlvh/coding-workflow.git "$UPSTREAM_PARENT/cw" - UPSTREAM_DIR="$UPSTREAM_PARENT/cw" -fi - -export REPO_ROOT -export UPSTREAM_DIR -export CODING_WORKFLOW_LANGUAGE=zh -python3 "$UPSTREAM_DIR/scripts/sync_coding_workflow.py" - -if [ "$FINAL_CHECK" -eq 1 ]; then - python3 "$UPSTREAM_DIR/scripts/sync_coding_workflow.py" \ - --check-final PR_BODY.md -elif [ -f "$REPO_ROOT/PR_BODY.md" ]; then - python3 "$UPSTREAM_DIR/scripts/sync_coding_workflow.py" \ - --update-pr-body PR_BODY.md -else - echo "PR_BODY.md not found; use .coding_workflow/diffs/pr_body_skeleton.md when drafting the sync PR body." -fi diff --git a/zh/scripts/sync_pr_review_system.md b/zh/scripts/sync_pr_review_system.md deleted file mode 100644 index 295e44b..0000000 --- a/zh/scripts/sync_pr_review_system.md +++ /dev/null @@ -1,131 +0,0 @@ -# Sync PR Review System Prompt - -## 你是谁 - -你是 sync PR reviewer。你的任务不是普通代码审查,而是判断一个 -`full_reconcile` PR 是否真的把当前项目事实和当前 upstream workflow 规则对齐。 - -本提示词只负责启动独立审查。PR body auto 区中的 `Sync Review Contract` -只列本轮必读输入和 final gate 分工;sentinel、auto 区时效、占位残留和 template residue -由 final gate 负责。本提示词负责语义审查方法,包括 pass 闭合、证据真实性、 -upstream 规则吸收和可操作性判断。 - ---- - -## 输入材料 - -你必须读取: - -- PR body,尤其是 `Sync Review Contract`、`Full Document Reconcile` 和 final gate 证据。 -- PR body 的 `PR Test Evidence`、`Upstream Drift Log`、`Agent Execution Evidence`。 -- PR changed files。 -- PR head 上 `Sync Review Contract` 列出的核心文档。 -- PR body 中 upstream 段落列出的全部 raw URL。 -- PR body 中引用的项目事实对应的代码、文档或命令证据。 - -如果 PR body 缺少 `Sync Review Contract`,或者列出的必读输入无法访问,不能给 PASS。 - ---- - -## 审查方法 - -按 PR body 的 `Sync Review Contract` 和 agent-owned 区逐项审核。重点判断: - -- PR body 是否有 final gate 通过证据。 -- Repo facts 是否有真实证据,不能把无证据判断写成事实。 -- 每个 pass 的 owned docs 是否都有 `Full Document Reconcile` 证据,且能追到核心文档。 -- TESTING 独立 pass 是否检查了测试冗余、必要性、真实失败覆盖、mock-only 风险和 E2E/scenario 价值。 -- Full reconcile 是否覆盖本轮核心文档,并说明 upstream semantic delta、adopted where、not adopted because、evidence 和 downstream impact。 -- PR Test Evidence 是否记录实际测试命令、结果和 N/A 原因,且没有把一次性测试证据混进长期 Repo Facts。 -- Upstream Drift Log 是否暴露了本轮 PR body 刷新期间的 upstream commit 漂移;如存在漂移,必须按当前 raw URL 重新核对 reconcile 行。 -- Agent Execution Evidence 是否给出每个 pass 的自报读取清单;这只是抽查入口,不能当成工具级读取证明。 -- Remaining Human Decisions 是否明确暴露了仍需用户或 reviewer 判断的语义风险。 -- 每条 downstream impact 是否被后续 pass 或治理文档消费,不能停在口头声明。 -- PR head 是否真正吸收或合理解释了 upstream 规则。 -- 下一位 agent 或开发者能否按本轮核心文档完成进入项目、测试、提交、架构理解和用户视角验收。 -- 至少抽查若干条 PR body 证据路径或命令声明,直接对照 PR head 内容或可复现命令,确认 agent 自报和文档 evidence 没有明显编造。 - -你看不到目标项目的本轮 scratch 目录时,不能假设里面的证据成立;PR body 必须转写关键证据。 - ---- - -## 输出格式 - -```markdown -## Sync PR Review - -### Verdict -PASS / WARN / BLOCKER - -### 1. Contract Compliance -PASS/WARN/BLOCKER + 证据 - -### 2. Evidence Quality -PASS/WARN/BLOCKER + 证据 - -### 3. Full Reconcile Closure -PASS/WARN/BLOCKER + 证据 - -### 4. Per-Pass Evidence and Propagation -PASS/WARN/BLOCKER + Full Document Reconcile、TESTING pass 与 downstream impact 闭合证据 - -### 5. Test, Drift, and Execution Evidence -PASS/WARN/BLOCKER + PR Test Evidence、Upstream Drift Log、Agent Execution Evidence 与抽查证据 - -### 6. Upstream Cross-check -PASS/WARN/BLOCKER + 实际打开的 raw URL - -### 7. Operability -PASS/WARN/BLOCKER + 证据 - -### 8. Top Issues -只列最高影响的问题,包含文件路径和行号 / PR body 引用 - -### 9. Evidence Index -列出实际读取过的 PR body 段、核心文档、raw URL 和验证命令 -``` - -使用 reviewer skill 输出 JSON 时,1–7 节分别保存为 -`review_sections.<section> = {"verdict": "PASS|WARN|BLOCKER", "evidence": "..."}` -的结构化对象,Top Issues 保存为 `findings`,第 9 节保存为非空 -`evidence_index`;顶层 verdict 必须等于全部 section verdict 与 finding level 的最高等级。 - ---- - -## 判定原则 - -BLOCKER: - -- 违反 final gate 或 `Sync Review Contract` 标记的必读输入或证据要求。 -- PR body 缺少 `Sync Review Contract` 或列出的必读材料。 -- raw URL 拼错、不可访问,或无法证明对应 upstream 内容。 -- Repo facts、文档判断或 upstream 吸收声明没有证据支撑。 -- `specialized` 被直接当成“无需更新”,没有说明 upstream semantic delta 与 adopted/not adopted 证据。 -- 任一 pass 的 owned docs 缺少 `Full Document Reconcile` 证据,或 evidence / downstream impact 无法支撑闭合判断。 -- TESTING pass 没有单独审查测试冗余、必要性、真实失败覆盖和 E2E/scenario 价值。 -- PR Test Evidence 缺少必要测试命令、结果或 N/A 原因。 -- Upstream Drift Log 非 `none`,但 reviewer 没有重新核对当前 upstream raw URL。 -- Agent Execution Evidence 把自报读取清单表述成工具级读取证明,或与 PR body 证据明显矛盾。 -- Remaining Human Decisions 隐藏了实际待判断事项,或与 PR head / PR body 其他声明矛盾。 -- downstream impact 没有在最终文件或后续 pass 中反向闭合。 -- final gate 失败,或者 PR body 没有说明 final gate 结果。 -- PR head 与 PR body 互相矛盾。 - -WARN: - -- 证据不足但不影响主流程判断。 -- 次要说明缺少更细证据,但 final gate 和必读输入已经闭环。 -- raw URL 因临时网络问题无法验证。 - -PASS: - -- 只有当 final gate、证据、upstream cross-check、文档可执行性和剩余人工决策的语义风险已被明确暴露并可接受时才能 PASS。 - ---- - -## 你不做什么 - -- 不维护或要求旧的增量 baseline manifest。 -- 不按 commit history 推断“上次同步以来”的漂移。 -- 不替代用户视角验收。 -- 不把本提示词当成机械清单真相源;机械细节由 final gate 判断。 diff --git a/zh/skills/workflow-docs-sync-review/SKILL.md b/zh/skills/workflow-docs-sync-review/SKILL.md deleted file mode 100644 index 2244d54..0000000 --- a/zh/skills/workflow-docs-sync-review/SKILL.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -name: workflow-docs-sync-review -description: 当需要在独立会话中审查工作流文档同步(workflow docs sync)PASS 项目化 PR、且不得修改 tracked 项目文件时使用。 ---- - -## 调用协议 - -- 只在与执行 skill 不同的独立新会话中调用;输入为 workflow-docs PR 编号。 -- 当前目录必须是该 PR 所属目标仓库且 HEAD 等于实际 PR head;显式提供 clean pinned upstream 路径和完整 SHA。 -- 本 skill 不创建隔离上下文,不修改 tracked 项目文件,不修复自己发现的问题;只允许写入已忽略的 `.coding_workflow/skill_results/`。 - -## 审查流程 - -1. 确认目标仓库在审查前工作区干净,并记录 `git status`;写入 ignored result 后再次核对,输出必须完全相同。 -2. 从 pinned checkout 读取 `zh/scripts/sync_pr_review_system.md` 全文,把它作为唯一审查规则。 -3. 使用 `gh` 读取指定 PR 的 body、changed files、完整 diff 与 head SHA;按审查规则定向读取证据。 -4. 只输出可由 PR、PR head、pinned upstream 或可复现命令证明的 finding;不得替执行者补写文档或 PR body。 -5. 将结果写入 `.coding_workflow/skill_results/review_<UTC时间戳>.json`。结果必须包含完整 `head_sha`、`upstream_sha`、七个 `{verdict, evidence}` `review_sections`、最高影响 findings 和非空 `evidence_index`;整体结论取分节判定和 finding 等级中最严重者,不允许人工覆盖。 -6. 把 `<workflow-docs-sync-review-root>` 解析为当前 canonical / installed `SKILL.md` - 所在目录的绝对路径;在目标仓库 cwd 运行 - `python3 "<workflow-docs-sync-review-root>/scripts/validate_review.py" --target-repo <目标仓库> --upstream-dir <pinned checkout> --upstream-sha <完整SHA> --review-file <结果文件> --pr-number <PR编号>`;validator 会再次查询真实 PR,并绑定 PR body upstream SHA、PR number、GitHub head、目标仓库 HEAD 和结果 JSON。 -7. 校验通过后,把结果文件中的同一 JSON 原样作为会话最后一条消息。 - -## 停止条件 - -- upstream checkout 缺失、不是 git 仓库,或 HEAD 与 PR body 声明的 pinned SHA 不一致。 -- PR 不存在、无法读取 diff、无法确定 head SHA,或目标仓库 HEAD / 参数 / 结果 JSON 与 GitHub PR identity 不一致。 -- 审查规则文件缺失或无法以 UTF-8 读取。 -- 审查前后 `git status` 不同,或出现 ignored result 之外的写入意图。 -- 输出校验失败。 diff --git a/zh/skills/workflow-docs-sync-review/agents/openai.yaml b/zh/skills/workflow-docs-sync-review/agents/openai.yaml deleted file mode 100644 index 363eed7..0000000 --- a/zh/skills/workflow-docs-sync-review/agents/openai.yaml +++ /dev/null @@ -1,7 +0,0 @@ -interface: - display_name: "工作流文档同步审查" - short_description: "在独立新会话审查 workflow-docs PR,不修改 tracked 项目文件" - default_prompt: "使用 $workflow-docs-sync-review 审查指定 PR。" - -policy: - allow_implicit_invocation: false diff --git a/zh/skills/workflow-docs-sync-review/references/review-schema.json b/zh/skills/workflow-docs-sync-review/references/review-schema.json deleted file mode 100644 index 94e9ae9..0000000 --- a/zh/skills/workflow-docs-sync-review/references/review-schema.json +++ /dev/null @@ -1,248 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "工作流文档同步独立审查结果", - "type": "object", - "additionalProperties": false, - "required": [ - "verdict", - "findings", - "pr", - "head_sha", - "upstream_sha", - "review_sections", - "evidence_index" - ], - "properties": { - "verdict": { - "type": "string", - "enum": [ - "PASS", - "WARN", - "BLOCKER" - ] - }, - "findings": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": [ - "level", - "claim", - "evidence" - ], - "properties": { - "level": { - "type": "string", - "enum": [ - "PASS", - "WARN", - "BLOCKER" - ] - }, - "claim": { - "type": "string", - "minLength": 1 - }, - "evidence": { - "type": "string", - "minLength": 1 - } - } - } - }, - "pr": { - "type": "integer", - "minimum": 1 - }, - "head_sha": { - "type": "string", - "minLength": 40, - "maxLength": 40, - "pattern": "^[0-9a-f]{40}$" - }, - "upstream_sha": { - "type": "string", - "minLength": 40, - "maxLength": 40, - "pattern": "^[0-9a-f]{40}$" - }, - "review_sections": { - "type": "object", - "additionalProperties": false, - "required": [ - "contract_compliance", - "evidence_quality", - "full_reconcile_closure", - "per_pass_evidence_and_propagation", - "test_drift_and_execution_evidence", - "upstream_cross_check", - "operability" - ], - "properties": { - "contract_compliance": { - "type": "object", - "additionalProperties": false, - "required": [ - "verdict", - "evidence" - ], - "properties": { - "verdict": { - "type": "string", - "enum": [ - "PASS", - "WARN", - "BLOCKER" - ] - }, - "evidence": { - "type": "string", - "minLength": 1 - } - } - }, - "evidence_quality": { - "type": "object", - "additionalProperties": false, - "required": [ - "verdict", - "evidence" - ], - "properties": { - "verdict": { - "type": "string", - "enum": [ - "PASS", - "WARN", - "BLOCKER" - ] - }, - "evidence": { - "type": "string", - "minLength": 1 - } - } - }, - "full_reconcile_closure": { - "type": "object", - "additionalProperties": false, - "required": [ - "verdict", - "evidence" - ], - "properties": { - "verdict": { - "type": "string", - "enum": [ - "PASS", - "WARN", - "BLOCKER" - ] - }, - "evidence": { - "type": "string", - "minLength": 1 - } - } - }, - "per_pass_evidence_and_propagation": { - "type": "object", - "additionalProperties": false, - "required": [ - "verdict", - "evidence" - ], - "properties": { - "verdict": { - "type": "string", - "enum": [ - "PASS", - "WARN", - "BLOCKER" - ] - }, - "evidence": { - "type": "string", - "minLength": 1 - } - } - }, - "test_drift_and_execution_evidence": { - "type": "object", - "additionalProperties": false, - "required": [ - "verdict", - "evidence" - ], - "properties": { - "verdict": { - "type": "string", - "enum": [ - "PASS", - "WARN", - "BLOCKER" - ] - }, - "evidence": { - "type": "string", - "minLength": 1 - } - } - }, - "upstream_cross_check": { - "type": "object", - "additionalProperties": false, - "required": [ - "verdict", - "evidence" - ], - "properties": { - "verdict": { - "type": "string", - "enum": [ - "PASS", - "WARN", - "BLOCKER" - ] - }, - "evidence": { - "type": "string", - "minLength": 1 - } - } - }, - "operability": { - "type": "object", - "additionalProperties": false, - "required": [ - "verdict", - "evidence" - ], - "properties": { - "verdict": { - "type": "string", - "enum": [ - "PASS", - "WARN", - "BLOCKER" - ] - }, - "evidence": { - "type": "string", - "minLength": 1 - } - } - } - } - }, - "evidence_index": { - "type": "array", - "minItems": 1, - "items": { - "type": "string", - "minLength": 1 - } - } - } -} diff --git a/zh/skills/workflow-docs-sync-review/scripts/validate_review.py b/zh/skills/workflow-docs-sync-review/scripts/validate_review.py deleted file mode 100644 index a3dfa98..0000000 --- a/zh/skills/workflow-docs-sync-review/scripts/validate_review.py +++ /dev/null @@ -1,354 +0,0 @@ -#!/usr/bin/env python3 -"""校验独立 review JSON,并绑定 clean pinned upstream 与真实 GitHub PR。""" - -from __future__ import annotations - -import argparse -import json -import os -import re -import subprocess -import sys -from pathlib import Path -from typing import Any - - -SKILL_ROOT = Path(__file__).resolve().parents[1] -SCHEMA_PATH = SKILL_ROOT / "references/review-schema.json" -SOURCE_PATH = SKILL_ROOT / ".source.json" -RESULTS_DIR = Path(".coding_workflow/skill_results") -RANKS = {"PASS": 0, "WARN": 1, "BLOCKER": 2} - - -class ReviewError(RuntimeError): - """保存必须以单行 JSON 返回的 review 校验错误。""" - - def __init__(self, *, error: str, detail: str) -> None: - """参数为摘要和证据;预期构造结构化异常。""" - super().__init__(error) - self.error = error - self.detail = detail - - -class JsonArgumentParser(argparse.ArgumentParser): - """把 argparse 错误转换为 ReviewError。""" - - def error(self, message: str) -> None: - """参数为 argparse 消息;预期抛出单行 JSON 错误。""" - raise ReviewError(error="参数无效", detail=message) - - -def emit_json(*, payload: dict[str, Any]) -> None: - """参数为结果对象;预期 stdout 写一行 UTF-8 JSON。""" - print(json.dumps(payload, ensure_ascii=False, separators=(",", ":"))) - - -def run(*, args: list[str], cwd: Path) -> subprocess.CompletedProcess[str]: - """参数为命令和 cwd;预期返回捕获 UTF-8 输出的进程结果。""" - return subprocess.run( - args=args, - cwd=cwd, - env=os.environ.copy(), - check=False, - capture_output=True, encoding="utf-8", - ) - - -def git(*, repo_root: Path, args: list[str]) -> Any: - """参数为仓库和 Git 参数;预期返回捕获输出的进程结果。""" - return run(args=["git", "-C", str(repo_root), *args], cwd=repo_root) - - -def require_sha(*, value: str, label: str) -> str: - """参数为候选值和字段名;预期返回完整小写 Git SHA。""" - if re.fullmatch(r"[0-9a-f]{40}", value) is None: - raise ReviewError( - error=f"{label}无效", - detail="必须是 40 位小写十六进制 Git SHA", - ) - return value - - -def require_repo(*, value: str, label: str) -> tuple[Path, str]: - """参数为仓库路径和角色;预期返回规范根目录与 HEAD。""" - repo_root = Path(value).expanduser().resolve(strict=True) - result = git( - repo_root=repo_root, args=["rev-parse", "--show-toplevel", "HEAD"] - ) - lines = result.stdout.splitlines() - if result.returncode != 0 or len(lines) != 2: - raise ReviewError( - error=f"{label}无效", - detail=result.stderr.strip() or "无法解析仓库根目录和 HEAD", - ) - actual_root = Path(lines[0]).resolve(strict=True) - if actual_root != repo_root: - raise ReviewError( - error=f"{label}必须是仓库根目录", - detail=f"传入 {repo_root},实际 {actual_root}", - ) - return repo_root, require_sha(value=lines[1], label=f"{label} HEAD") - - -def status_text(*, repo_root: Path, tracked_only: bool) -> str: - """参数为仓库和 tracked 开关;预期返回 NUL Git status 原文。""" - untracked_arg = f"--untracked-files={'no' if tracked_only else 'all'}" - result = git( - repo_root=repo_root, - args=["status", "--porcelain=v1", "-z", untracked_arg], - ) - if result.returncode != 0: - raise ReviewError(error="无法读取 Git 状态", detail=result.stderr.strip()) - return result.stdout - - -def require_upstream(*, value: str, expected_sha: str) -> tuple[Path, str]: - """参数为 upstream 路径/SHA;预期返回 clean pinned checkout。""" - expected_sha = require_sha(value=expected_sha, label="上游 SHA") - upstream, actual_sha = require_repo(value=value, label="上游目录") - if actual_sha != expected_sha: - raise ReviewError( - error="上游 SHA 不匹配", - detail=f"期望 {expected_sha},实际 {actual_sha}", - ) - if status_text(repo_root=upstream, tracked_only=False): - raise ReviewError( - error="上游目录工作区不干净", - detail="存在 tracked 或 untracked 改动", - ) - return upstream, actual_sha - - -def validate_install_source(*, upstream_sha: str) -> None: - """参数为调用 SHA;预期安装副本的三字段来源记录与其一致。""" - if not SOURCE_PATH.is_file(): - return - source = json.loads(SOURCE_PATH.read_text(encoding="utf-8")) - if ( - not isinstance(source, dict) - or set(source) != { - "upstream_sha", "canonical_relative_path", "platform" - } - or source["canonical_relative_path"] - != "zh/skills/workflow-docs-sync-review" - or source["platform"] not in {"codex", "claude"} - ): - raise ReviewError( - error="reviewer Skill 来源记录无效", - detail=".source.json 必须是合法三字段安装记录", - ) - if source["upstream_sha"] != upstream_sha: - raise ReviewError( - error="reviewer Skill 来源 SHA 不匹配", - detail=f"安装 {source['upstream_sha']},调用 {upstream_sha}", - ) - - -def review_path(*, repo_root: Path, value: str) -> Path: - """参数为仓库和结果路径;预期返回 skill_results 内真实文件。""" - candidate = Path(value).expanduser() - candidate = candidate if candidate.is_absolute() else repo_root / candidate - resolved = candidate.resolve(strict=True) - try: - resolved.relative_to((repo_root / RESULTS_DIR).resolve()) - except ValueError as exc: - raise ReviewError( - error="review 路径无效", - detail=f"必须位于 {RESULTS_DIR}/", - ) from exc - return resolved - - -def query_pr(*, repo_root: Path, number: int) -> dict[str, Any]: - """参数为仓库和 PR number;预期返回真实 open PR identity。""" - result = run( - args=[ - "gh", "pr", "view", str(number), "--json", - "number,state,headRefOid,body", - ], - cwd=repo_root, - ) - if result.returncode != 0: - raise ReviewError( - error="无法读取实际审查 PR", - detail=result.stderr.strip() or result.stdout.strip(), - ) - payload = json.loads(result.stdout) - if ( - not isinstance(payload, dict) - or set(payload) != {"number", "state", "headRefOid", "body"} - or payload["number"] != number - or payload["state"] != "OPEN" or not isinstance(payload["body"], str) - ): - raise ReviewError( - error="实际审查 PR identity 不匹配", - detail=repr(payload), - ) - require_sha(value=payload["headRefOid"], label="实际 PR head") - return payload - - -def validate_review(*, review: Any, schema: Any) -> list[str]: - """参数为 review/schema;预期返回全部形状与 verdict 派生错误。""" - if not isinstance(review, dict) or not isinstance(schema, dict): - return ["review 与 schema 根节点必须是对象"] - properties = schema["properties"] - required = set(schema["required"]) - errors = [ - f"$ 缺少字段 {key}" for key in required if key not in review - ] + [ - f"$ 包含未声明字段 {key}" - for key in review - if key not in properties - ] - if errors: - return errors - levels: list[str] = [] - findings = review["findings"] - if not isinstance(findings, list): - errors.append("$.findings 必须是数组") - else: - for index, finding in enumerate(findings): - if ( - not isinstance(finding, dict) - or set(finding) != {"level", "claim", "evidence"} - or finding["level"] not in RANKS - or not isinstance(finding["claim"], str) - or not finding["claim"].strip() - or not isinstance(finding["evidence"], str) - or not finding["evidence"].strip() - ): - errors.append(f"$.findings[{index}] 字段无效") - else: - levels.append(finding["level"]) - sections = review["review_sections"] - expected_sections = set(properties["review_sections"]["required"]) - if not isinstance(sections, dict) or set(sections) != expected_sections: - errors.append("$.review_sections 字段无效") - else: - for name in sorted(expected_sections): - section = sections[name] - if ( - not isinstance(section, dict) - or set(section) != {"verdict", "evidence"} - or section["verdict"] not in RANKS - or not isinstance(section["evidence"], str) - or not section["evidence"].strip() - ): - errors.append(f"$.review_sections.{name} 字段无效") - else: - levels.append(section["verdict"]) - if ( - not isinstance(review["pr"], int) - or isinstance(review["pr"], bool) - or review["pr"] < 1 - ): - errors.append("$.pr 必须是正整数") - for key in ("head_sha", "upstream_sha"): - if ( - not isinstance(review[key], str) - or re.fullmatch(r"[0-9a-f]{40}", review[key]) is None - ): - errors.append(f"$.{key} 必须是完整 SHA") - evidence = review["evidence_index"] - if ( - not isinstance(evidence, list) - or not evidence - or not all(isinstance(item, str) and item.strip() for item in evidence) - ): - errors.append("$.evidence_index 必须是非空字符串数组") - if review["verdict"] not in RANKS: - errors.append("$.verdict 非法") - elif levels and review["verdict"] != max( - levels, - key=lambda value: RANKS[value], - ): - errors.append("顶层 verdict 必须等于 sections/findings 最高等级") - return errors - - -def parse_args() -> Any: - """无参数;预期返回五个必填 CLI 值。""" - parser = JsonArgumentParser(description="校验独立 review JSON。") - for name in ("target-repo", "upstream-dir", "upstream-sha", "review-file"): - parser.add_argument(f"--{name}", required=True) - parser.add_argument("--pr-number", required=True, type=int) - args = parser.parse_args() - if args.pr_number < 1: - raise ReviewError(error="参数无效", detail="pr-number 必须为正整数") - return args - - -def execute() -> dict[str, Any]: - """无参数;预期绑定 upstream、真实 PR、target HEAD 与 review JSON。""" - args = parse_args() - _, upstream_sha = require_upstream( - value=args.upstream_dir, expected_sha=args.upstream_sha - ) - validate_install_source(upstream_sha=upstream_sha) - target, target_head = require_repo(value=args.target_repo, label="目标仓库") - if status_text(repo_root=target, tracked_only=False): - raise ReviewError( - error="目标仓库工作区不干净", detail="不得有 tracked 或 untracked 改动", - ) - pull_request = query_pr(repo_root=target, number=args.pr_number) - auto = re.search( - r"(?s)<!-- sync:auto:start -->(.*?)<!-- sync:auto:end -->", - pull_request["body"], - ) - declared = [] if auto is None else re.findall( - r"(?m)^- upstream_resolved_commit: ([0-9a-f]{40})$", - auto.group(1), - ) - if declared != [upstream_sha]: - raise ReviewError( - error="PR body upstream SHA 不匹配", - detail=f"期望唯一声明 {upstream_sha},实际 {declared}", - ) - path = review_path(repo_root=target, value=args.review_file) - review = json.loads(path.read_text(encoding="utf-8")) - schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8")) - errors = validate_review(review=review, schema=schema) - if isinstance(review, dict) and not errors: - expected = { - "pr": pull_request["number"], - "head_sha": pull_request["headRefOid"], - "upstream_sha": upstream_sha, - } - for key, value in expected.items(): - if review[key] != value: - errors.append(f"review.{key} 与真实值不一致") - if target_head != pull_request["headRefOid"]: - errors.append("目标仓库 HEAD 与实际 PR head 不一致") - if errors: - raise ReviewError( - error="审查结果校验失败", - detail=";".join(errors), - ) - return { - "status": "passed", "review_file": str(path), "pr": review["pr"], - "head_sha": review["head_sha"], "verdict": review["verdict"], - } - - -def run_cli() -> int: - """无参数;预期成功返回 0,已知本地错误返回 1。""" - try: - payload = execute() - except ReviewError as exc: - emit_json(payload={"error": exc.error, "detail": exc.detail}) - return 1 - except (KeyError, OSError, TypeError, UnicodeError, ValueError) as exc: - emit_json( - payload={ - "error": "review validator 执行失败", - "detail": f"{type(exc).__name__}: {exc}", - } - ) - return 1 - emit_json(payload=payload) - return 0 - - -if __name__ == "__main__": - sys.exit(run_cli()) diff --git a/zh/skills/workflow-docs-sync/SKILL.md b/zh/skills/workflow-docs-sync/SKILL.md index 8ae421b..5d79e06 100644 --- a/zh/skills/workflow-docs-sync/SKILL.md +++ b/zh/skills/workflow-docs-sync/SKILL.md @@ -1,65 +1,64 @@ --- name: workflow-docs-sync -description: 当需要把工作流文档同步(workflow docs sync)的 PASS 流程项目化为独立会话、分 mode 执行时使用。 +description: 在一个会话内把 wlvh/coding-workflow 的九份核心工作流文档同步并项目化到目标 Git 仓库。用户要求同步 workflow docs、补齐或核对 architecture/capability/interact/business guide/testing/governance 文档时使用;只接受目标仓库、可选 zh/en 语言和可选结束后创建 draft PR。 --- -## 调用协议 +# Workflow Docs Sync -- 只接受 `PREPARE`、`PASS_1`、`PASS_2`、`PASS_3`、`PASS_4`、`SUBMIT` 六种 mode。 -- 每个 mode 必须在独立新会话中调用;本 skill 不创建隔离上下文,也不声称替调用方完成跨平台上下文隔离。 -- 每次调用必须显式提供目标仓库、clean pinned upstream 路径、完整 SHA 和 mode;命令见 `references/modes.md`。 -- Studio 直接加载 canonical Skill;已安装副本通过 `.source.json` 记录来源 SHA,切换 SHA 后必须重新安装。 -- mode 顺序、边界快照和 SUBMIT 起点保存在 ignored `.coding_workflow/skill_runtime/`;确定性结果保存在 ignored `.coding_workflow/skill_results/`。 -- AI 负责 PASS 语义判断和文档改写;调用方按 `references/modes.md` 解析当前 Skill - 根目录的绝对路径,再运行其中 `scripts/harness.py` 的机械命令。 +在一个会话内完成编排。始终由主 Agent 独占目标工作区写入;领域分析和审计只读。 -## 通用执行规则 +## 用户输入 -1. 只通过文件和 JSON 传递状态,不依赖前一会话聊天摘要。 -2. PASS 语义真相源始终是 pinned upstream 的 `zh/scripts/OPERATIONS.md`;Skill 不复制或扩写 PASS prompt。 -3. `start-pass` 返回当前标题、owned 文件、允许的 PR body section 和 prompt 行号;只执行 code block 中标记为“Skill 模式”的语义部分。 -4. 不运行 code block 的人工 curl;pinned 普通 sync 由调用方在 `finish-pass` 中执行。 -5. 不修改 sync auto 区、其他 PASS 文件或未授权 PR body section。 -6. harness 非零退出时立即停止,不跨 mode 补做或静默降级。 +- 要求目标仓库路径。 +- 语言仅允许 `zh` 或 `en`,未提供时使用 `zh`。 +- 未明确要求时不创建 draft PR。 +- 不要求或接受用户提供上游 SHA、上游 checkout、内部命令或分析分工。 -## 各 mode 语义 +## 上游解析 -### PREPARE +1. 先判断当前 Skill 是否位于 canonical `wlvh/coding-workflow` checkout:其 Git 根目录 + 必须同时包含当前 Skill 和所选语言的九份模板。满足时直接复用该 checkout。 +2. 无法定位 canonical checkout 时,在仓库外创建临时目录并 shallow clone + `https://github.com/wlvh/coding-workflow.git`。网络失败时停止并报告,不回退到缓存模板。 +3. 调用 `scripts/sync_docs.py prepare --target-repo <target> --upstream-dir <upstream> + --language <zh|en>`,只消费其单行 JSON。 +4. 保存返回的 `target_head` 与 `upstream_sha` 于当前会话;整轮使用同一 SHA。 +5. 会话结束时清理本轮临时 clone;canonical checkout 不清理。 -调用方运行 `harness.py prepare`。它校验仓库和 pinned upstream、运行普通 sync、 -初始化 run state 并写 PREPARE result。AI 不做项目语义改写。 +## 执行流程 -### PASS_1 至 PASS_4 +1. 主 Agent 读取目标仓库规则、入口、代码、测试和现有文档,建立带路径证据的代码地图。 +2. 完整读取 [references/sections.md](references/sections.md),启动四个相互隔离的只读分析: + Architecture、Capability / User Behavior、Testing、Governance。明确禁止它们编辑、 + stage、commit 或运行会产生项目 artifact 的命令;发现只在当前会话返回。 +3. 平台不支持 subagent 时,主 Agent 按 reference 中四个章节的顺序逐章分析,保持相同 + 输出结构和最终用户体验。 +4. 主 Agent 合并四份发现,只修改九份核心文档;`.gitignore` 仅在目标项目确有忽略 + 需求时修改。不要创建运行记录、模板镜像或工单;整个同步流程不读取、创建、改写或删除 + 仓库内 `PR_BODY.md`。 +5. 完整读取 [references/audit.md](references/audit.md),启动一个独立只读对抗性审计。 +6. 主 Agent 修复全部 BLOCKER 和所有可行动 WARN,再要求审计者只读轻量复审修复点 + 及其跨文档影响。无法闭合的产品判断保留为未解决决策,不得编造事实。 +7. 主 Agent 依据目标 `TESTING.md` 和真实代码路径运行必要测试。记录原样命令、结果和 + 未运行原因;测试输出不是检查器 receipt。 +8. 调用: -1. 调用方先运行 `harness.py start-pass --mode <PASS>`。 -2. 按返回的 pinned prompt 位置执行 Skill 模式语义部分。 -3. 只修改返回的 owned 文件和 PR body sections。 -4. 调用方运行 `harness.py finish-pass --mode <PASS>`;它检查本 mode delta、运行 pinned sync、写结果并推进顺序。 + ```bash + python3 <skill-root>/scripts/sync_docs.py check \ + --target-repo <target> \ + --upstream-dir <upstream> \ + --upstream-sha <prepare 返回的 upstream_sha> \ + --expected-target-head <prepare 返回的 target_head> \ + --language <zh|en> + ``` -### SUBMIT +9. 检查失败时修复真实问题并重跑相关测试和检查;不得放宽断言、跳过必要测试或引入兼容实现。 -1. 调用方运行 `harness.py prepare-submit`,建立 active、unsealed 基线;此时 - `submit_ready=false`,且尚未执行 final gate。 -2. AI 按 OPERATIONS.md 的 evidence 阶段运行测试,只填写 `PR Test Evidence`,不得 - commit、push 或更新远端 PR。 -3. 调用方运行 `harness.py seal-submit`。它要求 HEAD 未变化、基线后只有提交阶段 - owned section 被编辑,再运行真实 pinned final gate;成功后封存 workflow 内容、 - 本地 PR body 和精确提交路径,并设置 `submit_ready=true`。 -4. AI 进入 seal 后发布阶段,按封存路径 commit、push 并更新远端 body。调用方最后 - 运行 `harness.py finish-submit`,把 sealed 内容绑定实际 commit 和远端 PR。 +## 最终报告 -`seal-submit` 失败不会退出 active SUBMIT,也不会要求删除 runtime。若只是提交证据 -不完整,修正 `PR Test Evidence` 后原地重试。若错误暴露 PASS-owned 文档问题,保留 -当前失败 runtime 作为证据,从失败仓库本地 clone 同一 `start_head`,在独立 checkout -恢复原 head 分支和真实发布 remote,再从 PREPARE 到 PASS_4 完整重跑;不得把当前 -内容设成新 baseline,也不需要中间 commit。 +一次性报告:修改文件、代码与测试证据路径、每条测试命令及结果、未解决决策、审计发现 +及处置、内部固定的上游 SHA。明确说明机械检查只验证最终仓库状态,不证明分析、审计或 +测试的执行历史。 -## 停止条件 - -- mode 顺序错误、mode 间出现新编辑或项目 HEAD 提前变化。 -- 当前 PASS 修改非 owned 路径或未授权 PR body section。 -- upstream 缺失、dirty 或 SHA 不匹配。 -- pinned sync 失败,或 seal 的 final gate 尚未通过。 -- seal 后 workflow 内容 / executable bit / PR body 变化,新增 commit 数不是 1,或实际 - 提交路径不精确等于 sealed allowed set。 -- 远端 PR number、base、head 或 body 与本地事实不一致。 +用户要求 draft PR 时,在上述流程成功后使用仓库外临时 Markdown body,把 commit、push +和 draft PR 创建交给通用 GitHub 发布能力。同步脚本不参与发布。 diff --git a/zh/skills/workflow-docs-sync/agents/openai.yaml b/zh/skills/workflow-docs-sync/agents/openai.yaml index 7b30180..c02e54e 100644 --- a/zh/skills/workflow-docs-sync/agents/openai.yaml +++ b/zh/skills/workflow-docs-sync/agents/openai.yaml @@ -1,7 +1,7 @@ interface: display_name: "工作流文档同步" - short_description: "按独立新会话 mode 执行工作流文档同步并输出结构化可校验结果" - default_prompt: "使用 $workflow-docs-sync 在独立新会话中执行指定 mode。" + short_description: "单会话编排九份核心工作流文档的分析、改写、审计与检查" + default_prompt: "使用 $workflow-docs-sync 同步目标仓库的工作流文档。" policy: allow_implicit_invocation: false diff --git a/zh/skills/workflow-docs-sync/evals/README.md b/zh/skills/workflow-docs-sync/evals/README.md index 0207f0b..37d1181 100644 --- a/zh/skills/workflow-docs-sync/evals/README.md +++ b/zh/skills/workflow-docs-sync/evals/README.md @@ -1,73 +1,17 @@ -# 影子验证协议 +# 真实仓库前向验证 -三个完整 Case 在 skill 合并后、正式推广前由用户执行。合并前必须先通过本地完整 mode -顺序集成测试和一次真实 Codex Skill smoke run;不能再把主流程首次执行推迟到合并以后。 -本协议把既有“手工四个新对话”流程与 mode 化 skill 放在相同输入上对照,确认自动化没有 -降低语义质量或扩大写入范围。 +在合并前从 clean disposable worktree 调用一次 `$workflow-docs-sync`,用户只提供目标仓库 +和语言,不创建 draft PR。验证主 Agent 是唯一写入者、分析与审计只读、没有仓库内运行 +状态或 PR body,并保存最终报告中的路径证据、测试命令、审计处置和固定上游 SHA。 -## 合并前 Gate +## Case A:SEC_metrics -- `tests/test_workflow_sync_skill.py` 必须执行 PREPARE、PASS_1–4、prepare-submit、缺 - `PR Test Evidence` 的 seal 失败、同一 runtime 补证据重试、seal 成功、commit、 - fake GitHub PR 和 finish-submit 的完整机械序列。 -- 上述集成测试必须使用真实 skeleton、普通 sync 和真实 final gate;只允许 GitHub - 查询使用 fake,不能替换 `sync.sh`。 -- 必须在临时仓库显式调用一次 Codex `$workflow-docs-sync`,保存命令和结果摘要。 -- dirty upstream、mode 间越权、per-mode scope、seal 失败重试、无 remote 分支的整轮 - restart、seal 后内容 / executable bit 篡改、非单 commit、漏提交 / 额外提交、远端 - stale body、review verdict、reviewer 绝对 Skill 路径、完整 clean target、PR body - upstream SHA、执行与 reviewer 安装来源 SHA 及跨平台 invocation metadata 必须有 - 回归测试。 +- 从当前真实代码重建 pipeline 语义,不依赖历史 shadow 文件。 +- 专门核对 stage 10、11、12 的输入、写入副作用、容错参数和 hard failure 位置。 +- 区分 unit tests、golden、report build、repair validation、light review 与 full validation。 +- 没有当前部署配置和运行证据时,不得把项目描述为已完成生产调度。 +- 文档同步 worktree 只运行低污染检查;会改写 pipeline artifact 的重型命令放到另一个 + 锁定同一提交的 disposable clone。 -## 验证准备 - -- 每个 Case 准备两个来自同一基线 commit 的独立工作副本。 -- 轨道一按 `zh/scripts/OPERATIONS.md` 手工复制 PASS 1–4,并执行既有提交与审查流程。 -- 轨道二依次在独立新会话调用 `PREPARE`、`PASS_1`、`PASS_2`、`PASS_3`、 - `PASS_4`、`SUBMIT`,再调用独立审查 skill。 -- 两条轨道使用同一个 pinned upstream SHA,不共享对话上下文或未记录的人工判断。 - -## Case A:缺核心文档的既有代码仓库 - -选择已有可运行代码、但缺少一个或多个核心 workflow 文档的仓库。验证两条轨道能否用 -代码证据补齐缺失文档,同时避免把模板占位、无证据能力或无关治理文件写入结果。 - -Shadow Case A 的 Skill 轨道还必须证明:PREPARE 后根 `PR_BODY.md` 已作为正式交接 -产物存在;每个 PASS 的成功结果同时对应正式 body 中当前 PASS 的 sentinel、heading、 -表格宽度、非空责任 cell 和 owned 文档 readiness。删除 `待补充` 但留下空 block / cell -必须失败;表格字面 `|` 必须转义为 `\|` 或改用 `<br>`。这些机械条件不裁定证据真实性, -独立 reviewer 仍负责语义 verdict。仅有 skeleton 不算正式交接或 PASS success。 - -## Case B:存在 class-2 上游漂移 - -选择核心文档已项目化、但未吸收当前 upstream 语义更新的仓库。验证两条轨道是否都能 -识别 upstream semantic delta,明确采纳位置或不采纳原因,并保持 PASS ownership 闭合。 - -## Case C:存在 class-3 代码漂移 - -选择代码、测试或用户可见行为已经变化,但对应架构、能力、测试或治理文档尚未同步的 -仓库。验证两条轨道能否从代码证据发现漂移,并由后续 PASS 消费 downstream impact。 - -## 双轨对比维度 - -每个 Case 都记录以下同口径证据: - -1. final gate 是否通过及全部违规项。 -2. 独立 reviewer 的 verdict 与新增 BLOCKER。 -3. changed files 的完整集合与两轨差异。 -4. pass ownership 之外的越权改动数量。 -5. 核心文档中的模板残留数量。 -6. `Remaining Human Decisions` 与 `待判断` 的具体条目。 -7. 需要人工跨会话搬运信息的次数。 -8. agent 结果需要人工纠正的次数与原因。 -9. 每个 mode 结果 JSON 是否稳定写入、通过 schema 校验并与会话末条消息一致。 - -## Go 条件 - -只有同时满足以下条件,skill 才可进入正式推广: - -- 三个 Case 的两条轨道 final gate 全部通过。 -- skill 轨道的独立 reviewer 相比手工轨道没有新增 BLOCKER。 -- 三个 Case 均为零越权改动。 -- skill 轨道在 changed files、模板残留、待判断暴露和语义闭合上不劣于手工轨道。 -- 所有 mode 与 review 结果 JSON 均稳定产出、可重复校验,失败结果也如实记录原因。 +完成条件:九份核心文档通过最终检查;测试与重型验证结果被准确记录;对抗审计没有未 +处理 BLOCKER,所有可行动 WARN 已修复;需要新产品决策的问题记入未解决决策。 diff --git a/zh/skills/workflow-docs-sync/references/audit.md b/zh/skills/workflow-docs-sync/references/audit.md new file mode 100644 index 0000000..0f7a90b --- /dev/null +++ b/zh/skills/workflow-docs-sync/references/audit.md @@ -0,0 +1,29 @@ +# 只读对抗性审计 + +在主 Agent 完成统一文档修改后执行。审计者只读目标工作区和固定上游模板,不能编辑、 +暂存、提交、生成审计文件或运行会写项目 artifact 的命令。机械检查不能替代本审计。 + +## 审计方法 + +1. 从当前代码、测试、配置和已提交 artifact 独立重建关键事实,不以主 Agent 总结为真相源。 +2. 逐份核对九个核心文件,并交叉检查架构、能力、用户行为、测试和治理之间是否矛盾。 +3. 对相邻 stage 或 gate 逐个读取实现,确认输入、写入副作用、容错参数和 hard + failure 位置;不要根据编号或名称推断语义。 +4. 明确区分 unit tests、golden 计算、report build、repair validation、light review 和 + full validation。检查一种成功是否被误写成另一种成功。 +5. 对“已上线”“已生产调度”“全量验证”等强声明寻找当前代码、配置和运行证据;证据 + 不足即要求降级为准确的当前状态。 +6. 核对固定上游规则是否被采用或有项目事实支持的不采用理由,并检查模板 token、空泛 + 标题、无锚点能力和不可执行命令。 + +## 严重度 + +- `BLOCKER`:事实矛盾、虚构能力、错误失败语义、把会写 artifact 的动作称为只读、 + 混淆验证层级、遗漏必需核心文档,或会直接误导实现/测试/发布的规则。 +- `WARN`:证据不足、跨文档未闭合、命令或路径可能漂移、边界表达含混。能明确修改且 + 不需要新产品决策时视为可行动。 +- `NOTE`:不影响正确性的改进建议或已确认无问题的高风险点。 + +每条 finding 返回唯一 ID、严重度、受影响文件、精确证据、风险和建议修复。没有发现时 +也要列出已核对的高风险语义。主 Agent 修复后,轻量复审只验证 finding 对应修改及其 +直接跨文档影响;新发现仍按同一严重度报告。 diff --git a/zh/skills/workflow-docs-sync/references/modes.md b/zh/skills/workflow-docs-sync/references/modes.md deleted file mode 100644 index 5313c58..0000000 --- a/zh/skills/workflow-docs-sync/references/modes.md +++ /dev/null @@ -1,85 +0,0 @@ -# 安装与 mode 人工调用 - -每个 mode 必须在独立新会话使用。先把 Skill 根路径、目标路径和 SHA 替换为真实值。 -`<workflow-docs-sync-root>` 必须解析为当前 canonical / installed `SKILL.md` 所在目录的 -绝对路径;命令从目标仓库 cwd 执行时也不得把它缩写成目标仓库内的 `scripts/`。 - -## 使用模型 - -- Studio / 自动编排:直接把 pinned upstream 的 `zh/skills/` 作为 canonical - skill source,不运行目标仓库安装器。 -- 个人使用:运行 `python3 zh/scripts/install_skills.py --upstream-dir <checkout> --upstream-sha <完整SHA>`; - 默认写入用户级 `~/.agents/skills/` 与 `~/.claude/skills/`。 -- 团队仓库安装:显式增加 `--scope repo --target-repo <仓库>`,并先通过独立安装 PR - 提交 `.agents/` 与 `.claude/`。 -- 每个安装副本只携带 `.source.json`,记录 upstream SHA、canonical 相对路径和平台。 - 切换 pinned SHA 前重新安装即可。 - -## Codex - -```text -使用 $workflow-docs-sync。Mode: <PREPARE|PASS_1|PASS_2|PASS_3|PASS_4|SUBMIT>。 -目标仓库:`<目标仓库绝对路径>`。Upstream SHA:`<完整 SHA>`。 -``` - -## Claude Code - -```text -/workflow-docs-sync Mode: <PREPARE|PASS_1|PASS_2|PASS_3|PASS_4|SUBMIT>。 -目标仓库:`<目标仓库绝对路径>`。Upstream SHA:`<完整 SHA>`。 -``` - -两个平台都必须由用户显式调用。Codex 安装产物设置 -`allow_implicit_invocation: false`;Claude 安装产物由安装器加入 -`disable-model-invocation: true`。 - -## Harness 命令 - -以下参数在每次调用中都显式提供: - -```bash -python3 "<workflow-docs-sync-root>/scripts/harness.py" prepare \ - --target-repo <仓库> --upstream-dir <checkout> --upstream-sha <SHA> - -python3 "<workflow-docs-sync-root>/scripts/harness.py" start-pass --mode PASS_1 \ - --target-repo <仓库> --upstream-dir <checkout> --upstream-sha <SHA> - -python3 "<workflow-docs-sync-root>/scripts/harness.py" finish-pass --mode PASS_1 \ - --target-repo <仓库> --upstream-dir <checkout> --upstream-sha <SHA> - -python3 "<workflow-docs-sync-root>/scripts/harness.py" prepare-submit \ - --target-repo <仓库> --upstream-dir <checkout> --upstream-sha <SHA> - -python3 "<workflow-docs-sync-root>/scripts/harness.py" seal-submit \ - --target-repo <仓库> --upstream-dir <checkout> --upstream-sha <SHA> - -python3 "<workflow-docs-sync-root>/scripts/harness.py" finish-submit --pr-number <编号> \ - --base <base> --head <head> \ - --target-repo <仓库> --upstream-dir <checkout> --upstream-sha <SHA> - -python3 "<workflow-docs-sync-root>/scripts/harness.py" status \ - --target-repo <仓库> -``` - -`prepare-submit` 只进入 active、unsealed 状态。测试和 `PR Test Evidence` 完成后才运行 -`seal-submit`;只有 seal 成功才可以 commit / push / 更新远端 PR。普通证据错误直接 -修正后重试 seal,不删除 runtime。`finish-submit` 要求从 SUBMIT `start_head` 到远端 -head 恰好新增一个 commit,并绑定 sealed 路径、内容、executable bit 与 PR body。 - -若 seal 暴露 PASS-owned 问题,不修改或删除失败 runtime,也不把当前 worktree -rebaseline。从失败仓库本地 clone(不依赖已发布 remote branch),把原 head 分支指向 -SUBMIT baseline 的 `start_head`,并恢复真实 GitHub `origin`,再从 `prepare`、 -PASS_1–4、`prepare-submit` 完整重跑。旧 run 只保留为失败证据;整个重启过程在最终 -SUBMIT 前不创建 commit。开始前必须从失败仓库记录真实发布 URL;缺失时 fail-fast, -不得把指向失败 worktree 的 clone source remote 当成发布 remote。 - -```bash -git -C <failed-repo> remote get-url origin # 记录为 <publish-url> -git clone --no-local --no-checkout --origin failed-source \ - <failed-repo> <restart-dir> -git -C <restart-dir> switch -C <head> "<SUBMIT start_head>" -git -C <restart-dir> remote add origin <publish-url> -test "$(git -C <restart-dir> rev-parse HEAD)" = "<SUBMIT start_head>" -test "$(git -C <restart-dir> branch --show-current)" = "<head>" -test "$(git -C <restart-dir> remote get-url origin)" = "<publish-url>" -``` diff --git a/zh/skills/workflow-docs-sync/references/pass_ownership.json b/zh/skills/workflow-docs-sync/references/pass_ownership.json deleted file mode 100644 index 02cc5ca..0000000 --- a/zh/skills/workflow-docs-sync/references/pass_ownership.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "PASS_1": { - "title": "2.1 PASS 1 - Code Facts / Architecture", - "owned": [ - "architecture.md" - ], - "pr_body_sections": [ - "repo_facts_map", - "full_document_reconcile", - "agent_execution_evidence" - ] - }, - "PASS_2": { - "title": "2.2 PASS 2 - Capability / User Behavior", - "owned": [ - "capability_contract.json", - "interact.md", - "docs/business_user_guide.md" - ], - "pr_body_sections": [ - "repo_facts_map", - "full_document_reconcile", - "agent_execution_evidence" - ] - }, - "PASS_3": { - "title": "2.3 PASS 3 - TESTING Independent Review", - "owned": [ - "TESTING.md" - ], - "pr_body_sections": [ - "repo_facts_map", - "full_document_reconcile", - "agent_execution_evidence" - ] - }, - "PASS_4": { - "title": "2.4 PASS 4 - Governance / Reverse Closure", - "owned": [ - "PR_Checklist.md", - "SOP.md", - "AGENTS.md", - ".github/pull_request_template.md" - ], - "pr_body_sections": [ - "full_document_reconcile", - "agent_execution_evidence", - "remaining_human_decisions" - ] - } -} diff --git a/zh/skills/workflow-docs-sync/references/sections.md b/zh/skills/workflow-docs-sync/references/sections.md new file mode 100644 index 0000000..e17201c --- /dev/null +++ b/zh/skills/workflow-docs-sync/references/sections.md @@ -0,0 +1,52 @@ +# 四领域只读分析 + +四个领域分析都以目标仓库当前代码、测试、九份现有文档和固定上游模板为证据。只读执行, +不编辑文件、不暂存改动、不运行会写项目 artifact 的命令,也不把发现写入磁盘。 + +每个分析返回同一结构:`Findings`(BLOCKER / WARN / NOTE)、`Evidence`(路径与符号或 +行号)、`Proposed updates`(建议由主 Agent 落盘的文件和内容边界)、`Open decisions`。 +事实不足时明确写未知;不得用上游模板替代项目事实。 + +## 1. Architecture + +负责 `architecture.md`。 + +- 读取真实入口、核心模块、调用链、数据流、状态与错误模型、外部依赖、配置和扩展点。 +- 核对架构不变量是否能由代码、配置或测试证伪;移除只描述愿景而无当前证据的断言。 +- 区分三类问题:仍是模板或缺段;固定上游新增的通用写作要求未吸收;代码已经变化而 + 文档未跟上。 +- 只提出架构文档修改。能力、测试或治理影响作为下游建议交回主 Agent,不越界改写。 + +## 2. Capability / User Behavior + +负责 `capability_contract.json`、`interact.md`、`docs/business_user_guide.md`。 + +- 从真实入口、响应、错误、测试和可见限制提取已实现能力、拒绝、追问、降级与责任边界。 +- 以 `capability_contract.json` 为能力边界真相源,以 `interact.md` 为用户可观察行为和 + 验收不变量真相源;business guide 只能教学性解释前两者已确认的内容。 +- 递归核对稳定 `anchor_id`、Markdown 引用和测试证据;不依赖 JSON 数组位置或固定桶路径。 +- 禁止把内部日志、仅代码可见状态、推测能力或未来计划写成用户已可用功能。 +- 对模板残留、上游语义变化、代码或测试行为漂移分别给出证据和建议。 + +## 3. Testing + +负责 `TESTING.md`。 + +- 盘点真实测试入口、层级、fixture、外部依赖、生成 artifact 的副作用和推荐执行顺序。 +- 区分 unit、contract、scenario、golden、report build、repair/validation gate 与 live test; + 不用其中一层的成功替代另一层。 +- 验证文档中的命令、环境前提、失败条件和产物与当前代码一致;标出重型或污染性命令, + 供主 Agent 在隔离 checkout 中运行。 +- 判断已有覆盖、冗余测试、真实高价值缺口、mock-only 风险和不值得新增的测试。 +- light review 只能按代码实际覆盖范围描述,不能写成 full validation。 + +## 4. Governance + +负责 `PR_Checklist.md`、`SOP.md`、`AGENTS.md`、`.github/pull_request_template.md`。 + +- 从前三个领域的下游影响反向检查规则入口、文档关系、测试证据和文件地图是否闭合。 +- 保持权威边界:`AGENTS.md` 做入口与规则,`SOP.md` 做流程骨架,`PR_Checklist.md` + 做提交核对,PR template 做长期通用 body 结构;避免四处复制易漂移细节。 +- 核对所有命令、路径、分支和发布声明是否仍真实;没有当前代码或配置证据时,不得声称 + 已完成生产部署、调度或自动化。 +- PR template 可以直接继承固定上游内容;其他八份核心文档必须体现目标项目事实。 diff --git a/zh/skills/workflow-docs-sync/scripts/harness.py b/zh/skills/workflow-docs-sync/scripts/harness.py deleted file mode 100644 index 763c431..0000000 --- a/zh/skills/workflow-docs-sync/scripts/harness.py +++ /dev/null @@ -1,1902 +0,0 @@ -#!/usr/bin/env python3 -"""在 mode 边界检查 Workflow Docs Sync。 - -调用关系:main -> run_cli -> dispatch -> prepare/start-pass/finish-pass/ -prepare-submit/seal-submit/finish-submit/status。输出为 run、baseline 和 result JSON。 -只防善意漏步骤、顺手越权和提交事实错误,不防同权限主动恶意执行者。 -""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import os -import re -import shutil -import subprocess -import sys -from datetime import datetime, timezone -from pathlib import Path -from typing import Any -from uuid import uuid4 - - -MODES = ("PREPARE", "PASS_1", "PASS_2", "PASS_3", "PASS_4", "SUBMIT") -PASS_MODES = ("PASS_1", "PASS_2", "PASS_3", "PASS_4") -RUNTIME_DIR = Path(".coding_workflow/skill_runtime") -RESULTS_DIR = Path(".coding_workflow/skill_results") -RUN_PATH = RUNTIME_DIR / "run.json" -BASELINES_DIR = RUNTIME_DIR / "baselines" -OWNERSHIP_PATH = ( - Path(__file__).resolve().parents[1] / "references/pass_ownership.json" -) -SKILL_ROOT = Path(__file__).resolve().parents[1] -SOURCE_METADATA_PATH = SKILL_ROOT / ".source.json" -WORKFLOW_EXTRA_PATHS = {".gitignore", "PR_BODY.md"} -PERMITTED_INHERIT_PATHS = {".github/pull_request_template.md"} -SYNC_PR_BODY_MARKER = "<!-- sync:pr-body version=1 -->" -SYNC_AUTO_START = "<!-- sync:auto:start -->" -SYNC_AUTO_END = "<!-- sync:auto:end -->" -UNFILLED_PLACEHOLDER = "待补充" -LITERAL_PIPE_GUIDANCE = "表格 cell 中的字面 | 必须写为 \\| 或改用 <br>" -AGENT_SECTIONS = ( - "repo_facts_map", - "full_document_reconcile", - "pr_test_evidence", - "upstream_drift_log", - "agent_execution_evidence", - "remaining_human_decisions", -) - - -class HarnessError(RuntimeError): - """表示必须 fail-fast 的可预期 harness 错误。""" - - def __init__( - self, - *, - error: str, - detail: str, - extra: dict[str, Any] | None = None, - ) -> None: - """参数为错误、证据和附加字段;预期构造可序列化异常。""" - super().__init__(error) - self.error = error - self.detail = detail - self.extra = {} if extra is None else extra - - -class JsonArgumentParser(argparse.ArgumentParser): - """把非法参数转换为单行 JSON 错误。""" - - def error(self, message: str) -> None: - """参数为 argparse 消息;预期抛出 HarnessError。""" - raise HarnessError(error="参数无效", detail=message) - - -def emit_json(*, payload: dict[str, Any]) -> None: - """参数为结果对象;预期 stdout 恰好写一行 UTF-8 JSON。""" - print( - json.dumps(payload, ensure_ascii=False, separators=(",", ":")), - file=sys.stdout, - ) - - -def utc_now() -> str: - """无参数;预期返回秒级 UTC ISO 8601 字符串。""" - return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - - -def run_process( - *, - args: list[str], - cwd: Path, - env: dict[str, str], -) -> subprocess.CompletedProcess[str]: - """参数为拆分命令、cwd 和环境;预期返回捕获 UTF-8 输出的进程结果。""" - return subprocess.run( - args=args, - cwd=cwd, - env=env, - check=False, - capture_output=True, - encoding="utf-8", - text=True, - ) - - -def run_git( - *, - repo_root: Path, - args: list[str], -) -> subprocess.CompletedProcess[str]: - """参数为仓库根目录和 Git 参数;预期返回捕获输出的进程结果。""" - return run_process( - args=["git", "-C", str(repo_root), *args], - cwd=repo_root, - env=os.environ.copy(), - ) - - -def require_full_sha(*, value: str, label: str) -> str: - """参数为候选值和字段名;预期返回 40 位小写 Git SHA。""" - if re.fullmatch(r"[0-9a-f]{40}", value) is None: - raise HarnessError( - error=f"{label}无效", - detail="必须是 40 位小写十六进制 Git SHA", - ) - return value - - -def require_repository(*, path: Path, label: str) -> tuple[Path, str]: - """参数为仓库路径和角色名;预期返回规范根目录与完整 HEAD。""" - resolved = path.expanduser().resolve(strict=True) - if not resolved.is_dir(): - raise HarnessError(error=f"{label}无效", detail=f"不是目录:{resolved}") - result = run_git( - repo_root=resolved, - args=["rev-parse", "--show-toplevel", "HEAD"], - ) - lines = result.stdout.splitlines() - if result.returncode != 0 or len(lines) != 2: - raise HarnessError( - error=f"{label}无效", - detail=result.stderr.strip() or "无法解析仓库根目录和 HEAD", - ) - repo_root = Path(lines[0]).resolve(strict=True) - if repo_root != resolved: - raise HarnessError( - error=f"{label}必须是仓库根目录", - detail=f"传入 {resolved},实际根目录 {repo_root}", - ) - return repo_root, require_full_sha(value=lines[1], label=f"{label} HEAD") - - -def git_status_paths( - *, - repo_root: Path, - tracked_only: bool = False, -) -> list[str]: - """参数为仓库和未跟踪开关;预期返回含 rename 两端的排序 dirty 路径。""" - untracked = "no" if tracked_only else "all" - result = run_git( - repo_root=repo_root, - args=[ - "status", - "--porcelain=v1", - "-z", - f"--untracked-files={untracked}", - ], - ) - if result.returncode != 0: - raise HarnessError( - error="无法读取 Git 状态", - detail=result.stderr.strip(), - ) - fields = iter(result.stdout.split("\0")) - paths: list[str] = [] - for row in fields: - if not row: - continue - if len(row) < 4 or row[2] != " ": - raise HarnessError(error="无法解析 Git 状态", detail=repr(row)) - paths.append(row[3:]) - if row[0] in {"R", "C"} or row[1] in {"R", "C"}: - source = next(fields, "") - if not source: - raise HarnessError( - error="无法解析 Git 状态", - detail="rename/copy 缺少原路径", - ) - paths.append(source) - return sorted(set(paths)) - - -def require_clean_repository(*, repo_root: Path, label: str) -> None: - """参数为仓库和角色名;预期仓库干净,否则列出 dirty 路径。""" - dirty_paths = git_status_paths(repo_root=repo_root) - if dirty_paths: - raise HarnessError( - error=f"{label}工作区不干净", - detail=", ".join(dirty_paths), - extra={"dirty_paths": dirty_paths}, - ) - - -def require_upstream( - *, - upstream_dir_value: str, - upstream_sha_value: str, -) -> tuple[Path, str]: - """参数为 upstream 路径和 SHA;预期返回 clean pinned 根目录与 SHA。""" - expected_sha = require_full_sha( - value=upstream_sha_value, - label="上游 SHA", - ) - upstream_dir, actual_sha = require_repository( - path=Path(upstream_dir_value), - label="上游目录", - ) - if actual_sha != expected_sha: - raise HarnessError( - error="上游 SHA 不匹配", - detail=f"期望 {expected_sha},实际 {actual_sha}", - ) - require_clean_repository(repo_root=upstream_dir, label="上游目录") - return upstream_dir, actual_sha - - -def validate_source_metadata(*, upstream_sha: str) -> None: - """参数为 pinned SHA;预期 canonical 通过、安装副本来源 SHA 一致。""" - if not SOURCE_METADATA_PATH.is_file(): - return - metadata = read_json(path=SOURCE_METADATA_PATH) - required = {"upstream_sha", "canonical_relative_path", "platform"} - if not isinstance(metadata, dict) or set(metadata) != required: - raise HarnessError( - error="Skill 来源记录无效", - detail=f"字段必须精确为:{', '.join(sorted(required))}", - ) - if ( - metadata["upstream_sha"] != upstream_sha - or metadata["canonical_relative_path"] - != "zh/skills/workflow-docs-sync" - or metadata["platform"] not in {"codex", "claude"} - ): - raise HarnessError( - error="Skill 来源 SHA 不匹配", - detail="切换 upstream SHA 后必须重新安装 Skill", - ) - - -def read_json(*, path: Path) -> Any: - """参数为 JSON 路径;预期返回 UTF-8 解析值。""" - return json.loads(path.read_text(encoding="utf-8")) - - -def write_json(*, path: Path, payload: dict[str, Any]) -> None: - """参数为路径和对象;预期原子写入带尾换行的 UTF-8 JSON。""" - path.parent.mkdir(parents=True, exist_ok=True) - temporary = path.with_name(f".{path.name}.{uuid4().hex}.tmp") - text = json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) - temporary.write_text(text + "\n", encoding="utf-8") - temporary.replace(path) - - -def read_pr_body(*, repo_root: Path) -> str | None: - """参数为目标仓库;预期返回本地 PR body,缺失时返回 None。""" - path = repo_root / "PR_BODY.md" - return path.read_text(encoding="utf-8") if path.is_file() else None - - -def load_ownership() -> dict[str, Any]: - """无参数;预期返回精确覆盖四个 PASS 的 ownership。""" - ownership = read_json(path=OWNERSHIP_PATH) - if not isinstance(ownership, dict) or set(ownership) != set(PASS_MODES): - raise HarnessError( - error="PASS ownership 无效", - detail="必须精确覆盖 PASS_1 至 PASS_4", - ) - for mode in PASS_MODES: - entry = ownership[mode] - required = {"title", "owned", "pr_body_sections"} - if not isinstance(entry, dict) or set(entry) != required: - raise HarnessError( - error="PASS ownership 无效", - detail=f"{mode} 字段必须精确为 title/owned/pr_body_sections", - ) - if ( - not isinstance(entry["title"], str) - or not entry["title"] - or not isinstance(entry["owned"], list) - or not entry["owned"] - or not all( - isinstance(path, str) and path - for path in entry["owned"] - ) - or not isinstance(entry["pr_body_sections"], list) - or not all( - isinstance(section, str) and section in AGENT_SECTIONS - for section in entry["pr_body_sections"] - ) - ): - raise HarnessError( - error="PASS ownership 无效", - detail=f"{mode} 含非法 title、owned 或 pr_body_sections", - ) - return ownership - - -def all_owned_paths(*, ownership: dict[str, Any]) -> set[str]: - """参数为 ownership;预期返回全部 owned 仓库相对路径并集。""" - paths: set[str] = set() - for mode in PASS_MODES: - paths.update(ownership[mode]["owned"]) - return paths - - -def is_runtime_path(*, path: str) -> bool: - """参数为仓库路径;预期 runtime、result 或 sync scratch 返回 True。""" - roots = ( - ".coding_workflow/diffs", - str(RUNTIME_DIR), - str(RESULTS_DIR), - ) - return any( - path == root or path.startswith(f"{root}/") - for root in roots - ) - - -def ordinary_dirty_paths(*, repo_root: Path) -> list[str]: - """参数为目标仓库;预期返回排除 runtime 的排序 dirty 路径。""" - return [ - path - for path in git_status_paths(repo_root=repo_root) - if not is_runtime_path(path=path) - ] - - -def assert_dirty_allowed( - *, - repo_root: Path, - allowed_paths: set[str], - error: str, -) -> list[str]: - """参数为仓库、允许集合和错误名;预期返回 dirty 路径或越权失败。""" - dirty_paths = ordinary_dirty_paths(repo_root=repo_root) - unauthorized = [ - path for path in dirty_paths if path not in allowed_paths - ] - if unauthorized: - raise HarnessError( - error=error, - detail=", ".join(unauthorized), - extra={"unauthorized_paths": unauthorized}, - ) - return dirty_paths - - -def snapshot_path(*, repo_root: Path, relative_path: str) -> dict[str, Any]: - """参数为仓库和相对路径;预期返回可比较的存在性与内容摘要。""" - path = repo_root / relative_path - if path.is_symlink(): - target = os.readlink(path) - return { - "kind": "symlink", - "sha256": hashlib.sha256( - target.encode("utf-8") - ).hexdigest(), - } - if path.is_file(): - return { - "kind": "file", - "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), - "executable": bool(path.stat().st_mode & 0o111), - } - if path.exists(): - raise HarnessError( - error="无法记录文件快照", - detail=f"不支持目录路径:{relative_path}", - ) - return {"kind": "missing", "sha256": None} - - -def snapshot_workflow( - *, - repo_root: Path, - workflow_paths: set[str], -) -> dict[str, Any]: - """参数为仓库和 workflow 路径;预期返回逐文件内容快照。""" - return { - path: snapshot_path(repo_root=repo_root, relative_path=path) - for path in sorted(workflow_paths) - } - - -def changed_snapshot_paths( - *, - baseline: dict[str, Any], - current: dict[str, Any], -) -> list[str]: - """参数为起止快照;预期返回内容或存在性变化的排序路径。""" - if set(baseline) != set(current): - raise HarnessError( - error="workflow 快照无效", - detail="baseline 与 current 路径集合不同", - ) - return sorted( - path - for path in baseline - if baseline[path] != current[path] - ) - - -def validate_run_state(*, state: Any) -> dict[str, Any]: - """参数为 run JSON;预期返回字段、顺序和类型合法的状态。""" - required = { - "schema_version", "run_id", "upstream_dir", "upstream_sha", - "project_head", "completed_modes", "active_mode", - "boundary_snapshot", "boundary_dirty_paths", "submit_ready", - "started_at_utc", "updated_at_utc", - } - if not isinstance(state, dict) or set(state) != required: - raise HarnessError( - error="workflow run 状态无效", - detail=f"字段必须精确为:{', '.join(sorted(required))}", - ) - completed = state["completed_modes"] - if ( - state["schema_version"] != 1 - or not isinstance(state["run_id"], str) - or not state["run_id"] - or not isinstance(completed, list) - or completed != list(MODES[:len(completed)]) - or state["active_mode"] not in (None, *MODES) - or not isinstance(state["boundary_snapshot"], dict) - or not isinstance(state["boundary_dirty_paths"], list) - or not isinstance(state["submit_ready"], bool) - ): - raise HarnessError( - error="workflow run 状态无效", - detail="schema、顺序或字段类型非法", - ) - require_full_sha(value=state["upstream_sha"], label="状态 upstream_sha") - require_full_sha(value=state["project_head"], label="状态 project_head") - return state - - -def load_run_state(*, repo_root: Path) -> dict[str, Any]: - """参数为目标仓库;预期返回已校验的 run state。""" - path = repo_root / RUN_PATH - if not path.is_file(): - raise HarnessError( - error="缺少 workflow run 状态", - detail="必须先执行 prepare", - ) - return validate_run_state(state=read_json(path=path)) - - -def write_run_state(*, repo_root: Path, state: dict[str, Any]) -> None: - """参数为仓库和状态;预期校验后原子替换 run.json。""" - state["updated_at_utc"] = utc_now() - write_json( - path=repo_root / RUN_PATH, - payload=validate_run_state(state=state), - ) - - -def validate_baseline(*, baseline: Any, mode: str) -> dict[str, Any]: - """参数为 baseline 和 mode;预期返回字段精确且 mode 匹配的对象。""" - required = { - "schema_version", "run_id", "mode", "upstream_sha", "start_head", - "snapshot", "dirty_paths", "allowed_commit_paths", "pr_body_text", - "created_at_utc", - } - if ( - not isinstance(baseline, dict) - or set(baseline) != required - or baseline["schema_version"] != 1 - or baseline["mode"] != mode - or not isinstance(baseline["snapshot"], dict) - or not isinstance(baseline["dirty_paths"], list) - or not isinstance(baseline["allowed_commit_paths"], list) - or baseline["pr_body_text"] is not None - and not isinstance(baseline["pr_body_text"], str) - ): - raise HarnessError( - error="mode baseline 无效", - detail=f"{mode} baseline 字段或类型非法", - ) - require_full_sha(value=baseline["upstream_sha"], label="baseline SHA") - require_full_sha(value=baseline["start_head"], label="baseline HEAD") - return baseline - - -def load_baseline(*, repo_root: Path, mode: str) -> dict[str, Any]: - """参数为仓库和 mode;预期返回已校验的 baseline。""" - path = repo_root / BASELINES_DIR / f"{mode}.json" - if not path.is_file(): - raise HarnessError( - error="缺少 mode baseline", - detail=f"必须先执行 {mode} 起始命令", - ) - return validate_baseline( - baseline=read_json(path=path), - mode=mode, - ) - - -def write_baseline( - *, - repo_root: Path, - run_id: str, - mode: str, - upstream_sha: str, - start_head: str, - snapshot: dict[str, Any], - dirty_paths: list[str], - allowed_commit_paths: list[str], -) -> dict[str, Any]: - """参数为 mode 起始事实;预期写入并返回轻量 baseline。""" - baseline = { - "schema_version": 1, - "run_id": run_id, - "mode": mode, - "upstream_sha": upstream_sha, - "start_head": start_head, - "snapshot": snapshot, - "dirty_paths": dirty_paths, - "allowed_commit_paths": allowed_commit_paths, - "pr_body_text": read_pr_body(repo_root=repo_root), - "created_at_utc": utc_now(), - } - write_json( - path=repo_root / BASELINES_DIR / f"{mode}.json", - payload=validate_baseline(baseline=baseline, mode=mode), - ) - return baseline - - -def write_result( - *, - repo_root: Path, - run_id: str, - mode: str, - upstream_sha: str, - project_head: str, - changed_paths: list[str], - details: dict[str, Any], -) -> dict[str, Any]: - """参数为真实 mode 事实;预期原子写入并返回 passed result。""" - payload = { - "schema_version": 1, - "run_id": run_id, - "mode": mode, - "status": "passed", - "upstream_sha": upstream_sha, - "project_head": project_head, - "created_at_utc": utc_now(), - "changed_paths": changed_paths, - "details": details, - "error": None, - } - write_json(path=repo_root / RESULTS_DIR / f"{mode}.json", payload=payload) - return payload - - -def run_pinned_sync( - *, - target_repo: Path, - upstream_dir: Path, - final: bool, -) -> dict[str, Any]: - """参数为目标、upstream 和 final 开关;预期返回成功 pinned sync 事实。""" - sync_script = upstream_dir / "zh/scripts/sync.sh" - if not sync_script.is_file(): - raise HarnessError( - error="pinned sync 入口缺失", - detail=str(sync_script), - ) - command = ["bash", str(sync_script)] - if final: - command.append("--final") - env = os.environ.copy() - env["CODING_WORKFLOW_UPSTREAM_DIR"] = str(upstream_dir) - result = run_process(args=command, cwd=target_repo, env=env) - if result.returncode != 0: - raise HarnessError( - error="pinned final gate 失败" if final else "pinned sync 失败", - detail=( - f"退出码 {result.returncode}\n" - f"stdout:\n{result.stdout[-4000:] or '无'}\n" - f"stderr:\n{result.stderr[-4000:] or '无'}" - ), - extra={"returncode": result.returncode}, - ) - if not final: - required_outputs = ( - Path(".coding_workflow/diffs/agent_workorder.md"), - Path(".coding_workflow/diffs/sync_state.json"), - ) - missing = [ - str(path) - for path in required_outputs - if not (target_repo / path).is_file() - ] - if missing: - raise HarnessError( - error="pinned sync 产物缺失", - detail=", ".join(missing), - ) - return { - "command": command, - "returncode": result.returncode, - "stdout": result.stdout, - "stderr": result.stderr, - } - - -def require_state_upstream( - *, - state: dict[str, Any], - upstream_dir: Path, - upstream_sha: str, -) -> None: - """参数为状态和 upstream;预期路径与 SHA 同时一致。""" - if ( - state["upstream_dir"] != str(upstream_dir) - or state["upstream_sha"] != upstream_sha - ): - raise HarnessError( - error="workflow run 上游不一致", - detail=( - f"状态 {state['upstream_dir']}@{state['upstream_sha']}," - f"当前 {upstream_dir}@{upstream_sha}" - ), - ) - - -def require_boundary_unchanged( - *, - repo_root: Path, - state: dict[str, Any], - workflow_paths: set[str], -) -> None: - """参数为仓库、状态和路径;预期 mode 间快照与 dirty 集合未变化。""" - current_snapshot = snapshot_workflow( - repo_root=repo_root, - workflow_paths=workflow_paths, - ) - changed = changed_snapshot_paths( - baseline=state["boundary_snapshot"], - current=current_snapshot, - ) - current_dirty = ordinary_dirty_paths(repo_root=repo_root) - if changed or current_dirty != state["boundary_dirty_paths"]: - raise HarnessError( - error="mode 间发现未归属改动", - detail=( - f"内容变化:{changed};" - f"上次 dirty:{state['boundary_dirty_paths']};" - f"当前 dirty:{current_dirty}" - ), - extra={ - "changed_paths": changed, - "previous_dirty_paths": state["boundary_dirty_paths"], - "current_dirty_paths": current_dirty, - }, - ) - - -def validated_sentinel_spans( - *, text: str, require_all: bool, canonical_order: bool -) -> dict[str, tuple[int, int]]: - """参数为 body 与 schema 强度;预期返回合法且不重叠的 sentinel spans。""" - count = text.count(SYNC_PR_BODY_MARKER) - if count != 1: - raise HarnessError( - error="PR_BODY sync sentinel 无效", - detail=f"{SYNC_PR_BODY_MARKER} 匹配次数:{count}", - ) - marker_start = text.index(SYNC_PR_BODY_MARKER) - spans = { - "marker": (marker_start, marker_start + len(SYNC_PR_BODY_MARKER)) - } - sentinels = [("auto", SYNC_AUTO_START, SYNC_AUTO_END, True)] - sentinels.extend( - ( - name, - f"<!-- sync:agent:start {name} -->", - f"<!-- sync:agent:end {name} -->", - require_all, - ) - for name in AGENT_SECTIONS - ) - for name, start, end, required in sentinels: - start_count, end_count = text.count(start), text.count(end) - if start_count == 0 and end_count == 0 and not required: - continue - if start_count != 1 or end_count != 1: - raise HarnessError( - error="PR_BODY sentinel 无效", - detail=f"{name}: start={start_count}, end={end_count}", - ) - start_index, end_index = text.index(start), text.index(end) - if end_index < start_index: - raise HarnessError( - error="PR_BODY sentinel 未闭合", - detail=name, - ) - spans[name] = (start_index, end_index + len(end)) - - # 旧 schema 可改变 section 顺序,但任何 span 都不能覆盖另一 span。 - ordered = sorted(spans.items(), key=lambda item: item[1]) - cursor = 0 - outside_parts: list[str] = [] - for name, (start_index, end_index) in ordered: - if start_index < cursor: - raise HarnessError( - error="PR_BODY sentinel 重叠", - detail=name, - ) - outside_parts.append(text[cursor:start_index]) - cursor = end_index - outside_parts.append(text[cursor:]) - if "".join(outside_parts).strip(): - raise HarnessError( - error="PR_BODY sentinel 外存在内容", - detail="请把人工内容迁移到对应 agent-owned section", - ) - expected = ["marker", "repo_facts_map", "auto", *AGENT_SECTIONS[1:]] - if canonical_order and [name for name, _ in ordered] != expected: - raise HarnessError( - error="PR_BODY sentinel 顺序非法", - detail="必须使用当前 skeleton canonical 顺序", - ) - return spans - - -def validate_refreshable_pr_body(*, text: str) -> None: - """参数为 sync 前 body;预期允许完整缺失的旧 agent section。""" - validated_sentinel_spans( - text=text, require_all=False, canonical_order=False, - ) - - -def parse_agent_sections(*, text: str) -> tuple[str, dict[str, str]]: - """参数为 current body;预期严格校验并返回骨架和 section。""" - spans = validated_sentinel_spans( - text=text, require_all=True, canonical_order=True, - ) - cursor = 0 - skeleton_parts: list[str] = [] - sections: dict[str, str] = {} - for name in AGENT_SECTIONS: - start = f"<!-- sync:agent:start {name} -->" - end = f"<!-- sync:agent:end {name} -->" - start_index, span_end = spans[name] - body_start = start_index + len(start) - body_end = span_end - len(end) - skeleton_parts.extend(( - text[cursor:body_start], f"<SECTION:{name}>", end, - )) - sections[name] = text[body_start:body_end] - cursor = span_end - skeleton_parts.append(text[cursor:]) - return "".join(skeleton_parts), sections - - -def assert_pr_body_scope( - *, - baseline_text: str | None, - current_text: str | None, - allowed_sections: set[str], -) -> tuple[dict[str, str], dict[str, str]]: - """参数为起止 body 和允许 section;预期返回已校验的 section。""" - if baseline_text is None or current_text is None: - raise HarnessError( - error="PR_BODY scope 无法校验", - detail="PASS 开始和结束都必须存在 PR_BODY.md", - ) - baseline_skeleton, baseline_sections = parse_agent_sections( - text=baseline_text, - ) - current_skeleton, current_sections = parse_agent_sections( - text=current_text, - ) - if baseline_skeleton != current_skeleton: - raise HarnessError( - error="PR_BODY 非 agent-owned 区域被修改", - detail="PASS 只能修改允许的 sentinel section", - ) - unauthorized = [ - section_name - for section_name in AGENT_SECTIONS - if section_name not in allowed_sections - and baseline_sections[section_name] != current_sections[section_name] - ] - if unauthorized: - raise HarnessError( - error="PR_BODY section 越权", - detail=", ".join(unauthorized), - extra={"unauthorized_sections": unauthorized}, - ) - return baseline_sections, current_sections - - -def ensure_prepared_pr_body(*, repo_root: Path, refreshable: bool) -> None: - """参数为目标仓库;预期保留合法 body 或从 sync skeleton 复制。""" - body_path = repo_root / "PR_BODY.md" - skeleton_path = repo_root / ".coding_workflow/diffs/pr_body_skeleton.md" - if body_path.is_file(): - try: - validator = ( - validate_refreshable_pr_body if refreshable - else parse_agent_sections - ) - validator(text=body_path.read_text(encoding="utf-8")) - except HarnessError as exc: - raise HarnessError( - error="已有 PR_BODY.md 无效", - detail=( - f"{exc.error}: {exc.detail};请移开现有文件,或人工迁移" - "旧内容到完整 sentinel body;原文件未覆盖" - ), - ) from exc - return - if body_path.exists(): - raise HarnessError( - error="已有 PR_BODY.md 无效", - detail="路径不是普通文件;请移开后重试,原路径未覆盖", - ) - if not skeleton_path.is_file(): - raise HarnessError( - error="PREPARE PR body skeleton 缺失", - detail=str(skeleton_path), - ) - skeleton_text = skeleton_path.read_text(encoding="utf-8") - parse_agent_sections(text=skeleton_text) - shutil.copy2(src=skeleton_path, dst=body_path) - - -def matching_table_rows( - *, text: str, key: str, prefer_code: bool -) -> list[str]: - """参数为 section、稳定行键和代码格式偏好;预期返回候选表格行。""" - table_lines = [line for line in text.splitlines() if line.startswith("|")] - if prefer_code: - code_rows = [line for line in table_lines if f"| `{key}` |" in line] - if code_rows: - return code_rows - return [line for line in table_lines if f"| {key} |" in line] - - -def split_table_row(*, row: str) -> list[str]: - """参数为 Markdown 表格行;预期仅按未转义竖线拆分 cells。""" - if not row.startswith("|") or not row.endswith("|") or row.endswith("\\|"): - return [] - cells: list[str] = [] - cell_start = 1 - for index, character in enumerate(row[1:-1], start=1): - if character == "|" and row[index - 1] != "\\": - cells.append(row[cell_start:index]) - cell_start = index + 1 - cells.append(row[cell_start:-1]) - return cells - - -def fail_completion(*, detail: str) -> None: - """参数为机械 completion 失败证据;预期抛出稳定 harness 错误。""" - raise HarnessError(error="PASS PR_BODY 未完成", detail=detail) - - -def require_current_table( - *, schema_text: str, current_text: str, heading: str, label: str -) -> int: - """参数为 pinned/current section;预期 current 表头保持 canonical。""" - schema_lines = schema_text.splitlines() - current_lines = current_text.splitlines() - schema_rows = [line for line in schema_lines if line.startswith("|")] - if schema_lines.count(heading) != 1 or not schema_rows: - fail_completion(detail=f"pinned {label} heading 或表头缺失") - header = schema_rows[0] - if current_lines.count(heading) != 1 or current_lines.count(header) != 1: - fail_completion(detail=f"{label} 表头不符;{LITERAL_PIPE_GUIDANCE}") - return len(split_table_row(row=header)) - - -def require_completed_row( - *, section_text: str, key: str, label: str, prefer_code: bool, - expected_cells: int, owned_cells: int -) -> None: - """参数为稳定行键与 schema 宽度;预期责任 cells 非空且已填写。""" - matches = matching_table_rows( - text=section_text, - key=key, - prefer_code=prefer_code, - ) - if len(matches) != 1: - fail_completion( - detail=f"{label} 匹配行数必须为 1,实际 {len(matches)}", - ) - cells = split_table_row(row=matches[0]) - if len(cells) != expected_cells: - fail_completion( - detail=( - f"{label} cell 数应为 {expected_cells},实际 {len(cells)};" - f"{LITERAL_PIPE_GUIDANCE}" - ), - ) - if any(not cell.strip() for cell in cells[-owned_cells:]): - fail_completion(detail=f"{label} 责任 cell 不得为空") - if UNFILLED_PLACEHOLDER in matches[0]: - fail_completion(detail=f"{label} 仍含 {UNFILLED_PLACEHOLDER}") - - -def assert_repo_facts_complete( - *, schema_text: str, current_text: str -) -> None: - """参数为 current skeleton 与 body;预期固定事实 block 证据非空。""" - headings = [ - line for line in schema_text.splitlines() if line.startswith("### ") - ] - if len(headings) != 10 or len(set(headings)) != 10: - fail_completion( - detail=f"current Repo Facts heading 数应为 10,实际 {len(headings)}", - ) - current_lines = current_text.splitlines() - if current_lines.count("## Repo Facts Map") != 1: - fail_completion(detail="Repo Facts Map heading 匹配次数必须为 1") - for heading in headings: - matches = [ - index - for index, line in enumerate(current_lines) - if line == heading - ] - if len(matches) != 1: - fail_completion( - detail=f"{heading} 匹配次数必须为 1,实际 {len(matches)}", - ) - block_end = next(( - index for index in range(matches[0] + 1, len(current_lines)) - if current_lines[index].startswith("### ") - ), len(current_lines)) - block = current_lines[matches[0] + 1:block_end] - evidence = [line for line in block if line.startswith("证据:")] - if len(evidence) != 1: - fail_completion( - detail=f"{heading} 的 证据: 行必须恰好一行", - ) - if not evidence[0].partition(":")[2].strip(): - fail_completion(detail=f"{heading} 的 证据: 值不得为空") - if UNFILLED_PLACEHOLDER in "\n".join(block): - fail_completion(detail=f"{heading} block 仍含 待补充") - - -def assert_remaining_decisions_complete(*, section_text: str) -> None: - """参数为 Remaining Human Decisions;预期 heading 后有非空 payload。""" - heading = "## Remaining Human Decisions" - lines = [line.strip() for line in section_text.splitlines()] - payload = [line for line in lines if line and line != heading] - if lines.count(heading) != 1 or not payload: - fail_completion(detail="Remaining Human Decisions 缺少非空 payload") - if UNFILLED_PLACEHOLDER in "\n".join(payload): - fail_completion(detail="Remaining Human Decisions 仍含 待补充") - - -def assert_pass_body_completion( - *, - mode: str, - ownership_entry: dict[str, Any], - schema_sections: dict[str, str], - current_sections: dict[str, str], -) -> None: - """参数为 PASS ownership 与 body sections;预期当前责任项机械完成。""" - if mode == "PASS_1": - assert_repo_facts_complete( - schema_text=schema_sections["repo_facts_map"], - current_text=current_sections["repo_facts_map"], - ) - reconcile_width = require_current_table( - schema_text=schema_sections["full_document_reconcile"], - current_text=current_sections["full_document_reconcile"], - heading="## Full Document Reconcile", - label="Full Document Reconcile", - ) - for relative_path in ownership_entry["owned"]: - require_completed_row( - section_text=current_sections["full_document_reconcile"], - key=relative_path, - label=f"{mode} reconcile `{relative_path}`", - prefer_code=True, - expected_cells=reconcile_width, - owned_cells=5, - ) - execution_title = re.sub( - pattern=r"^\d+\.\d+\s+", - repl="", - string=ownership_entry["title"], - ) - execution_width = require_current_table( - schema_text=schema_sections["agent_execution_evidence"], - current_text=current_sections["agent_execution_evidence"], - heading="## Agent Execution Evidence", - label="Agent Execution Evidence", - ) - require_completed_row( - section_text=current_sections["agent_execution_evidence"], - key=execution_title, - label=f"{execution_title} execution evidence", - prefer_code=False, - expected_cells=execution_width, - owned_cells=3, - ) - if mode == "PASS_4": - assert_remaining_decisions_complete( - section_text=current_sections["remaining_human_decisions"], - ) - - -def assert_owned_docs_ready( - *, - mode: str, - target_repo: Path, - upstream_dir: Path, - ownership_entry: dict[str, Any], -) -> None: - """参数为 PASS 与 pinned upstream;预期 owned 文档存在且已项目化。""" - for relative_path in ownership_entry["owned"]: - target_path = target_repo / relative_path - if not target_path.is_file(): - raise HarnessError( - error="PASS owned 文档缺失", - detail=relative_path, - extra={"owned_path": relative_path}, - ) - if relative_path in PERMITTED_INHERIT_PATHS: - continue - upstream_path = upstream_dir / "zh" / relative_path - if not upstream_path.is_file(): - raise HarnessError( - error="pinned owned 模板缺失", - detail=relative_path, - extra={"owned_path": relative_path}, - ) - current_text = target_path.read_text(encoding="utf-8") - upstream_text = upstream_path.read_text(encoding="utf-8") - normalized_current = current_text.replace("\r\n", "\n").replace( - "\r", "\n" - ) - normalized_upstream = upstream_text.replace("\r\n", "\n").replace( - "\r", "\n" - ) - if normalized_current == normalized_upstream: - raise HarnessError( - error="PASS owned 文档未项目化", - detail=f"{mode}: {relative_path} 仍与 pinned zh 模板完全相同", - extra={"owned_path": relative_path}, - ) - - -def git_paths_between( - *, - repo_root: Path, - start_sha: str, - end_sha: str, -) -> list[str]: - """参数为仓库和起止 SHA;预期返回排序 committed path。""" - result = run_git( - repo_root=repo_root, - args=["diff", "--name-only", "-z", f"{start_sha}..{end_sha}", "--"], - ) - if result.returncode != 0: - raise HarnessError( - error="无法读取 SUBMIT committed scope", - detail=result.stderr.strip(), - ) - return sorted( - path for path in set(result.stdout.split("\0")) if path - ) - - -def git_commit_count_between( - *, - repo_root: Path, - start_sha: str, - end_sha: str, -) -> int: - """参数为仓库和起止 SHA;预期返回两者之间的 commit 数。""" - result = run_git( - repo_root=repo_root, - args=["rev-list", "--count", f"{start_sha}..{end_sha}"], - ) - count = result.stdout.strip() - if result.returncode != 0 or not count.isdecimal(): - raise HarnessError( - error="无法读取 SUBMIT commit 数", - detail=result.stderr.strip() or repr(count), - ) - return int(count) - - -def query_pull_request( - *, - repo_root: Path, - pr_number: int, -) -> dict[str, Any]: - """参数为仓库和 PR number;预期返回真实 state/head/base/body。""" - result = run_process( - args=[ - "gh", "pr", "view", str(pr_number), "--json", - "number,state,headRefOid,headRefName,baseRefName,body,url", - ], - cwd=repo_root, - env=os.environ.copy(), - ) - if result.returncode != 0: - raise HarnessError( - error="无法读取 GitHub PR", - detail=result.stderr.strip() or result.stdout.strip(), - ) - payload = json.loads(result.stdout) - required = { - "number", "state", "headRefOid", "headRefName", "baseRefName", - "body", "url", - } - if not isinstance(payload, dict) or set(payload) != required: - raise HarnessError( - error="GitHub PR 证据无效", - detail="字段不完整或含未声明字段", - ) - return payload - - -def common_context( - *, - args: Any, -) -> tuple[Path, str, Path, str, dict[str, Any], set[str]]: - """参数为公共 CLI 值;预期返回仓库、upstream 和 ownership 事实。""" - target_repo, project_head = require_repository( - path=Path(args.target_repo), label="目标仓库" - ) - upstream_dir, upstream_sha = require_upstream( - upstream_dir_value=args.upstream_dir, - upstream_sha_value=args.upstream_sha, - ) - validate_source_metadata(upstream_sha=upstream_sha) - ownership = load_ownership() - workflow_paths = ( - all_owned_paths(ownership=ownership) | WORKFLOW_EXTRA_PATHS - ) - return ( - target_repo, project_head, upstream_dir, - upstream_sha, ownership, workflow_paths, - ) - - -def bound_context( - *, args: Any -) -> tuple[Path, str, Path, str, dict[str, Any], set[str], dict[str, Any]]: - """参数为公共 CLI 值;预期追加已绑定的 run state。""" - context = common_context(args=args) - state = load_run_state(repo_root=context[0]) - require_state_upstream( - state=state, upstream_dir=context[2], upstream_sha=context[3] - ) - return (*context, state) - - -def prepare(*, args: Any) -> dict[str, Any]: - """参数为 PREPARE CLI;预期完成 pinned sync、run 和 result。""" - ( - target_repo, project_head, upstream_dir, - upstream_sha, ownership, workflow_paths, - ) = common_context(args=args) - existing_run = target_repo / RUN_PATH - if existing_run.is_file(): - state = load_run_state(repo_root=target_repo) - if state["completed_modes"] != list(MODES): - raise HarnessError( - error="已有未完成 workflow run", - detail="先完成当前 run,或显式删除 ignored runtime 后重启", - ) - - # sync 允许旧 schema migration;先只读拒绝损坏 body,避免它被自动补写。 - if (target_repo / "PR_BODY.md").exists(): - ensure_prepared_pr_body(repo_root=target_repo, refreshable=True) - - # PREPARE 之前不接受混入的业务或核心文档改动。 - before_dirty = assert_dirty_allowed( - repo_root=target_repo, - allowed_paths={"PR_BODY.md"}, - error="PREPARE 前工作区含无关改动", - ) - before_snapshot = snapshot_workflow( - repo_root=target_repo, - workflow_paths=workflow_paths, - ) - sync = run_pinned_sync( - target_repo=target_repo, - upstream_dir=upstream_dir, - final=False, - ) - - # 正式 handoff body 必须在状态快照前落盘,后续 PASS 不再依赖 skeleton fallback。 - ensure_prepared_pr_body(repo_root=target_repo, refreshable=False) - - # pinned sync 只能产生 workflow 管理路径。 - after_dirty = assert_dirty_allowed( - repo_root=target_repo, - allowed_paths=workflow_paths, - error="PREPARE sync 产生越权改动", - ) - after_snapshot = snapshot_workflow( - repo_root=target_repo, - workflow_paths=workflow_paths, - ) - now = utc_now() - state = { - "schema_version": 1, - "run_id": uuid4().hex, - "upstream_dir": str(upstream_dir), - "upstream_sha": upstream_sha, - "project_head": project_head, - "completed_modes": ["PREPARE"], - "active_mode": None, - "boundary_snapshot": after_snapshot, - "boundary_dirty_paths": after_dirty, - "submit_ready": False, - "started_at_utc": now, - "updated_at_utc": now, - } - write_baseline( - repo_root=target_repo, - run_id=state["run_id"], - mode="PREPARE", - upstream_sha=upstream_sha, - start_head=project_head, - snapshot=before_snapshot, - dirty_paths=before_dirty, - allowed_commit_paths=[], - ) - write_run_state(repo_root=target_repo, state=state) - changed = changed_snapshot_paths( - baseline=before_snapshot, - current=after_snapshot, - ) - result = write_result( - repo_root=target_repo, - run_id=state["run_id"], - mode="PREPARE", - upstream_sha=upstream_sha, - project_head=project_head, - changed_paths=changed, - details={ - "sync_command": sync["command"], - "dirty_paths": after_dirty, - }, - ) - return { - "status": "passed", - "mode": "PREPARE", - "run_id": state["run_id"], - "result": str(target_repo / RESULTS_DIR / "PREPARE.json"), - "changed_paths": result["changed_paths"], - } - - -def start_pass(*, args: Any) -> dict[str, Any]: - """参数为 PASS CLI;预期保存 baseline 并返回 ownership/prompt 位置。""" - ( - target_repo, project_head, upstream_dir, - upstream_sha, ownership, workflow_paths, state, - ) = bound_context(args=args) - if state["active_mode"] is not None: - raise HarnessError( - error="已有 active mode", - detail=str(state["active_mode"]), - ) - if state["completed_modes"] == list(MODES): - raise HarnessError( - error="workflow 已完成", - detail="不能再次启动 PASS", - ) - expected_mode = MODES[len(state["completed_modes"])] - if args.mode != expected_mode or args.mode not in PASS_MODES: - raise HarnessError( - error="mode 顺序无效", - detail=f"期望 {expected_mode},实际 {args.mode}", - ) - if project_head != state["project_head"]: - raise HarnessError( - error="PASS 前项目 HEAD 变化", - detail=f"状态 {state['project_head']},当前 {project_head}", - ) - - # boundary 快照在建本 mode baseline 前发现跨会话顺手越权。 - require_boundary_unchanged( - repo_root=target_repo, - state=state, - workflow_paths=workflow_paths, - ) - dirty_paths = assert_dirty_allowed( - repo_root=target_repo, - allowed_paths=workflow_paths, - error="PASS 前工作区含越权路径", - ) - snapshot = snapshot_workflow( - repo_root=target_repo, - workflow_paths=workflow_paths, - ) - write_baseline( - repo_root=target_repo, - run_id=state["run_id"], - mode=args.mode, - upstream_sha=upstream_sha, - start_head=project_head, - snapshot=snapshot, - dirty_paths=dirty_paths, - allowed_commit_paths=[], - ) - state["active_mode"] = args.mode - state["submit_ready"] = False - write_run_state(repo_root=target_repo, state=state) - operations_path = upstream_dir / "zh/scripts/OPERATIONS.md" - title = ownership[args.mode]["title"] - heading = f"### {title}" - matches = [ - index for index, line in enumerate( - operations_path.read_text(encoding="utf-8").splitlines(), start=1 - ) if line == heading - ] - if len(matches) != 1: - raise HarnessError( - error="PASS prompt 标题无效", - detail=f"{heading} 匹配次数:{len(matches)}", - ) - return { - "status": "passed", - "mode": args.mode, - "title": title, - "owned_paths": ownership[args.mode]["owned"], - "pr_body_sections": ownership[args.mode]["pr_body_sections"], - "operations_path": str(operations_path), - "operations_heading_line": matches[0], - "instruction": ( - "只执行该 PASS code block 的 Skill 模式语义部分;" - "不要执行人工模式 curl。" - ), - } - - -def finish_pass(*, args: Any) -> dict[str, Any]: - """参数为 PASS CLI;预期检查 ownership、sync 并写结果。""" - ( - target_repo, project_head, upstream_dir, - upstream_sha, ownership, workflow_paths, state, - ) = bound_context(args=args) - if args.mode not in PASS_MODES or state["active_mode"] != args.mode: - raise HarnessError( - error="active PASS 不匹配", - detail=f"状态 {state['active_mode']!r},实际 {args.mode}", - ) - baseline = load_baseline(repo_root=target_repo, mode=args.mode) - if ( - baseline["run_id"] != state["run_id"] - or baseline["start_head"] != project_head - ): - raise HarnessError( - error="PASS baseline 不匹配", - detail="run_id 或项目 HEAD 已变化", - ) - assert_dirty_allowed( - repo_root=target_repo, - allowed_paths=workflow_paths, - error="PASS 产生越权路径", - ) - before_sync_snapshot = snapshot_workflow( - repo_root=target_repo, - workflow_paths=workflow_paths, - ) - changed = changed_snapshot_paths( - baseline=baseline["snapshot"], - current=before_sync_snapshot, - ) - allowed = set(ownership[args.mode]["owned"]) | {"PR_BODY.md"} - unauthorized = [path for path in changed if path not in allowed] - if unauthorized: - raise HarnessError( - error="PASS ownership 越权", - detail=", ".join(unauthorized), - extra={"unauthorized_paths": unauthorized}, - ) - _, current_sections = assert_pr_body_scope( - baseline_text=baseline["pr_body_text"], - current_text=read_pr_body(repo_root=target_repo), - allowed_sections=set(ownership[args.mode]["pr_body_sections"]), - ) - schema_path = target_repo / ".coding_workflow/diffs/pr_body_skeleton.md" - if not schema_path.is_file(): - raise HarnessError( - error="current PR body skeleton 缺失", - detail=str(schema_path), - ) - _, schema_sections = parse_agent_sections( - text=schema_path.read_text(encoding="utf-8"), - ) - assert_pass_body_completion( - mode=args.mode, - ownership_entry=ownership[args.mode], - schema_sections=schema_sections, - current_sections=current_sections, - ) - assert_owned_docs_ready( - mode=args.mode, - target_repo=target_repo, - upstream_dir=upstream_dir, - ownership_entry=ownership[args.mode], - ) - - # ownership 与 completion 全通过后才 sync,失败不会污染 auto 区或推进状态。 - sync = run_pinned_sync( - target_repo=target_repo, - upstream_dir=upstream_dir, - final=False, - ) - after_dirty = assert_dirty_allowed( - repo_root=target_repo, - allowed_paths=workflow_paths, - error="PASS sync 产生越权改动", - ) - after_snapshot = snapshot_workflow( - repo_root=target_repo, - workflow_paths=workflow_paths, - ) - state["completed_modes"].append(args.mode) - state["active_mode"] = None - state["boundary_snapshot"] = after_snapshot - state["boundary_dirty_paths"] = after_dirty - write_run_state(repo_root=target_repo, state=state) - result = write_result( - repo_root=target_repo, - run_id=state["run_id"], - mode=args.mode, - upstream_sha=upstream_sha, - project_head=project_head, - changed_paths=changed, - details={ - "owned_paths": ownership[args.mode]["owned"], - "pr_body_sections": ownership[args.mode]["pr_body_sections"], - "sync_command": sync["command"], - "dirty_paths_after_sync": after_dirty, - }, - ) - return { - "status": "passed", - "mode": args.mode, - "result": str(target_repo / RESULTS_DIR / f"{args.mode}.json"), - "changed_paths": result["changed_paths"], - } - - -def prepare_submit(*, args: Any) -> dict[str, Any]: - """参数为 SUBMIT CLI;预期建立 active、unsealed 提交基线。""" - ( - target_repo, project_head, upstream_dir, - upstream_sha, ownership, workflow_paths, state, - ) = bound_context(args=args) - if state["completed_modes"] != list(MODES[:-1]): - raise HarnessError( - error="SUBMIT 前 mode 未完成", - detail=str(state["completed_modes"]), - ) - if state["active_mode"] is not None: - raise HarnessError( - error="SUBMIT 前仍有 active mode", - detail=str(state["active_mode"]), - ) - if project_head != state["project_head"]: - raise HarnessError( - error="SUBMIT 前项目 HEAD 变化", - detail=f"状态 {state['project_head']},当前 {project_head}", - ) - require_boundary_unchanged( - repo_root=target_repo, - state=state, - workflow_paths=workflow_paths, - ) - dirty_paths = assert_dirty_allowed( - repo_root=target_repo, - allowed_paths=workflow_paths, - error="SUBMIT 前工作区含越权路径", - ) - legal_commit_paths = all_owned_paths(ownership=ownership) | { - ".gitignore" - } - allowed_commit_paths = [ - path for path in dirty_paths if path in legal_commit_paths - ] - write_baseline( - repo_root=target_repo, - run_id=state["run_id"], - mode="SUBMIT", - upstream_sha=upstream_sha, - start_head=project_head, - snapshot=snapshot_workflow( - repo_root=target_repo, - workflow_paths=workflow_paths, - ), - dirty_paths=dirty_paths, - allowed_commit_paths=allowed_commit_paths, - ) - state["active_mode"] = "SUBMIT" - state["submit_ready"] = False - write_run_state(repo_root=target_repo, state=state) - return { - "status": "passed", - "mode": "SUBMIT", - "submit_start_head": project_head, - "allowed_commit_paths": allowed_commit_paths, - "next_command": "seal-submit", - } - - -def seal_submit(*, args: Any) -> dict[str, Any]: - """参数为 SUBMIT CLI;预期运行 final gate 并封存待提交内容。""" - ( - target_repo, project_head, upstream_dir, - upstream_sha, ownership, workflow_paths, state, - ) = bound_context(args=args) - if state["active_mode"] != "SUBMIT" or state["submit_ready"]: - raise HarnessError( - error="SUBMIT 无法 seal", - detail="必须处于 prepare-submit 后的 active、unsealed 状态", - ) - baseline = load_baseline(repo_root=target_repo, mode="SUBMIT") - if ( - baseline["run_id"] != state["run_id"] - or baseline["start_head"] != project_head - ): - raise HarnessError( - error="SUBMIT seal baseline 不匹配", - detail="run_id 或项目 HEAD 已变化", - ) - before_snapshot = snapshot_workflow( - repo_root=target_repo, - workflow_paths=workflow_paths, - ) - changed = changed_snapshot_paths( - baseline=baseline["snapshot"], - current=before_snapshot, - ) - if any(path != "PR_BODY.md" for path in changed): - raise HarnessError( - error="SUBMIT seal 前 workflow 内容变化", - detail=", ".join(changed), - extra={"changed_paths": changed}, - ) - local_body = read_pr_body(repo_root=target_repo) - assert_pr_body_scope( - baseline_text=baseline["pr_body_text"], - current_text=local_body, - allowed_sections={"pr_test_evidence"}, - ) - assert_dirty_allowed( - repo_root=target_repo, - allowed_paths=workflow_paths, - error="SUBMIT seal 前工作区含越权路径", - ) - - # 失败只吸收本次真实 sync 生成的 PR auto 区域,保留可重试状态。 - try: - final_gate = run_pinned_sync( - target_repo=target_repo, - upstream_dir=upstream_dir, - final=True, - ) - except HarnessError: - failed_snapshot = snapshot_workflow( - repo_root=target_repo, - workflow_paths=workflow_paths, - ) - gate_changes = changed_snapshot_paths( - baseline=before_snapshot, - current=failed_snapshot, - ) - if gate_changes and set(gate_changes) == {"PR_BODY.md"}: - baseline["snapshot"] = failed_snapshot - baseline["dirty_paths"] = ordinary_dirty_paths( - repo_root=target_repo, - ) - baseline["pr_body_text"] = read_pr_body(repo_root=target_repo) - write_json( - path=target_repo / BASELINES_DIR / "SUBMIT.json", - payload=validate_baseline(baseline=baseline, mode="SUBMIT"), - ) - raise - - # final reconcile 不得替 PASS agent 静默改写其 owned 文档。 - sealed_snapshot = snapshot_workflow( - repo_root=target_repo, - workflow_paths=workflow_paths, - ) - gate_changes = changed_snapshot_paths( - baseline=before_snapshot, - current=sealed_snapshot, - ) - if any(path != "PR_BODY.md" for path in gate_changes): - raise HarnessError( - error="final reconcile 改写 PASS-owned 内容", - detail="必须显式重启整轮 workflow:" + ", ".join(gate_changes), - extra={"changed_paths": gate_changes}, - ) - sealed_dirty = assert_dirty_allowed( - repo_root=target_repo, - allowed_paths=workflow_paths, - error="SUBMIT seal 后工作区含越权路径", - ) - legal_commit_paths = all_owned_paths(ownership=ownership) | {".gitignore"} - baseline["snapshot"] = sealed_snapshot - baseline["dirty_paths"] = sealed_dirty - baseline["allowed_commit_paths"] = [ - path for path in sealed_dirty if path in legal_commit_paths - ] - baseline["pr_body_text"] = read_pr_body(repo_root=target_repo) - write_json( - path=target_repo / BASELINES_DIR / "SUBMIT.json", - payload=validate_baseline(baseline=baseline, mode="SUBMIT"), - ) - state["submit_ready"] = True - write_run_state(repo_root=target_repo, state=state) - return { - "status": "passed", - "mode": "SUBMIT", - "allowed_commit_paths": baseline["allowed_commit_paths"], - "final_gate_command": final_gate["command"], - } - - -def finish_submit(*, args: Any) -> dict[str, Any]: - """参数为 PR 事实;预期把 sealed 内容绑定 commit 与远端 PR。""" - ( - target_repo, project_head, upstream_dir, - upstream_sha, _ownership, workflow_paths, state, - ) = bound_context(args=args) - if state["active_mode"] != "SUBMIT" or not state["submit_ready"]: - raise HarnessError( - error="SUBMIT 尚未 seal", - detail="必须先成功执行 seal-submit", - ) - baseline = load_baseline(repo_root=target_repo, mode="SUBMIT") - if baseline["run_id"] != state["run_id"]: - raise HarnessError( - error="SUBMIT baseline 不匹配", - detail="run_id 已变化", - ) - commit_count = git_commit_count_between( - repo_root=target_repo, - start_sha=baseline["start_head"], - end_sha=project_head, - ) - if commit_count != 1: - raise HarnessError( - error="SUBMIT commit 数不匹配", - detail=f"期望 1,实际 {commit_count}", - ) - committed_paths = git_paths_between( - repo_root=target_repo, - start_sha=baseline["start_head"], - end_sha=project_head, - ) - expected_paths = sorted(set(baseline["allowed_commit_paths"])) - if committed_paths != expected_paths: - raise HarnessError( - error="SUBMIT committed scope 不匹配", - detail=f"期望 {expected_paths},实际 {committed_paths}", - extra={ - "expected_committed_paths": expected_paths, - "actual_committed_paths": committed_paths, - }, - ) - current_snapshot = snapshot_workflow( - repo_root=target_repo, - workflow_paths=workflow_paths, - ) - changed = changed_snapshot_paths( - baseline=baseline["snapshot"], - current=current_snapshot, - ) - if changed: - raise HarnessError( - error="sealed workflow 内容已变化", - detail=", ".join(changed), - extra={"changed_paths": changed}, - ) - remaining_dirty = ordinary_dirty_paths(repo_root=target_repo) - unexpected_dirty = [ - path for path in remaining_dirty if path != "PR_BODY.md" - ] - if unexpected_dirty: - raise HarnessError( - error="SUBMIT 后工作区不干净", - detail=", ".join(unexpected_dirty), - extra={"remaining_dirty_paths": remaining_dirty}, - ) - branch_result = run_git( - repo_root=target_repo, - args=["branch", "--show-current"], - ) - branch = branch_result.stdout.strip() - if ( - branch_result.returncode != 0 - or branch != args.head - or branch in {"main", "master", args.base} - ): - raise HarnessError( - error="SUBMIT head branch 不匹配", - detail=f"期望 {args.head},实际 {branch}", - ) - pull_request = query_pull_request( - repo_root=target_repo, - pr_number=args.pr_number, - ) - local_body = read_pr_body(repo_root=target_repo) - if local_body is None: - raise HarnessError( - error="缺少本地 PR_BODY.md", - detail="无法绑定远端 PR body", - ) - if local_body != baseline["pr_body_text"]: - raise HarnessError( - error="本地 PR_BODY 与 sealed body 不匹配", - detail="seal 后不得修改本地 PR body", - ) - remote_body = pull_request["body"] - sealed_body = baseline["pr_body_text"] - body_matches = isinstance(remote_body, str) and ( - remote_body.replace("\r\n", "\n").replace("\r", "\n").rstrip("\n") - == sealed_body.replace("\r\n", "\n").replace("\r", "\n").rstrip("\n") - ) - if ( - pull_request["number"] != args.pr_number - or pull_request["state"] != "OPEN" - or pull_request["headRefOid"] != project_head - or pull_request["headRefName"] != args.head - or pull_request["baseRefName"] != args.base - or not body_matches - ): - raise HarnessError( - error="远端 PR 与本地提交不匹配", - detail=( - f"PR={pull_request['number']} state={pull_request['state']} " - f"head={pull_request['headRefName']}@" - f"{pull_request['headRefOid']} base=" - f"{pull_request['baseRefName']} body_match=" - f"{body_matches}" - ), - ) - - # GitHub 已确认远端 head/body 后才把 SUBMIT 标记为完成。 - state["completed_modes"].append("SUBMIT") - state["active_mode"] = None - state["submit_ready"] = False - state["project_head"] = project_head - state["boundary_snapshot"] = current_snapshot - state["boundary_dirty_paths"] = remaining_dirty - write_run_state(repo_root=target_repo, state=state) - result = write_result( - repo_root=target_repo, - run_id=state["run_id"], - mode="SUBMIT", - upstream_sha=upstream_sha, - project_head=project_head, - changed_paths=committed_paths, - details={ - "pr_number": args.pr_number, - "pr_url": pull_request["url"], - "base": args.base, - "head": args.head, - "remote_body_matches": True, - "remaining_dirty_paths": remaining_dirty, - }, - ) - return { - "status": "passed", - "mode": "SUBMIT", - "result": str(target_repo / RESULTS_DIR / "SUBMIT.json"), - "pr_url": pull_request["url"], - "project_head": result["project_head"], - "committed_paths": committed_paths, - } - - -def status(*, args: Any) -> dict[str, Any]: - """参数为目标仓库;预期返回 run、mode 和结果路径状态。""" - target_repo, project_head = require_repository( - path=Path(args.target_repo), - label="目标仓库", - ) - path = target_repo / RUN_PATH - if not path.is_file(): - return { - "status": "not_started", - "target_repo": str(target_repo), - "project_head": project_head, - } - state = load_run_state(repo_root=target_repo) - result_files = { - mode: str(target_repo / RESULTS_DIR / f"{mode}.json") - for mode in state["completed_modes"] - } - return { - "status": "started", - "run_id": state["run_id"], - "completed_modes": state["completed_modes"], - "active_mode": state["active_mode"], - "submit_ready": state["submit_ready"], - "state_project_head": state["project_head"], - "current_project_head": project_head, - "results": result_files, - } - - -def add_common_arguments(*, parser: argparse.ArgumentParser) -> None: - """参数为 subparser;预期加入三个 required target/upstream 参数。""" - parser.add_argument("--target-repo", required=True) - parser.add_argument("--upstream-dir", required=True) - parser.add_argument("--upstream-sha", required=True) - - -def parse_args() -> Any: - """无参数;预期返回含 subcommand 和全部显式值的 Namespace。""" - parser = JsonArgumentParser( - description="Workflow Docs Sync 轻量 mode harness。", - ) - subparsers = parser.add_subparsers(dest="command", required=True) - prepare_parser = subparsers.add_parser("prepare") - add_common_arguments(parser=prepare_parser) - for command in ("start-pass", "finish-pass"): - pass_parser = subparsers.add_parser(command) - add_common_arguments(parser=pass_parser) - pass_parser.add_argument( - "--mode", - required=True, - choices=PASS_MODES, - ) - for command in ("prepare-submit", "seal-submit"): - submit_parser = subparsers.add_parser(command) - add_common_arguments(parser=submit_parser) - finish_submit_parser = subparsers.add_parser("finish-submit") - add_common_arguments(parser=finish_submit_parser) - finish_submit_parser.add_argument( - "--pr-number", - required=True, - type=int, - ) - finish_submit_parser.add_argument("--base", required=True) - finish_submit_parser.add_argument("--head", required=True) - status_parser = subparsers.add_parser("status") - status_parser.add_argument("--target-repo", required=True) - return parser.parse_args() - - -def dispatch(*, args: Any) -> dict[str, Any]: - """参数为解析结果;预期返回对应 subcommand 的结构化结果。""" - operations = { - "prepare": prepare, - "start-pass": start_pass, - "finish-pass": finish_pass, - "prepare-submit": prepare_submit, - "seal-submit": seal_submit, - "finish-submit": finish_submit, - "status": status, - } - return operations[args.command](args=args) - - -def run_cli() -> int: - """无参数;预期成功返回 0,已知本地错误返回 1,并只输出一行 JSON。""" - try: - payload = dispatch(args=parse_args()) - except HarnessError as exc: - error_payload = {"error": exc.error, "detail": exc.detail} - for key, value in exc.extra.items(): - error_payload[key] = value - emit_json(payload=error_payload) - return 1 - except (KeyError, OSError, TypeError, UnicodeError, ValueError) as exc: - emit_json( - payload={ - "error": "harness 执行失败", - "detail": f"{type(exc).__name__}: {exc}", - } - ) - return 1 - emit_json(payload=payload) - return 0 - - -if __name__ == "__main__": - sys.exit(run_cli()) diff --git a/zh/skills/workflow-docs-sync/scripts/sync_docs.py b/zh/skills/workflow-docs-sync/scripts/sync_docs.py new file mode 100644 index 0000000..a36ef25 --- /dev/null +++ b/zh/skills/workflow-docs-sync/scripts/sync_docs.py @@ -0,0 +1,546 @@ +#!/usr/bin/env python3 +"""为单会话 Workflow Docs Sync 准备模板并检查最终仓库状态。 + +调用关系:CLI 解析参数后,``prepare`` 解析两个仓库的 HEAD、从固定上游提交读取 +九份模板并只补齐缺失文件;``check`` 使用同一上游提交只读验证目标 HEAD、文件范围、 +内容和 whitespace。脚本不运行项目测试,也不执行任何 Git 发布动作。 +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +CORE_FILES = ( + "architecture.md", + "capability_contract.json", + "interact.md", + "docs/business_user_guide.md", + "TESTING.md", + "PR_Checklist.md", + "SOP.md", + "AGENTS.md", + ".github/pull_request_template.md", +) +PR_TEMPLATE = ".github/pull_request_template.md" +EDITABLE_PATHS = frozenset((*CORE_FILES, ".gitignore")) +LANGUAGES = ("zh", "en") +SHA_PATTERN = re.compile(r"[0-9a-f]{40}") +ATX_HEADING = re.compile(r"^[ \t]{0,3}#{1,6}(?:[ \t]+(.*)|[ \t]*)$") +FENCE = re.compile(r"^[ \t]{0,3}(`{3,}|~{3,})") +TEMPLATE_TOKENS = ( + "<项目名>", + "<项目 / agent / app 名称>", + "<对象>", + "<指标 / 结果>", + "<project name>", + "<project / agent / app name>", + "<object>", + "<metric / result>", + "Case 1:确认一个对象最近是否异常", + "Case 1: Check whether one object is abnormal", + "待项目负责人补充", + "project owner must replace this", + "sample_supported_question", + "sample_multi_object_comparison_not_supported", + "sample_no_final_business_decision", + "sample_requires_context_before_answer", + "CAPABILITY.sample_", + "BOUNDARY.sample_", + "RESPONSIBILITY.sample_", + "BEHAVIOR.sample_", + "<!-- capability-anchor: TODO -->", + "<!-- test-anchor: TODO -->", +) + + +class SyncError(RuntimeError): + """表示可预期且应以单行 JSON 返回的同步失败。""" + + def __init__(self, *, error: str, detail: str) -> None: + """保存稳定错误摘要和具体证据。""" + super().__init__(error) + self.error = error + self.detail = detail + + +class JsonArgumentParser(argparse.ArgumentParser): + """把参数错误转换为稳定的同步错误。""" + + def error(self, message: str) -> None: + """接收 argparse 消息并立即终止当前解析。""" + raise SyncError(error="参数无效", detail=message) + + +@dataclass(frozen=True) +class Repository: + """保存已经验证的 Git 根目录和当前提交。""" + + root: Path + head: str + + +@dataclass(frozen=True) +class StatusEntry: + """保存一条 porcelain 状态及其涉及的全部相对路径。""" + + code: str + paths: tuple[str, ...] + + +def emit_json(*, payload: dict[str, Any]) -> None: + """把结果写成一行 UTF-8 JSON,供当前会话直接消费。""" + print(json.dumps(payload, ensure_ascii=False, separators=(",", ":"))) + + +def run_git(*, repo_root: Path, args: list[str]) -> subprocess.CompletedProcess[str]: + """在指定仓库运行只含显式参数的 Git 命令并捕获 UTF-8 输出。""" + environment = os.environ.copy() + environment.update({"LC_ALL": "C", "GIT_OPTIONAL_LOCKS": "0"}) + return subprocess.run( + args=["git", "-C", str(repo_root), *args], + cwd=repo_root, + env=environment, + check=False, + capture_output=True, + encoding="utf-8", + text=True, + ) + + +def git_output(*, repo_root: Path, args: list[str], purpose: str) -> str: + """运行必须成功的 Git 命令并返回 stdout。""" + result = run_git(repo_root=repo_root, args=args) + if result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() or "Git 无输出" + raise SyncError(error=purpose, detail=detail) + return result.stdout + + +def require_sha(*, value: str, label: str) -> str: + """验证并返回完整小写 Git SHA。""" + if SHA_PATTERN.fullmatch(value) is None: + raise SyncError(error=f"{label}无效", detail="必须是 40 位小写十六进制 Git SHA") + return value + + +def require_repository(*, value: str, label: str) -> Repository: + """验证路径恰好是 Git 工作树根目录并解析当前提交。""" + try: + candidate = Path(value).expanduser().resolve(strict=True) + except (OSError, RuntimeError) as exc: + raise SyncError(error=f"{label}无效", detail=str(exc)) from exc + root_text = git_output( + repo_root=candidate, + args=["rev-parse", "--show-toplevel"], + purpose=f"无法解析{label}", + ).strip() + actual_root = Path(root_text).resolve(strict=True) + if actual_root != candidate: + raise SyncError( + error=f"{label}必须是 Git 根目录", + detail=f"传入 {candidate},实际 {actual_root}", + ) + head = git_output( + repo_root=candidate, + args=["rev-parse", "--verify", "HEAD^{commit}"], + purpose=f"无法解析{label} HEAD", + ).strip() + return Repository(root=candidate, head=require_sha(value=head, label=f"{label} HEAD")) + + +def require_commit(*, repo_root: Path, value: str, label: str) -> str: + """验证固定提交存在于指定上游对象库。""" + commit = require_sha(value=value, label=label) + resolved = git_output( + repo_root=repo_root, + args=["rev-parse", "--verify", f"{commit}^{{commit}}"], + purpose=f"{label}不存在", + ).strip() + if resolved != commit: + raise SyncError(error=f"{label}解析不一致", detail=f"期望 {commit},实际 {resolved}") + return commit + + +def parse_status(*, output: str) -> list[StatusEntry]: + """解析 ``git status --porcelain=v1 -z``,包含 rename/copy 两端路径。""" + fields = output.split("\0") + entries: list[StatusEntry] = [] + index = 0 + while index < len(fields) and fields[index] != "": + row = fields[index] + if len(row) < 4 or row[2] != " ": + raise SyncError(error="Git 状态格式无效", detail=repr(row)) + code = row[:2] + paths = [row[3:]] + index += 1 + if "R" in code or "C" in code: + if index >= len(fields) or fields[index] == "": + raise SyncError(error="Git 状态格式无效", detail="rename/copy 缺少来源路径") + paths.append(fields[index]) + index += 1 + entries.append(StatusEntry(code=code, paths=tuple(paths))) + return entries + + +def read_status(*, repo_root: Path) -> list[StatusEntry]: + """读取目标仓库全部 tracked 和 untracked 状态。""" + output = git_output( + repo_root=repo_root, + args=["status", "--porcelain=v1", "-z", "--untracked-files=all"], + purpose="无法读取目标仓库状态", + ) + return parse_status(output=output) + + +def require_editable_dirty(*, repo_root: Path) -> list[StatusEntry]: + """在任何写入前拒绝九份文档和可选 gitignore 之外的 dirty 路径。""" + entries = read_status(repo_root=repo_root) + outside = sorted({ + path + for entry in entries + for path in entry.paths + if path not in EDITABLE_PATHS + }) + if outside: + raise SyncError(error="存在同步范围外的 dirty path", detail=", ".join(outside)) + return entries + + +def read_template( + *, upstream_root: Path, upstream_sha: str, language: str, relative_path: str +) -> str: + """只通过固定提交的 Git 对象读取一个语言模板。""" + source_path = f"{language}/{relative_path}" + return git_output( + repo_root=upstream_root, + args=["show", f"{upstream_sha}:{source_path}"], + purpose="无法读取固定上游模板", + ) + + +def read_templates( + *, upstream_root: Path, upstream_sha: str, language: str +) -> dict[str, str]: + """读取九份固定上游模板,缺少任意模板时不返回部分结果。""" + return { + path: read_template( + upstream_root=upstream_root, + upstream_sha=upstream_sha, + language=language, + relative_path=path, + ) + for path in CORE_FILES + } + + +def inspect_destinations(*, target_root: Path) -> tuple[list[str], list[str]]: + """区分缺失和已有目标文件,并在写入前拒绝目录冲突。""" + missing: list[str] = [] + existing: list[str] = [] + for relative_path in CORE_FILES: + destination = target_root / relative_path + current = target_root + for part in Path(relative_path).parts: + current /= part + if current.is_symlink(): + raise SyncError(error="核心文档路径不能是符号链接", detail=relative_path) + if current != destination and current.exists() and not current.is_dir(): + raise SyncError(error="核心文档父路径不是目录", detail=relative_path) + if destination.is_file(): + existing.append(relative_path) + elif destination.exists(): + raise SyncError(error="核心文档路径不是普通文件", detail=relative_path) + else: + missing.append(relative_path) + return missing, existing + + +def install_missing( + *, target_root: Path, templates: dict[str, str], missing: list[str] +) -> None: + """只创建缺失模板及必要父目录,绝不重写已有文档。""" + for relative_path in missing: + destination = target_root / relative_path + destination.parent.mkdir(parents=True, exist_ok=True) + with destination.open(mode="x", encoding="utf-8", newline="") as stream: + stream.write(templates[relative_path]) + + +def prepare(*, target_repo: str, upstream_dir: str, language: str) -> dict[str, Any]: + """执行写入前检查,只补齐缺失模板并返回当前会话所需事实。""" + target = require_repository(value=target_repo, label="目标仓库") + upstream = require_repository(value=upstream_dir, label="上游目录") + templates = read_templates( + upstream_root=upstream.root, + upstream_sha=upstream.head, + language=language, + ) + require_editable_dirty(repo_root=target.root) + missing, existing = inspect_destinations(target_root=target.root) + install_missing(target_root=target.root, templates=templates, missing=missing) + return { + "status": "prepared", + "target_repo": str(target.root), + "target_head": target.head, + "upstream_dir": str(upstream.root), + "upstream_sha": upstream.head, + "language": language, + "installed": missing, + "existing": existing, + "required_files": list(CORE_FILES), + } + + +def normalize_text(*, text: str) -> str: + """只规范换行符,使跨平台模板原样复制判断稳定。""" + return text.replace("\r\n", "\n").replace("\r", "\n") + + +def line_hits(*, relative_path: str, text: str) -> list[str]: + """返回明确模板 token 和未填写中文占位的逐行错误。""" + failures: list[str] = [] + tokens = (*TEMPLATE_TOKENS, "待补充") + for line_number, line in enumerate(text.splitlines(), start=1): + for token in tokens: + if token in line: + failures.append( + f"{relative_path}:{line_number}: 检测到未项目化内容 {token}" + ) + return failures + + +def markdown_failures(*, relative_path: str, text: str) -> list[str]: + """忽略 fenced code,要求至少一个非空 ATX 标题且不存在空标题。""" + failures: list[str] = [] + valid_heading = False + fence_character = "" + fence_width = 0 + for line_number, line in enumerate(text.splitlines(), start=1): + fence_match = FENCE.match(line) + if fence_match is not None: + marker = fence_match.group(1) + if not fence_character: + fence_character, fence_width = marker[0], len(marker) + elif marker[0] == fence_character and len(marker) >= fence_width: + fence_character, fence_width = "", 0 + continue + if fence_character: + continue + heading = ATX_HEADING.match(line) + if heading is None: + continue + title = (heading.group(1) or "").strip() + title = re.sub(r"[ \t]+#+[ \t]*$", "", title).strip() + if re.fullmatch(r"#+", title) is not None: + title = "" + if title: + valid_heading = True + else: + failures.append(f"{relative_path}:{line_number}: Markdown 标题为空") + if not valid_heading: + failures.append(f"{relative_path}: 缺少非空 Markdown 标题") + return failures + + +def capability_failures(*, text: str) -> list[str]: + """验证 capability contract 是合法 JSON object,不机械裁定项目语义。""" + try: + payload = json.loads(text) + except json.JSONDecodeError as exc: + return [f"capability_contract.json:{exc.lineno}: JSON 无效: {exc.msg}"] + if not isinstance(payload, dict): + return ["capability_contract.json: 顶层必须是 JSON object"] + return [] + + +def content_failures(*, target_root: Path, templates: dict[str, str]) -> tuple[ + list[str], dict[str, str] +]: + """验证九份文件的存在性、编码、内容、标题、JSON 和模板差异。""" + failures: list[str] = [] + texts: dict[str, str] = {} + for relative_path in CORE_FILES: + path = target_root / relative_path + if path.is_symlink(): + failures.append(f"核心文档不能是符号链接: {relative_path}") + continue + if not path.is_file(): + failures.append(f"缺少必需文件: {relative_path}") + continue + try: + text = path.read_bytes().decode("utf-8") + except UnicodeDecodeError as exc: + failures.append(f"{relative_path}: 不是有效 UTF-8: {exc}") + continue + texts[relative_path] = text + if not text.strip(): + failures.append(f"{relative_path}: 文件为空") + continue + if relative_path.endswith(".md"): + failures.extend(markdown_failures(relative_path=relative_path, text=text)) + failures.extend(line_hits(relative_path=relative_path, text=text)) + if ( + relative_path != PR_TEMPLATE + and normalize_text(text=text) + == normalize_text(text=templates[relative_path]) + ): + failures.append(f"{relative_path}: 不允许与固定上游模板完全相同") + if "capability_contract.json" in texts: + failures.extend(capability_failures(text=texts["capability_contract.json"])) + return failures, texts + + +def tracked_whitespace_failures(*, target_root: Path) -> list[str]: + """对 working tree 和 index 的 tracked diff 运行 Git whitespace 检查。""" + failures: list[str] = [] + for args, label in ( + (["diff", "--check"], "working tree"), + (["diff", "--cached", "--check"], "index"), + ): + result = run_git(repo_root=target_root, args=args) + if result.returncode != 0: + detail = result.stdout.strip() or result.stderr.strip() or "无诊断输出" + failures.append(f"{label} whitespace 检查失败: {detail}") + return failures + + +def file_whitespace_failures(*, target_root: Path) -> list[str]: + """用 Git 的同一规则检查九份最终文档和存在的辅助 gitignore。""" + failures: list[str] = [] + paths = [path for path in CORE_FILES if (target_root / path).is_file()] + gitignore = target_root / ".gitignore" + if gitignore.is_symlink(): + failures.append(".gitignore: 不能是符号链接") + elif gitignore.exists() and not gitignore.is_file(): + failures.append(".gitignore: 不是普通文件") + elif gitignore.is_file(): + try: + gitignore.read_bytes().decode("utf-8") + except UnicodeDecodeError as exc: + failures.append(f".gitignore: 不是有效 UTF-8: {exc}") + if not gitignore.is_symlink() and gitignore.is_file(): + paths.append(".gitignore") + for relative_path in paths: + result = run_git( + repo_root=target_root, + args=[ + "diff", + "--no-index", + "--check", + "--", + os.devnull, + str(target_root / relative_path), + ], + ) + if result.returncode not in (0, 1): + detail = result.stdout.strip() or result.stderr.strip() or "无诊断输出" + failures.append(f"最终文件 whitespace 检查失败: {detail}") + return failures + + +def check( + *, + target_repo: str, + upstream_dir: str, + upstream_sha: str, + expected_target_head: str, + language: str, +) -> dict[str, Any]: + """只读验证固定上游提交对应的最终目标仓库状态。""" + target = require_repository(value=target_repo, label="目标仓库") + expected_head = require_sha(value=expected_target_head, label="预期目标 HEAD") + if target.head != expected_head: + raise SyncError( + error="目标 HEAD 已变化", + detail=f"期望 {expected_head},实际 {target.head}", + ) + upstream = require_repository(value=upstream_dir, label="上游目录") + pinned_sha = require_commit( + repo_root=upstream.root, + value=upstream_sha, + label="上游 SHA", + ) + entries = require_editable_dirty(repo_root=target.root) + templates = read_templates( + upstream_root=upstream.root, + upstream_sha=pinned_sha, + language=language, + ) + failures, _ = content_failures(target_root=target.root, templates=templates) + failures.extend(tracked_whitespace_failures(target_root=target.root)) + failures.extend(file_whitespace_failures(target_root=target.root)) + if failures: + raise SyncError(error="最终仓库检查失败", detail=" | ".join(failures)) + return { + "status": "passed", + "target_repo": str(target.root), + "target_head": target.head, + "upstream_sha": pinned_sha, + "language": language, + "files_checked": len(CORE_FILES), + "dirty_paths": sorted({path for entry in entries for path in entry.paths}), + } + + +def build_parser() -> JsonArgumentParser: + """构建只暴露 prepare 和 check 的内部 CLI。""" + parser = JsonArgumentParser(description="准备并检查 Workflow Docs Sync。") + commands = parser.add_subparsers(dest="command", required=True) + prepare_parser = commands.add_parser("prepare") + prepare_parser.add_argument("--target-repo", required=True) + prepare_parser.add_argument("--upstream-dir", required=True) + prepare_parser.add_argument("--language", choices=LANGUAGES, required=True) + check_parser = commands.add_parser("check") + check_parser.add_argument("--target-repo", required=True) + check_parser.add_argument("--upstream-dir", required=True) + check_parser.add_argument("--upstream-sha", required=True) + check_parser.add_argument("--expected-target-head", required=True) + check_parser.add_argument("--language", choices=LANGUAGES, required=True) + return parser + + +def execute(*, args: argparse.Namespace) -> dict[str, Any]: + """按已解析子命令调用对应实现并返回可序列化结果。""" + if args.command == "prepare": + return prepare( + target_repo=args.target_repo, + upstream_dir=args.upstream_dir, + language=args.language, + ) + if args.command == "check": + return check( + target_repo=args.target_repo, + upstream_dir=args.upstream_dir, + upstream_sha=args.upstream_sha, + expected_target_head=args.expected_target_head, + language=args.language, + ) + raise SyncError(error="参数无效", detail=f"未知子命令: {args.command}") + + +def run_cli() -> int: + """运行 CLI;成功或已知失败均只输出一行 JSON。""" + try: + payload = execute(args=build_parser().parse_args()) + except SyncError as exc: + emit_json(payload={"status": "failed", "error": exc.error, "detail": exc.detail}) + return 1 + except (OSError, RuntimeError, TypeError, UnicodeError, ValueError) as exc: + detail = f"{type(exc).__name__}: {exc}" + emit_json(payload={"status": "failed", "error": "同步器执行失败", "detail": detail}) + return 1 + emit_json(payload=payload) + return 0 + + +if __name__ == "__main__": + sys.exit(run_cli())