From 63e1e0d64260613378629d49a9c0cd817dc0279b Mon Sep 17 00:00:00 2001 From: avabbbb <89175608+avabbbb@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:59:50 +0800 Subject: [PATCH 1/3] fix(dev): relax non-safety gates without weakening hard boundaries --- .github/workflows/branch-freshness.yml | 4 +- .github/workflows/build.yml | 65 +++++++++++++++++ AGENTS.md | 6 +- .../services/agentic_interaction_policy.py | 69 ++++++++++++------- backend/scripts/dev/branch_guard.py | 24 ++++--- .../tests/test_agentic_interaction_policy.py | 58 +++++++++++++--- backend/tests/test_branch_guard.py | 40 ++++++++--- docs/09-quality-and-release.md | 10 +++ .../components/workbench/DecisionPlanView.tsx | 25 +++++++ .../workbench/PlanReviewInbox.test.tsx | 23 +++++++ 10 files changed, 265 insertions(+), 59 deletions(-) diff --git a/.github/workflows/branch-freshness.yml b/.github/workflows/branch-freshness.yml index 42ca4df6..4e90cf3d 100644 --- a/.github/workflows/branch-freshness.yml +++ b/.github/workflows/branch-freshness.yml @@ -9,7 +9,7 @@ permissions: jobs: branch-freshness: - name: PR includes latest main + name: PR baseline freshness (advisory) runs-on: ubuntu-latest steps: @@ -23,7 +23,7 @@ jobs: run: | git fetch --no-tags origin "${{ github.event.pull_request.base.ref }}:refs/remotes/origin/${{ github.event.pull_request.base.ref }}" - - name: Reject stale PR baseline + - name: Report stale PR baseline run: | python backend/scripts/dev/branch_guard.py \ --mode pr \ diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e39e90c5..efce8310 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,8 +12,49 @@ permissions: contents: read jobs: + changes: + name: Detect changed areas + runs-on: ubuntu-latest + outputs: + backend: ${{ steps.filter.outputs.backend }} + frontend: ${{ steps.filter.outputs.frontend }} + agent_runtime: ${{ steps.filter.outputs.agent_runtime }} + extension: ${{ steps.filter.outputs.extension }} + tauri: ${{ steps.filter.outputs.tauri }} + ci: ${{ steps.filter.outputs.ci }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + - id: filter + name: Classify changed paths + shell: bash + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + run: | + set -euo pipefail + base="${BASE_SHA:-}" + if [ -z "$base" ] || [ "$base" = "0000000000000000000000000000000000000000" ]; then + base="$(git rev-parse HEAD^ 2>/dev/null || git rev-parse HEAD)" + fi + git diff --name-only "$base" HEAD > "$RUNNER_TEMP/offeru-changed-files.txt" + flag() { + if grep -Eq "$1" "$RUNNER_TEMP/offeru-changed-files.txt"; then echo true; else echo false; fi + } + { + echo "backend=$(flag '^(backend/|requirements.*|pyproject\.toml)')" + echo "frontend=$(flag '^frontend/')" + echo "agent_runtime=$(flag '^agent-runtime/')" + echo "extension=$(flag '^extension/')" + echo "tauri=$(flag '^frontend/src-tauri/')" + echo "ci=$(flag '^(\.github/workflows/|AGENTS\.md$|backend/scripts/dev/branch_guard\.py$)')" + } >> "$GITHUB_OUTPUT" + backend-tests: name: Backend tests + needs: changes + if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.ci == 'true' }} runs-on: ubuntu-latest steps: @@ -36,22 +77,29 @@ jobs: run: python -m pytest tests -q - name: Audit release-facing product claims + if: ${{ github.event_name != 'pull_request' }} run: python backend/scripts/release/audit_product_claims.py --repo-root . --json - name: Audit architecture and Operation Registry boundaries + if: ${{ github.event_name != 'pull_request' }} run: python backend/scripts/release/audit_architecture.py --json - name: Audit release severity ledger + if: ${{ github.event_name != 'pull_request' }} run: python backend/scripts/release/audit_release_severity.py --repo-root . --json - name: Audit release checklist semantics + if: ${{ github.event_name != 'pull_request' }} run: python backend/scripts/release/audit_release_readiness.py --repo-root . --json - name: Audit release version consistency + if: ${{ github.event_name != 'pull_request' }} run: python backend/scripts/release/audit_version_consistency.py --repo-root . --json frontend-quality: name: Frontend typecheck and build + needs: changes + if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.frontend == 'true' || needs.changes.outputs.ci == 'true' }} runs-on: ubuntu-latest steps: @@ -81,11 +129,15 @@ jobs: run: npm run build - name: Audit production dependencies + if: ${{ github.event_name != 'pull_request' }} working-directory: frontend run: npm audit --omit=dev --audit-level=high agent-runtime-quality: name: Agent runtime install and audit + needs: changes + if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.agent_runtime == 'true' || needs.changes.outputs.ci == 'true' }} + continue-on-error: ${{ github.event_name == 'pull_request' }} runs-on: ubuntu-latest steps: @@ -108,6 +160,9 @@ jobs: rust-security-audit: name: RustSec dependency audit + needs: changes + if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.tauri == 'true' || needs.changes.outputs.ci == 'true' }} + continue-on-error: ${{ github.event_name == 'pull_request' }} runs-on: ubuntu-latest steps: @@ -132,6 +187,8 @@ jobs: extension-quality: name: Browser extension typecheck, test and build + needs: changes + if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.extension == 'true' || needs.changes.outputs.ci == 'true' }} runs-on: ubuntu-latest steps: @@ -165,10 +222,12 @@ jobs: run: python ../backend/scripts/release/audit_architecture.py --generated-extension-artifacts --json - name: Audit extension production dependencies + if: ${{ github.event_name != 'pull_request' }} working-directory: extension run: npm audit --omit=dev --audit-level=high browser-smoke: + if: ${{ github.event_name != 'pull_request' }} name: Isolated browser release smoke (${{ matrix.scenario }}) runs-on: ubuntu-latest needs: [backend-tests, frontend-quality] @@ -350,6 +409,7 @@ jobs: if-no-files-found: ignore reliability-failure-recovery: + if: ${{ github.event_name != 'pull_request' }} name: Cross-process provider failure and restart recovery runs-on: ubuntu-latest needs: [backend-tests] @@ -374,6 +434,7 @@ jobs: run: python backend/scripts/e2e/test_public_release_failure_recovery.py critical-browser-repeatability: + if: ${{ github.event_name != 'pull_request' }} name: Critical new-user browser repeatability (${{ matrix.repeat }}) runs-on: ubuntu-latest needs: [backend-tests, frontend-quality] @@ -529,6 +590,7 @@ jobs: done migration-browser-smoke: + if: ${{ github.event_name != 'pull_request' }} name: Previous-release migration browser smoke runs-on: ubuntu-latest needs: [backend-tests, frontend-quality] @@ -636,6 +698,7 @@ jobs: fi desktop-package: + if: ${{ github.event_name != 'pull_request' }} name: Windows desktop package runs-on: windows-latest needs: [backend-tests, frontend-quality, extension-quality, browser-smoke, reliability-failure-recovery, critical-browser-repeatability, migration-browser-smoke] @@ -782,11 +845,13 @@ jobs: if-no-files-found: error macos-package: + if: ${{ github.event_name != 'pull_request' }} needs: [backend-tests, frontend-quality, extension-quality] uses: ./.github/workflows/macos-package.yml secrets: inherit desktop-installed-smoke: + if: ${{ github.event_name != 'pull_request' }} name: Windows installed-app and sidecar smoke runs-on: windows-latest needs: [desktop-package] diff --git a/AGENTS.md b/AGENTS.md index d996612a..9348a17f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -136,11 +136,11 @@ Issues 和 PRD 使用当前 Git remote 对应的 GitHub Issues,统一用 `gh` ### 本地分支预算与归档 -- **分支规则现在有机器闸门,不再只靠提醒**:开始一轮开发前运行 `python backend/scripts/dev/branch_guard.py --mode start`;集成 worker 前运行 `--mode integrate`;任何真人/Desktop/安装包验收前必须运行 `--mode owner-test`。Guard 会先 fetch `origin/main`,HEAD 落后 main 或本地命名分支超过 3 条时直接失败;集成/真人验收默认还要求 clean worktree。详细规则以 `backend/scripts/dev/branch_guard.py` 为准;原始说明已归档在 `docs/archive/pre-2026-10/agents/branch-and-test-baseline.md`。 +- **分支 Guard 优先服务开发速度,真人验收才 fail-closed**:开始一轮开发前运行 `python backend/scripts/dev/branch_guard.py --mode start`;集成 worker 前运行 `--mode integrate`;任何真人/Desktop/安装包验收前必须运行 `--mode owner-test`。start / integrate / pr 模式下,落后 main、dirty worktree、命名分支超预算只输出 warning,不阻止施工;只有 owner-test 要求最新 baseline、clean worktree 与精确运行物身份。详细规则以 `backend/scripts/dev/branch_guard.py` 为准。 - **子代理默认只用 detached worktree,不创建永久 worker 分支**:主代理先记录唯一 integration baseline SHA,再用 `git worktree add --detach ` 隔离;子代理提交后返回 commit SHA,由主代理尽早审查并合入唯一集成分支。不得为 Luna/review/fix/experiment 每项工作长期新增命名分支。 - **真人测试必须证明“源码身份”和“运行物身份”一致**:`--mode owner-test` 输出 HEAD、origin/main、ahead/behind、dirty、branch/worktree 数;启动 Desktop/后端后再用 `--runtime-health-url http://127.0.0.1:8766/api/health` 核对运行时 `build_identity.commit == HEAD`。源码已更新但实际运行旧 EXE/sidecar/DMG 时,测试结果一律不能算当前版本验收。 -- **PR 也不得长期漂在旧 main 上**:`.github/workflows/branch-freshness.yml` 从 PR 实际 head 检查当前 base 是否为其祖先。失败时先同步当前 main 再继续集成/验收;不要在 stale PR 上继续堆新功能。 -- **本地最多三条分支**:保留 `main`、当前开发分支、当前集成分支。此次为 `main`、`feat/agent-contract-desktop-binding`、`codex/proposal-v2-integration-20261002`;任务切换时调整职责,不能不断累加实验、评审或子代理分支。需要隔离并行时优先 `git worktree add --detach`,主代理审查并集成明确提交。 +- **PR freshness 是提醒,不是开发闸门**:`.github/workflows/branch-freshness.yml` 从 PR 实际 head 检查当前 base 是否为其祖先;落后 main 时提示尽快同步,但不阻止继续开发或创建 PR。进入 owner-test / release 前必须同步到当前 main。 +- **本地三条分支是建议预算,不是硬限制**:通常保留 `main`、当前开发分支、当前集成分支;并行施工需要更多命名分支时可以继续,不得因为数量本身中断开发。仍优先用 detached worktree,任务完成后再归档/清理。 - **减少分支不等于合并所有功能**:已合并内容与 cherry-pick 后的等价修改核对后收尾;未验收内容列入待集成清单,不能为了数量目标混进 main。移除含独有提交的分支前,必须生成并验证可恢复 Git bundle、保存原分支 SHA 和工作树状态;有未提交改动的 worktree 保留目录及文件,不 force-remove、不 reset。归档不计为功能完成。 - **归档必须能实际恢复**:持久归档放在仓库 Git common directory 的 `archives/`,不能只有临时目录中的单份备份;在隔离目录恢复并比对原 SHA,记录恢复命令。远端分支和开放 PR 单独核对,未获授权不删除或改写远端。当前清单与恢复步骤见 `docs/agents/branch-consolidation-20261005.md`。 diff --git a/backend/app/services/agentic_interaction_policy.py b/backend/app/services/agentic_interaction_policy.py index 549651bf..f5d1e773 100644 --- a/backend/app/services/agentic_interaction_policy.py +++ b/backend/app/services/agentic_interaction_policy.py @@ -1,8 +1,10 @@ """Project Plan execution facts into the user's interaction states. -This module does not authorize or execute Operations. It keeps malformed or -stale review packets out of the user Inbox and leaves execution authority with -the existing Proposal Plan and Operation Registry. +This module does not authorize or execute Operations. Review-card quality is +advisory: missing Before/After/Why/evidence/source metadata is shown as a +warning but does not deadlock an otherwise valid decision. Hard blocking is +reserved for stale snapshots, unknown execution state, explicit policy blocks +and the existing destructive/external authorization boundary. """ from __future__ import annotations @@ -92,7 +94,10 @@ def _requested_outcome(group: Mapping[str, Any]) -> str: outcome = str(display.get("interaction_outcome") or "").strip().upper() if outcome: return outcome - return "AUTO" if group.get("risk") == "prepare" else "REVIEW" + # L1/prepare used to default to AUTO, but there is no universal executor + # consuming those groups. Surface them as ordinary review decisions so the + # user always has an exit instead of leaving a durable Run parked forever. + return "REVIEW" def assess_group(group: Mapping[str, Any], *, source_current: bool | None = True) -> dict[str, Any]: @@ -113,9 +118,7 @@ def assess_group(group: Mapping[str, Any], *, source_current: bool | None = True return {"reviewability": {"status": "needs_preparation", "reason_codes": ["group_not_pending"], "counts_as_user_decision": False}, "interaction_state": "system_recovering"} - reasons = _review_packet_reasons(group) - if source_current is False: - reasons.append("source_changed_or_unavailable") + warnings = _review_packet_reasons(group) operations = {str(node.get("operation") or "") for node in nodes} risk = str(group.get("risk") or "") @@ -123,32 +126,48 @@ def assess_group(group: Mapping[str, Any], *, source_current: bool | None = True if outcome not in {"AUTO", "ASK", "REVIEW", "AUTHORIZE", "BLOCK"}: return {"reviewability": {"status": "needs_preparation", "reason_codes": ["invalid_interaction_outcome"], "counts_as_user_decision": False}, "interaction_state": "system_blocked"} - if risk == "external" or operations & _DESTRUCTIVE_OPERATIONS: - outcome = "AUTHORIZE" - if outcome == "AUTHORIZE" and reasons: - # Owner-authorized destructive actions show their own preview payload - # and intentionally clear sources; only the affected scope still - # gates them. Diff material and source freshness do not apply. - reasons = [code for code in reasons if code == "missing_scope"] - if reasons: - return {"reviewability": {"status": "needs_preparation", "reason_codes": sorted(set(reasons)), "counts_as_user_decision": False}, + + # Canonical source freshness is a real safety invariant. Metadata being + # absent is only a quality warning; a source that was captured and then + # changed is a stale-snapshot blocker and must be regenerated. + if source_current is False: + return {"reviewability": {"status": "needs_preparation", "reason_codes": ["source_changed_or_unavailable"], "counts_as_user_decision": False}, "interaction_state": "system_recovering"} + + destructive = bool(operations & _DESTRUCTIVE_OPERATIONS) + if risk == "external" or destructive: + outcome = "AUTHORIZE" + + # A destructive action must still identify its affected scope before the + # user can authorize it. Other review-card omissions are warnings only. + if destructive and "missing_scope" in warnings: + return {"reviewability": {"status": "needs_preparation", "reason_codes": ["missing_scope"], "counts_as_user_decision": False}, + "interaction_state": "system_blocked"} + if outcome == "BLOCK": return {"reviewability": {"status": "needs_preparation", "reason_codes": ["policy_blocked"], "counts_as_user_decision": False}, "interaction_state": "system_blocked"} + + warning_codes = sorted(set(warnings)) if outcome == "ASK": - # Ask is projected from the separately persisted AgentInputRequest; - # a Plan display hint cannot manufacture an actionable user question. - return {"reviewability": {"status": "needs_preparation", "reason_codes": ["input_request_not_bound_to_group"], "counts_as_user_decision": False}, - "interaction_state": "system_recovering"} + # If the caller forgot to persist a dedicated AgentInputRequest, keep + # the group actionable instead of parking the Run forever. Approval is + # explicit and still binds the exact immutable Plan/Group digests. + warning_codes = sorted(set([*warning_codes, "input_request_not_bound_to_group"])) + outcome = "REVIEW" + if outcome == "AUTHORIZE": - return {"reviewability": {"status": "ready", "reason_codes": [], "counts_as_user_decision": True}, + return {"reviewability": {"status": "ready", "reason_codes": warning_codes, "counts_as_user_decision": True}, "interaction_state": "needs_user_authorization"} - if outcome == "REVIEW": - return {"reviewability": {"status": "ready", "reason_codes": [], "counts_as_user_decision": True}, + if outcome in {"REVIEW", "AUTO"}: + # AUTO is also reviewable as a fallback. A missing autonomous executor + # must never make a durable operation unreachable from the UI. + if outcome == "AUTO": + warning_codes = sorted(set([*warning_codes, "auto_fallback_requires_confirmation"])) + return {"reviewability": {"status": "ready", "reason_codes": warning_codes, "counts_as_user_decision": True}, "interaction_state": "needs_user_review"} - return {"reviewability": {"status": "ready", "reason_codes": [], "counts_as_user_decision": False}, - "interaction_state": "system_recovering"} + return {"reviewability": {"status": "ready", "reason_codes": warning_codes, "counts_as_user_decision": True}, + "interaction_state": "needs_user_review"} def run_interaction_state(run: Mapping[str, Any]) -> str: diff --git a/backend/scripts/dev/branch_guard.py b/backend/scripts/dev/branch_guard.py index b0afb08f..ae99680a 100644 --- a/backend/scripts/dev/branch_guard.py +++ b/backend/scripts/dev/branch_guard.py @@ -105,25 +105,29 @@ def evaluate( warnings: list[str] = [] if state.behind > 0: - failures.append( + message = ( f"HEAD is {state.behind} commit(s) behind {state.base_ref}; " - "refresh/rebase the test or integration baseline first" + "refresh/rebase before owner acceptance or release evidence" ) + if state.mode == "owner-test": + failures.append(message) + else: + warnings.append(message) - if state.mode in {"owner-test", "integrate", "pr"} and state.dirty and not allow_dirty: + if state.mode == "owner-test" and state.dirty and not allow_dirty: failures.append( - "working tree is dirty; owner/integration evidence must name an exact committed source identity" + "working tree is dirty; owner acceptance evidence must name an exact committed source identity" ) elif state.dirty: warnings.append( - "working tree is dirty; results must be reported as dirty_worktree " - "and cannot represent a clean build" + "working tree is dirty; development/integration may continue, but results must be reported as " + "dirty_worktree and cannot represent clean owner/release evidence" ) - if state.mode != "pr" and state.named_local_branches > max_named_branches: - failures.append( - f"{state.named_local_branches} local named branches exceed the budget of " - f"{max_named_branches}; use detached worker worktrees or archive/consolidate first" + if state.named_local_branches > max_named_branches: + warnings.append( + f"{state.named_local_branches} local named branches exceed the advisory budget of " + f"{max_named_branches}; prefer detached worker worktrees, but do not block active development" ) if state.branch == "main" and state.ahead > 0: diff --git a/backend/tests/test_agentic_interaction_policy.py b/backend/tests/test_agentic_interaction_policy.py index 95def36b..bc0489eb 100644 --- a/backend/tests/test_agentic_interaction_policy.py +++ b/backend/tests/test_agentic_interaction_policy.py @@ -40,26 +40,62 @@ def test_complete_packet_is_user_review_and_external_effect_keeps_authorization_ assert authorize["reviewability"]["counts_as_user_decision"] is True -def test_incomplete_packet_and_unknown_execution_are_system_work(): +def test_incomplete_packet_is_warning_but_unknown_execution_still_blocks(): incomplete = assess_group(_group(display={"before": "Old", "after": "New"})) uncertain = assess_group(_group(nodes=[{"operation": "update_resume_section", "status": "uncertain"}])) - assert incomplete["reviewability"]["status"] == "needs_preparation" - assert incomplete["reviewability"]["counts_as_user_decision"] is False + assert incomplete["reviewability"]["status"] == "ready" + assert incomplete["reviewability"]["counts_as_user_decision"] is True assert {"missing_why", "missing_evidence"}.issubset(incomplete["reviewability"]["reason_codes"]) - assert incomplete["interaction_state"] == "system_recovering" + assert incomplete["interaction_state"] == "needs_user_review" assert uncertain["reviewability"]["status"] == "needs_reconciliation" assert uncertain["interaction_state"] == "system_recovering" -def test_prepare_auto_and_unbound_ask_do_not_become_review_decisions(): - automatic = assess_group(_group(risk="prepare")) +def test_prepare_and_unbound_ask_always_have_a_user_exit(): + preparation = assess_group(_group(risk="prepare")) ask = assess_group(_group(display={**_group()["display"], "interaction_outcome": "ASK"})) - - assert automatic["reviewability"]["status"] == "ready" - assert automatic["reviewability"]["counts_as_user_decision"] is False - assert ask["interaction_state"] == "system_recovering" - assert ask["reviewability"]["reason_codes"] == ["input_request_not_bound_to_group"] + explicit_auto = assess_group(_group(display={**_group()["display"], "interaction_outcome": "AUTO"})) + + assert preparation["reviewability"]["status"] == "ready" + assert preparation["reviewability"]["counts_as_user_decision"] is True + assert preparation["interaction_state"] == "needs_user_review" + assert ask["interaction_state"] == "needs_user_review" + assert ask["reviewability"]["counts_as_user_decision"] is True + assert "input_request_not_bound_to_group" in ask["reviewability"]["reason_codes"] + assert explicit_auto["interaction_state"] == "needs_user_review" + assert "auto_fallback_requires_confirmation" in explicit_auto["reviewability"]["reason_codes"] + + + +def test_missing_source_metadata_warns_but_captured_stale_source_still_blocks(): + missing = _group(source_versions={}, nodes=[{"operation": "update_resume_section", "status": "pending"}]) + advisory = assess_group(missing) + stale = assess_group(_group(), source_current=False) + + assert advisory["reviewability"]["status"] == "ready" + assert advisory["reviewability"]["counts_as_user_decision"] is True + assert "missing_current_source" in advisory["reviewability"]["reason_codes"] + assert stale["reviewability"]["status"] == "needs_preparation" + assert stale["reviewability"]["counts_as_user_decision"] is False + assert stale["reviewability"]["reason_codes"] == ["source_changed_or_unavailable"] + + +def test_destructive_operation_keeps_explicit_authorization_and_scope_floor(): + group = _group( + risk="prepare", + scope="", + display={"before": "all local data", "after": "empty workspace"}, + nodes=[{"operation": "reset_local_business_data", "status": "pending"}], + ) + blocked = assess_group(group) + group["scope"] = "local_business_data" + authorized = assess_group(group) + + assert blocked["interaction_state"] == "system_blocked" + assert blocked["reviewability"]["reason_codes"] == ["missing_scope"] + assert authorized["interaction_state"] == "needs_user_authorization" + assert authorized["reviewability"]["counts_as_user_decision"] is True def test_verified_empty_resume_section_list_is_an_explicit_before_value(): diff --git a/backend/tests/test_branch_guard.py b/backend/tests/test_branch_guard.py index 5c880717..e0cb03c1 100644 --- a/backend/tests/test_branch_guard.py +++ b/backend/tests/test_branch_guard.py @@ -40,24 +40,48 @@ def test_owner_test_rejects_dirty_worktree() -> None: assert any("working tree is dirty" in item for item in failures) -def test_start_rejects_named_branch_budget_overflow() -> None: - failures, _ = evaluate( +def test_start_warns_on_branch_budget_and_stale_or_dirty_state() -> None: + failures, warnings = evaluate( _state( mode="start", + behind=2, + dirty=True, named_local_branches=4, named_branches=["main", "dev", "integration", "worker-1"], ), - allow_dirty=True, + allow_dirty=False, max_named_branches=3, ) - assert any("exceed the budget" in item for item in failures) + assert failures == [] + assert any("behind origin/main" in item for item in warnings) + assert any("working tree is dirty" in item for item in warnings) + assert any("advisory budget" in item for item in warnings) -def test_pr_mode_ignores_clone_local_branch_count_but_not_staleness() -> None: - failures, _ = evaluate( +def test_integrate_mode_is_advisory_but_owner_test_keeps_hard_floor() -> None: + failures, warnings = evaluate( + _state(mode="integrate", behind=1, dirty=True, named_local_branches=9), + allow_dirty=False, + max_named_branches=3, + ) + assert failures == [] + assert len(warnings) >= 3 + + owner_failures, _ = evaluate( + _state(mode="owner-test", behind=1, dirty=True, named_local_branches=9), + allow_dirty=False, + max_named_branches=3, + ) + assert any("behind origin/main" in item for item in owner_failures) + assert any("working tree is dirty" in item for item in owner_failures) + + +def test_pr_mode_reports_staleness_without_blocking() -> None: + failures, warnings = evaluate( _state(mode="pr", named_local_branches=9, behind=1), allow_dirty=False, max_named_branches=3, ) - assert len(failures) == 1 - assert "behind origin/main" in failures[0] + assert failures == [] + assert any("behind origin/main" in item for item in warnings) + assert any("advisory budget" in item for item in warnings) diff --git a/docs/09-quality-and-release.md b/docs/09-quality-and-release.md index 82437ddb..eaba55f9 100644 --- a/docs/09-quality-and-release.md +++ b/docs/09-quality-and-release.md @@ -71,6 +71,16 @@ Codex、Claude Code、OpenCode、WorkBuddy、Pi 等宿主共用同一组业务 - 依赖审计(含 RustSec)、下载产物审计、产物中的 PII 和符号链接防护、错误脱敏、canary 协议,详见根目录 `SECURITY.md`。 - 发布前必须完成产品隐私披露。 +## 8.1 开发 / PR 与发布门禁分层 + +日常开发和 PR 的目标是**尽快发现当前改动引入的确定性回归**,不是重复执行整套 Public Release Qualification。 + +- PR 只对受影响路径运行核心 typecheck / unit test / build;release claims、readiness ledger、依赖审计、RustSec、浏览器重复性、迁移、安装包与 clean-machine smoke 不作为普通 PR 的合并阻塞。 +- 依赖审计或 RustSec 在相关 PR 上可以作为 advisory 运行,失败不得阻止其它 job 或 PR;合入 `main`、tag / release 与手动发布验收仍按本文件的安全门槛严格执行。 +- `.github/workflows/branch-freshness.yml` 只提醒 PR baseline 落后;真正的最新 main、clean worktree、运行物 identity 要求收敛到 `owner-test` / release evidence。 +- path-filter 使用 job 级 `if`,workflow 本身仍创建并返回明确状态;不要用会让 required workflow 长期 Pending 的整工作流 path skip。 +- 任何会导致数据覆盖、重复执行、外部发送/提交、破坏性删除或效果状态未知的检查仍是 hard gate,不能因为“快速开发”而降级。 + ## 9. 外部发布阻塞项 代码签名证书(Windows / macOS)、升级路径(需要上一版安装包)、Tauri updater、干净系统上的陌生用户人工验收。实时状态以 `KNOWN_ISSUES.md` 为准。 diff --git a/frontend/src/components/workbench/DecisionPlanView.tsx b/frontend/src/components/workbench/DecisionPlanView.tsx index bb682e67..7798ba6b 100644 --- a/frontend/src/components/workbench/DecisionPlanView.tsx +++ b/frontend/src/components/workbench/DecisionPlanView.tsx @@ -51,6 +51,17 @@ const TONE_CHIP: Record = { muted: "border-[var(--border)] text-[var(--foreground-muted)]", }; +const REVIEW_WARNING_LABELS: Record = { + missing_before: "缺少 Before", + missing_after: "缺少 After", + missing_why: "缺少修改理由", + missing_evidence: "缺少证据说明", + missing_current_source: "未绑定来源版本", + missing_scope: "未标注作用范围", + input_request_not_bound_to_group: "Ask 未绑定独立问题卡", + auto_fallback_requires_confirmation: "自动执行无人接管,已降级为手动确认", +}; + function StatusChip({ status, labels }: { status: string; labels: Record }) { const label = labels[status] ?? status; return ( @@ -304,6 +315,20 @@ function DecisionGroupCard({ + {group.reviewability?.status === "ready" && group.reviewability.reason_codes?.length > 0 && ( +
+

信息提示,不影响确认

+

+ {group.reviewability.reason_codes + .map((code) => REVIEW_WARNING_LABELS[code] ?? code) + .join(" · ")} +

+
+ )} + {!digestsReady && actionable && (

摘要缺失,无法安全批准此分组。 diff --git a/frontend/src/components/workbench/PlanReviewInbox.test.tsx b/frontend/src/components/workbench/PlanReviewInbox.test.tsx index 999f4c32..ba921b00 100644 --- a/frontend/src/components/workbench/PlanReviewInbox.test.tsx +++ b/frontend/src/components/workbench/PlanReviewInbox.test.tsx @@ -96,6 +96,29 @@ describe("PlanReviewInbox", () => { expect(screen.getByRole("button", { name: "批准:更新项目经历措辞" })).toBeInTheDocument(); }); + it("keeps incomplete review material actionable and shows it as a warning", async () => { + api.decisionPlansPending.mockResolvedValue({ + plans: [plan({ + groups: [group({ + reviewability: { + status: "ready", + reason_codes: ["missing_why", "missing_evidence", "missing_current_source"], + counts_as_user_decision: true, + }, + })], + })], + }); + render(); + const user = userEvent.setup(); + await openInbox(user); + + expect(screen.getByRole("note")).toHaveTextContent("信息提示,不影响确认"); + expect(screen.getByRole("note")).toHaveTextContent("缺少修改理由"); + expect(screen.getByRole("note")).toHaveTextContent("缺少证据说明"); + expect(screen.getByRole("note")).toHaveTextContent("未绑定来源版本"); + expect(screen.getByRole("button", { name: "批准:更新项目经历措辞" })).toBeEnabled(); + }); + it("sends plan and group digests with a client decision_id on approve", async () => { api.decisionPlansPending.mockResolvedValue({ plans: [plan()] }); api.decideDecisionGroup.mockResolvedValue({ From 991ecba73f48412a617e3de10b40f2e145e6504f Mon Sep 17 00:00:00 2001 From: avabbbb <89175608+avabbbb@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:05:53 +0800 Subject: [PATCH 2/3] docs: make non-safety review gaps advisory --- docs/02-interaction-design.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/02-interaction-design.md b/docs/02-interaction-design.md index 84b94803..a8168fc8 100644 --- a/docs/02-interaction-design.md +++ b/docs/02-interaction-design.md @@ -42,7 +42,7 @@ | **已完成 / 失败(可重试)** | completed、failed、cancelled、system_blocked(附原因和一个下一步按钮) | 否 | - **S-1** 「待处理」数字只统计用户真正需要做决定的事项。 -- **S-2** 不完整的审阅包(缺少 Before/After、理由、证据、来源或当前版本)属于系统要修复的问题,**不能**进入用户收件箱。 +- **S-2** 审阅包缺少 Before/After、理由、证据或来源标记时,界面必须把缺口显示为提醒,但这些**质量提醒不能禁用确认或把用户困在系统恢复态**。系统应尽量自动补齐;真正的硬阻断只用于 stale / digest 或 revision 不一致 / 执行结果未知 / 越权或泄密 / 未明确授权的破坏性与外部副作用。 - **S-3** 超过 2 秒的任务必须显示:进度或阶段、取消按钮、失败原因。 - **S-4** 失败必须可见、可重试,并带 `error_id`;绝不能伪装成成功。 @@ -72,6 +72,7 @@ | `AUTHORIZE` | **确认执行** / 取消 | 强调色、二次确认,写清「会发生什么、不会发生什么」 | - **修改 ≠ 拒绝。** 修改保留同一任务的身份,用自然语言反馈后由 Agent 重新生成修订版;只重新打开受影响的那部分决定,已经采用的部分不再重复问。 +- **Prepare 不得成为孤儿状态。** 可逆准备默认仍可以 AUTO;但一旦它已经被物化成 pending Proposal / DecisionGroup,就必须存在可见执行出口。没有自动执行器接管时,界面允许用户直接确认或拒绝,不得因为 `risk=prepare`、缺 Before/After/理由/证据等质量字段而永久等待。 - **内部分组不等于用户决定。** 事务、回滚、依赖、审计都可以拆分,但一次「按产品型定位改写简历」只算用户的一个决定。 - **单个岗位的简历定制**:只问真正没决定的事,给出一个完整可审阅的版本,最终采用决定 ≤ 1 次(除非确实存在独立的高风险选择)。一次定制让用户审 50–80 个独立 Proposal,直接判定为 **UX 不合格**。 @@ -93,6 +94,7 @@ Ask 支持 `selection_mode: single | multiple`、推荐项、可跳过、自由 | **D-6** | Before/After 由系统根据当前状态和提案状态生成,**永远不要求用户自己提供 diff** 才能解锁审批。 | | **D-7** | 任何确认弹窗都必须能关闭(Esc 或取消),关闭后回到之前的状态,不丢失已输入的内容。 | | **D-8** | 同一个对象只能有一个写入者。两个面板同时编辑同一份简历时,后保存的一方看到冲突提示,而不是静默覆盖。 | +| **D-9** | Before/After、理由、证据、来源标记等审阅质量字段缺失时只能提示,不能把确认按钮变灰或让 Proposal 无出口。硬阻断只允许用于 stale / digest 或 revision mismatch / unknown effect / 越权或泄密 / 未明确授权的破坏性与外部副作用。 | ## 6. 不硬编码规则 @@ -173,7 +175,7 @@ Ask 支持 `selection_mode: single | multiple`、推荐项、可跳过、自由 - 任务进行中,聊天输入始终可用;导航从不隐式中止 Run; - 重启后不会强制打开旧的待办; - 可见的待处理数 = 真正需要人做的决定数; -- 进入用户收件箱的不完整审阅包 = 0;需要用户自己提供 Before/After 的次数 = 0; +- 审阅信息缺口必须可见但不能造成死锁;需要用户自己提供 Before/After 才能解锁确认的次数 = 0; - 正常旅程中手动选择 Skill 的次数 = 0;普通研究、草稿、产物保存需要的审批次数 = 0; - 能在同一个任务上用自然语言提出修改; - 简历最终采用决定 ≤ 1 次(存在独立的高风险选择时除外); From 2596da0a6651e13f11ced8787005eb4f805fdee8 Mon Sep 17 00:00:00 2001 From: avabbbb <89175608+avabbbb@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:07:04 +0800 Subject: [PATCH 3/3] ci: report dependency audits without blocking pull requests --- .github/workflows/build.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index efce8310..1cd034d2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -129,7 +129,7 @@ jobs: run: npm run build - name: Audit production dependencies - if: ${{ github.event_name != 'pull_request' }} + continue-on-error: ${{ github.event_name == 'pull_request' }} working-directory: frontend run: npm audit --omit=dev --audit-level=high @@ -137,7 +137,6 @@ jobs: name: Agent runtime install and audit needs: changes if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.agent_runtime == 'true' || needs.changes.outputs.ci == 'true' }} - continue-on-error: ${{ github.event_name == 'pull_request' }} runs-on: ubuntu-latest steps: @@ -155,6 +154,7 @@ jobs: run: npm ci - name: Audit agent runtime production dependencies + continue-on-error: ${{ github.event_name == 'pull_request' }} working-directory: agent-runtime run: npm audit --omit=dev --audit-level=high @@ -222,7 +222,7 @@ jobs: run: python ../backend/scripts/release/audit_architecture.py --generated-extension-artifacts --json - name: Audit extension production dependencies - if: ${{ github.event_name != 'pull_request' }} + continue-on-error: ${{ github.event_name == 'pull_request' }} working-directory: extension run: npm audit --omit=dev --audit-level=high