From 5e7eba1006d196600474ac3576dcecfbc5909979 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 16:22:47 +0800 Subject: [PATCH 01/13] docs: design CodeGraph freshness hardening --- ...19-codegraph-freshness-hardening-design.md | 407 ++++++++++++++++++ 1 file changed, 407 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-19-codegraph-freshness-hardening-design.md diff --git a/docs/superpowers/specs/2026-08-19-codegraph-freshness-hardening-design.md b/docs/superpowers/specs/2026-08-19-codegraph-freshness-hardening-design.md new file mode 100644 index 0000000..5b0b693 --- /dev/null +++ b/docs/superpowers/specs/2026-08-19-codegraph-freshness-hardening-design.md @@ -0,0 +1,407 @@ +# Polaris CodeGraph Freshness Hardening Design + +## Status + +- Date: 2026-08-19 +- Status: approved for specification +- Scope: Polaris-only CodeGraph query and evidence behavior +- Provider repository changes: prohibited + +## Context + +Polaris already routes workflow-owned CodeGraph evidence through the +project-scoped `polaris_codegraph_explore` proxy. The proxy checks CodeGraph +status, can run one incremental sync, executes one explore query, checks status +again, and places a freshness envelope before graph output. + +The current implementation does not yet meet the desired contract: + +- the caller can set `sync_if_needed: false`, so freshness depends on Agent + behavior rather than the proxy; +- an unreadable or timed-out pre-query status prevents the query, even when the + repository identity is known and stale graph data would still be useful for + navigation; +- response classification is coupled to older CodeGraph warning text and does + not precisely recognize current `indexing in progress`, auto-sync-disabled, + or changed-on-disk notices; +- a broad suspicious-word scan can confuse warning-like words in verbatim source + with CodeGraph response framing; +- the proxy bundle does not explicitly identify the automatic refresh policy + that governed the query. + +CodeGraph is an external Provider. This change must not modify its repository, +commands, MCP tools, watcher, daemon, configuration, or index implementation. + +## Goals + +1. Return graph data that is as fresh as Polaris can obtain with one bounded + incremental reconciliation. +2. Return known-stale graph data when it remains useful, but make its staleness + impossible to miss. +3. Return graph data when freshness cannot be verified, clearly marking it as + unknown and requiring it to be treated as stale. +4. Prevent stale or unverifiable relationships from becoming planning, + implementation, documentation, or Review conclusions without current source + or Git verification. +5. Preserve the optional, non-gating role of Code Intelligence. +6. Preserve all committed Code Intelligence v1, v2, and v3 records unchanged. + +## Non-Goals + +- Changing the CodeGraph repository or asking CodeGraph to add an API. +- Automatically running `codegraph index` or otherwise performing a full + rebuild. Full rebuilds are always initiated by the user. +- Installing, initializing, starting, configuring, or supervising CodeGraph. +- Waiting for a watcher, polling, retrying, or adding a daemon or scheduler. +- Proving that CodeGraph parsing or inferred relationships are semantically + correct. +- Claiming that a result remains current after it has been delivered. +- Making CodeGraph availability or freshness a workflow gate. + +## Decisions + +### Bounded freshness + +The only positive freshness claim is `CURRENT_AT_CHECK`. It means Polaris found +no stale or unverifiable signal during the bounded pre-query, optional-sync, +query, and post-query window. It is not a permanent guarantee and is not a +claim of strict Git-commit equivalence. + +### Automatic incremental reconciliation + +The proxy, not the caller, owns the refresh decision. If the pre-query status +reports any pending added, modified, or removed files, the proxy runs exactly +one bounded `codegraph sync` and then checks status once more. It does this for +every Polaris stage that queries CodeGraph. + +The proxy never runs `codegraph index`. Index states that require or recommend a +full rebuild remain stale and include a user-action reason. + +### Useful stale and unknown output + +A failed, timed-out, or malformed freshness check does not by itself prevent an +explore query when Polaris has independently established the fixed repository +identity and safe paths. The graph result is delivered as `UNKNOWN`, with +`TREAT_AS_STALE` and `NAVIGATION_ONLY` restrictions. + +Known stale signals produce `STALE`. Both `STALE` and `UNKNOWN` results may guide +navigation, but no relationship or conclusion derived from them is usable until +the relevant current source or Git facts have been checked. + +Repository/worktree identity mismatch or unsafe path resolution prevents the +query. Polaris must not deliver another checkout's graph as navigation for the +current checkout. + +## Considered Approaches + +### 1. Harden the existing Polaris proxy — selected + +Keep status, optional incremental synchronization, query, post-query status, +classification, evidence, and delivery in one Polaris-owned operation. This is +the only approach that makes the freshness warning mechanically adjacent to the +graph output while leaving CodeGraph unchanged. + +### 2. Mark every result stale + +This is safe but discards useful `CURRENT_AT_CHECK` evidence and does not satisfy +the goal of obtaining the freshest practical data. + +### 3. Use separate status and raw CodeGraph calls + +This requires the Agent to preserve the association between two independent +tool calls. It can omit or overlook the warning, and it leaves a wider race +between the status observation and delivered graph content. + +## Architecture + +`polaris_codegraph_explore` remains the only CodeGraph path that can create +Polaris Code Intelligence evidence. Raw CodeGraph MCP and shell commands remain +available outside that evidence path and are always unverified for Polaris. + +The project-scoped MCP server fixes the repository root at launch. A tool call +provides task, stage, sequential query ID, purpose, and query, but no repository +path and no refresh-policy switch. The proxy performs all operations with that +fixed repository as the working directory. + +The components retain narrow responsibilities: + +- `codegraph_adapter.py` invokes and normalizes CodeGraph CLI status, sync, and + explore operations and classifies Provider response framing. +- `code_intelligence_proxy.py` validates Polaris stage context, owns the bounded + query window, merges observations, persists immutable runtime evidence, and + renders the freshness envelope. +- `code_intelligence_mcp.py` exposes the single project-scoped MCP tool and + guarantees that the envelope precedes graph content. +- `code_intelligence_protocol.py` validates and projects proxy evidence into the + existing Code Intelligence record. +- the Code Intelligence Skill performs required current-source or Git fallback + before using stale or unknown graph conclusions. + +## Query Flow + +1. Validate protocol compatibility, policy, fixed repository root, task and + stage context, query ID, purpose, and evidence-path confinement. +2. Verify that `.codegraph/` and the CodeGraph CLI are available. +3. Execute `codegraph status --json` in the fixed repository. +4. If the status proves a repository/worktree identity mismatch, stop without + querying and return no graph content. +5. If pending changes are known, execute exactly one incremental + `codegraph sync`, then execute one post-sync status check. +6. Do not sync merely because the index is partial, failed, built with an older + extraction version, or has another index-wide stale reason. Those conditions + cannot be repaired reliably by pretending an incremental sync is a rebuild. + If pending changes coexist with an index-wide stale reason, still perform the + one incremental sync for those changes while retaining any reason that + remains after the post-sync check. +7. If status is unreadable or unavailable for a verification reason, retain the + failure observation and continue. Missing Provider capability or an unsafe + identity/path remains a no-query condition. +8. Execute one bounded `codegraph explore`. Do not retry. +9. Hash and persist an exact UTF-8 response only under the task's ignored runtime + evidence directory. Reject overwrite or digest mismatch. +10. Classify only CodeGraph response framing and metadata notices. +11. Execute one post-query status check whenever an explore response was + obtained. +12. Merge all observations conservatively and persist the immutable proxy + bundle. +13. Return the freshness envelope as the first MCP content block. Return raw + graph output, when safe and available, only in a later block. +14. Require and record the source/Git fallback for every `STALE` or `UNKNOWN` + result before projecting or using its conclusions. + +There is no wait, poll, query retry, sync retry, full rebuild, or raw-MCP +substitution. + +## Delivery States + +### `CURRENT_AT_CHECK` + +All of the following are required: + +- the effective pre-query status is structurally valid and belongs to the fixed + repository; +- pending added, modified, and removed counts are zero after any allowed sync; +- there is no worktree mismatch, partial/indexing/failed index, pending + resolution work, or reindex recommendation; +- explore succeeds and response framing carries no stale or unverifiable + signal; +- post-query status is structurally valid, belongs to the same repository, and + has zero pending changes and no unhealthy index signal. + +Usage is `NON_AUTHORITATIVE_CONTEXT`. Source, Git, builds, tests, Review, +Validation, and Human decisions remain authoritative. + +### `STALE` + +At least one known stale signal exists, such as: + +- pending changes remain before or after the query; +- the one allowed sync fails; +- CodeGraph reports pending sync, indexing in progress, changed-on-disk source, + or disabled auto-sync; +- the index is partial, indexing, failed, has pending resolution work, or + recommends a rebuild; +- a query-time or post-query observation proves the index changed during the + window. + +Graph output is returned when safe. Usage is `NAVIGATION_ONLY`, and the envelope +contains the exact known reasons and required fallback. + +### `UNKNOWN` + +Freshness cannot be established, including status timeout, malformed status, +unrecognized Provider freshness framing, post-query verification failure, or +response-integrity uncertainty. + +Graph output is still returned when repository identity, path confinement, and +response integrity are safe enough to deliver it. The envelope includes +`freshness: TREAT_AS_STALE`, usage is `NAVIGATION_ONLY`, and current-source or +Git fallback is mandatory. + +If a known stale signal and a verification failure coexist, the delivery must +retain both. The top-level state is `STALE` because known staleness must remain +explicit; the verification failure is an additional reason and cannot promote +the result. + +### `UNAVAILABLE` + +No CodeGraph data is available because policy disables it, `.codegraph/` is +absent, or the CLI is missing, so no Provider query can be attempted. Polaris +continues with source and Git. If explore is attempted but fails, the result is +instead `UNKNOWN` with no graph content because Polaris observed a verification +failure rather than Provider absence. + +An identity mismatch is represented as an unverifiable no-graph result rather +than Provider absence, so diagnostics preserve the security-relevant reason. + +## Response Classification + +CodeGraph returns human-readable text rather than a versioned structured +freshness object. Polaris therefore maintains a conservative compatibility +adapter without modifying CodeGraph. + +The classifier recognizes current documented framing for: + +- referenced files pending sync; +- referenced files whose indexing is in progress; +- pending files elsewhere in the project; +- auto-sync disabled or watcher degradation; +- files changed on disk after their last index sync; +- worktree/index-root mismatch. + +Classification is framing-aware. It examines only leading notices, recognized +file-section metadata, and recognized trailing notices. It must not search +verbatim source bodies for generic words such as `stale`, `warning`, or +`pending`, because those words can be legitimate program text. + +Exact recognized notices create precise file- or index-scoped stale points. A +new or malformed warning-like notice in a framing position produces `UNKNOWN`. +Ordinary source text cannot create a freshness downgrade. Status JSON remains +the primary machine-readable freshness basis; response parsing is an additional +race and degradation signal. + +## Freshness Envelope + +Every successful proxy tool result starts with a finite block similar to: + +```text +[POLARIS_CODEGRAPH_FRESHNESS] +state: UNKNOWN +record_status: NOT_VERIFIED +freshness: TREAT_AS_STALE +reason: PRE_STATUS_TIMEOUT +checked_at: 2026-08-19T00:00:00Z +pending_added: 0 +pending_modified: 0 +pending_removed: 0 +usage: NAVIGATION_ONLY +required_fallback: SEARCH_SOURCE +evidence_bundle: runtime/code-intelligence/CIQ-001.json +[/POLARIS_CODEGRAPH_FRESHNESS] +``` + +The envelope is always the first content block. No stdout, stderr, diagnostic, +or graph bytes may precede it. Raw graph output, if retained, is a separate later +content block. + +## Source and Git Fallback + +`STALE` and `UNKNOWN` evidence cannot support a workflow conclusion until the +required fallback is complete: + +- for a safe named current regular file, read it and record `READ_SOURCE` with + its current SHA-256; +- for a safe missing or deleted file, inspect the registered subject's Git diff + and record `INSPECT_GIT_DIFF` with the bound base, head, and diff hashes; +- for an unsafe, index-wide, or unknown point, perform a bounded repository + search and record `SEARCH_SOURCE` with zero to 100 confined current regular + files and their SHA-256 values. + +An old graph relationship can choose where to look. Only the resulting current +source or Git fact can support a plan, edit, documentation conclusion, or Review +verdict. + +## Interface and Versioning + +- Polaris protocol/package version advances from `0.1.21` to `0.1.22`. +- Workflow remains `0.1.3`; no workflow node, edge, status, or transition gate + changes. +- `polaris_codegraph_explore` removes the public `sync_if_needed` argument. The + proxy always owns the incremental-sync decision. +- New runtime proxy evidence uses `bundle_version: 2` and records the automatic + refresh policy. +- Bundle v1 remains readable for an interrupted pre-upgrade task, but new calls + never write it. +- New durable Code Intelligence records remain `record_version: 3`; the current + record already represents pre/post status, sync, query, delivery state, + reasons, and source fallback. +- Existing v1, v2, and v3 durable records remain immutable and valid. +- The adjacent migration updates vendored protocol files, host MCP definitions, + Skills, and validators without rewriting Code Intelligence records or + changing workflow state. + +## Failure Handling + +| Condition | Query? | Delivery | Required action | +|---|---:|---|---| +| Clean pre/post status | Yes | `CURRENT_AT_CHECK` | None beyond normal authority checks | +| Pending, sync succeeds, post-sync clean | Yes | Eligible for `CURRENT_AT_CHECK` | None beyond normal authority checks | +| Pending, sync fails | Yes | `STALE` | Source/Git fallback | +| Pending remains after sync | Yes | `STALE` | Source/Git fallback | +| Index partial/failed/rebuild recommended | Yes | `STALE` | Source/Git fallback; user may rebuild | +| Pre-status timeout/malformed | Yes | `UNKNOWN` | Treat as stale; source/Git fallback | +| Post-status timeout/malformed | Yes | `UNKNOWN`, unless known stale also exists | Treat as stale; source/Git fallback | +| Unknown response-framing warning | Yes, already completed | `UNKNOWN` | Treat as stale; source/Git fallback | +| Repository/worktree identity mismatch | No | `UNKNOWN`, no graph | Source/Git fallback | +| Unsafe response path or digest mismatch | No deliverable graph | `UNKNOWN` | Source/Git fallback | +| Policy disabled, marker absent, or CLI absent | No | `UNAVAILABLE` | Use source/Git | +| Explore failure | Attempted | `UNKNOWN`, no graph | Treat as stale; use source/Git | + +No failure path invokes `codegraph index`. + +## Implementation Scope + +Expected Polaris files include: + +- `scripts/internal/codegraph_adapter.py` +- `scripts/internal/code_intelligence_proxy.py` +- `scripts/internal/code_intelligence_protocol.py` +- `scripts/code_intelligence_mcp.py` +- Code Intelligence schemas and runtime bundle validation as needed +- `skills/code-intelligence/SKILL.md` and stage Skills that call it +- host-rendered/vendored instructions and templates +- `plan.md`, README files, and usage documentation +- protocol version and adjacent migration metadata +- `tests/test_codegraph.py` and relevant core/vendoring tests + +The CodeGraph repository is outside implementation scope and must remain +unchanged. + +## Testing + +Deterministic tests must cover: + +1. clean pre/post observations produce `CURRENT_AT_CHECK`; +2. pending changes cause exactly one incremental sync for every querying stage; +3. a successful sync followed by clean status can produce `CURRENT_AT_CHECK`; +4. sync failure or remaining pending changes still runs explore and produces + `STALE`; +5. unreadable, malformed, failed, or timed-out pre-status still runs explore and + produces `UNKNOWN`; +6. repository/worktree mismatch prevents explore; +7. pending changes first observed after explore downgrade the result; +8. current CodeGraph pending, indexing, degraded/disabled, changed-on-disk, and + mismatch notices classify correctly; +9. warning-like words inside returned source do not affect classification; +10. an unknown warning in a framing position produces `UNKNOWN`; +11. the envelope is always the first content block and graph output is never + delivered before it; +12. stale and unknown records without the exact required source/Git fallback are + rejected; +13. the MCP schema has no caller-controlled sync bypass; +14. command-runner tests prove no proxy branch can invoke `codegraph index`; +15. bundle v2 is validated and bundle v1 remains readable for interrupted + upgrade recovery; +16. committed Code Intelligence v1, v2, and v3 records remain byte-identical and + valid; +17. Windows paths and CRLF, macOS, and Linux behavior are covered without + platform-specific assumptions; +18. the complete Polaris suite passes without CodeGraph installed; +19. an optional real-CLI smoke test uses only a disposable temporary repository. + +## Acceptance Criteria + +1. Every Polaris-delivered graph response is preceded by a machine-readable and + human-visible freshness envelope. +2. A pending change always triggers at most one automatic incremental sync and + can never trigger a full rebuild. +3. No caller can disable the automatic incremental-sync policy. +4. A freshness-check failure still permits safe graph delivery as `UNKNOWN`. +5. A known stale signal is always visible as `STALE`, even when other + verification failures coexist. +6. Only a fully clean bounded window can produce `CURRENT_AT_CHECK`. +7. `STALE` and `UNKNOWN` graph data is navigation-only until current source or + Git fallback is recorded. +8. Repository/worktree mismatch and unsafe paths never deliver graph content. +9. Existing durable Code Intelligence records remain unchanged and valid. +10. No file in the CodeGraph repository is modified. From ab277962517d498e164d1bef2819c46dffc95f00 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 16:56:04 +0800 Subject: [PATCH 02/13] docs: translate CodeGraph freshness design --- ...19-codegraph-freshness-hardening-design.md | 572 +++++++----------- 1 file changed, 224 insertions(+), 348 deletions(-) diff --git a/docs/superpowers/specs/2026-08-19-codegraph-freshness-hardening-design.md b/docs/superpowers/specs/2026-08-19-codegraph-freshness-hardening-design.md index 5b0b693..89da91f 100644 --- a/docs/superpowers/specs/2026-08-19-codegraph-freshness-hardening-design.md +++ b/docs/superpowers/specs/2026-08-19-codegraph-freshness-hardening-design.md @@ -1,268 +1,174 @@ -# Polaris CodeGraph Freshness Hardening Design - -## Status - -- Date: 2026-08-19 -- Status: approved for specification -- Scope: Polaris-only CodeGraph query and evidence behavior -- Provider repository changes: prohibited +# Polaris CodeGraph 新鲜度加固设计 -## Context +## 状态 -Polaris already routes workflow-owned CodeGraph evidence through the -project-scoped `polaris_codegraph_explore` proxy. The proxy checks CodeGraph -status, can run one incremental sync, executes one explore query, checks status -again, and places a freshness envelope before graph output. +- 日期:2026-08-19 +- 状态:设计已确认,等待书面规格审阅 +- 范围:仅修改 Polaris 的 CodeGraph 查询与证据行为 +- CodeGraph 仓库:禁止修改 -The current implementation does not yet meet the desired contract: +## 背景 -- the caller can set `sync_if_needed: false`, so freshness depends on Agent - behavior rather than the proxy; -- an unreadable or timed-out pre-query status prevents the query, even when the - repository identity is known and stale graph data would still be useful for - navigation; -- response classification is coupled to older CodeGraph warning text and does - not precisely recognize current `indexing in progress`, auto-sync-disabled, - or changed-on-disk notices; -- a broad suspicious-word scan can confuse warning-like words in verbatim source - with CodeGraph response framing; -- the proxy bundle does not explicitly identify the automatic refresh policy - that governed the query. +Polaris 已经通过项目级 `polaris_codegraph_explore` 代理承载 Workflow 内的 CodeGraph 证据。代理会检查 CodeGraph 状态、按条件执行一次增量同步、执行一次 explore 查询、再次检查状态,并把新鲜度 envelope 放在图输出之前。 -CodeGraph is an external Provider. This change must not modify its repository, -commands, MCP tools, watcher, daemon, configuration, or index implementation. +当前实现仍未完全满足目标契约: -## Goals - -1. Return graph data that is as fresh as Polaris can obtain with one bounded - incremental reconciliation. -2. Return known-stale graph data when it remains useful, but make its staleness - impossible to miss. -3. Return graph data when freshness cannot be verified, clearly marking it as - unknown and requiring it to be treated as stale. -4. Prevent stale or unverifiable relationships from becoming planning, - implementation, documentation, or Review conclusions without current source - or Git verification. -5. Preserve the optional, non-gating role of Code Intelligence. -6. Preserve all committed Code Intelligence v1, v2, and v3 records unchanged. - -## Non-Goals - -- Changing the CodeGraph repository or asking CodeGraph to add an API. -- Automatically running `codegraph index` or otherwise performing a full - rebuild. Full rebuilds are always initiated by the user. -- Installing, initializing, starting, configuring, or supervising CodeGraph. -- Waiting for a watcher, polling, retrying, or adding a daemon or scheduler. -- Proving that CodeGraph parsing or inferred relationships are semantically - correct. -- Claiming that a result remains current after it has been delivered. -- Making CodeGraph availability or freshness a workflow gate. - -## Decisions - -### Bounded freshness - -The only positive freshness claim is `CURRENT_AT_CHECK`. It means Polaris found -no stale or unverifiable signal during the bounded pre-query, optional-sync, -query, and post-query window. It is not a permanent guarantee and is not a -claim of strict Git-commit equivalence. - -### Automatic incremental reconciliation - -The proxy, not the caller, owns the refresh decision. If the pre-query status -reports any pending added, modified, or removed files, the proxy runs exactly -one bounded `codegraph sync` and then checks status once more. It does this for -every Polaris stage that queries CodeGraph. - -The proxy never runs `codegraph index`. Index states that require or recommend a -full rebuild remain stale and include a user-action reason. - -### Useful stale and unknown output - -A failed, timed-out, or malformed freshness check does not by itself prevent an -explore query when Polaris has independently established the fixed repository -identity and safe paths. The graph result is delivered as `UNKNOWN`, with -`TREAT_AS_STALE` and `NAVIGATION_ONLY` restrictions. - -Known stale signals produce `STALE`. Both `STALE` and `UNKNOWN` results may guide -navigation, but no relationship or conclusion derived from them is usable until -the relevant current source or Git facts have been checked. - -Repository/worktree identity mismatch or unsafe path resolution prevents the -query. Polaris must not deliver another checkout's graph as navigation for the -current checkout. - -## Considered Approaches - -### 1. Harden the existing Polaris proxy — selected - -Keep status, optional incremental synchronization, query, post-query status, -classification, evidence, and delivery in one Polaris-owned operation. This is -the only approach that makes the freshness warning mechanically adjacent to the -graph output while leaving CodeGraph unchanged. - -### 2. Mark every result stale - -This is safe but discards useful `CURRENT_AT_CHECK` evidence and does not satisfy -the goal of obtaining the freshest practical data. - -### 3. Use separate status and raw CodeGraph calls - -This requires the Agent to preserve the association between two independent -tool calls. It can omit or overlook the warning, and it leaves a wider race -between the status observation and delivered graph content. - -## Architecture - -`polaris_codegraph_explore` remains the only CodeGraph path that can create -Polaris Code Intelligence evidence. Raw CodeGraph MCP and shell commands remain -available outside that evidence path and are always unverified for Polaris. - -The project-scoped MCP server fixes the repository root at launch. A tool call -provides task, stage, sequential query ID, purpose, and query, but no repository -path and no refresh-policy switch. The proxy performs all operations with that -fixed repository as the working directory. - -The components retain narrow responsibilities: - -- `codegraph_adapter.py` invokes and normalizes CodeGraph CLI status, sync, and - explore operations and classifies Provider response framing. -- `code_intelligence_proxy.py` validates Polaris stage context, owns the bounded - query window, merges observations, persists immutable runtime evidence, and - renders the freshness envelope. -- `code_intelligence_mcp.py` exposes the single project-scoped MCP tool and - guarantees that the envelope precedes graph content. -- `code_intelligence_protocol.py` validates and projects proxy evidence into the - existing Code Intelligence record. -- the Code Intelligence Skill performs required current-source or Git fallback - before using stale or unknown graph conclusions. - -## Query Flow - -1. Validate protocol compatibility, policy, fixed repository root, task and - stage context, query ID, purpose, and evidence-path confinement. -2. Verify that `.codegraph/` and the CodeGraph CLI are available. -3. Execute `codegraph status --json` in the fixed repository. -4. If the status proves a repository/worktree identity mismatch, stop without - querying and return no graph content. -5. If pending changes are known, execute exactly one incremental - `codegraph sync`, then execute one post-sync status check. -6. Do not sync merely because the index is partial, failed, built with an older - extraction version, or has another index-wide stale reason. Those conditions - cannot be repaired reliably by pretending an incremental sync is a rebuild. - If pending changes coexist with an index-wide stale reason, still perform the - one incremental sync for those changes while retaining any reason that - remains after the post-sync check. -7. If status is unreadable or unavailable for a verification reason, retain the - failure observation and continue. Missing Provider capability or an unsafe - identity/path remains a no-query condition. -8. Execute one bounded `codegraph explore`. Do not retry. -9. Hash and persist an exact UTF-8 response only under the task's ignored runtime - evidence directory. Reject overwrite or digest mismatch. -10. Classify only CodeGraph response framing and metadata notices. -11. Execute one post-query status check whenever an explore response was - obtained. -12. Merge all observations conservatively and persist the immutable proxy - bundle. -13. Return the freshness envelope as the first MCP content block. Return raw - graph output, when safe and available, only in a later block. -14. Require and record the source/Git fallback for every `STALE` or `UNKNOWN` - result before projecting or using its conclusions. - -There is no wait, poll, query retry, sync retry, full rebuild, or raw-MCP -substitution. - -## Delivery States +- 调用方可以传入 `sync_if_needed: false`,导致是否争取最新数据取决于 Agent 行为,而不是代理协议; +- 查询前状态超时、损坏或不可读时,代理直接放弃查询;即使仓库身份明确、旧图仍可用于导航,也拿不到图数据; +- 响应分类器绑定了旧版 CodeGraph 的警告文案,无法精确识别当前的 `indexing in progress`、auto-sync disabled 和 changed-on-disk 等提示; +- 当前对可疑词的宽泛扫描可能把返回源码正文中的 `stale`、`warning` 等普通文本误判为 CodeGraph 新鲜度警告; +- 代理 bundle 没有明确记录本次查询所遵循的自动刷新策略。 + +CodeGraph 是外部 Provider。本次改动不得修改 CodeGraph 仓库、命令、MCP 工具、watcher、daemon、配置或索引实现。 + +## 目标 + +1. 在一次有界增量协调能力内,让 Polaris 尽可能取得最新的图数据。 +2. 旧图仍有导航价值时允许返回,但必须让“数据已过期”这一事实无法被忽略。 +3. 无法验证新鲜度时仍允许返回图数据,但必须明确标为未知,并要求按过期数据处理。 +4. 过期或无法验证的关系在经过当前源码或 Git 核验前,不得成为 Planning、Implementation、Documentation Sync 或 Review 的结论。 +5. Code Intelligence 继续保持可选、非门禁能力。 +6. 已提交的 Code Intelligence v1、v2、v3 record 保持原样。 + +## 非目标 + +- 修改 CodeGraph 仓库,或要求 CodeGraph 新增接口。 +- 自动执行 `codegraph index` 或其他全量重建操作。全量重建始终由用户主动触发。 +- 安装、初始化、启动、配置或监管 CodeGraph。 +- 等待 watcher、轮询、重试,或增加 daemon、scheduler。 +- 证明 CodeGraph 的解析或关系推断在语义上正确。 +- 声称结果交付之后仍会持续保持最新。 +- 把 CodeGraph 的可用性或新鲜度变成 Workflow 门禁。 + +## 已确认决策 + +### 有界新鲜度 + +唯一允许的正向新鲜度声明是 `CURRENT_AT_CHECK`。它表示 Polaris 在查询前、可选增量同步、查询和查询后检查组成的有界窗口内,没有发现过期或不可验证信号。它不是永久保证,也不表示结果与某个 Git commit 严格等价。 + +### 自动增量协调 + +刷新决策归代理所有,不再归调用方所有。查询前状态只要报告任意 pending added、modified 或 removed 文件,代理就在所有会查询 CodeGraph 的 Polaris 阶段中执行且仅执行一次有界 `codegraph sync`,随后再检查一次状态。 + +代理绝不执行 `codegraph index`。需要或建议全量重建的索引状态继续标记为过期,并在原因中明确提示这是用户动作。 + +### 返回有用的旧数据和未知数据 + +状态检查失败、超时或格式损坏,本身不再阻止 explore 查询;前提是 Polaris 已独立确认固定仓库身份和路径安全。图结果以 `UNKNOWN` 交付,并带有 `TREAT_AS_STALE` 与 `NAVIGATION_ONLY` 限制。 + +已知的过期信号产生 `STALE`。`STALE` 和 `UNKNOWN` 都可以指引导航,但从中得到的关系或结论必须先由当前源码或 Git 事实核验,才可用于 Workflow。 + +仓库或 worktree 身份不匹配、路径不安全时禁止查询。Polaris 不得把另一个 checkout 的图当作当前 checkout 的导航数据。 + +## 备选方案 + +### 方案一:加固现有 Polaris 代理——采用 + +把状态检查、可选增量同步、查询、查询后检查、响应分类、证据和交付继续收敛在一个 Polaris 自有操作内。它能在不修改 CodeGraph 的前提下,机械保证新鲜度警告与图输出相邻交付。 + +### 方案二:所有结果一律标为过期 + +这个方案安全但会丢弃本可证明的 `CURRENT_AT_CHECK`,也不符合“尽可能拿到新鲜数据”的目标。 + +### 方案三:先独立检查状态,再调用原始 CodeGraph 工具 + +这要求 Agent 自行维护两个工具调用之间的关联。Agent 可能漏掉警告,而且状态检查与图交付之间的竞态窗口更大。 + +## 架构 + +`polaris_codegraph_explore` 继续作为唯一能够生成 Polaris Code Intelligence 证据的 CodeGraph 路径。原始 CodeGraph MCP 和 shell 命令仍可在该证据路径之外使用,但它们对 Polaris 而言始终属于未验证数据。 + +项目级 MCP server 在启动时固定仓库根。工具调用只提供 task、stage、顺序 query ID、purpose 和 query,不提供仓库路径,也不提供刷新策略开关。代理的所有操作都以该固定仓库为工作目录。 + +各组件继续保持单一职责: + +- `codegraph_adapter.py`:调用并标准化 CodeGraph CLI 的 status、sync、explore,以及分类 Provider 响应框架; +- `code_intelligence_proxy.py`:校验 Polaris 阶段上下文,控制有界查询窗口,合并观察结果,持久化不可变 runtime 证据,并渲染新鲜度 envelope; +- `code_intelligence_mcp.py`:暴露唯一的项目级 MCP 工具,并保证 envelope 先于图内容; +- `code_intelligence_protocol.py`:校验代理证据,并投影为现有 Code Intelligence record; +- Code Intelligence Skill:在使用过期或未知图结论之前,完成所需的当前源码或 Git 回退核验。 + +## 查询流程 + +1. 校验协议兼容性、策略、固定仓库根、task/stage 上下文、query ID、purpose 和 evidence 路径边界。 +2. 验证 `.codegraph/` 与 CodeGraph CLI 是否可用。 +3. 在固定仓库内执行 `codegraph status --json`。 +4. 如果状态证明仓库或 worktree 身份不匹配,停止且不执行查询,不返回图内容。 +5. 如果已知存在 pending changes,执行且仅执行一次增量 `codegraph sync`,然后执行一次同步后状态检查。 +6. 不因为索引处于 partial、failed、由旧 extraction version 构建或存在其他索引级过期原因而单独触发 sync;这些情况不能通过假装增量同步等同于全量重建来可靠修复。如果 pending changes 与索引级过期原因同时存在,仍对这些变更执行一次增量同步,但同步后仍存在的索引级原因必须保留。 +7. 如果状态因为验证错误而不可读或不可确认,保留失败观察并继续查询。Provider 能力缺失、仓库身份不安全或路径不安全仍属于禁止查询条件。 +8. 执行一次有界 `codegraph explore`,不重试。 +9. 仅在 task 的 Git ignored runtime evidence 目录下保存精确 UTF-8 响应及其哈希;已存在目标或摘要不一致时拒绝覆盖。 +10. 只分类 CodeGraph 响应框架和元数据提示。 +11. 只要取得 explore 响应,就执行一次查询后状态检查。 +12. 保守合并全部观察,并保存不可变代理 bundle。 +13. 把 freshness envelope 作为 MCP 的第一个内容块返回;安全且可用的原始图输出只能出现在后续内容块。 +14. 每个 `STALE` 或 `UNKNOWN` 结果都必须完成并记录源码/Git 回退,之后才允许投影或使用其结论。 + +整个流程不等待、不轮询、不重试查询、不重试同步、不全量重建,也不改用原始 MCP 作为替代路径。 + +## 交付状态 ### `CURRENT_AT_CHECK` -All of the following are required: +必须同时满足以下条件: -- the effective pre-query status is structurally valid and belongs to the fixed - repository; -- pending added, modified, and removed counts are zero after any allowed sync; -- there is no worktree mismatch, partial/indexing/failed index, pending - resolution work, or reindex recommendation; -- explore succeeds and response framing carries no stale or unverifiable - signal; -- post-query status is structurally valid, belongs to the same repository, and - has zero pending changes and no unhealthy index signal. +- 有效的查询前状态或同步后状态结构正确,且属于固定仓库; +- 任何允许的同步完成后,pending added、modified、removed 数量均为零; +- 不存在 worktree mismatch、partial/indexing/failed index、pending resolution 或 reindex recommendation; +- explore 成功,响应框架不包含过期或不可验证信号; +- 查询后状态结构正确,属于同一仓库,pending 数量均为零且索引健康。 -Usage is `NON_AUTHORITATIVE_CONTEXT`. Source, Git, builds, tests, Review, -Validation, and Human decisions remain authoritative. +其用途是 `NON_AUTHORITATIVE_CONTEXT`。源码、Git、构建、测试、Review、Validation 和 Human decision 继续拥有权威性。 ### `STALE` -At least one known stale signal exists, such as: +至少存在一个明确的过期信号,例如: -- pending changes remain before or after the query; -- the one allowed sync fails; -- CodeGraph reports pending sync, indexing in progress, changed-on-disk source, - or disabled auto-sync; -- the index is partial, indexing, failed, has pending resolution work, or - recommends a rebuild; -- a query-time or post-query observation proves the index changed during the - window. +- 查询前或查询后仍有 pending changes; +- 唯一一次允许的增量同步失败; +- CodeGraph 报告 pending sync、indexing in progress、changed on disk 或 auto-sync disabled; +- 索引为 partial、indexing、failed,存在 pending resolution,或建议重建; +- 查询期间或查询后观察证明索引在窗口中发生变化。 -Graph output is returned when safe. Usage is `NAVIGATION_ONLY`, and the envelope -contains the exact known reasons and required fallback. +安全时仍返回图输出。用途是 `NAVIGATION_ONLY`,envelope 必须包含已知原因和所需 fallback。 ### `UNKNOWN` -Freshness cannot be established, including status timeout, malformed status, -unrecognized Provider freshness framing, post-query verification failure, or -response-integrity uncertainty. +无法建立新鲜度证明,包括 status 超时、status 格式损坏、无法识别的 Provider freshness framing、查询后验证失败或响应完整性不确定。 -Graph output is still returned when repository identity, path confinement, and -response integrity are safe enough to deliver it. The envelope includes -`freshness: TREAT_AS_STALE`, usage is `NAVIGATION_ONLY`, and current-source or -Git fallback is mandatory. +只要仓库身份、路径边界和响应完整性足以安全交付,仍返回图输出。envelope 必须包含 `freshness: TREAT_AS_STALE`,用途是 `NAVIGATION_ONLY`,并强制执行当前源码或 Git 回退。 -If a known stale signal and a verification failure coexist, the delivery must -retain both. The top-level state is `STALE` because known staleness must remain -explicit; the verification failure is an additional reason and cannot promote -the result. +如果已知过期信号与验证失败同时存在,两者都必须保留。顶层状态使用 `STALE`,因为明确的过期事实不能被 `UNKNOWN` 隐藏;验证失败作为附加原因存在,也不得把结果升级。 ### `UNAVAILABLE` -No CodeGraph data is available because policy disables it, `.codegraph/` is -absent, or the CLI is missing, so no Provider query can be attempted. Polaris -continues with source and Git. If explore is attempted but fails, the result is -instead `UNKNOWN` with no graph content because Polaris observed a verification -failure rather than Provider absence. +策略禁用 Code Intelligence、缺少 `.codegraph/` 或缺少 CLI,导致无法尝试 Provider 查询时,没有 CodeGraph 数据可返回,Polaris 直接使用源码和 Git。 + +如果 explore 已经尝试但失败,状态改为 `UNKNOWN` 且不返回图内容,因为 Polaris 观察到的是验证失败,而不是 Provider 缺失。 -An identity mismatch is represented as an unverifiable no-graph result rather -than Provider absence, so diagnostics preserve the security-relevant reason. +身份不匹配表示“无法验证且禁止交付图”,不是 Provider 缺失,因此必须保留对应的安全诊断原因,不能伪装成普通 `UNAVAILABLE`。 -## Response Classification +## 响应分类 -CodeGraph returns human-readable text rather than a versioned structured -freshness object. Polaris therefore maintains a conservative compatibility -adapter without modifying CodeGraph. +CodeGraph 返回人类可读文本,而不是带版本的结构化 freshness 对象。因此 Polaris 维护一个保守的兼容适配层,但不修改 CodeGraph。 -The classifier recognizes current documented framing for: +分类器识别当前 CodeGraph 的以下响应框架: -- referenced files pending sync; -- referenced files whose indexing is in progress; -- pending files elsewhere in the project; -- auto-sync disabled or watcher degradation; -- files changed on disk after their last index sync; -- worktree/index-root mismatch. +- 响应引用的文件正在等待同步; +- 响应引用的文件正在建立索引; +- 项目内其他文件正在等待同步; +- auto-sync disabled 或 watcher degraded; +- 文件在上次索引同步后已在磁盘上变化; +- worktree 与 index root 不匹配。 -Classification is framing-aware. It examines only leading notices, recognized -file-section metadata, and recognized trailing notices. It must not search -verbatim source bodies for generic words such as `stale`, `warning`, or -`pending`, because those words can be legitimate program text. +分类器必须理解响应结构。它只检查开头提示、已识别的文件区块元数据和已识别的结尾提示,不得在逐字返回的源码正文中搜索 `stale`、`warning`、`pending` 等通用词,因为它们可能只是合法的程序文本。 -Exact recognized notices create precise file- or index-scoped stale points. A -new or malformed warning-like notice in a framing position produces `UNKNOWN`. -Ordinary source text cannot create a freshness downgrade. Status JSON remains -the primary machine-readable freshness basis; response parsing is an additional -race and degradation signal. +精确识别的提示生成文件级或索引级 stale point。响应框架位置出现新的或格式损坏的 warning-like 提示时,结果降级为 `UNKNOWN`。普通源码文本不能触发新鲜度降级。status JSON 是主要的机器可读 freshness 基础;响应解析只承担额外的竞态与降级信号。 -## Freshness Envelope +## 新鲜度 envelope -Every successful proxy tool result starts with a finite block similar to: +每个成功的代理工具结果都必须以一个有限文本块开头,例如: ```text [POLARIS_CODEGRAPH_FRESHNESS] @@ -280,128 +186,98 @@ evidence_bundle: runtime/code-intelligence/CIQ-001.json [/POLARIS_CODEGRAPH_FRESHNESS] ``` -The envelope is always the first content block. No stdout, stderr, diagnostic, -or graph bytes may precede it. Raw graph output, if retained, is a separate later -content block. - -## Source and Git Fallback - -`STALE` and `UNKNOWN` evidence cannot support a workflow conclusion until the -required fallback is complete: - -- for a safe named current regular file, read it and record `READ_SOURCE` with - its current SHA-256; -- for a safe missing or deleted file, inspect the registered subject's Git diff - and record `INSPECT_GIT_DIFF` with the bound base, head, and diff hashes; -- for an unsafe, index-wide, or unknown point, perform a bounded repository - search and record `SEARCH_SOURCE` with zero to 100 confined current regular - files and their SHA-256 values. - -An old graph relationship can choose where to look. Only the resulting current -source or Git fact can support a plan, edit, documentation conclusion, or Review -verdict. - -## Interface and Versioning - -- Polaris protocol/package version advances from `0.1.21` to `0.1.22`. -- Workflow remains `0.1.3`; no workflow node, edge, status, or transition gate - changes. -- `polaris_codegraph_explore` removes the public `sync_if_needed` argument. The - proxy always owns the incremental-sync decision. -- New runtime proxy evidence uses `bundle_version: 2` and records the automatic - refresh policy. -- Bundle v1 remains readable for an interrupted pre-upgrade task, but new calls - never write it. -- New durable Code Intelligence records remain `record_version: 3`; the current - record already represents pre/post status, sync, query, delivery state, - reasons, and source fallback. -- Existing v1, v2, and v3 durable records remain immutable and valid. -- The adjacent migration updates vendored protocol files, host MCP definitions, - Skills, and validators without rewriting Code Intelligence records or - changing workflow state. - -## Failure Handling - -| Condition | Query? | Delivery | Required action | +envelope 永远是第一个内容块。任何 stdout、stderr、诊断或图数据都不得出现在它之前。保留的原始图输出必须位于独立的后续内容块。 + +## 源码与 Git 回退 + +`STALE` 和 `UNKNOWN` 证据在完成所需回退之前,不得支持 Workflow 结论: + +- 对安全、具名且当前存在的普通文件,读取文件并记录 `READ_SOURCE` 与当前 SHA-256; +- 对安全但缺失或已删除的文件,检查注册 subject 的 Git diff,并记录 `INSPECT_GIT_DIFF` 以及绑定的 base、head、diff hash; +- 对不安全、索引级或未知失效点,执行有界仓库搜索,并记录 `SEARCH_SOURCE`;结果为 0 到 100 个位于仓库边界内的当前普通文件及其 SHA-256。 + +旧图关系可以决定“去哪里查”。只有由此得到的当前源码或 Git 事实,才能支持计划、编辑、文档结论或 Review verdict。 + +## 接口与版本 + +- Polaris 协议/包版本从 `0.1.21` 升级到 `0.1.22`。 +- Workflow 保持 `0.1.3`;不改变 Workflow node、edge、status 或 transition gate。 +- `polaris_codegraph_explore` 移除公开的 `sync_if_needed` 参数,增量同步决策始终归代理。 +- 新的 runtime proxy evidence 使用 `bundle_version: 2`,并记录自动刷新策略。 +- bundle v1 继续可读,以恢复升级时尚未完成投影的任务;新调用不再写入 v1。 +- 新的耐久 Code Intelligence record 继续使用 `record_version: 3`;现有格式已经能够表达查询前后状态、sync、query、delivery state、reason 和 source fallback。 +- 已存在的 v1、v2、v3 耐久 record 保持不可变且继续有效。 +- 相邻迁移更新 vendored 协议文件、宿主 MCP 定义、Skills 和 validators,不重写 Code Intelligence record,也不改变 Workflow 状态。 + +## 失败处理 + +| 条件 | 是否查询 | 交付状态 | 必须动作 | |---|---:|---|---| -| Clean pre/post status | Yes | `CURRENT_AT_CHECK` | None beyond normal authority checks | -| Pending, sync succeeds, post-sync clean | Yes | Eligible for `CURRENT_AT_CHECK` | None beyond normal authority checks | -| Pending, sync fails | Yes | `STALE` | Source/Git fallback | -| Pending remains after sync | Yes | `STALE` | Source/Git fallback | -| Index partial/failed/rebuild recommended | Yes | `STALE` | Source/Git fallback; user may rebuild | -| Pre-status timeout/malformed | Yes | `UNKNOWN` | Treat as stale; source/Git fallback | -| Post-status timeout/malformed | Yes | `UNKNOWN`, unless known stale also exists | Treat as stale; source/Git fallback | -| Unknown response-framing warning | Yes, already completed | `UNKNOWN` | Treat as stale; source/Git fallback | -| Repository/worktree identity mismatch | No | `UNKNOWN`, no graph | Source/Git fallback | -| Unsafe response path or digest mismatch | No deliverable graph | `UNKNOWN` | Source/Git fallback | -| Policy disabled, marker absent, or CLI absent | No | `UNAVAILABLE` | Use source/Git | -| Explore failure | Attempted | `UNKNOWN`, no graph | Treat as stale; use source/Git | +| 查询前后状态干净 | 是 | `CURRENT_AT_CHECK` | 继续遵守普通 Authority 规则 | +| 有 pending,同步成功且同步后干净 | 是 | 可成为 `CURRENT_AT_CHECK` | 继续遵守普通 Authority 规则 | +| 有 pending,但同步失败 | 是 | `STALE` | 源码/Git 回退 | +| 同步后仍有 pending | 是 | `STALE` | 源码/Git 回退 | +| 索引 partial/failed/建议重建 | 是 | `STALE` | 源码/Git 回退;用户可主动重建 | +| 查询前 status 超时/损坏 | 是 | `UNKNOWN` | 按过期处理;源码/Git 回退 | +| 查询后 status 超时/损坏 | 是 | `UNKNOWN`;若另有已知过期则为 `STALE` | 按过期处理;源码/Git 回退 | +| 未知响应框架警告 | 查询已完成 | `UNKNOWN` | 按过期处理;源码/Git 回退 | +| 仓库/worktree 身份不匹配 | 否 | `UNKNOWN`,无图 | 源码/Git 回退 | +| 不安全响应路径或摘要不一致 | 不交付图 | `UNKNOWN` | 源码/Git 回退 | +| 策略禁用、缺 marker 或缺 CLI | 否 | `UNAVAILABLE` | 使用源码/Git | +| explore 失败 | 已尝试 | `UNKNOWN`,无图 | 按过期处理;使用源码/Git | -No failure path invokes `codegraph index`. +任何失败路径都不得调用 `codegraph index`。 -## Implementation Scope +## 实现范围 -Expected Polaris files include: +预计涉及的 Polaris 文件包括: - `scripts/internal/codegraph_adapter.py` - `scripts/internal/code_intelligence_proxy.py` - `scripts/internal/code_intelligence_protocol.py` - `scripts/code_intelligence_mcp.py` -- Code Intelligence schemas and runtime bundle validation as needed -- `skills/code-intelligence/SKILL.md` and stage Skills that call it -- host-rendered/vendored instructions and templates -- `plan.md`, README files, and usage documentation -- protocol version and adjacent migration metadata -- `tests/test_codegraph.py` and relevant core/vendoring tests - -The CodeGraph repository is outside implementation scope and must remain -unchanged. - -## Testing - -Deterministic tests must cover: - -1. clean pre/post observations produce `CURRENT_AT_CHECK`; -2. pending changes cause exactly one incremental sync for every querying stage; -3. a successful sync followed by clean status can produce `CURRENT_AT_CHECK`; -4. sync failure or remaining pending changes still runs explore and produces - `STALE`; -5. unreadable, malformed, failed, or timed-out pre-status still runs explore and - produces `UNKNOWN`; -6. repository/worktree mismatch prevents explore; -7. pending changes first observed after explore downgrade the result; -8. current CodeGraph pending, indexing, degraded/disabled, changed-on-disk, and - mismatch notices classify correctly; -9. warning-like words inside returned source do not affect classification; -10. an unknown warning in a framing position produces `UNKNOWN`; -11. the envelope is always the first content block and graph output is never - delivered before it; -12. stale and unknown records without the exact required source/Git fallback are - rejected; -13. the MCP schema has no caller-controlled sync bypass; -14. command-runner tests prove no proxy branch can invoke `codegraph index`; -15. bundle v2 is validated and bundle v1 remains readable for interrupted - upgrade recovery; -16. committed Code Intelligence v1, v2, and v3 records remain byte-identical and - valid; -17. Windows paths and CRLF, macOS, and Linux behavior are covered without - platform-specific assumptions; -18. the complete Polaris suite passes without CodeGraph installed; -19. an optional real-CLI smoke test uses only a disposable temporary repository. - -## Acceptance Criteria - -1. Every Polaris-delivered graph response is preceded by a machine-readable and - human-visible freshness envelope. -2. A pending change always triggers at most one automatic incremental sync and - can never trigger a full rebuild. -3. No caller can disable the automatic incremental-sync policy. -4. A freshness-check failure still permits safe graph delivery as `UNKNOWN`. -5. A known stale signal is always visible as `STALE`, even when other - verification failures coexist. -6. Only a fully clean bounded window can produce `CURRENT_AT_CHECK`. -7. `STALE` and `UNKNOWN` graph data is navigation-only until current source or - Git fallback is recorded. -8. Repository/worktree mismatch and unsafe paths never deliver graph content. -9. Existing durable Code Intelligence records remain unchanged and valid. -10. No file in the CodeGraph repository is modified. +- 必要的 Code Intelligence schema 与 runtime bundle validation +- `skills/code-intelligence/SKILL.md` 及调用它的阶段 Skills +- 宿主渲染/vendoring 指令与模板 +- `plan.md`、README、使用文档 +- 协议版本与相邻迁移元数据 +- `tests/test_codegraph.py` 及相关 core/vendoring 测试 + +CodeGraph 仓库不在实现范围内,必须保持无修改。 + +## 测试 + +确定性测试必须覆盖: + +1. 查询前后观察干净时产生 `CURRENT_AT_CHECK`; +2. 所有查询阶段发现 pending changes 时都恰好执行一次增量同步; +3. 同步成功且状态转为干净后可以产生 `CURRENT_AT_CHECK`; +4. 同步失败或同步后仍 pending 时,仍执行 explore 并产生 `STALE`; +5. 查询前 status 不可读、格式损坏、失败或超时时,仍执行 explore 并产生 `UNKNOWN`; +6. 仓库/worktree 身份不匹配时不执行 explore; +7. explore 后首次出现 pending changes 时降级结果; +8. 当前 CodeGraph 的 pending、indexing、degraded/disabled、changed-on-disk 和 mismatch 提示都能正确分类; +9. 返回源码中的 warning-like 单词不影响分类; +10. 响应框架位置出现未知警告时产生 `UNKNOWN`; +11. envelope 永远是第一个内容块,图输出永远不能出现在它之前; +12. 缺少精确 source/Git fallback 的 stale 或 unknown record 被拒绝; +13. MCP schema 不再提供调用方控制的同步绕过开关; +14. command runner 测试证明任何代理分支都不能调用 `codegraph index`; +15. bundle v2 得到校验,bundle v1 继续支持升级中断恢复; +16. 已提交的 Code Intelligence v1、v2、v3 record 保持字节不变且继续有效; +17. 覆盖 Windows 路径与 CRLF、macOS、Linux 行为,不引入平台特定假设; +18. 未安装 CodeGraph 时,完整 Polaris 测试套件仍通过; +19. 可选真实 CLI smoke test 只使用一次性临时仓库。 + +## 验收标准 + +1. Polaris 交付的每个图响应之前,都有机器可读且人类可见的 freshness envelope。 +2. pending change 始终触发至多一次自动增量同步,且永远不能触发全量重建。 +3. 调用方不能关闭自动增量同步策略。 +4. freshness 检查失败时,仍可把安全图数据作为 `UNKNOWN` 交付。 +5. 已知过期信号始终明确显示为 `STALE`,即使还同时存在其他验证失败。 +6. 只有完整干净的有界窗口才能产生 `CURRENT_AT_CHECK`。 +7. `STALE` 和 `UNKNOWN` 图数据在记录当前源码或 Git 回退前,只能用于导航。 +8. 仓库/worktree 身份不匹配或路径不安全时,永不交付图内容。 +9. 已存在的耐久 Code Intelligence record 保持不变且继续有效。 +10. CodeGraph 仓库中的任何文件都不被修改。 From c731e4a21dc3ef5f82451897f2127b61d6e865a0 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 17:11:25 +0800 Subject: [PATCH 03/13] docs: plan CodeGraph freshness hardening --- ...026-08-19-codegraph-freshness-hardening.md | 852 ++++++++++++++++++ 1 file changed, 852 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-19-codegraph-freshness-hardening.md diff --git a/docs/superpowers/plans/2026-08-19-codegraph-freshness-hardening.md b/docs/superpowers/plans/2026-08-19-codegraph-freshness-hardening.md new file mode 100644 index 0000000..d2544bc --- /dev/null +++ b/docs/superpowers/plans/2026-08-19-codegraph-freshness-hardening.md @@ -0,0 +1,852 @@ +# Polaris CodeGraph 新鲜度加固实施计划 + +> **供 Agent Worker 使用:** 必须使用 `superpowers:subagent-driven-development`(推荐)或 `superpowers:executing-plans`,按任务逐项实施本计划。所有步骤使用 checkbox(`- [ ]`)跟踪。 + +**目标:** 在不修改 CodeGraph 的前提下,让 Polaris 每次查询都自动争取一次增量同步后的最新图数据,并把已知过期或无法验证的数据明确标记为只能导航使用。 + +**架构:** 保留项目级 `polaris_codegraph_explore` 代理,把状态检查、至多一次自动增量同步、一次 explore、查询后复查和新鲜度 envelope 收敛在同一个有界窗口。`codegraph_adapter.py` 只负责外部 CLI 与响应框架兼容,`code_intelligence_proxy.py` 负责窗口编排,MCP 层不再暴露同步开关;新的 bundle v2 记录自动刷新策略,但耐久 record 继续使用 v3。 + +**技术栈:** Python 3.10+ 标准库(`hashlib`、`json`、`re`、`subprocess`、`unittest`)、JSON Schema、JSON-RPC 2.0/MCP stdio 协议 `2025-11-25`、Git。 + +**规格:** `docs/superpowers/specs/2026-08-19-codegraph-freshness-hardening-design.md` + +## 全局约束 + +- 只修改 Polaris 仓库;`/Users/zero/Documents/work/ai/codegraph` 必须始终保持无修改。 +- Polaris 协议/包版本从 `0.1.21` 精确升级到 `0.1.22`。 +- Workflow 版本保持 `0.1.3`,不得改变节点、边、状态或 transition gate。 +- Runtime 除 Python 标准库外不得增加依赖。 +- pending changes 只允许触发一次 `codegraph sync`;任何路径都不得执行 `codegraph index`。 +- `polaris_codegraph_explore` 不再接受 `sync_if_needed`,调用方不能跳过自动增量同步。 +- 查询前状态无法验证但仓库身份安全时仍执行一次 explore,并返回 `UNKNOWN`。 +- 仓库/worktree 身份不匹配或路径不安全时不执行 explore,也不交付图内容。 +- 只有完整干净的有界窗口才能产生 `CURRENT_AT_CHECK`。 +- `STALE` 和 `UNKNOWN` 都是 `NAVIGATION_ONLY`;`UNKNOWN` 必须明确写出 `TREAT_AS_STALE`。 +- 新 runtime evidence 使用 bundle v2;升级中断时仍可读取 bundle v1。 +- 新耐久 Code Intelligence record 继续使用 v3;已提交的 v1、v2、v3 record 不得重写。 +- Validation 继续完全不调用 CodeGraph;没有安装 CodeGraph 时完整测试套件必须通过。 + +--- + +## 文件职责 + +- `scripts/internal/codegraph_adapter.py`:低层 status/sync/explore 调用、状态标准化、CodeGraph 响应框架分类。 +- `scripts/internal/code_intelligence_proxy.py`:Polaris 阶段约束、自动增量同步、查询窗口、bundle v2、状态合并和 envelope。 +- `scripts/code_intelligence_mcp.py`:只处理 MCP 生命周期、输入 schema 与 envelope-first 返回顺序。 +- `scripts/internal/code_intelligence_protocol.py`:读取 bundle v1/v2、校验自动刷新策略、投影现有 record v3。 +- `skills/code-intelligence/SKILL.md`:统一阶段行为;明确 UNKNOWN 继续查询但必须按过期处理。 +- `skills/architecture-planning/SKILL.md`、`skills/implementation/SKILL.md`、`skills/documentation-sync/SKILL.md`、`skills/adversarial-review/SKILL.md`:各阶段只声明查询目的,不再声明同步策略。 +- `templates/AGENTS.md`:vendored 项目共享行为边界。 +- `README.md`、`README.zh-CN.md`、`docs/USAGE.md`、`plan.md`:用户与产品 Authority。 +- `VERSION`、`pyproject.toml`、`templates/project.json`、`templates/task-sources/state.json`、`templates/task/state.json`:协议版本单一事实的各生成/模板表面。 +- `workflow/migrations.json`、`scripts/internal/migration_protocol.py`:显式相邻 `0.1.21 → 0.1.22` 版本迁移,不改变 Workflow。 +- `tests/test_codegraph.py`:适配器、代理、MCP、bundle、record、Skill 和可选真实 CLI 覆盖。 +- `tests/test_core.py`:版本、vendoring、迁移和宿主配置覆盖。 + +--- + +### Task 1:让响应分类器适配当前 CodeGraph,同时忽略源码正文中的警告词 + +**Files:** +- Modify: `scripts/internal/codegraph_adapter.py:24-229` +- Test: `tests/test_codegraph.py:3530-3680` + +**Interfaces:** +- Consumes: `classify_response(repo: Path, response: str, *, checked_at: str | None = None) -> dict[str, Any]`。 +- Produces: 相同签名;返回的 `classification` 仍只允许 `NONE`、`PARTIAL_STALE`、`INDEX_STALE`、`NOT_VERIFIED`,不改变下游类型。 + +- [ ] **Step 1:为当前 CodeGraph 的 framing 写失败测试** + +在 `CodeGraphTests` 中加入: + +```python +def test_current_codegraph_freshness_framing_is_classified(self) -> None: + samples = { + "pending": ( + "⚠️ Some files referenced below were edited since the last index sync — " + "their codegraph entries may be stale:\n" + " - src/a.py (edited 12ms ago, pending sync)\n" + "For accurate content of those specific files, Read them directly. " + "The rest of this response is fresh.\n", + "PARTIAL_STALE", + ), + "indexing": ( + "⚠️ Some files referenced below were edited since the last index sync — " + "their codegraph entries may be stale:\n" + " - src/a.py (edited 12ms ago, indexing in progress)\n" + "For accurate content of those specific files, Read them directly. " + "The rest of this response is fresh.\n", + "PARTIAL_STALE", + ), + "disabled": ( + "⚠️ CodeGraph auto-sync is DISABLED — live file watching stopped, so the " + "index is frozen and any file edited since then is stale here.\n", + "INDEX_STALE", + ), + "drift": ( + "**`src/a.py`** — A(function) · ⚠ changed since last index sync — " + "source below is current; the symbol list may be outdated\n", + "PARTIAL_STALE", + ), + "worktree": ( + "⚠ CodeGraph results below come from a different git worktree " + "(/tmp/main), not where you're working (/tmp/wt) — they may reflect " + "another branch.\n", + "INDEX_STALE", + ), + } + for name, (response, expected) in samples.items(): + with self.subTest(name=name): + self.assertEqual( + self.classify_response(response)["classification"], expected + ) +``` + +- [ ] **Step 2:运行 framing 测试并确认 RED** + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests.test_current_codegraph_freshness_framing_is_classified -v` + +Expected: pending/indexing/disabled/drift/worktree 中至少一个不等于期望状态。 + +- [ ] **Step 3:为源码正文误判写失败测试** + +```python +def test_warning_words_inside_verbatim_source_do_not_change_freshness(self) -> None: + response = ( + "**`src/a.py`** — A(function)\n\n" + "```python\n" + "def A():\n" + " warning = 'stale pending sync out-of-date ⚠'\n" + " return warning\n" + "```\n" + ) + self.assertEqual( + self.classify_response(response)["classification"], "NONE" + ) +``` + +- [ ] **Step 4:运行源码正文测试并确认 RED** + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests.test_warning_words_inside_verbatim_source_do_not_change_freshness -v` + +Expected: 当前全文 `_SUSPICIOUS_FRESHNESS_SIGNAL` 扫描返回 `NOT_VERIFIED`。 + +- [ ] **Step 5:实现 framing-aware 分类** + +把旧版固定文案常量替换为当前 CodeGraph 兼容规则,并增加只返回代码围栏之外文本的帮助函数: + +```python +_PARTIAL_BANNER_HEADER = ( + "⚠️ Some files referenced below were edited since the last index sync — " + "their codegraph entries may be stale:\n" +) +_PARTIAL_BANNER_ROW = re.compile( + r"^ - (?P.+) \(edited [^\n()]+, " + r"(?:pending sync|indexing in progress)\)$" +) +_DISABLED_BANNER_PREFIX = "⚠️ CodeGraph auto-sync is DISABLED —" +_WORKTREE_BANNER_PREFIX = "⚠ CodeGraph results below come from a different git worktree" +_DRIFTED_FILE_HEADER = re.compile( + r"^\*\*`(?P[^`]+)`\*\* — .*⚠ changed (?:since last index sync|on disk after the last index sync)" +) + +def _framing_lines(response: str) -> list[str]: + lines: list[str] = [] + inside_fence = False + for line in response.splitlines(): + if line.startswith("```"): + inside_fence = not inside_fence + continue + if not inside_fence: + lines.append(line) + return lines +``` + +处理顺序固定为:worktree/disabled 顶部提示 → partial 顶部列表 → drifted file header → 已识别尾部提示 → framing 位置的未知 warning-like 文本降级 `NOT_VERIFIED` → `NONE`。文件路径继续通过 `_response_file_point` 做仓库边界和 SHA-256 校验;无法安全拆分的项目级尾部提示生成 `INDEX_STALE + SEARCH_SOURCE`,不猜测文件名。 + +- [ ] **Step 6:运行适配器分类测试** + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests.test_current_codegraph_freshness_framing_is_classified tests.test_codegraph.CodeGraphTests.test_warning_words_inside_verbatim_source_do_not_change_freshness tests.test_codegraph.CodeGraphTests.test_response_banner_marks_only_named_files_stale tests.test_codegraph.CodeGraphTests.test_response_banner_rejects_unsafe_and_symlink_paths tests.test_codegraph.CodeGraphTests.test_response_banner_rejects_unsafe_windows_style_paths -v` + +Expected: all PASS。 + +- [ ] **Step 7:提交 Task 1** + +```bash +git add scripts/internal/codegraph_adapter.py tests/test_codegraph.py +git commit -m "fix: classify current CodeGraph freshness framing" +``` + +--- + +### Task 2:把自动增量同步、UNKNOWN 查询和 bundle v2 固化到代理契约 + +**Files:** +- Modify: `scripts/internal/code_intelligence_proxy.py:200-622` +- Modify: `scripts/code_intelligence_mcp.py:24-190` +- Modify: `scripts/internal/code_intelligence_protocol.py:1200-1360` +- Test: `tests/test_codegraph.py:320-1565` + +**Interfaces:** +- Consumes: Task 1 的 `classify_response`,以及现有 `inspect_status`、`synchronize_observed_status`、`run_explore`。 +- Produces: `execute_proxy_query(repo, task_id, stage, query_id, purpose, query, *, runner=subprocess.run) -> dict[str, Any]`,不再接受同步布尔值。 +- Produces: bundle v2 顶层 `refresh_policy`,值固定为 `AUTO_INCREMENTAL_ON_PENDING`。 +- Produces: MCP `polaris_codegraph_explore` 输入只包含 task/stage/query ID/purpose/query。 + +- [ ] **Step 1:写自动同步且无法绕过的失败测试** + +```python +def test_proxy_automatically_syncs_pending_without_a_caller_switch(self) -> None: + self.qualify_task() + (self.repo / ".codegraph").mkdir() + pending = json.loads(healthy_status(self.repo)) + pending["pendingChanges"]["modified"] = 1 + responses = [ + completed(json.dumps(pending)), + completed("synced\n"), + completed(healthy_status(self.repo)), + completed("graph bytes\n"), + completed(healthy_status(self.repo)), + ] + calls = [] + + def runner(command, **_kwargs): + calls.append(command) + return responses.pop(0) + + proxy = self.proxy_module() + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + result = proxy.execute_proxy_query( + self.repo, "TASK-0001", "PLANNING", "CIQ-001", + "locate A", "symbol A", runner=runner, + ) + + self.assertEqual([item[1] for item in calls], [ + "status", "sync", "status", "explore", "status" + ]) + self.assertEqual(result["bundle"]["bundle_version"], 2) + self.assertEqual(result["bundle"]["refresh_policy"], { + "mode": "AUTO_INCREMENTAL_ON_PENDING", + "max_sync_attempts": 1, + "full_rebuild": "USER_ONLY", + }) +``` + +- [ ] **Step 2:写 pre-status 无法验证但仍查询的失败测试** + +```python +def test_proxy_queries_unknown_pre_status_and_treats_result_as_stale(self) -> None: + self.qualify_task() + (self.repo / ".codegraph").mkdir() + responses = [ + completed("not-json\n"), + completed("graph bytes\n"), + completed(healthy_status(self.repo)), + ] + calls = [] + + def runner(command, **_kwargs): + calls.append(command) + return responses.pop(0) + + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + result = self.proxy_module().execute_proxy_query( + self.repo, "TASK-0001", "PLANNING", "CIQ-001", + "locate A", "symbol A", runner=runner, + ) + + self.assertEqual([item[1] for item in calls], ["status", "explore", "status"]) + self.assertEqual(result["response"], "graph bytes\n") + self.assertEqual(result["bundle"]["delivery"]["state"], "UNKNOWN") + self.assertIn("freshness: TREAT_AS_STALE", result["envelope"]) +``` + +- [ ] **Step 3:写身份不匹配时禁止查询的失败测试** + +```python +def test_proxy_does_not_query_a_different_project_index(self) -> None: + self.qualify_task() + (self.repo / ".codegraph").mkdir() + wrong = json.loads(healthy_status(self.repo)) + wrong["projectPath"] = str(self.repo / "other-checkout") + calls = [] + + def runner(command, **_kwargs): + calls.append(command) + return completed(json.dumps(wrong)) + + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + result = self.proxy_module().execute_proxy_query( + self.repo, "TASK-0001", "PLANNING", "CIQ-001", + "locate A", "symbol A", runner=runner, + ) + + self.assertEqual([item[1] for item in calls], ["status"]) + self.assertIsNone(result["response"]) + self.assertEqual(result["bundle"]["delivery"]["state"], "UNKNOWN") + self.assertEqual(result["bundle"]["delivery"]["reason"], "PROJECT_MISMATCH") +``` + +- [ ] **Step 4:运行三项代理测试并确认 RED** + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests.test_proxy_automatically_syncs_pending_without_a_caller_switch tests.test_codegraph.CodeGraphTests.test_proxy_queries_unknown_pre_status_and_treats_result_as_stale tests.test_codegraph.CodeGraphTests.test_proxy_does_not_query_a_different_project_index -v` + +Expected: 旧签名、旧 early-return 或 bundle v1 至少导致一项失败。 + +- [ ] **Step 5:实现固定自动刷新策略和新代理签名** + +在 `code_intelligence_proxy.py` 增加唯一策略常量: + +```python +REFRESH_POLICY = { + "mode": "AUTO_INCREMENTAL_ON_PENDING", + "max_sync_attempts": 1, + "full_rebuild": "USER_ONLY", +} + +def execute_proxy_query( + repo: Path, + task_id: str, + stage: str, + query_id: str, + purpose: str, + query: str, + *, + runner: Any = subprocess.run, +) -> dict[str, Any]: + """Execute one automatically refreshed, immutable CodeGraph query window.""" +``` + +以上代码块只替换现有函数声明和 docstring;函数主体按本步骤后续规则原位修改,不新建第二个入口。删除对 `sync_if_needed` 的输入校验。`_bundle_base` 写 `bundle_version: 2` 和 `refresh_policy: dict(REFRESH_POLICY)`。只要 `pre_status.get("needs_sync")` 为真,就无条件调用一次 `synchronize_observed_status`。 + +把旧的 `effective_pre["status"] == "NOT_VERIFIED"` early return 拆成两个分支,并使用一个精确判断函数: + +```python +def _pre_status_blocks_query(observation: dict[str, Any]) -> bool: + if any( + point.get("reason") == "WORKTREE_MISMATCH" + for point in observation.get("stale_points", []) + ): + return True + error = str(observation.get("error") or "").lower() + return any(token in error for token in ( + "different project", + "unsafe project marker", + "repository root", + "symlink", + )) +``` + +`_pre_status_blocks_query(effective_pre)` 为真时不查询;普通 timeout/JSON/执行验证失败继续调用 `run_explore`。查询成功后仍执行 post-status,最终 `_delivery` 保留 pre-status 的 UNKNOWN 原因。 + +- [ ] **Step 6:更新 envelope 的显式处理语义** + +在 `render_freshness_envelope` 中加入: + +```python +freshness = ( + "VERIFIED_AT_CHECK" + if delivery["state"] == "CURRENT" + else "NO_GRAPH" + if delivery["state"] == "UNAVAILABLE" + else "TREAT_AS_STALE" +) +lines.insert(3, f"freshness: {freshness}") +``` + +`STALE` 与 `UNKNOWN` 都必须输出 `TREAT_AS_STALE`;不得只依赖 `usage: NAVIGATION_ONLY` 暗示。 + +- [ ] **Step 7:为 MCP 公开 schema 写失败测试** + +把 MCP schema 测试改为: + +```python +tool = server.handle({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}) +schema = tool["result"]["tools"][0]["inputSchema"] +self.assertNotIn("sync_if_needed", schema["properties"]) +self.assertNotIn("sync_if_needed", schema["required"]) +self.assertEqual(set(schema["required"]), { + "task_id", "stage", "query_id", "purpose", "query", +}) +``` + +- [ ] **Step 8:运行 MCP schema 测试并确认 RED** + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests.test_mcp_server_initializes_and_lists_one_proxy_tool -v` + +Expected: 当前 schema 仍包含 `sync_if_needed`,测试失败。 + +- [ ] **Step 9:删除 MCP 公开同步开关** + +从 `TOOL`、`_validate_arguments` 和 `_call_tool` 删除 `sync_if_needed`。调用精确改为: + +```python +proxy = execute_proxy_query( + self.repo, + arguments["task_id"], + arguments["stage"], + arguments["query_id"], + arguments["purpose"], + arguments["query"], +) +``` + +- [ ] **Step 10:为 bundle v1/v2 兼容性写失败测试** + +给现有 `record_current_v3_fixture` 增加测试专用参数: + +```python +def record_current_v3_fixture( + self, + *, + legacy_bundle: bool = False, + invalid_refresh_policy: bool = False, +) -> tuple[dict[str, object], dict[str, object]]: +``` + +在该 helper 已生成 `query`、尚未调用 `record_proxy_bundle` 的位置加入: + +```python +bundle_path = query["bundle_path"] +bundle = json.loads(bundle_path.read_text(encoding="utf-8")) +if legacy_bundle: + bundle["bundle_version"] = 1 + bundle.pop("refresh_policy") +elif invalid_refresh_policy: + bundle["refresh_policy"]["max_sync_attempts"] = 2 +if legacy_bundle or invalid_refresh_policy: + write_json_atomic(bundle_path, bundle) +``` + +然后添加: + +```python +def test_bundle_v1_remains_projectable_but_v2_policy_is_fixed(self) -> None: + recorded, _query = self.record_current_v3_fixture(legacy_bundle=True) + self.assertEqual(recorded["record_version"], 3) + + self.tearDown() + self.setUp() + with self.assertRaisesRegex(RuleFailure, "refresh policy"): + self.record_current_v3_fixture(invalid_refresh_policy=True) +``` + +- [ ] **Step 11:运行 bundle 兼容测试并确认 RED** + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests.test_bundle_v1_remains_projectable_but_v2_policy_is_fixed -v` + +Expected: bundle v2 尚未被支持,或错误策略尚未被拒绝。 + +- [ ] **Step 12:让 bundle v1/v2 都可投影,但新查询只写 v2** + +在 `record_proxy_bundle` 中按版本选择精确键集合: + +```python +version = bundle.get("bundle_version") +base_keys = { + "bundle_version", "proxy", "provider", "repository", "task_context", + "query", "pre_status", "sync", "post_sync_status", + "response_classification", "post_query_status", "delivery", + "response_path", +} +if version == 1: + _require_exact_keys(bundle, base_keys, "CodeGraph proxy bundle") +elif version == 2: + _require_exact_keys( + bundle, {*base_keys, "refresh_policy"}, "CodeGraph proxy bundle" + ) + if bundle["refresh_policy"] != REFRESH_POLICY: + raise RuleFailure("CodeGraph proxy bundle has an invalid refresh policy") +else: + raise RuleFailure("CodeGraph proxy bundle has an unsupported identity") +``` + +在 `record_proxy_bundle` 函数内与现有 `resolve_stage_context` 延迟导入放在同一位置,从 `code_intelligence_proxy` 导入同一个 `REFRESH_POLICY`,不要复制第二份策略常量。record v3 输出结构保持不变;bundle digest 继续绑定输入 bundle 的精确字节。 + +- [ ] **Step 13:机械更新所有代理调用,并证明公开表面无旧参数** + +把测试中的旧调用: + +```python +query = proxy.execute_proxy_query( + self.repo, + "TASK-0001", + "PLANNING", + "CIQ-001", + "locate A", + "symbol A", + False, + runner=runner, +) +``` + +统一移除第七个位置布尔参数,改为: + +```python +query = proxy.execute_proxy_query( + self.repo, + "TASK-0001", + "PLANNING", + "CIQ-001", + "locate A", + "symbol A", + runner=runner, +) +``` + +Run: `rg -n "sync_if_needed" scripts/code_intelligence_mcp.py scripts/internal/code_intelligence_proxy.py` + +Expected: no matches。`scripts/internal/codegraph_adapter.py` 与 `scripts/code_intelligence_runtime.py` 的低层显式 sync helper 保留,不属于 MCP 查询绕过开关。 + +- [ ] **Step 14:运行代理、MCP 与 record 聚焦测试** + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests.test_proxy_automatically_syncs_pending_without_a_caller_switch tests.test_codegraph.CodeGraphTests.test_proxy_queries_unknown_pre_status_and_treats_result_as_stale tests.test_codegraph.CodeGraphTests.test_proxy_does_not_query_a_different_project_index tests.test_codegraph.CodeGraphTests.test_proxy_window_requires_clean_pre_and_post_status_for_current tests.test_codegraph.CodeGraphTests.test_proxy_window_never_promotes_failed_or_post_stale_queries tests.test_codegraph.CodeGraphTests.test_mcp_server_initializes_and_lists_one_proxy_tool tests.test_codegraph.CodeGraphTests.test_mcp_server_returns_envelope_before_graph_and_preserves_bundle tests.test_codegraph.CodeGraphTests.test_bundle_v1_remains_projectable_but_v2_policy_is_fixed tests.test_codegraph.CodeGraphTests.test_v3_record_projects_exact_proxy_bundle tests.test_codegraph.CodeGraphTests.test_failed_explore_proxy_bundle_projects_to_unknown_v3 tests.test_codegraph.CodeGraphTests.test_failed_sync_proxy_bundle_preserves_only_observed_post_status -v` + +Expected: all PASS;任一测试都不得观察到两次 sync、两次 explore 或 `codegraph index`。 + +- [ ] **Step 15:提交 Task 2** + +```bash +git add scripts/internal/code_intelligence_proxy.py scripts/code_intelligence_mcp.py scripts/internal/code_intelligence_protocol.py tests/test_codegraph.py +git commit -m "feat: enforce automatic CodeGraph freshness windows" +``` + +--- + +### Task 3:统一所有阶段 Skill 和用户文档的新鲜度行为 + +**Files:** +- Modify: `skills/code-intelligence/SKILL.md` +- Modify: `skills/architecture-planning/SKILL.md` +- Modify: `skills/implementation/SKILL.md` +- Modify: `skills/documentation-sync/SKILL.md` +- Modify: `skills/adversarial-review/SKILL.md` +- Modify: `templates/AGENTS.md` +- Modify: `README.md` +- Modify: `README.zh-CN.md` +- Modify: `docs/USAGE.md` +- Modify: `plan.md` +- Test: `tests/test_codegraph.py:130-240,2970-3070` + +**Interfaces:** +- Consumes: Task 2 的无同步参数 MCP schema 和四态 envelope。 +- Produces: 所有宿主渲染后相同的阶段行为;Planning、Implementation、Documentation Sync、Review 不再选择同步策略。 + +- [ ] **Step 1:先加行为锚点失败测试** + +```python +def test_all_agent_surfaces_require_automatic_freshness_policy(self) -> None: + paths = [ + ROOT / "skills/code-intelligence/SKILL.md", + ROOT / "skills/architecture-planning/SKILL.md", + ROOT / "skills/implementation/SKILL.md", + ROOT / "skills/documentation-sync/SKILL.md", + ROOT / "skills/adversarial-review/SKILL.md", + ROOT / "templates/AGENTS.md", + ] + required = ( + "automatically runs at most one incremental `codegraph sync`", + "never runs `codegraph index`", + "UNKNOWN", + "TREAT_AS_STALE", + "source/Git fallback", + ) + for path in paths: + text = path.read_text(encoding="utf-8") + self.assertNotIn("sync_if_needed", text, path.as_posix()) + for anchor in required: + self.assertIn(anchor, text, f"{path}: {anchor}") +``` + +更新 `test_documentation_sync_uses_one_proxy_query`:删除 `sync_if_needed: true` 锚点,改为断言 `changed source paths`、`documented symbols`、`automatic incremental sync` 和 `no separate status/sync MCP tool`。 + +- [ ] **Step 2:运行 Skill 行为测试并确认 RED** + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests.test_all_agent_surfaces_require_automatic_freshness_policy tests.test_codegraph.CodeGraphTests.test_documentation_sync_uses_one_proxy_query -v` + +Expected: 当前 Skill 仍包含 `sync_if_needed`,测试失败。 + +- [ ] **Step 3:更新 canonical Skills** + +`skills/code-intelligence/SKILL.md` 必须明确以下单一流程: + +```text +Call only polaris_codegraph_explore with task ID, stage, next CIQ-NNN, +purpose, and query. The proxy automatically runs at most one incremental +`codegraph sync` when pending changes exist and never runs `codegraph index`. +Read the freshness envelope before graph content. CURRENT_AT_CHECK is bounded +non-authoritative context. STALE and UNKNOWN/TREAT_AS_STALE are navigation-only +and require the exact source/Git fallback before any conclusion is used. +``` + +各阶段 Skill 只保留 task/stage/query 范围和查询次数约束: + +- Planning:冻结范围内关系发现; +- Implementation:修改前可查,修改后需要结论时必须重新调用; +- Documentation Sync:仅 supported source 发生变化时,对 changed paths/symbols 调用一次; +- Review:Reviewer 独立调用,不继承 Implementer envelope; +- Validation:继续禁止 Code Intelligence。 + +- [ ] **Step 4:更新共享模板和用户文档** + +在 `templates/AGENTS.md`、README、`docs/USAGE.md`、`plan.md` 中统一写明: + +```text +代理在查询前检查状态;存在 pending changes 时自动且至多执行一次增量 +`codegraph sync`;状态无法验证但仓库身份安全时仍查询并标记 UNKNOWN / +TREAT_AS_STALE;全量 `codegraph index` 始终由用户主动执行。 +``` + +删除所有“按调用参数决定是否同步”和 `sync_if_needed: true/false` 描述。保留 CodeGraph 的安装、初始化、watcher、daemon、raw MCP 与全量重建归用户所有的边界。 + +- [ ] **Step 5:运行渲染与行为测试** + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests.test_all_agent_surfaces_require_proxy_provenance tests.test_codegraph.CodeGraphTests.test_all_agent_surfaces_require_automatic_freshness_policy tests.test_codegraph.CodeGraphTests.test_documentation_sync_uses_one_proxy_query tests.test_codegraph.CodeGraphTests.test_validation_remains_graph_free tests.test_core.PolarisCoreTests.test_host_adapters_render_from_one_host_neutral_skill_source -v` + +Expected: all PASS;Validation 渲染中没有代理、status 或 sync 调用。 + +- [ ] **Step 6:提交 Task 3** + +```bash +git add skills templates/AGENTS.md README.md README.zh-CN.md docs/USAGE.md plan.md tests/test_codegraph.py +git commit -m "docs: define automatic CodeGraph freshness behavior" +``` + +--- + +### Task 4:升级协议到 0.1.22,并增加不重写证据的相邻迁移 + +**Files:** +- Modify: `VERSION` +- Modify: `pyproject.toml` +- Modify: `templates/project.json` +- Modify: `templates/task-sources/state.json` +- Modify: `templates/task/state.json` +- Modify: `workflow/migrations.json` +- Modify: `scripts/internal/migration_protocol.py:280-480` +- Modify: `README.md` +- Modify: `README.zh-CN.md` +- Modify: `docs/USAGE.md` +- Modify: `plan.md` +- Test: `tests/test_codegraph.py:160-205,1720-1990` +- Test: `tests/test_core.py:2570-2620` + +**Interfaces:** +- Consumes: 现有 migration protocol v2 的 `replace_version` / `append_version_event`。 +- Produces: 唯一相邻步骤 `0.1.21-to-0.1.22`,Workflow 前后均为 `0.1.3`。 + +- [ ] **Step 1:写 0.1.21 → 0.1.22 迁移失败测试** + +在 `tests/test_codegraph.py` 中使用现有 v3 fixture: + +```python +def test_0122_migration_preserves_v3_code_intelligence_records(self) -> None: + recorded, _query = self.record_current_v3_fixture() + actual_path = ( + self.repo + / ".polaris/tasks/TASK-0001/code-intelligence/r001/planning.json" + ) + self.assertEqual( + json.loads(actual_path.read_text(encoding="utf-8")), recorded + ) + before = actual_path.read_bytes() + self.set_protocol_version("0.1.21") + + with protocol_source_at("0.1.22") as source: + vendor(source, self.repo, False) + result = migrate_project(self.repo) + + self.assertEqual(result["from"], "0.1.21") + self.assertEqual(result["to"], "0.1.22") + self.assertEqual(actual_path.read_bytes(), before) + migration = json.loads(Path(result["record"]).read_text(encoding="utf-8")) + self.assertEqual(migration["retired_code_intelligence_records"], []) +``` + +- [ ] **Step 2:写 Workflow 不得变化的失败测试** + +```python +def test_0122_version_only_migration_rejects_workflow_change(self) -> None: + self.set_protocol_version("0.1.21") + with protocol_source_at("0.1.22") as source: + migrations_path = source / "workflow/migrations.json" + migrations = read_json(migrations_path) + migrations["steps"][-1]["to_workflow_version"] = "0.1.4" + write_json_atomic(migrations_path, migrations) + vendor(source, self.repo, False) + with self.assertRaisesRegex( + RuleFailure, "workflow migration requires replacement" + ): + migrate_project(self.repo) +``` + +- [ ] **Step 3:运行迁移测试并确认 RED** + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests.test_0122_migration_preserves_v3_code_intelligence_records tests.test_core.PolarisCoreTests.test_0122_version_only_migration_rejects_workflow_change -v` + +Expected: 缺少相邻迁移步骤,或 v3 record 被旧 retirement inventory 拒绝。 + +- [ ] **Step 4:追加迁移步骤并限定历史 inventory** + +在 `workflow/migrations.json` 末尾追加: + +```json +{ + "migration_id": "0.1.21-to-0.1.22", + "from_polaris_version": "0.1.21", + "to_polaris_version": "0.1.22", + "from_workflow_version": "0.1.3", + "to_workflow_version": "0.1.3", + "project_strategy": "replace_version", + "task_strategy": "append_version_event" +} +``` + +`_retired_code_intelligence_records` 只服务 `0.1.20-to-0.1.21` 的历史 retirement。`_new_record` 和中断恢复重算 inventory 时,仅对该 migration ID 调用它;`0.1.21-to-0.1.22` 的 `retired_code_intelligence_records` 固定为空列表。不得重新清点或重写 v3 record。 + +- [ ] **Step 5:升级所有当前版本 Authority** + +把以下当前版本值精确改为 `0.1.22`: + +```text +VERSION +pyproject.toml project.version +templates/project.json polaris_version +templates/task-sources/state.json polaris_version +templates/task/state.json polaris_version +README.md / README.zh-CN.md / docs/USAGE.md / plan.md 当前版本说明 +``` + +历史迁移说明中的 `0.1.20 → 0.1.21` 保留,并新增 `0.1.21 → 0.1.22` 行为说明。现有测试中对当前版本的断言更新为 `0.1.22`,历史 fixture 和旧迁移断言不得机械替换。 + +- [ ] **Step 6:运行版本与迁移测试** + +Run: `python3 -m unittest tests.test_codegraph.CodeGraphTests.test_0122_migration_preserves_v3_code_intelligence_records tests.test_codegraph.CodeGraphTests.test_migration_inventories_frozen_v2_records_without_rewriting_them tests.test_codegraph.CodeGraphTests.test_migration_resume_rejects_mutated_frozen_v2_inventory tests.test_core.PolarisCoreTests.test_0122_version_only_migration_rejects_workflow_change tests.test_core.PolarisCoreTests.test_version_only_migration_rejects_a_workflow_version_change tests.test_core.PolarisCoreTests.test_migration_rejects_an_undeclared_version_jump -v` + +Expected: all PASS;旧 `0.1.20 → 0.1.21` inventory 行为保持不变,新迁移不清点 v3。 + +- [ ] **Step 7:重新生成 task layout 并验证无漂移** + +Run: `python3 scripts/materialize_task_layout.py` + +Run: `git diff --check` + +Expected: 只出现计划内的模板版本变化;生成树与 `task-sources` 保持一致。 + +- [ ] **Step 8:提交 Task 4** + +```bash +git add VERSION pyproject.toml templates workflow/migrations.json scripts/internal/migration_protocol.py README.md README.zh-CN.md docs/USAGE.md plan.md tests/test_codegraph.py tests/test_core.py +git commit -m "chore: advance Polaris protocol to 0.1.22" +``` + +--- + +### Task 5:端到端验收、跨平台检查和完成前验证 + +**Files:** +- Modify if a failing assertion exposes a gap: only files already named in Tasks 1-4 +- Test: `tests/test_codegraph.py` +- Test: `tests/test_core.py` + +**Interfaces:** +- Consumes: Tasks 1-4 的完整实现。 +- Produces: 可审查、可迁移、无需修改 CodeGraph 的 Polaris `0.1.22`。 + +- [ ] **Step 1:加固 fake-CLI 端到端断言** + +扩展现有 `test_vendored_mcp_proxy_runs_one_auditable_fake_cli_window`,让 fake CLI 记录 argv/cwd,并断言: + +```python +self.assertEqual( + [entry["argv"][0] for entry in calls], + ["status", "sync", "status", "explore", "status"], +) +self.assertTrue(all(Path(entry["cwd"]).resolve() == repo.resolve() for entry in calls)) +self.assertNotIn("index", [arg for entry in calls for arg in entry["argv"]]) +self.assertTrue(first_content.startswith("[POLARIS_CODEGRAPH_FRESHNESS]\n")) +self.assertIn("freshness: VERIFIED_AT_CHECK", first_content) +self.assertEqual(record_value["record_version"], 3) +``` + +fake status 的第一次响应必须包含一个 pending modified,sync 后与 query 后响应必须干净,从而真实覆盖自动增量同步,而不是手工传入同步开关。 + +- [ ] **Step 2:运行 CodeGraph 聚焦全套** + +Run: `python3 -m unittest tests.test_codegraph -v` + +Expected: all PASS;如果本机未安装 CodeGraph,仅真实 CLI smoke test 可以 SKIP。 + +- [ ] **Step 3:运行 Core 聚焦全套** + +Run: `python3 -m unittest tests.test_core -v` + +Expected: all PASS。 + +- [ ] **Step 4:运行仓库完整验证** + +Run: `python3 tests/run_tests.py` + +Run: `python3 -m compileall -q polaris_cli.py scripts tests` + +Run: `python3 scripts/materialize_task_layout.py` + +Run: `git diff --check` + +Expected: 完整测试 PASS;编译与格式检查退出 0;materialize 不产生未解释漂移。 + +- [ ] **Step 5:证明不存在查询绕过和全量重建路径** + +Run: `rg -n "sync_if_needed" skills templates/AGENTS.md scripts/code_intelligence_mcp.py scripts/internal/code_intelligence_proxy.py README.md README.zh-CN.md docs/USAGE.md plan.md` + +Expected: no matches。 + +Run: `rg -n "codegraph index" scripts skills templates README.md README.zh-CN.md docs/USAGE.md plan.md` + +Expected: 只出现“Polaris 不执行、由用户主动执行”的文档语句;`scripts/` 内不得出现可执行 command 组装。 + +Run: `git -C /Users/zero/Documents/work/ai/codegraph status --short` + +Expected: no output。 + +- [ ] **Step 6:对照规格建立最终覆盖表** + +| 规格要求 | 必须通过的测试 | +|---|---| +| 当前 CodeGraph framing | `test_current_codegraph_freshness_framing_is_classified` | +| 源码 warning 不误判 | `test_warning_words_inside_verbatim_source_do_not_change_freshness` | +| pending 自动同步一次 | `test_proxy_automatically_syncs_pending_without_a_caller_switch` | +| pre-status unknown 仍查询 | `test_proxy_queries_unknown_pre_status_and_treats_result_as_stale` | +| 身份不匹配不查询 | `test_proxy_does_not_query_a_different_project_index` | +| clean window 才 CURRENT | `test_proxy_window_requires_clean_pre_and_post_status_for_current` | +| envelope 永远在前 | `test_mcp_server_returns_envelope_before_graph_and_preserves_bundle` | +| bundle v2 / record v3 | `test_v3_record_projects_exact_proxy_bundle` | +| STALE/UNKNOWN 强制 fallback | `test_v3_record_rejects_mutated_window_identity_and_fallbacks` | +| 所有阶段无同步开关 | `test_all_agent_surfaces_require_automatic_freshness_policy` | +| Validation graph-free | `test_validation_remains_graph_free` | +| v3 record 迁移不重写 | `test_0122_migration_preserves_v3_code_intelligence_records` | +| Workflow 保持 0.1.3 | `test_0122_version_only_migration_rejects_workflow_change` | +| vendored fake CLI 全窗口 | `test_vendored_mcp_proxy_runs_one_auditable_fake_cli_window` | +| CodeGraph 仓库不修改 | Step 5 的独立 `git status` 检查 | + +- [ ] **Step 7:提交仅由验收暴露的修正** + +```bash +git add scripts skills templates workflow tests README.md README.zh-CN.md docs/USAGE.md plan.md VERSION pyproject.toml +git commit -m "test: verify CodeGraph freshness hardening end to end" +``` + +如果 Steps 2-6 没有产生文件变化,则跳过该提交。 + +- [ ] **Step 8:请求代码审查并进入分支收尾** + +完整验证通过后,依次使用 `superpowers:requesting-code-review` 和 `superpowers:finishing-a-development-branch`。审查必须特别核对:没有 CodeGraph 仓库改动、没有 `codegraph index` 执行路径、没有同步绕过参数、UNKNOWN 图仍可返回但必须按过期处理。 From 0ec75416dfe30f994c39b2a930a4c53b09084f16 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 17:21:39 +0800 Subject: [PATCH 04/13] fix: classify current CodeGraph freshness framing --- scripts/internal/codegraph_adapter.py | 171 +++++++++++++++++++------- tests/test_codegraph.py | 71 +++++++++++ 2 files changed, 198 insertions(+), 44 deletions(-) diff --git a/scripts/internal/codegraph_adapter.py b/scripts/internal/codegraph_adapter.py index 0d51a1d..11b34d3 100644 --- a/scripts/internal/codegraph_adapter.py +++ b/scripts/internal/codegraph_adapter.py @@ -33,16 +33,26 @@ } _PARTIAL_BANNER_HEADER = ( - "⚠️ Some files referenced below were edited since the last index sync —\n" + "⚠️ Some files referenced below were edited since the last index sync — " "their codegraph entries may be stale:\n" ) +_LEGACY_PARTIAL_BANNER_HEADER = _PARTIAL_BANNER_HEADER.replace(" — ", " —\n") _PARTIAL_BANNER_FOOTER = ( "For accurate content of those specific files, Read them directly." ) _PARTIAL_BANNER_ROW = re.compile( - r"^ - (?P.+) \(edited [^\n()]+, pending sync\)$" + r"^ - (?P.+) \(edited [^\n()]+, " + r"(?:pending sync|indexing in progress)\)$" ) -_DISABLED_BANNER = "⚠️ CodeGraph auto-sync is DISABLED — the index is frozen." +_DISABLED_BANNER_PREFIX = "⚠️ CodeGraph auto-sync is DISABLED —" +_WORKTREE_BANNER_PREFIX = ( + "⚠ CodeGraph results below come from a different git worktree" +) +_DRIFTED_FILE_HEADER = re.compile( + r"^\*\*`(?P[^`]+)`\*\* — .*⚠ changed " + r"(?:since last index sync|on disk after the last index sync)" +) +_DRIFTED_PROJECT_TAIL_PREFIX = "> ⚠ Changed on disk after the last index sync:" _SUSPICIOUS_FRESHNESS_SIGNAL = re.compile( r"(?:⚠|\bwarning\b|\bstale\b|\bpending(?:[- ]sync)?\b|\bout[- ]of[- ]date\b)", re.IGNORECASE, @@ -183,13 +193,31 @@ def _response_file_point(repo: Path, raw_path: str) -> dict[str, Any]: } +def _framing_lines(response: str) -> list[str]: + """Return response lines outside Markdown code fences.""" + lines: list[str] = [] + inside_fence = False + for line in response.splitlines(): + if line.startswith("```"): + inside_fence = not inside_fence + continue + if not inside_fence: + lines.append(line) + return lines + + +def _with_response_sha256(result: dict[str, Any], response_sha256: str) -> dict[str, Any]: + result["response_sha256"] = response_sha256 + return result + + def classify_response( repo: Path, response: str, *, checked_at: str | None = None, ) -> dict[str, Any]: - """Classify only documented banners beginning at response byte zero. + """Classify documented freshness framing outside source-code fences. Leading whitespace and a UTF-8 BOM are not accepted as an official banner. """ @@ -198,49 +226,104 @@ def classify_response( return _response_not_verified(checked_at, "CodeGraph response is not text") response_sha256 = hashlib.sha256(response.encode("utf-8")).hexdigest() normalized = response.replace("\r\n", "\n").replace("\r", "\n") - if normalized.startswith(_DISABLED_BANNER): - result = _response_result( - "INDEX_STALE", - checked_at, - stale_points=[_index_point("AUTO_SYNC_DISABLED")], + if normalized.startswith(_WORKTREE_BANNER_PREFIX): + return _with_response_sha256( + _response_result( + "INDEX_STALE", + checked_at, + stale_points=[_index_point("WORKTREE_MISMATCH")], + ), + response_sha256, + ) + if normalized.startswith(_DISABLED_BANNER_PREFIX): + return _with_response_sha256( + _response_result( + "INDEX_STALE", + checked_at, + stale_points=[_index_point("AUTO_SYNC_DISABLED")], + ), + response_sha256, ) - result["response_sha256"] = response_sha256 - return result - if not normalized.startswith(_PARTIAL_BANNER_HEADER): - if _SUSPICIOUS_FRESHNESS_SIGNAL.search(normalized): - result = _response_not_verified( - checked_at, "unrecognized CodeGraph freshness warning" + header = next( + ( + candidate + for candidate in (_PARTIAL_BANNER_HEADER, _LEGACY_PARTIAL_BANNER_HEADER) + if normalized.startswith(candidate) + ), + None, + ) + if header is not None: + listed = normalized[len(header) :] + footer_index = listed.find(_PARTIAL_BANNER_FOOTER) + if footer_index < 0: + return _with_response_sha256( + _response_not_verified(checked_at, "malformed CodeGraph stale banner"), + response_sha256, ) - result["response_sha256"] = response_sha256 - return result - result = _response_result("NONE", checked_at, stale_points=[]) - result["response_sha256"] = response_sha256 - return result - - listed = normalized[len(_PARTIAL_BANNER_HEADER) :] - footer_index = listed.find(_PARTIAL_BANNER_FOOTER) - if footer_index < 0: - result = _response_not_verified(checked_at, "malformed CodeGraph stale banner") - result["response_sha256"] = response_sha256 - return result - rows = listed[:footer_index].splitlines() - if not rows or any(_PARTIAL_BANNER_ROW.fullmatch(row) is None for row in rows): - result = _response_not_verified(checked_at, "malformed CodeGraph stale banner") - result["response_sha256"] = response_sha256 - return result - try: - stale_points = [ - _response_file_point(repo, _PARTIAL_BANNER_ROW.fullmatch(row)["path"]) - for row in rows - ] - except (InputFailure, RuleFailure, OSError, ValueError) as error: - result = _response_not_verified(checked_at, error) - result["response_sha256"] = response_sha256 - return result - result = _response_result("PARTIAL_STALE", checked_at, stale_points=stale_points) - result["response_sha256"] = response_sha256 - return result + rows = listed[:footer_index].splitlines() + matches = [_PARTIAL_BANNER_ROW.fullmatch(row) for row in rows] + if not rows or any(match is None for match in matches): + return _with_response_sha256( + _response_not_verified(checked_at, "malformed CodeGraph stale banner"), + response_sha256, + ) + try: + stale_points = [ + _response_file_point(repo, match["path"]) + for match in matches + if match is not None + ] + except (InputFailure, RuleFailure, OSError, ValueError) as error: + return _with_response_sha256( + _response_not_verified(checked_at, error), response_sha256 + ) + return _with_response_sha256( + _response_result("PARTIAL_STALE", checked_at, stale_points=stale_points), + response_sha256, + ) + + framing = _framing_lines(normalized) + drifted_headers = [ + match + for line in framing + if (match := _DRIFTED_FILE_HEADER.match(line)) is not None + ] + if drifted_headers: + try: + stale_points = [ + _response_file_point(repo, match["path"]) + for match in drifted_headers + ] + except (InputFailure, RuleFailure, OSError, ValueError) as error: + return _with_response_sha256( + _response_not_verified(checked_at, error), response_sha256 + ) + return _with_response_sha256( + _response_result("PARTIAL_STALE", checked_at, stale_points=stale_points), + response_sha256, + ) + + if any(line.startswith(_DRIFTED_PROJECT_TAIL_PREFIX) for line in framing): + return _with_response_sha256( + _response_result( + "INDEX_STALE", + checked_at, + stale_points=[_index_point("PENDING_CHANGES")], + ), + response_sha256, + ) + + if _SUSPICIOUS_FRESHNESS_SIGNAL.search("\n".join(framing)): + return _with_response_sha256( + _response_not_verified( + checked_at, "unrecognized CodeGraph freshness warning" + ), + response_sha256, + ) + return _with_response_sha256( + _response_result("NONE", checked_at, stale_points=[]), response_sha256 + ) def _unique_items(items: list[Any]) -> list[Any]: diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 0b76da9..3a3bec0 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -3526,6 +3526,77 @@ def runner(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[ self.assertEqual(result["sync"]["status"], "SKIPPED") self.assertEqual(calls, []) + def test_current_codegraph_freshness_framing_is_classified(self) -> None: + samples = { + "pending": ( + "⚠️ Some files referenced below were edited since the last index sync — " + "their codegraph entries may be stale:\n" + " - src/a.py (edited 12ms ago, pending sync)\n" + "For accurate content of those specific files, Read them directly. " + "The rest of this response is fresh.\n", + "PARTIAL_STALE", + ), + "indexing": ( + "⚠️ Some files referenced below were edited since the last index sync — " + "their codegraph entries may be stale:\n" + " - src/a.py (edited 12ms ago, indexing in progress)\n" + "For accurate content of those specific files, Read them directly. " + "The rest of this response is fresh.\n", + "PARTIAL_STALE", + ), + "disabled": ( + "⚠️ CodeGraph auto-sync is DISABLED — live file watching stopped, so the " + "index is frozen and any file edited since then is stale here.\n", + "INDEX_STALE", + ), + "drift": ( + "**`src/a.py`** — A(function) · ⚠ changed since last index sync — " + "source below is current; the symbol list may be outdated\n", + "PARTIAL_STALE", + ), + "worktree": ( + "⚠ CodeGraph results below come from a different git worktree " + "(/tmp/main), not where you're working (/tmp/wt) — they may reflect " + "another branch.\n", + "INDEX_STALE", + ), + } + for name, (response, expected) in samples.items(): + with self.subTest(name=name): + self.assertEqual( + self.classify_response(response)["classification"], expected + ) + + def test_warning_words_inside_verbatim_source_do_not_change_freshness(self) -> None: + response = ( + "**`src/a.py`** — A(function)\n\n" + "```python\n" + "def A():\n" + " warning = 'stale pending sync out-of-date ⚠'\n" + " return warning\n" + "```\n" + ) + + self.assertEqual(self.classify_response(response)["classification"], "NONE") + + def test_project_drift_tail_requires_source_search(self) -> None: + response = ( + "> ⚠ Changed on disk after the last index sync: src/a.py, src/b.py. " + "Line numbers referencing these files elsewhere in this response may be " + "shifted until that project's next sync re-indexes them.\n" + ) + + result = self.classify_response(response) + + self.assertEqual(result["classification"], "INDEX_STALE") + self.assertEqual(result["stale_points"], [{ + "scope": "INDEX", + "path": None, + "reason": "PENDING_CHANGES", + "fallback": "SEARCH_SOURCE", + "observed_sha256": None, + }]) + def test_response_banner_marks_only_named_files_stale(self) -> None: source = self.repo / "src/widget.py" source.parent.mkdir() From b6c1c7ab189458a43fa3b7a2d022de2d812cc65b Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 17:35:00 +0800 Subject: [PATCH 05/13] feat: enforce automatic CodeGraph freshness windows --- scripts/code_intelligence_mcp.py | 6 - .../internal/code_intelligence_protocol.py | 42 ++-- scripts/internal/code_intelligence_proxy.py | 113 +++++++---- tests/test_codegraph.py | 187 ++++++++++++++++-- tests/test_core.py | 1 - 5 files changed, 267 insertions(+), 82 deletions(-) diff --git a/scripts/code_intelligence_mcp.py b/scripts/code_intelligence_mcp.py index 919afb7..468182d 100644 --- a/scripts/code_intelligence_mcp.py +++ b/scripts/code_intelligence_mcp.py @@ -33,7 +33,6 @@ "query_id", "purpose", "query", - "sync_if_needed", ], "additionalProperties": False, "properties": { @@ -50,7 +49,6 @@ "query_id": {"type": "string", "pattern": r"^CIQ-[0-9]{3}$"}, "purpose": {"type": "string", "minLength": 1, "maxLength": 240}, "query": {"type": "string", "minLength": 1, "maxLength": 8000}, - "sync_if_needed": {"type": "boolean"}, }, }, } @@ -92,7 +90,6 @@ def _validate_arguments(value: Any) -> list[str]: "query_id", "purpose", "query", - "sync_if_needed", } errors: list[str] = [] missing = expected - set(value) @@ -118,8 +115,6 @@ def _validate_arguments(value: Any) -> list[str]: item = value.get(key) if not isinstance(item, str) or not item.strip() or len(item) > maximum: errors.append(f"{key} must contain 1 to {maximum} characters") - if not isinstance(value.get("sync_if_needed"), bool): - errors.append("sync_if_needed must be a boolean") return errors @@ -179,7 +174,6 @@ def _call_tool(self, request_id: Any, params: dict[str, Any]) -> dict[str, Any]: arguments["query_id"], arguments["purpose"], arguments["query"], - arguments["sync_if_needed"], ) content = [{"type": "text", "text": proxy["envelope"]}] if proxy["response"] is not None: diff --git a/scripts/internal/code_intelligence_protocol.py b/scripts/internal/code_intelligence_protocol.py index 93296a1..4750bfb 100644 --- a/scripts/internal/code_intelligence_protocol.py +++ b/scripts/internal/code_intelligence_protocol.py @@ -1217,26 +1217,26 @@ def record_proxy_bundle( require_regular_file(candidate, "CodeGraph proxy bundle") bundle_digest = file_sha256(candidate) bundle = read_json(candidate) - _require_exact_keys( - bundle, - { - "bundle_version", - "proxy", - "provider", - "repository", - "task_context", - "query", - "pre_status", - "sync", - "post_sync_status", - "response_classification", - "post_query_status", - "delivery", - "response_path", - }, - "CodeGraph proxy bundle", - ) - if bundle["bundle_version"] != 1 or bundle["proxy"] != { + from .code_intelligence_proxy import REFRESH_POLICY, resolve_stage_context + + version = bundle.get("bundle_version") + base_keys = { + "bundle_version", "proxy", "provider", "repository", "task_context", + "query", "pre_status", "sync", "post_sync_status", + "response_classification", "post_query_status", "delivery", + "response_path", + } + if version == 1: + _require_exact_keys(bundle, base_keys, "CodeGraph proxy bundle") + elif version == 2: + _require_exact_keys( + bundle, {*base_keys, "refresh_policy"}, "CodeGraph proxy bundle" + ) + if bundle["refresh_policy"] != REFRESH_POLICY: + raise RuleFailure("CodeGraph proxy bundle has an invalid refresh policy") + else: + raise RuleFailure("CodeGraph proxy bundle has an unsupported identity") + if bundle["proxy"] != { "server_id": "polaris-codegraph", "tool": "polaris_codegraph_explore", }: @@ -1259,8 +1259,6 @@ def record_proxy_bundle( ) if context["task_id"] != task_id: raise RuleFailure("CodeGraph proxy bundle targets the wrong task") - from .code_intelligence_proxy import resolve_stage_context - if context != resolve_stage_context(repo, task_id, context["stage"]): raise RuleFailure("CodeGraph proxy bundle stage context is no longer current") query = _require_exact_keys( diff --git a/scripts/internal/code_intelligence_proxy.py b/scripts/internal/code_intelligence_proxy.py index 24f2e45..e2c4bfa 100644 --- a/scripts/internal/code_intelligence_proxy.py +++ b/scripts/internal/code_intelligence_proxy.py @@ -49,6 +49,11 @@ "DOCUMENTATION_SYNC": {"IMPLEMENTING"}, "REVIEW": {"REVIEWING"}, } +REFRESH_POLICY = { + "mode": "AUTO_INCREMENTAL_ON_PENDING", + "max_sync_attempts": 1, + "full_rebuild": "USER_ONLY", +} _INDEX_FALLBACK = { "scope": "INDEX", "path": None, @@ -272,6 +277,21 @@ def _unsafe_response(classification: dict[str, Any]) -> bool: ) +def _pre_status_blocks_query(observation: dict[str, Any]) -> bool: + if any( + point.get("reason") == "WORKTREE_MISMATCH" + for point in observation.get("stale_points", []) + ): + return True + error = str(observation.get("error") or "").lower() + return any(token in error for token in ( + "different project", + "unsafe project marker", + "repository root", + "symlink", + )) + + def _delivery( effective_pre: dict[str, Any], query_result: dict[str, Any], @@ -292,14 +312,16 @@ def _delivery( *((classification or {}).get("stale_points", [])), *_observation_points(post_status), ]) - known_stale = any( + pre_status_blocks_query = _pre_status_blocks_query(effective_pre) + known_stale = not pre_status_blocks_query and (any( point.get("reason") != "STATUS_UNREADABLE" for point in points ) or effective_pre.get("status") in {"PARTIAL_STALE", "INDEX_STALE"} or ( post_status is not None and post_status.get("status") in {"PARTIAL_STALE", "INDEX_STALE"} - ) or (classification or {}).get("classification") in {"PARTIAL_STALE", "INDEX_STALE"} + ) or (classification or {}).get("classification") in {"PARTIAL_STALE", "INDEX_STALE"}) unknown = ( - forced_unknown is not None + pre_status_blocks_query + or forced_unknown is not None or query_result.get("status") != "SUCCESS" or _is_unknown(effective_pre) or post_status is None @@ -336,7 +358,16 @@ def _delivery( elif unknown: state = "UNKNOWN" record_status = "NOT_VERIFIED" - if forced_unknown: + if pre_status_blocks_query: + reason = ( + "WORKTREE_MISMATCH" + if any( + point.get("reason") == "WORKTREE_MISMATCH" + for point in effective_pre.get("stale_points", []) + ) + else "PROJECT_MISMATCH" + ) + elif forced_unknown: reason = "RESPONSE_INTEGRITY_UNVERIFIED" elif _is_unknown(effective_pre): reason = ( @@ -381,7 +412,8 @@ def _bundle_base( ) -> dict[str, Any]: project = read_json(repo / ".polaris/project.json") return { - "bundle_version": 1, + "bundle_version": 2, + "refresh_policy": dict(REFRESH_POLICY), "proxy": { "server_id": "polaris-codegraph", "tool": "polaris_codegraph_explore", @@ -428,17 +460,14 @@ def execute_proxy_query( query_id: str, purpose: str, query: str, - sync_if_needed: bool, *, runner: Any = subprocess.run, ) -> dict[str, Any]: - """Execute one immutable CodeGraph query window and persist its evidence.""" + """Execute one automatically refreshed, immutable CodeGraph query window.""" if not isinstance(purpose, str) or not purpose.strip() or len(purpose) > 240: raise InputFailure("CodeGraph query purpose must contain 1 to 240 characters") if not isinstance(query, str) or not query.strip() or len(query) > 8000: raise InputFailure("CodeGraph query must contain 1 to 8000 characters") - if not isinstance(sync_if_needed, bool): - raise InputFailure("sync_if_needed must be a boolean") repo = repo.absolute() if repo.is_symlink() or not repo.is_dir(): raise RuleFailure("CodeGraph proxy repository root must be a fixed real directory") @@ -489,7 +518,7 @@ def execute_proxy_query( pre_status = inspect_status(repo, descriptor, runner=runner) bundle["pre_status"] = pre_status effective_pre = pre_status - if sync_if_needed and pre_status.get("needs_sync"): + if pre_status.get("needs_sync"): synchronized = synchronize_observed_status( repo, descriptor, pre_status, runner=runner ) @@ -497,30 +526,40 @@ def execute_proxy_query( effective_pre = synchronized["freshness"] bundle["post_sync_status"] = synchronized["post_sync_status"] - if effective_pre["status"] in {"UNAVAILABLE", "NOT_VERIFIED"}: - bundle["query"]["status"] = ( - "UNAVAILABLE" if effective_pre["status"] == "UNAVAILABLE" else "FAILED" - ) + if effective_pre["status"] == "UNAVAILABLE": + bundle["query"]["status"] = "UNAVAILABLE" bundle["query"]["error"] = effective_pre.get("error") - if effective_pre["status"] == "UNAVAILABLE": - bundle["delivery"] = { - "state": "UNAVAILABLE", - "record_status": "UNAVAILABLE", - "reason": "PROVIDER_UNAVAILABLE", - "checked_at": effective_pre["checked_at"], - "usage": "NO_GRAPH", - "required_fallback": "SEARCH_SOURCE", - "stale_points": [], - "pending_changes": {"added": 0, "modified": 0, "removed": 0}, - "error": effective_pre.get("error"), - } - else: - bundle["delivery"] = _delivery( - effective_pre, - bundle["query"], - None, - None, - ) + bundle["delivery"] = { + "state": "UNAVAILABLE", + "record_status": "UNAVAILABLE", + "reason": "PROVIDER_UNAVAILABLE", + "checked_at": effective_pre["checked_at"], + "usage": "NO_GRAPH", + "required_fallback": "SEARCH_SOURCE", + "stale_points": [], + "pending_changes": {"added": 0, "modified": 0, "removed": 0}, + "error": effective_pre.get("error"), + } + _write_bundle(bundle_path, bundle) + return { + "bundle": bundle, + "bundle_path": bundle_path, + "response": None, + "envelope": render_freshness_envelope(bundle), + } + + if _pre_status_blocks_query(effective_pre): + blocked_error = effective_pre.get("error") or ( + "CodeGraph status reports a worktree mismatch" + ) + bundle["query"]["status"] = "FAILED" + bundle["query"]["error"] = blocked_error + bundle["delivery"] = _delivery( + effective_pre, + bundle["query"], + None, + None, + ) _write_bundle(bundle_path, bundle) return { "bundle": bundle, @@ -595,6 +634,13 @@ def render_freshness_envelope(bundle: dict[str, Any]) -> str: delivery = bundle["delivery"] pending = delivery.get("pending_changes") or {} error = " ".join(str(delivery.get("error") or "").split())[:240] + freshness = ( + "VERIFIED_AT_CHECK" + if delivery["state"] == "CURRENT" + else "NO_GRAPH" + if delivery["state"] == "UNAVAILABLE" + else "TREAT_AS_STALE" + ) bundle_path = task_relative_path( "code_intelligence_proxy_bundle", record_name=bundle["task_context"]["record_name"], @@ -613,6 +659,7 @@ def render_freshness_envelope(bundle: dict[str, Any]) -> str: f"required_fallback: {delivery['required_fallback']}", f"evidence_bundle: {bundle_path}", ] + lines.insert(3, f"freshness: {freshness}") if error: lines.append(f"error: {error}") lines.append("[/POLARIS_CODEGRAPH_FRESHNESS]") diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 3a3bec0..e3253e8 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -333,7 +333,12 @@ def qualify_task(self) -> None: def proxy_module(self) -> object: return importlib.import_module("internal.code_intelligence_proxy") - def record_current_v3_fixture(self) -> tuple[dict[str, object], dict[str, object]]: + def record_current_v3_fixture( + self, + *, + legacy_bundle: bool = False, + invalid_refresh_policy: bool = False, + ) -> tuple[dict[str, object], dict[str, object]]: self.qualify_task() (self.repo / ".codegraph").mkdir() source = self.repo / "src/a.py" @@ -357,9 +362,17 @@ def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess "CIQ-001", "locate A", "symbol A", - False, runner=runner, ) + bundle_path = query["bundle_path"] + bundle = json.loads(bundle_path.read_text(encoding="utf-8")) + if legacy_bundle: + bundle["bundle_version"] = 1 + bundle.pop("refresh_policy") + elif invalid_refresh_policy: + bundle["refresh_policy"]["max_sync_attempts"] = 2 + if legacy_bundle or invalid_refresh_policy: + write_json_atomic(bundle_path, bundle) protocol = importlib.import_module("internal.code_intelligence_protocol") result = protocol.record_proxy_bundle( self.repo, @@ -487,7 +500,6 @@ def runner(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[ "CIQ-001", "locate affected symbols", "symbol A", - False, runner=runner, ) @@ -506,10 +518,130 @@ def runner(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[ bundle["query"]["response_sha256"], ) + def test_proxy_automatically_syncs_pending_without_a_caller_switch(self) -> None: + self.qualify_task() + (self.repo / ".codegraph").mkdir() + pending = json.loads(healthy_status(self.repo)) + pending["pendingChanges"]["modified"] = 1 + responses = [ + completed(json.dumps(pending)), + completed("synced\n"), + completed(healthy_status(self.repo)), + completed("graph bytes\n"), + completed(healthy_status(self.repo)), + ] + calls = [] + + def runner(command, **_kwargs): + calls.append(command) + return responses.pop(0) + + proxy = self.proxy_module() + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + result = proxy.execute_proxy_query( + self.repo, "TASK-0001", "PLANNING", "CIQ-001", + "locate A", "symbol A", runner=runner, + ) + + self.assertEqual([item[1] for item in calls], [ + "status", "sync", "status", "explore", "status" + ]) + self.assertEqual(result["bundle"]["bundle_version"], 2) + self.assertEqual(result["bundle"]["refresh_policy"], { + "mode": "AUTO_INCREMENTAL_ON_PENDING", + "max_sync_attempts": 1, + "full_rebuild": "USER_ONLY", + }) + + def test_proxy_queries_unknown_pre_status_and_treats_result_as_stale(self) -> None: + self.qualify_task() + (self.repo / ".codegraph").mkdir() + responses = [ + completed("not-json\n"), + completed("graph bytes\n"), + completed(healthy_status(self.repo)), + ] + calls = [] + + def runner(command, **_kwargs): + calls.append(command) + return responses.pop(0) + + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + result = self.proxy_module().execute_proxy_query( + self.repo, "TASK-0001", "PLANNING", "CIQ-001", + "locate A", "symbol A", runner=runner, + ) + + self.assertEqual([item[1] for item in calls], ["status", "explore", "status"]) + self.assertEqual(result["response"], "graph bytes\n") + self.assertEqual(result["bundle"]["delivery"]["state"], "UNKNOWN") + self.assertIn("freshness: TREAT_AS_STALE", result["envelope"]) + + def test_proxy_does_not_query_a_different_project_index(self) -> None: + self.qualify_task() + (self.repo / ".codegraph").mkdir() + wrong = json.loads(healthy_status(self.repo)) + wrong["projectPath"] = str(self.repo / "other-checkout") + calls = [] + + def runner(command, **_kwargs): + calls.append(command) + return completed(json.dumps(wrong)) + + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + result = self.proxy_module().execute_proxy_query( + self.repo, "TASK-0001", "PLANNING", "CIQ-001", + "locate A", "symbol A", runner=runner, + ) + + self.assertEqual([item[1] for item in calls], ["status"]) + self.assertIsNone(result["response"]) + self.assertEqual(result["bundle"]["delivery"]["state"], "UNKNOWN") + self.assertEqual(result["bundle"]["delivery"]["reason"], "PROJECT_MISMATCH") + + def test_proxy_worktree_mismatch_is_unknown_with_finite_error_evidence(self) -> None: + self.qualify_task() + (self.repo / ".codegraph").mkdir() + wrong = json.loads(healthy_status(self.repo)) + wrong["worktreeMismatch"] = {"reason": "different checkout"} + calls = [] + + def runner(command, **_kwargs): + calls.append(command) + return completed(json.dumps(wrong)) + + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + result = self.proxy_module().execute_proxy_query( + self.repo, "TASK-0001", "PLANNING", "CIQ-001", + "locate A", "symbol A", runner=runner, + ) + + self.assertEqual([item[1] for item in calls], ["status"]) + self.assertIsNone(result["response"]) + self.assertEqual(result["bundle"]["delivery"]["state"], "UNKNOWN") + self.assertEqual( + result["bundle"]["delivery"]["reason"], "WORKTREE_MISMATCH" + ) + self.assertIsNotNone(result["bundle"]["query"]["error"]) + self.assertIsNotNone(result["bundle"]["delivery"]["error"]) + def test_proxy_window_downgrades_pending_unknown_and_unavailable_states(self) -> None: cases = [ - ("pending", "STALE", 3), - ("malformed", "UNKNOWN", 1), + ("pending", "STALE", 5), + ("malformed", "UNKNOWN", 3), ("missing_marker", "UNAVAILABLE", 0), ] for index, (case, expected_state, expected_calls) in enumerate(cases, start=1): @@ -525,12 +657,18 @@ def test_proxy_window_downgrades_pending_unknown_and_unavailable_states(self) -> if case == "pending": status["pendingChanges"]["modified"] = 1 responses = [ + completed(json.dumps(status)), + completed("synced\n"), completed(json.dumps(status)), completed("graph bytes\n"), completed(json.dumps(status)), ] else: - responses = [completed("not-json\n")] + responses = [ + completed("not-json\n"), + completed("graph bytes\n"), + completed(healthy_status(self.repo)), + ] if case != "missing_marker": (self.repo / ".codegraph").mkdir() @@ -546,7 +684,6 @@ def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess query_id, "inspect freshness", "symbol A", - False, runner=runner, ) self.assertEqual(result["bundle"]["delivery"]["state"], expected_state) @@ -581,7 +718,6 @@ def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess "CIQ-001", "refresh one query window", "symbol A", - True, runner=runner, ) @@ -634,7 +770,6 @@ def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess "CIQ-001", "verify failure handling", "symbol A", - False, runner=runner, ) self.assertEqual(result["bundle"]["delivery"]["state"], expected_state) @@ -685,7 +820,6 @@ def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess "CIQ-001", "classify response", "symbol A", - False, runner=runner, ) self.assertEqual(result["bundle"]["delivery"]["state"], expected_state) @@ -714,7 +848,6 @@ def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess "CIQ-001", "reject cross-project status", "symbol A", - False, runner=runner, ) self.assertEqual([command[1] for command in calls], ["status"]) @@ -765,7 +898,6 @@ def runner(*_args: object, **_kwargs: object) -> subprocess.CompletedProcess[str "CIQ-001", "verify activation gate", "symbol A", - True, runner=runner, ) self.assertEqual(result["bundle"]["delivery"]["state"], "UNAVAILABLE") @@ -837,7 +969,13 @@ def test_mcp_server_initializes_and_lists_one_proxy_tool(self) -> None: ) tools = responses[1]["result"]["tools"] self.assertEqual([item["name"] for item in tools], ["polaris_codegraph_explore"]) - self.assertNotIn("repository", tools[0]["inputSchema"]["properties"]) + schema = tools[0]["inputSchema"] + self.assertNotIn("repository", schema["properties"]) + self.assertNotIn("sync_if_needed", schema["properties"]) + self.assertNotIn("sync_if_needed", schema["required"]) + self.assertEqual(set(schema["required"]), { + "task_id", "stage", "query_id", "purpose", "query", + }) self.assertEqual(completed_process.stderr, "") def test_mcp_server_returns_envelope_before_graph_and_preserves_bundle(self) -> None: @@ -879,7 +1017,6 @@ def test_mcp_server_returns_envelope_before_graph_and_preserves_bundle(self) -> "query_id": "CIQ-001", "purpose": "locate symbols", "query": "symbol A", - "sync_if_needed": False, }, }, } @@ -939,7 +1076,6 @@ def test_mcp_server_rejects_lifecycle_tool_and_input_errors(self) -> None: "query_id": "CIQ-000", "purpose": "locate symbols", "query": "symbol A", - "sync_if_needed": False, }, }, }) @@ -1142,7 +1278,6 @@ def test_vendored_mcp_proxy_runs_one_auditable_fake_cli_window(self) -> None: "query_id": "CIQ-001", "purpose": "locate A", "query": "symbol A", - "sync_if_needed": True, }, }, }), @@ -1275,6 +1410,15 @@ def test_v3_record_projects_exact_proxy_bundle(self) -> None: ) self.assertEqual(recorded["query"]["symbols"][0]["path"], "src/a.py") + def test_bundle_v1_remains_projectable_but_v2_policy_is_fixed(self) -> None: + recorded, _query = self.record_current_v3_fixture(legacy_bundle=True) + self.assertEqual(recorded["record_version"], 3) + + self.tearDown() + self.setUp() + with self.assertRaisesRegex(RuleFailure, "refresh policy"): + self.record_current_v3_fixture(invalid_refresh_policy=True) + def test_v3_record_rejects_mutated_window_identity_and_fallbacks(self) -> None: recorded, _query = self.record_current_v3_fixture() protocol = importlib.import_module("internal.code_intelligence_protocol") @@ -1446,7 +1590,6 @@ def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess "CIQ-001", "locate A", "symbol A", - False, runner=runner, ) protocol = importlib.import_module("internal.code_intelligence_protocol") @@ -1508,7 +1651,6 @@ def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess "CIQ-001", "locate A after one sync attempt", "symbol A", - True, runner=runner, ) self.assertIsNone(query["bundle"]["post_sync_status"]) @@ -1564,12 +1706,18 @@ def test_v3_record_preserves_stale_unknown_and_unavailable_restrictions(self) -> pending = json.loads(healthy_status(self.repo)) pending["pendingChanges"]["modified"] = 1 responses = [ + completed(json.dumps(pending)), + completed("synced\n"), completed(json.dumps(pending)), completed("A is defined in src/a.py\n"), completed(json.dumps(pending)), ] elif case == "unknown": - responses = [completed("not-json\n")] + responses = [ + completed("not-json\n"), + completed("A is defined in src/a.py\n"), + completed(healthy_status(self.repo)), + ] else: responses = [] @@ -1590,7 +1738,6 @@ def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess "CIQ-001", "locate A conservatively", "symbol A", - False, runner=runner, ) fallback = { diff --git a/tests/test_core.py b/tests/test_core.py index 0303c29..ea23960 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -398,7 +398,6 @@ def runner( "CIQ-001", "bind final subject", "final subject symbols", - False, runner=runner, ) return record_proxy_bundle( From b3eda95700a668fb3c56211ed032b2811fc5a4e3 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 17:46:43 +0800 Subject: [PATCH 06/13] fix: preserve CodeGraph identity failures --- scripts/internal/code_intelligence_proxy.py | 41 +++++- tests/test_codegraph.py | 150 ++++++++++++++++++++ 2 files changed, 185 insertions(+), 6 deletions(-) diff --git a/scripts/internal/code_intelligence_proxy.py b/scripts/internal/code_intelligence_proxy.py index e2c4bfa..b82255e 100644 --- a/scripts/internal/code_intelligence_proxy.py +++ b/scripts/internal/code_intelligence_proxy.py @@ -313,7 +313,13 @@ def _delivery( *_observation_points(post_status), ]) pre_status_blocks_query = _pre_status_blocks_query(effective_pre) - known_stale = not pre_status_blocks_query and (any( + post_status_blocks_query = ( + post_status is not None and _pre_status_blocks_query(post_status) + ) + pre_status_unknown = _is_unknown(effective_pre) + known_stale = not ( + pre_status_blocks_query or post_status_blocks_query or pre_status_unknown + ) and (any( point.get("reason") != "STATUS_UNREADABLE" for point in points ) or effective_pre.get("status") in {"PARTIAL_STALE", "INDEX_STALE"} or ( post_status is not None @@ -321,9 +327,10 @@ def _delivery( ) or (classification or {}).get("classification") in {"PARTIAL_STALE", "INDEX_STALE"}) unknown = ( pre_status_blocks_query + or post_status_blocks_query or forced_unknown is not None or query_result.get("status") != "SUCCESS" - or _is_unknown(effective_pre) + or pre_status_unknown or post_status is None or _is_unknown(post_status) or (classification or {}).get("classification") == "NOT_VERIFIED" @@ -358,18 +365,22 @@ def _delivery( elif unknown: state = "UNKNOWN" record_status = "NOT_VERIFIED" - if pre_status_blocks_query: + if pre_status_blocks_query or post_status_blocks_query: + identity_observation = ( + effective_pre if pre_status_blocks_query else post_status + ) + assert identity_observation is not None reason = ( "WORKTREE_MISMATCH" if any( point.get("reason") == "WORKTREE_MISMATCH" - for point in effective_pre.get("stale_points", []) + for point in identity_observation.get("stale_points", []) ) else "PROJECT_MISMATCH" ) elif forced_unknown: reason = "RESPONSE_INTEGRITY_UNVERIFIED" - elif _is_unknown(effective_pre): + elif pre_status_unknown: reason = ( "PROJECT_MISMATCH" if "different project" in str(effective_pre.get("error", "")).lower() @@ -523,8 +534,17 @@ def execute_proxy_query( repo, descriptor, pre_status, runner=runner ) bundle["sync"] = synchronized["sync"] - effective_pre = synchronized["freshness"] bundle["post_sync_status"] = synchronized["post_sync_status"] + effective_pre = synchronized["freshness"] + if ( + bundle["post_sync_status"] is not None + and _pre_status_blocks_query(bundle["post_sync_status"]) + and bundle["post_sync_status"].get("error") + ): + effective_pre = { + **effective_pre, + "error": bundle["post_sync_status"]["error"], + } if effective_pre["status"] == "UNAVAILABLE": bundle["query"]["status"] = "UNAVAILABLE" @@ -610,6 +630,15 @@ def execute_proxy_query( ).as_posix() post_status = inspect_status(repo, descriptor, runner=runner) bundle["post_query_status"] = post_status + if _pre_status_blocks_query(post_status): + forced_unknown = str( + post_status.get("error") + or "CodeGraph post-query status reports a worktree mismatch" + ) + if response_path.exists(): + response_path.unlink() + bundle["response_path"] = None + response = None bundle["delivery"] = _delivery( effective_pre, diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index e3253e8..0d72b7b 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -556,6 +556,96 @@ def runner(command, **_kwargs): "full_rebuild": "USER_ONLY", }) + def test_proxy_blocks_post_sync_project_mismatch_before_explore(self) -> None: + self.qualify_task() + (self.repo / ".codegraph").mkdir() + pending = json.loads(healthy_status(self.repo)) + pending["pendingChanges"]["modified"] = 1 + wrong = json.loads(healthy_status(self.repo)) + wrong["projectPath"] = str(self.repo / "other-checkout") + responses = [ + completed(json.dumps(pending)), + completed("synced\n"), + completed(json.dumps(wrong)), + completed("graph bytes must not be queried\n"), + completed(healthy_status(self.repo)), + ] + calls = [] + + def runner(command, **_kwargs): + calls.append(command) + return responses.pop(0) + + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + result = self.proxy_module().execute_proxy_query( + self.repo, "TASK-0001", "PLANNING", "CIQ-001", + "locate A", "symbol A", runner=runner, + ) + + self.assertEqual( + [item[1] for item in calls], ["status", "sync", "status"] + ) + self.assertIsNone(result["response"]) + self.assertIsNone(result["bundle"]["response_path"]) + self.assertEqual(result["bundle"]["delivery"]["state"], "UNKNOWN") + self.assertEqual( + result["bundle"]["delivery"]["reason"], "PROJECT_MISMATCH" + ) + self.assertIn( + "different project", result["bundle"]["post_sync_status"]["error"] + ) + self.assertEqual( + [ + point["reason"] + for point in result["bundle"]["delivery"]["stale_points"] + ], + ["STATUS_UNREADABLE", "SYNC_FAILED"], + ) + + def test_proxy_discards_response_after_post_query_project_mismatch(self) -> None: + self.qualify_task() + (self.repo / ".codegraph").mkdir() + wrong = json.loads(healthy_status(self.repo)) + wrong["projectPath"] = str(self.repo / "other-checkout") + responses = [ + completed(healthy_status(self.repo)), + completed("graph bytes must be discarded\n"), + completed(json.dumps(wrong)), + ] + calls = [] + + def runner(command, **_kwargs): + calls.append(command) + return responses.pop(0) + + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + result = self.proxy_module().execute_proxy_query( + self.repo, "TASK-0001", "PLANNING", "CIQ-001", + "locate A", "symbol A", runner=runner, + ) + + response_path = ( + self.repo + / ".polaris/tasks/TASK-0001/runtime/code-intelligence/planning" + / "CIQ-001.response.txt" + ) + self.assertEqual( + [item[1] for item in calls], ["status", "explore", "status"] + ) + self.assertIsNone(result["response"]) + self.assertIsNone(result["bundle"]["response_path"]) + self.assertFalse(response_path.exists()) + self.assertEqual(result["bundle"]["delivery"]["state"], "UNKNOWN") + self.assertEqual( + result["bundle"]["delivery"]["reason"], "PROJECT_MISMATCH" + ) + def test_proxy_queries_unknown_pre_status_and_treats_result_as_stale(self) -> None: self.qualify_task() (self.repo / ".codegraph").mkdir() @@ -584,6 +674,66 @@ def runner(command, **_kwargs): self.assertEqual(result["bundle"]["delivery"]["state"], "UNKNOWN") self.assertIn("freshness: TREAT_AS_STALE", result["envelope"]) + def test_proxy_unknown_pre_status_overrides_later_stale_signals(self) -> None: + cases = [ + ( + "post_pending", + "graph bytes\n", + "PENDING_CHANGES", + ), + ( + "response_stale", + "⚠️ CodeGraph auto-sync is DISABLED — the index is frozen.\n", + "AUTO_SYNC_DISABLED", + ), + ] + for index, (case, response, stale_reason) in enumerate(cases, start=1): + with self.subTest(case=case): + if index > 1: + self.tearDown() + self.setUp() + self.qualify_task() + (self.repo / ".codegraph").mkdir() + post_status = json.loads(healthy_status(self.repo)) + if case == "post_pending": + post_status["pendingChanges"]["modified"] = 1 + responses = [ + completed("not-json\n"), + completed(response), + completed(json.dumps(post_status)), + ] + calls = [] + + def runner(command, **_kwargs): + calls.append(command) + return responses.pop(0) + + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + result = self.proxy_module().execute_proxy_query( + self.repo, "TASK-0001", "PLANNING", "CIQ-001", + "locate A", "symbol A", runner=runner, + ) + + self.assertEqual( + [item[1] for item in calls], ["status", "explore", "status"] + ) + self.assertEqual(result["response"], response) + self.assertEqual(result["bundle"]["delivery"]["state"], "UNKNOWN") + self.assertEqual( + result["bundle"]["delivery"]["reason"], "STATUS_UNREADABLE" + ) + self.assertIn( + stale_reason, + [ + point["reason"] + for point in result["bundle"]["delivery"]["stale_points"] + ], + ) + self.assertIn("freshness: TREAT_AS_STALE", result["envelope"]) + def test_proxy_does_not_query_a_different_project_index(self) -> None: self.qualify_task() (self.repo / ".codegraph").mkdir() From 6a9ef4f42d771478cee7eba3b4a04a598e8515d9 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 17:54:24 +0800 Subject: [PATCH 07/13] docs: define automatic CodeGraph freshness behavior --- README.md | 4 ++-- README.zh-CN.md | 4 ++-- docs/USAGE.md | 8 ++++---- plan.md | 6 +++--- skills/adversarial-review/SKILL.md | 2 +- skills/architecture-planning/SKILL.md | 2 +- skills/code-intelligence/SKILL.md | 6 +++--- skills/documentation-sync/SKILL.md | 4 ++-- skills/implementation/SKILL.md | 2 +- templates/AGENTS.md | 2 +- tests/test_codegraph.py | 25 ++++++++++++++++++++++++- 11 files changed, 44 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 797faa4..fe1717f 100644 --- a/README.md +++ b/README.md @@ -91,9 +91,9 @@ polaris code-intelligence add codegraph --repo . Run these commands from the target repository as appropriate. `codegraph init` creates the `.codegraph/` marker; without it Polaris uses source and Git directly and creates no stage record. Vendoring registers the project-scoped `polaris-codegraph` proxy in `.codex/config.toml` and `.mcp.json` without replacing unrelated settings. The host may require project trust or first-use approval; that approval remains the user's decision. -Polaris stages call only `polaris_codegraph_explore`. The proxy checks status, may internally perform one bounded `codegraph sync` when requested and pending, runs one explore, rechecks status, and returns a freshness envelope before graph content. There is no separate stage status/sync MCP call. `CURRENT` means `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN` mean `NAVIGATION_ONLY` and require the named source/Git fallback; `UNAVAILABLE` means no graph. A current named file uses `READ_SOURCE`, a deleted file uses `INSPECT_GIT_DIFF`, and an index-wide or unsafe result uses `SEARCH_SOURCE`. Validation remains graph-free and relies on source, Git, builds, tests, static checks, and Human Checks. +Polaris stages call only `polaris_codegraph_explore`. The proxy checks status and automatically runs at most one bounded incremental `codegraph sync` when pending changes exist, then runs one explore, rechecks status, and returns a freshness envelope before graph content. There is no separate stage status/sync MCP call. `CURRENT` means `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN`/`TREAT_AS_STALE` mean `NAVIGATION_ONLY` and require the exact source/Git fallback before a conclusion is used; `UNAVAILABLE` means no graph. A current named file uses `READ_SOURCE`, a deleted file uses `INSPECT_GIT_DIFF`, and an index-wide or unsafe result uses `SEARCH_SOURCE`. Validation remains graph-free and relies on source, Git, builds, tests, static checks, and Human Checks. -The repository owner, not Polaris, owns CodeGraph installation, initialization, configuration, raw MCP registration, watcher, and daemon. Polaris never starts, configures, reconfigures, waits for, or manages them. Raw `codegraph_explore` or `codegraph explore` remains available out-of-band but cannot back `CURRENT` Polaris evidence. New records are v3 projections of the retained proxy bundle and completed fallbacks; v1/v2 are historical only. CodeGraph remains optional and never becomes a workflow gate. +The repository owner, not Polaris, owns CodeGraph installation, initialization, configuration, raw MCP registration, watcher, daemon, and every full `codegraph index` rebuild. Polaris never starts, configures, reconfigures, waits for, or manages them. Raw `codegraph_explore` or `codegraph explore` remains available out-of-band but cannot back `CURRENT` Polaris evidence. New records are v3 projections of the retained proxy bundle and completed fallbacks; v1/v2 are historical only. CodeGraph remains optional and never becomes a workflow gate. Protocol `0.1.21` adds the project-scoped Polaris CodeGraph proxy, host adapter v3 registration, and auditable Code Intelligence record v3 while leaving Workflow at `0.1.3`. Record v1 and v2 are immutable historical evidence only; new evidence is projected from a retained proxy bundle into v3. diff --git a/README.zh-CN.md b/README.zh-CN.md index c7d1397..0ae2a8a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -91,9 +91,9 @@ polaris code-intelligence add codegraph --repo . `codegraph init` 创建 `.codegraph/` marker;没有 marker 时 Polaris 直接使用源码和 Git,不生成阶段 record。Vendoring 会在 `.codex/config.toml` 与 `.mcp.json` 中非破坏地注册项目级 `polaris-codegraph` 代理,并保留其他设置。宿主可能要求信任项目或首次使用确认;是否批准仍由用户决定。 -Polaris 阶段只调用 `polaris_codegraph_explore`。代理先检查 status,按请求且确有 pending 时至多执行一次有界 `codegraph sync`,再执行一次 explore、复查 status,并保证 freshness envelope 位于图内容之前;阶段没有独立的 status/sync MCP 调用。`CURRENT` 表示 `NON_AUTHORITATIVE_CONTEXT`;`STALE` 与 `UNKNOWN` 表示 `NAVIGATION_ONLY`,必须完成 envelope 指定的源码/Git 回退;`UNAVAILABLE` 表示没有图内容。当前具名文件使用 `READ_SOURCE`,已删除文件使用 `INSPECT_GIT_DIFF`,索引级或不安全结果使用 `SEARCH_SOURCE`。Validation 不调用 CodeGraph,仍以源码、Git、构建、测试、静态检查和 Human Check 为准。 +Polaris 阶段只调用 `polaris_codegraph_explore`。代理先检查 status,存在 pending changes 时自动且至多执行一次有界增量 `codegraph sync`,再执行一次 explore、复查 status,并保证 freshness envelope 位于图内容之前;阶段没有独立的 status/sync MCP 调用。`CURRENT` 表示 `NON_AUTHORITATIVE_CONTEXT`;`STALE` 与 `UNKNOWN`/`TREAT_AS_STALE` 表示 `NAVIGATION_ONLY`,在使用任何结论前必须完成 envelope 指定的精确源码/Git 回退;`UNAVAILABLE` 表示没有图内容。当前具名文件使用 `READ_SOURCE`,已删除文件使用 `INSPECT_GIT_DIFF`,索引级或不安全结果使用 `SEARCH_SOURCE`。Validation 不调用 CodeGraph,仍以源码、Git、构建、测试、静态检查和 Human Check 为准。 -CodeGraph 的安装、初始化、配置、raw MCP 注册、watcher 与 daemon 归仓库所有者,而不是 Polaris。Polaris 绝不启动、配置、重新配置、等待或管理这些能力。raw `codegraph_explore` 或 `codegraph explore` 仍可作为带外工具使用,但不能支持 Polaris 的 `CURRENT` 证据。新 record 必须由保留的代理 bundle 与已完成回退投影为 v3;v1/v2 仅供历史读取。CodeGraph 始终可选,永远不是 Workflow 门禁。 +CodeGraph 的安装、初始化、配置、raw MCP 注册、watcher、daemon 与每次全量 `codegraph index` 重建都归仓库所有者,而不是 Polaris;全量重建始终由用户主动执行。Polaris 绝不启动、配置、重新配置、等待或管理这些能力。raw `codegraph_explore` 或 `codegraph explore` 仍可作为带外工具使用,但不能支持 Polaris 的 `CURRENT` 证据。新 record 必须由保留的代理 bundle 与已完成回退投影为 v3;v1/v2 仅供历史读取。CodeGraph 始终可选,永远不是 Workflow 门禁。 协议 `0.1.21` 新增项目级 Polaris CodeGraph 代理、Host Adapter v3 注册和可审计的 Code Intelligence record v3,Workflow 仍为 `0.1.3`。record v1/v2 仅作为不可变历史证据读取;新证据必须由保留的代理 bundle 投影为 v3。 diff --git a/docs/USAGE.md b/docs/USAGE.md index ed1f6da..f711dee 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -219,13 +219,13 @@ Python CLI 无法直接查看 Codex 或 Claude Code 当前会话中的 MCP 工 } ``` -CodeGraph watcher 与连接时 reconciliation 是常规实时更新机制。Polaris 阶段只调用 `polaris_codegraph_explore`:代理在同一有界窗口内检查 status,按调用参数且确有 pending 时至多运行一次 `codegraph sync`,执行一次 explore,再复查 status。阶段没有独立的 status/sync MCP 工具,也不会等待 watcher、轮询、重试、启动 daemon 或改写用户的 raw MCP 配置。Documentation Sync 仅在 supported source 变化时执行一次查询,使用 `sync_if_needed: true`,并把 query 限制到 changed source paths 与 documented symbols。 +CodeGraph watcher 与连接时 reconciliation 是常规实时更新机制。Polaris 阶段只调用 `polaris_codegraph_explore`:代理在同一有界窗口内检查 status,存在 pending changes 时自动且至多运行一次增量 `codegraph sync`,执行一次 explore,再复查 status。阶段没有独立的 status/sync MCP 工具,也不会等待 watcher、轮询、重试、启动 daemon 或改写用户的 raw MCP 配置。Documentation Sync 仅在 supported source 变化时执行一次查询,把 query 限制到 changed source paths 与 documented symbols;automatic incremental sync 仅由代理负责。 -代理结果的第一个内容块总是 freshness envelope。`CURRENT / NON_AUTHORITATIVE_CONTEXT` 表示图可作为非权威上下文;`STALE / NAVIGATION_ONLY` 表示已知失效;`UNKNOWN / NAVIGATION_ONLY` 表示无法证明新鲜度;`UNAVAILABLE / NO_GRAPH` 表示没有图输出。任何状态都不宣称与 Git commit 严格一致,`UNKNOWN` 绝不能当作 current。raw `codegraph_explore` 或 `codegraph explore` 仍可由用户带外调用,但不能支持 Polaris 的 `CURRENT` 证据。 +代理结果的第一个内容块总是 freshness envelope。`CURRENT / NON_AUTHORITATIVE_CONTEXT` 表示图可作为非权威上下文;`STALE / NAVIGATION_ONLY` 表示已知失效;`UNKNOWN / TREAT_AS_STALE / NAVIGATION_ONLY` 表示无法证明新鲜度;`UNAVAILABLE / NO_GRAPH` 表示没有图输出。任何状态都不宣称与 Git commit 严格一致,`UNKNOWN` 必须按 `TREAT_AS_STALE` 处理,绝不能当作 current。raw `codegraph_explore` 或 `codegraph explore` 仍可由用户带外调用,但不能支持 Polaris 的 `CURRENT` 证据。 -`STALE` 或 `UNKNOWN` 必须先完成 envelope 指定的源码/Git fallback。当前具名普通文件直接读取并记录 `READ_SOURCE` 与当前 SHA-256;安全但已删除的路径检查注册 subject 的 Git diff,记录 `INSPECT_GIT_DIFF`、null observed SHA-256 与 base/head/diff hashes;不安全路径或索引级失效执行 `SEARCH_SOURCE`,记录有限、受限的当前文件路径与 SHA-256。图不能扩大冻结 scope、替代源码或决定 Review verdict,Validation 完全不调用 CodeGraph。 +`STALE` 或 `UNKNOWN`/`TREAT_AS_STALE` 必须先完成 envelope 指定的精确源码/Git fallback。当前具名普通文件直接读取并记录 `READ_SOURCE` 与当前 SHA-256;安全但已删除的路径检查注册 subject 的 Git diff,记录 `INSPECT_GIT_DIFF`、null observed SHA-256 与 base/head/diff hashes;不安全路径或索引级失效执行 `SEARCH_SOURCE`,记录有限、受限的当前文件路径与 SHA-256。图不能扩大冻结 scope、替代源码或决定 Review verdict,Validation 完全不调用 CodeGraph。 -每次代理调用都会把精确响应和 bundle 留在 ignored 的 `runtime/code-intelligence/`。完成 fallback 后,Agent 写只含 summary、已确认 symbols 和 source_fallbacks 的 annotations JSON,再运行 `record_code_intelligence.py --repo . --bundle --annotations ` 投影不可变 v3 record。不得手写 record;没有代理调用就省略 record。v1/v2 record 仅作为不可变历史证据读取。 +每次代理调用都会把精确响应和 bundle 留在 ignored 的 `runtime/code-intelligence/`。完成 fallback 后,Agent 写只含 summary、已确认 symbols 和 source_fallbacks 的 annotations JSON,再运行 `record_code_intelligence.py --repo . --bundle --annotations ` 投影不可变 v3 record。不得手写 record;没有代理调用就省略 record。全量 `codegraph index` 始终由用户主动执行,v1/v2 record 仅作为不可变历史证据读取。 ## 4. Polaris 仓库自举 diff --git a/plan.md b/plan.md index d383d9c..e603817 100644 --- a/plan.md +++ b/plan.md @@ -488,9 +488,9 @@ AGENTS.md - v0.1 的唯一正式 Provider 是 [colbymchenry/codegraph](https://github.com/colbymchenry/codegraph)。项目级 `polaris-codegraph` MCP 只暴露 `polaris_codegraph_explore`;raw `codegraph_explore` 与 shell 仍可带外使用,但不能支持 Polaris `CURRENT` 证据。 - `.codegraph/` 与 CodeGraph 安装、初始化、配置、raw MCP、watcher 和 daemon 由用户拥有。Polaris 只非破坏地管理自身项目代理注册;缺少 marker 或策略禁用时直接回退源码,不生成阶段 record。 -- 代理在一个有界窗口内完成 pre-status、可选一次 `codegraph sync`、一次 explore 和 post-status,并先返回 freshness envelope。阶段不分别选择 status/sync;不等待、轮询或重试。 -- envelope 状态为 `CURRENT / NON_AUTHORITATIVE_CONTEXT`、`STALE / NAVIGATION_ONLY`、`UNKNOWN / NAVIGATION_ONLY` 或 `UNAVAILABLE / NO_GRAPH`。`STALE`/`UNKNOWN` 必须先完成具名 `READ_SOURCE`、删除路径 `INSPECT_GIT_DIFF` 或索引级 `SEARCH_SOURCE` 回退;`UNKNOWN` 不得提升为 current。 -- Planning、Implementation 与 Reviewer 只在冻结范围内使用图关系;Implementation 修改关系后必须 fresh proxy call,Reviewer 必须独立调用且不得继承 Implementer envelope。Documentation Sync 仅在 supported source 改变时,以 `sync_if_needed: true` 对 changed paths/symbols执行一次查询。Validation 完全不调用 CodeGraph。 +- 代理在一个有界窗口内完成 pre-status、存在 pending changes 时自动且至多一次增量 `codegraph sync`、一次 explore 和 post-status,并先返回 freshness envelope。阶段不分别选择 status/sync;不等待、轮询或重试;全量 `codegraph index` 始终由用户主动执行。 +- envelope 状态为 `CURRENT / NON_AUTHORITATIVE_CONTEXT`、`STALE / NAVIGATION_ONLY`、`UNKNOWN / TREAT_AS_STALE / NAVIGATION_ONLY` 或 `UNAVAILABLE / NO_GRAPH`。`STALE`/`UNKNOWN` 必须先完成具名 `READ_SOURCE`、删除路径 `INSPECT_GIT_DIFF` 或索引级 `SEARCH_SOURCE` 的精确源码/Git fallback;`UNKNOWN` 必须按 `TREAT_AS_STALE` 处理,不得提升为 current。 +- Planning、Implementation 与 Reviewer 只在冻结范围内使用图关系;Implementation 修改关系后必须 fresh proxy call,Reviewer 必须独立调用且不得继承 Implementer envelope。Documentation Sync 仅在 supported source 改变时,对 changed paths/symbols 执行一次查询,自动增量同步由代理负责。Validation 完全不调用 CodeGraph。 - 代理 bundle 与原始响应只进入 ignored runtime。Agent 完成 fallback 后提供 annotations,由 `record_code_intelligence.py --bundle ... --annotations ...` 投影不可变 v3 record;没有代理调用就省略 record。v1/v2 仅作为不可变历史证据读取。 ## 9. 确定性脚本 diff --git a/skills/adversarial-review/SKILL.md b/skills/adversarial-review/SKILL.md index 421befd..c54406b 100644 --- a/skills/adversarial-review/SKILL.md +++ b/skills/adversarial-review/SKILL.md @@ -22,4 +22,4 @@ Return a concise structured result to the dispatcher with verdict, Review attemp Only the Reviewer context may write `ACCEPT`. -Proxy evidence contract: `CURRENT` is `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN` are `NAVIGATION_ONLY`. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only completed current source/Git fallback evidence does, and index-wide uncertainty affects the entire graph response. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after fallback is required. A raw `codegraph_explore` or `codegraph explore` result is out-of-band and cannot back `CURRENT` Polaris evidence. Never run `codegraph init` or manage the Provider. Graph evidence cannot determine the Review verdict. +Proxy evidence contract: the proxy automatically runs at most one incremental `codegraph sync` when pending changes exist and never runs `codegraph index`. `CURRENT` is `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN`/`TREAT_AS_STALE` are `NAVIGATION_ONLY` and require the exact source/Git fallback before any conclusion is used. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only completed current source/Git fallback evidence does, and index-wide uncertainty affects the entire graph response. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after fallback is required. A raw `codegraph_explore` or `codegraph explore` result is out-of-band and cannot back `CURRENT` Polaris evidence. Never run `codegraph init` or manage the Provider. Graph evidence cannot determine the Review verdict. diff --git a/skills/architecture-planning/SKILL.md b/skills/architecture-planning/SKILL.md index e89fe2f..f1dd333 100644 --- a/skills/architecture-planning/SKILL.md +++ b/skills/architecture-planning/SKILL.md @@ -21,4 +21,4 @@ After the transition succeeds, reload state and emit `[POLARIS:PLAN_READY]` with Do not modify the frozen Work Item or start implementation from this stage. -Proxy evidence contract: `CURRENT` is `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN` are `NAVIGATION_ONLY`. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only completed current source/Git fallback evidence does, and index-wide uncertainty affects the entire graph response. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after fallback is required. A raw `codegraph_explore` or `codegraph explore` result is out-of-band and cannot back `CURRENT` Polaris evidence. After a proxy operation, write annotations and run `record_code_intelligence.py --repo . --bundle --annotations ` to project v3; without a proxy operation, omit the Code Intelligence record. Never run `codegraph init` or manage the Provider. +Proxy evidence contract: the proxy automatically runs at most one incremental `codegraph sync` when pending changes exist and never runs `codegraph index`. `CURRENT` is `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN`/`TREAT_AS_STALE` are `NAVIGATION_ONLY` and require the exact source/Git fallback before any conclusion is used. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only completed current source/Git fallback evidence does, and index-wide uncertainty affects the entire graph response. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after fallback is required. A raw `codegraph_explore` or `codegraph explore` result is out-of-band and cannot back `CURRENT` Polaris evidence. After a proxy operation, write annotations and run `record_code_intelligence.py --repo . --bundle --annotations ` to project v3; without a proxy operation, omit the Code Intelligence record. Never run `codegraph init` or manage the Provider. diff --git a/skills/code-intelligence/SKILL.md b/skills/code-intelligence/SKILL.md index e584d19..96414d3 100644 --- a/skills/code-intelligence/SKILL.md +++ b/skills/code-intelligence/SKILL.md @@ -8,10 +8,10 @@ description: Internal optional Polaris stage support for bounded CodeGraph relat CodeGraph is optional navigation context. Source, Git, builds, tests, frozen artifacts, Review, Validation, and Human decisions remain authority. 1. Load `.polaris/code-intelligence.json` and project rules. If policy disables Code Intelligence or the repository root lacks `.codegraph/`, skip the proxy, use source/Git, and omit the Code Intelligence record because no proxy operation ran. Never run `codegraph init`. -2. For Polaris graph evidence call only `polaris_codegraph_explore`, using the active task ID, legal stage, next `CIQ-NNN`, bounded purpose/query, and the stage's declared `sync_if_needed` value. The project registration fixes the repository root. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after an envelope requires the stage fallback. +2. Call only `polaris_codegraph_explore` with task ID, stage, next `CIQ-NNN`, purpose, and query. The proxy automatically runs at most one incremental `codegraph sync` when pending changes exist and never runs `codegraph index`. The project registration fixes the repository root. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after an envelope requires the stage fallback. 3. Read the `freshness envelope` before any graph content: - `CURRENT` with `usage: NON_AUTHORITATIVE_CONTEXT` permits the graph only as non-authoritative context. - - `STALE` or `UNKNOWN` with `usage: NAVIGATION_ONLY` requires every named source/Git fallback. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only the resulting current source/Git evidence does. Index-wide uncertainty affects the entire graph response. `UNKNOWN` is never current. + - `STALE` and `UNKNOWN`/`TREAT_AS_STALE` with `usage: NAVIGATION_ONLY` require the exact source/Git fallback before any conclusion is used. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only the resulting current source/Git evidence does. Index-wide uncertainty affects the entire graph response. `UNKNOWN` is never current. - `UNAVAILABLE` with `usage: NO_GRAPH` means use source/Git and do not expect graph content. 4. Complete fallbacks exactly. For a safe named current regular file, read it and record `READ_SOURCE` with its current SHA-256. For a safe missing/deleted path, inspect the registered subject diff and record `INSPECT_GIT_DIFF` with null observed SHA-256 and bound base/head/diff hashes. For an unsafe path or index-wide stale/unknown result, perform an actual bounded repository search and record `SEARCH_SOURCE` with zero to 100 unique confined POSIX `result_paths`, each a current regular file and current SHA-256; an empty result is valid only when that search found no current file. In `STALE`/`UNKNOWN`, annotate a symbol only when its current path is covered by `READ_SOURCE` or a hashed `SEARCH_SOURCE` result. 5. A raw `codegraph_explore` MCP call or `codegraph explore` shell command remains user-accessible out-of-band, but its output is always unverified for Polaris and cannot back `CURRENT` Polaris evidence. Never project raw Provider output into a Polaris record. @@ -22,6 +22,6 @@ Stage policy: - Planning: query only frozen-task relationships needed to justify Working Set entries. Confirm safe current returned paths in current source before recording the query ID as `discovered_from`; confirm a safe missing/deleted path through the registered subject Git diff instead. - Implementation: make a bounded handoff-scoped call before editing when useful. Any conclusion needed after edits requires a fresh `polaris_codegraph_explore` call; never reuse the entry freshness envelope. If an earlier non-current envelope ended graph use for the stage, use source/Git only rather than making that post-edit call. -- Documentation Sync: only when supported source changed, make one query over changed source paths and documented symbols with `sync_if_needed: true`; there is no separate status/sync MCP tool. +- Documentation Sync: only when supported source changed, make one query over changed source paths and documented symbols; the proxy's automatic incremental sync is the only freshness action, and there is no separate status/sync MCP tool. - Review: independently query only registered-subject impact relationships. Never inherit or reuse the Implementer's envelope, bundle, or conclusions. - Validation: do not invoke this Skill. Validation remains graph-free. diff --git a/skills/documentation-sync/SKILL.md b/skills/documentation-sync/SKILL.md index c9793b4..b5bb40b 100644 --- a/skills/documentation-sync/SKILL.md +++ b/skills/documentation-sync/SKILL.md @@ -12,7 +12,7 @@ description: Internal Polaris worker stage for an explicitly started `{{skill:en 5. Record failed attempts with `record_exploration.py`. Keep task-only conclusions in the task; promote reusable, evidence-backed conclusions to `.polaris/explorations/` with the same script. 6. Leave no unresolved `STALE` entry. 7. Create the final subject checkpoint and recompute the subject diff hash. -8. When the final subject includes supported source changes, policy is enabled, and `.codegraph/` exists, invoke `{{skill:code-intelligence}}` once at the Documentation Sync boundary. Call `polaris_codegraph_explore` with stage `DOCUMENTATION_SYNC`, a query limited to changed source paths and documented symbols, and `sync_if_needed: true`; there is no separate status/sync MCP tool. Read the freshness envelope first and finish every required source/Git fallback. Then create annotations and run `record_code_intelligence.py --repo . --bundle --annotations ` to project the immutable v3 record and reference it from the Knowledge Delta. Otherwise omit the Code Intelligence record and optional artifact reference. +8. When the final subject includes supported source changes, policy is enabled, and `.codegraph/` exists, invoke `{{skill:code-intelligence}}` once at the Documentation Sync boundary. Call `polaris_codegraph_explore` with stage `DOCUMENTATION_SYNC` and a query limited to changed source paths and documented symbols; automatic incremental sync is owned by the proxy, and there is no separate status/sync MCP tool. Read the freshness envelope first and finish every required source/Git fallback. Then create annotations and run `record_code_intelligence.py --repo . --bundle --annotations ` to project the immutable v3 record and reference it from the Knowledge Delta. Otherwise omit the Code Intelligence record and optional artifact reference. 9. Refresh the Working Set if a promoted exploration, documentation change, or confirmed Code Intelligence dependency alters the next stage's justified inputs. 10. Run `check_docs.py` with the final subject base/head. When live telemetry exists, append its result with `ADD_CHECK`, then use `SET_PHASE` to enter `COMPLETED` with no blocker. Return the Knowledge Delta path, final subject base/head, diff hash, changed documentation, promoted explorations, Code Intelligence refresh status, and check result. @@ -20,4 +20,4 @@ Do not run workflow transitions or emit a Polaris checkpoint marker. The main `{ Do not edit Review, Validation, Result, event, or state artifacts directly. -Proxy evidence contract: `CURRENT` is `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN` are `NAVIGATION_ONLY`. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only completed current source/Git fallback evidence does, and index-wide uncertainty affects the entire graph response. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after fallback is required. A raw `codegraph_explore` or `codegraph explore` result is out-of-band and cannot back `CURRENT` Polaris evidence. Never run `codegraph init` or manage the Provider. Graph evidence never gates documentation checks or state changes. +Proxy evidence contract: the proxy automatically runs at most one incremental `codegraph sync` when pending changes exist and never runs `codegraph index`. `CURRENT` is `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN`/`TREAT_AS_STALE` are `NAVIGATION_ONLY` and require the exact source/Git fallback before any conclusion is used. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only completed current source/Git fallback evidence does, and index-wide uncertainty affects the entire graph response. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after fallback is required. A raw `codegraph_explore` or `codegraph explore` result is out-of-band and cannot back `CURRENT` Polaris evidence. Never run `codegraph init` or manage the Provider. Graph evidence never gates documentation checks or state changes. diff --git a/skills/implementation/SKILL.md b/skills/implementation/SKILL.md index 95240af..5888516 100644 --- a/skills/implementation/SKILL.md +++ b/skills/implementation/SKILL.md @@ -19,4 +19,4 @@ description: Internal Polaris worker stage for an explicitly started `{{skill:en Do not run workflow transitions, Review, Validation, or task closure. Do not emit a Polaris checkpoint marker; the main `{{skill:engineering-task}}` validates the artifact and continues this same task for `{{skill:documentation-sync}}` while authority remains `IMPLEMENTING`. -Proxy evidence contract: `CURRENT` is `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN` are `NAVIGATION_ONLY`. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only completed current source/Git fallback evidence does, and index-wide uncertainty affects the entire graph response. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after fallback is required. A raw `codegraph_explore` or `codegraph explore` result is out-of-band and cannot back `CURRENT` Polaris evidence. Never run `codegraph init` or manage the Provider. +Proxy evidence contract: the proxy automatically runs at most one incremental `codegraph sync` when pending changes exist and never runs `codegraph index`. `CURRENT` is `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN`/`TREAT_AS_STALE` are `NAVIGATION_ONLY` and require the exact source/Git fallback before any conclusion is used. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only completed current source/Git fallback evidence does, and index-wide uncertainty affects the entire graph response. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after fallback is required. A raw `codegraph_explore` or `codegraph explore` result is out-of-band and cannot back `CURRENT` Polaris evidence. Never run `codegraph init` or manage the Provider. diff --git a/templates/AGENTS.md b/templates/AGENTS.md index 30726bc..9af1977 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -16,7 +16,7 @@ ## Optional CodeGraph rules - Use CodeGraph only when project policy permits it and the repository root already contains `.codegraph/`. Otherwise skip the proxy, use source/Git, and omit the Code Intelligence record; agents never run `codegraph init`. -- For Polaris evidence call only `polaris_codegraph_explore` and read its freshness envelope before graph content. `CURRENT` is `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN` are `NAVIGATION_ONLY`. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only completed current source/Git fallback evidence does, and index-wide uncertainty affects the entire graph response. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after fallback is required. `UNAVAILABLE` means no graph. +- For Polaris evidence call only `polaris_codegraph_explore` and read its freshness envelope before graph content. The proxy automatically runs at most one incremental `codegraph sync` when pending changes exist and never runs `codegraph index`. `CURRENT` is `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN`/`TREAT_AS_STALE` are `NAVIGATION_ONLY` and require the exact source/Git fallback before any conclusion is used. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only completed current source/Git fallback evidence does, and index-wide uncertainty affects the entire graph response. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after fallback is required. `UNAVAILABLE` means no graph. - Complete fallbacks exactly: a safe current regular file uses `READ_SOURCE` with current SHA-256; a safe missing/deleted path uses `INSPECT_GIT_DIFF` with null observed SHA-256 and bound base/head/diff hashes; unsafe or index-wide stale/unknown results use `SEARCH_SOURCE` with finite confined POSIX result paths and current hashes. - A raw `codegraph_explore` or `codegraph explore` result is out-of-band and cannot back `CURRENT` Polaris evidence. If the proxy ran, write annotations and run `record_code_intelligence.py --repo . --bundle --annotations ` to project v3; do not hand-author a record. - Never install, initialize, start, authenticate, configure, reconfigure, or manage CodeGraph, its watcher, daemon, lock, raw MCP registration, or index. CodeGraph cannot expand frozen scope or replace source, Git, builds, tests, Review, Validation, or Human gates. diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 0d72b7b..7ff2c67 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -3329,6 +3329,29 @@ def assert_contract(text: str, label: str) -> None: with self.assertRaises(AssertionError): assert_contract(mutation, "mutated implementation") + def test_all_agent_surfaces_require_automatic_freshness_policy(self) -> None: + """Every CodeGraph-capable Agent surface follows proxy-owned freshness.""" + paths = [ + ROOT / "skills/code-intelligence/SKILL.md", + ROOT / "skills/architecture-planning/SKILL.md", + ROOT / "skills/implementation/SKILL.md", + ROOT / "skills/documentation-sync/SKILL.md", + ROOT / "skills/adversarial-review/SKILL.md", + ROOT / "templates/AGENTS.md", + ] + required = ( + "automatically runs at most one incremental `codegraph sync`", + "never runs `codegraph index`", + "UNKNOWN", + "TREAT_AS_STALE", + "source/Git fallback", + ) + for path in paths: + text = path.read_text(encoding="utf-8") + self.assertNotIn("sync_if_needed", text, path.as_posix()) + for anchor in required: + self.assertIn(anchor, text, f"{path}: {anchor}") + def test_documentation_sync_uses_one_proxy_query(self) -> None: """Documentation Sync uses one bounded changed-path/symbol proxy query.""" source = (ROOT / "skills/documentation-sync/SKILL.md").read_text( @@ -3343,9 +3366,9 @@ def test_documentation_sync_uses_one_proxy_query(self) -> None: ) for anchor in ( "polaris_codegraph_explore", - "sync_if_needed: true", "changed source paths", "documented symbols", + "automatic incremental sync", "no separate status/sync MCP tool", ): self.assertIn(anchor, rendered, f"{adapter['host_id']}: {anchor}") From 76c87b08db171deb387fbbf457abfaa62e6b2fc1 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 17:59:37 +0800 Subject: [PATCH 08/13] docs: clarify CodeGraph unknown-status queries --- README.md | 2 ++ README.zh-CN.md | 2 ++ docs/USAGE.md | 2 ++ plan.md | 1 + skills/code-intelligence/SKILL.md | 2 +- templates/AGENTS.md | 2 +- tests/test_codegraph.py | 39 +++++++++++++++++++++++++++++++ 7 files changed, 48 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fe1717f..5999a29 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,8 @@ Run these commands from the target repository as appropriate. `codegraph init` c Polaris stages call only `polaris_codegraph_explore`. The proxy checks status and automatically runs at most one bounded incremental `codegraph sync` when pending changes exist, then runs one explore, rechecks status, and returns a freshness envelope before graph content. There is no separate stage status/sync MCP call. `CURRENT` means `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN`/`TREAT_AS_STALE` mean `NAVIGATION_ONLY` and require the exact source/Git fallback before a conclusion is used; `UNAVAILABLE` means no graph. A current named file uses `READ_SOURCE`, a deleted file uses `INSPECT_GIT_DIFF`, and an index-wide or unsafe result uses `SEARCH_SOURCE`. Validation remains graph-free and relies on source, Git, builds, tests, static checks, and Human Checks. +When status cannot be verified but the project has a safe repository identity, the proxy still calls `polaris_codegraph_explore` and returns `UNKNOWN`/`TREAT_AS_STALE`. The graph remains navigation-only: use the exact source/Git fallback before any conclusion. + The repository owner, not Polaris, owns CodeGraph installation, initialization, configuration, raw MCP registration, watcher, daemon, and every full `codegraph index` rebuild. Polaris never starts, configures, reconfigures, waits for, or manages them. Raw `codegraph_explore` or `codegraph explore` remains available out-of-band but cannot back `CURRENT` Polaris evidence. New records are v3 projections of the retained proxy bundle and completed fallbacks; v1/v2 are historical only. CodeGraph remains optional and never becomes a workflow gate. Protocol `0.1.21` adds the project-scoped Polaris CodeGraph proxy, host adapter v3 registration, and auditable Code Intelligence record v3 while leaving Workflow at `0.1.3`. Record v1 and v2 are immutable historical evidence only; new evidence is projected from a retained proxy bundle into v3. diff --git a/README.zh-CN.md b/README.zh-CN.md index 0ae2a8a..53b9e0c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -93,6 +93,8 @@ polaris code-intelligence add codegraph --repo . Polaris 阶段只调用 `polaris_codegraph_explore`。代理先检查 status,存在 pending changes 时自动且至多执行一次有界增量 `codegraph sync`,再执行一次 explore、复查 status,并保证 freshness envelope 位于图内容之前;阶段没有独立的 status/sync MCP 调用。`CURRENT` 表示 `NON_AUTHORITATIVE_CONTEXT`;`STALE` 与 `UNKNOWN`/`TREAT_AS_STALE` 表示 `NAVIGATION_ONLY`,在使用任何结论前必须完成 envelope 指定的精确源码/Git 回退;`UNAVAILABLE` 表示没有图内容。当前具名文件使用 `READ_SOURCE`,已删除文件使用 `INSPECT_GIT_DIFF`,索引级或不安全结果使用 `SEARCH_SOURCE`。Validation 不调用 CodeGraph,仍以源码、Git、构建、测试、静态检查和 Human Check 为准。 +当 status 无法验证但仓库身份安全时,代理仍执行 `polaris_codegraph_explore`,并返回 `UNKNOWN`/`TREAT_AS_STALE`。图只用于导航;在使用任何结论前,必须完成精确源码/Git 回退。 + CodeGraph 的安装、初始化、配置、raw MCP 注册、watcher、daemon 与每次全量 `codegraph index` 重建都归仓库所有者,而不是 Polaris;全量重建始终由用户主动执行。Polaris 绝不启动、配置、重新配置、等待或管理这些能力。raw `codegraph_explore` 或 `codegraph explore` 仍可作为带外工具使用,但不能支持 Polaris 的 `CURRENT` 证据。新 record 必须由保留的代理 bundle 与已完成回退投影为 v3;v1/v2 仅供历史读取。CodeGraph 始终可选,永远不是 Workflow 门禁。 协议 `0.1.21` 新增项目级 Polaris CodeGraph 代理、Host Adapter v3 注册和可审计的 Code Intelligence record v3,Workflow 仍为 `0.1.3`。record v1/v2 仅作为不可变历史证据读取;新证据必须由保留的代理 bundle 投影为 v3。 diff --git a/docs/USAGE.md b/docs/USAGE.md index f711dee..8c8fd5e 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -221,6 +221,8 @@ Python CLI 无法直接查看 Codex 或 Claude Code 当前会话中的 MCP 工 CodeGraph watcher 与连接时 reconciliation 是常规实时更新机制。Polaris 阶段只调用 `polaris_codegraph_explore`:代理在同一有界窗口内检查 status,存在 pending changes 时自动且至多运行一次增量 `codegraph sync`,执行一次 explore,再复查 status。阶段没有独立的 status/sync MCP 工具,也不会等待 watcher、轮询、重试、启动 daemon 或改写用户的 raw MCP 配置。Documentation Sync 仅在 supported source 变化时执行一次查询,把 query 限制到 changed source paths 与 documented symbols;automatic incremental sync 仅由代理负责。 +当 status 无法验证但仓库身份安全时,代理仍执行 `polaris_codegraph_explore`,并返回 `UNKNOWN`/`TREAT_AS_STALE`。图只用于导航;在使用任何结论前,必须完成精确源码/Git fallback。 + 代理结果的第一个内容块总是 freshness envelope。`CURRENT / NON_AUTHORITATIVE_CONTEXT` 表示图可作为非权威上下文;`STALE / NAVIGATION_ONLY` 表示已知失效;`UNKNOWN / TREAT_AS_STALE / NAVIGATION_ONLY` 表示无法证明新鲜度;`UNAVAILABLE / NO_GRAPH` 表示没有图输出。任何状态都不宣称与 Git commit 严格一致,`UNKNOWN` 必须按 `TREAT_AS_STALE` 处理,绝不能当作 current。raw `codegraph_explore` 或 `codegraph explore` 仍可由用户带外调用,但不能支持 Polaris 的 `CURRENT` 证据。 `STALE` 或 `UNKNOWN`/`TREAT_AS_STALE` 必须先完成 envelope 指定的精确源码/Git fallback。当前具名普通文件直接读取并记录 `READ_SOURCE` 与当前 SHA-256;安全但已删除的路径检查注册 subject 的 Git diff,记录 `INSPECT_GIT_DIFF`、null observed SHA-256 与 base/head/diff hashes;不安全路径或索引级失效执行 `SEARCH_SOURCE`,记录有限、受限的当前文件路径与 SHA-256。图不能扩大冻结 scope、替代源码或决定 Review verdict,Validation 完全不调用 CodeGraph。 diff --git a/plan.md b/plan.md index e603817..227f77a 100644 --- a/plan.md +++ b/plan.md @@ -490,6 +490,7 @@ AGENTS.md - `.codegraph/` 与 CodeGraph 安装、初始化、配置、raw MCP、watcher 和 daemon 由用户拥有。Polaris 只非破坏地管理自身项目代理注册;缺少 marker 或策略禁用时直接回退源码,不生成阶段 record。 - 代理在一个有界窗口内完成 pre-status、存在 pending changes 时自动且至多一次增量 `codegraph sync`、一次 explore 和 post-status,并先返回 freshness envelope。阶段不分别选择 status/sync;不等待、轮询或重试;全量 `codegraph index` 始终由用户主动执行。 - envelope 状态为 `CURRENT / NON_AUTHORITATIVE_CONTEXT`、`STALE / NAVIGATION_ONLY`、`UNKNOWN / TREAT_AS_STALE / NAVIGATION_ONLY` 或 `UNAVAILABLE / NO_GRAPH`。`STALE`/`UNKNOWN` 必须先完成具名 `READ_SOURCE`、删除路径 `INSPECT_GIT_DIFF` 或索引级 `SEARCH_SOURCE` 的精确源码/Git fallback;`UNKNOWN` 必须按 `TREAT_AS_STALE` 处理,不得提升为 current。 +- 当 status 无法验证但仓库身份安全时,代理仍执行 `polaris_codegraph_explore` 并返回 `UNKNOWN`/`TREAT_AS_STALE`;图仅用于导航,任何结论都必须先完成精确源码/Git fallback。 - Planning、Implementation 与 Reviewer 只在冻结范围内使用图关系;Implementation 修改关系后必须 fresh proxy call,Reviewer 必须独立调用且不得继承 Implementer envelope。Documentation Sync 仅在 supported source 改变时,对 changed paths/symbols 执行一次查询,自动增量同步由代理负责。Validation 完全不调用 CodeGraph。 - 代理 bundle 与原始响应只进入 ignored runtime。Agent 完成 fallback 后提供 annotations,由 `record_code_intelligence.py --bundle ... --annotations ...` 投影不可变 v3 record;没有代理调用就省略 record。v1/v2 仅作为不可变历史证据读取。 diff --git a/skills/code-intelligence/SKILL.md b/skills/code-intelligence/SKILL.md index 96414d3..3c84a32 100644 --- a/skills/code-intelligence/SKILL.md +++ b/skills/code-intelligence/SKILL.md @@ -8,7 +8,7 @@ description: Internal optional Polaris stage support for bounded CodeGraph relat CodeGraph is optional navigation context. Source, Git, builds, tests, frozen artifacts, Review, Validation, and Human decisions remain authority. 1. Load `.polaris/code-intelligence.json` and project rules. If policy disables Code Intelligence or the repository root lacks `.codegraph/`, skip the proxy, use source/Git, and omit the Code Intelligence record because no proxy operation ran. Never run `codegraph init`. -2. Call only `polaris_codegraph_explore` with task ID, stage, next `CIQ-NNN`, purpose, and query. The proxy automatically runs at most one incremental `codegraph sync` when pending changes exist and never runs `codegraph index`. The project registration fixes the repository root. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after an envelope requires the stage fallback. +2. Call only `polaris_codegraph_explore` with task ID, stage, next `CIQ-NNN`, purpose, and query. The proxy automatically runs at most one incremental `codegraph sync` when pending changes exist and never runs `codegraph index`. When status cannot be verified but the project has a safe repository identity, the proxy still calls `polaris_codegraph_explore` and returns `UNKNOWN`/`TREAT_AS_STALE`; graph content remains navigation-only and any conclusion requires the exact source/Git fallback. The project registration fixes the repository root. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after an envelope requires the stage fallback. 3. Read the `freshness envelope` before any graph content: - `CURRENT` with `usage: NON_AUTHORITATIVE_CONTEXT` permits the graph only as non-authoritative context. - `STALE` and `UNKNOWN`/`TREAT_AS_STALE` with `usage: NAVIGATION_ONLY` require the exact source/Git fallback before any conclusion is used. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only the resulting current source/Git evidence does. Index-wide uncertainty affects the entire graph response. `UNKNOWN` is never current. diff --git a/templates/AGENTS.md b/templates/AGENTS.md index 9af1977..6eaaddb 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -16,7 +16,7 @@ ## Optional CodeGraph rules - Use CodeGraph only when project policy permits it and the repository root already contains `.codegraph/`. Otherwise skip the proxy, use source/Git, and omit the Code Intelligence record; agents never run `codegraph init`. -- For Polaris evidence call only `polaris_codegraph_explore` and read its freshness envelope before graph content. The proxy automatically runs at most one incremental `codegraph sync` when pending changes exist and never runs `codegraph index`. `CURRENT` is `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN`/`TREAT_AS_STALE` are `NAVIGATION_ONLY` and require the exact source/Git fallback before any conclusion is used. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only completed current source/Git fallback evidence does, and index-wide uncertainty affects the entire graph response. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after fallback is required. `UNAVAILABLE` means no graph. +- For Polaris evidence call only `polaris_codegraph_explore` and read its freshness envelope before graph content. The proxy automatically runs at most one incremental `codegraph sync` when pending changes exist and never runs `codegraph index`. When status cannot be verified but the project has a safe repository identity, the proxy still calls `polaris_codegraph_explore` and returns `UNKNOWN`/`TREAT_AS_STALE`; graph content remains navigation-only and any conclusion requires the exact source/Git fallback. `CURRENT` is `NON_AUTHORITATIVE_CONTEXT`; `STALE` and `UNKNOWN`/`TREAT_AS_STALE` are `NAVIGATION_ONLY` and require the exact source/Git fallback before any conclusion is used. `NAVIGATION_ONLY` never substantiates an edit or conclusion, even after fallback; only completed current source/Git fallback evidence does, and index-wide uncertainty affects the entire graph response. Use no separate status/sync MCP tool and do not retry, poll, wait, or run another query after fallback is required. `UNAVAILABLE` means no graph. - Complete fallbacks exactly: a safe current regular file uses `READ_SOURCE` with current SHA-256; a safe missing/deleted path uses `INSPECT_GIT_DIFF` with null observed SHA-256 and bound base/head/diff hashes; unsafe or index-wide stale/unknown results use `SEARCH_SOURCE` with finite confined POSIX result paths and current hashes. - A raw `codegraph_explore` or `codegraph explore` result is out-of-band and cannot back `CURRENT` Polaris evidence. If the proxy ran, write annotations and run `record_code_intelligence.py --repo . --bundle --annotations ` to project v3; do not hand-author a record. - Never install, initialize, start, authenticate, configure, reconfigure, or manage CodeGraph, its watcher, daemon, lock, raw MCP registration, or index. CodeGraph cannot expand frozen scope or replace source, Git, builds, tests, Review, Validation, or Human gates. diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 7ff2c67..bcc0db3 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -3352,6 +3352,45 @@ def test_all_agent_surfaces_require_automatic_freshness_policy(self) -> None: for anchor in required: self.assertIn(anchor, text, f"{path}: {anchor}") + def test_safe_identity_unknown_status_still_queries_and_is_stale(self) -> None: + """Safe identity keeps the bounded query available after an unreadable status.""" + paths = { + ROOT / "skills/code-intelligence/SKILL.md": ( + "safe repository identity", + "still calls `polaris_codegraph_explore`", + "UNKNOWN`/`TREAT_AS_STALE", + ), + ROOT / "templates/AGENTS.md": ( + "safe repository identity", + "still calls `polaris_codegraph_explore`", + "UNKNOWN`/`TREAT_AS_STALE", + ), + ROOT / "README.md": ( + "safe repository identity", + "still calls `polaris_codegraph_explore`", + "UNKNOWN`/`TREAT_AS_STALE", + ), + ROOT / "README.zh-CN.md": ( + "仓库身份安全", + "仍执行 `polaris_codegraph_explore`", + "UNKNOWN`/`TREAT_AS_STALE", + ), + ROOT / "docs/USAGE.md": ( + "仓库身份安全", + "仍执行 `polaris_codegraph_explore`", + "UNKNOWN`/`TREAT_AS_STALE", + ), + ROOT / "plan.md": ( + "仓库身份安全", + "仍执行 `polaris_codegraph_explore`", + "UNKNOWN`/`TREAT_AS_STALE", + ), + } + for path, required in paths.items(): + text = path.read_text(encoding="utf-8") + for anchor in required: + self.assertIn(anchor, text, f"{path}: {anchor}") + def test_documentation_sync_uses_one_proxy_query(self) -> None: """Documentation Sync uses one bounded changed-path/symbol proxy query.""" source = (ROOT / "skills/documentation-sync/SKILL.md").read_text( From 854a8808dd5e07e7aca756f3d2449fbbd5ada6e2 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 18:04:10 +0800 Subject: [PATCH 09/13] test: bind CodeGraph unknown-status query contract --- tests/test_codegraph.py | 71 +++++++++++++++++++++++++++-------------- 1 file changed, 47 insertions(+), 24 deletions(-) diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index bcc0db3..1ae6565 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -3295,7 +3295,7 @@ def test_all_agent_surfaces_require_proxy_provenance(self) -> None: ) available_skills = set(discover_skills(ROOT)) - def assert_contract(text: str, label: str) -> None: + def assert_contract(text: str, label: Path) -> None: for anchor in anchors: self.assertIn(anchor, text, f"{label}: {anchor}") self.assertIn("record_code_intelligence.py", text, label) @@ -3354,42 +3354,65 @@ def test_all_agent_surfaces_require_automatic_freshness_policy(self) -> None: def test_safe_identity_unknown_status_still_queries_and_is_stale(self) -> None: """Safe identity keeps the bounded query available after an unreadable status.""" - paths = { + contracts = { ROOT / "skills/code-intelligence/SKILL.md": ( - "safe repository identity", - "still calls `polaris_codegraph_explore`", - "UNKNOWN`/`TREAT_AS_STALE", + "When status cannot be verified but the project has a safe repository " + "identity, the proxy still calls `polaris_codegraph_explore` and " + "returns `UNKNOWN`/`TREAT_AS_STALE`; graph content remains " + "navigation-only and any conclusion requires the exact source/Git " + "fallback." ), ROOT / "templates/AGENTS.md": ( - "safe repository identity", - "still calls `polaris_codegraph_explore`", - "UNKNOWN`/`TREAT_AS_STALE", + "When status cannot be verified but the project has a safe repository " + "identity, the proxy still calls `polaris_codegraph_explore` and " + "returns `UNKNOWN`/`TREAT_AS_STALE`; graph content remains " + "navigation-only and any conclusion requires the exact source/Git " + "fallback." ), ROOT / "README.md": ( - "safe repository identity", - "still calls `polaris_codegraph_explore`", - "UNKNOWN`/`TREAT_AS_STALE", + "When status cannot be verified but the project has a safe repository " + "identity, the proxy still calls `polaris_codegraph_explore` and " + "returns `UNKNOWN`/`TREAT_AS_STALE`. The graph remains " + "navigation-only: use the exact source/Git fallback before any " + "conclusion." ), ROOT / "README.zh-CN.md": ( - "仓库身份安全", - "仍执行 `polaris_codegraph_explore`", - "UNKNOWN`/`TREAT_AS_STALE", + "当 status 无法验证但仓库身份安全时,代理仍执行 " + "`polaris_codegraph_explore`,并返回 `UNKNOWN`/`TREAT_AS_STALE`。" + "图只用于导航;在使用任何结论前,必须完成精确源码/Git 回退。" ), ROOT / "docs/USAGE.md": ( - "仓库身份安全", - "仍执行 `polaris_codegraph_explore`", - "UNKNOWN`/`TREAT_AS_STALE", + "当 status 无法验证但仓库身份安全时,代理仍执行 " + "`polaris_codegraph_explore`,并返回 `UNKNOWN`/`TREAT_AS_STALE`。" + "图只用于导航;在使用任何结论前,必须完成精确源码/Git fallback。" ), ROOT / "plan.md": ( - "仓库身份安全", - "仍执行 `polaris_codegraph_explore`", - "UNKNOWN`/`TREAT_AS_STALE", + "当 status 无法验证但仓库身份安全时,代理仍执行 " + "`polaris_codegraph_explore` 并返回 `UNKNOWN`/`TREAT_AS_STALE`;" + "图仅用于导航,任何结论都必须先完成精确源码/Git fallback。" ), } - for path, required in paths.items(): - text = path.read_text(encoding="utf-8") - for anchor in required: - self.assertIn(anchor, text, f"{path}: {anchor}") + + def assert_contract(text: str, label: str) -> None: + self.assertIn(contracts[label], text, label) + + for path in contracts: + assert_contract(path.read_text(encoding="utf-8"), path) + + canonical = contracts[ROOT / "skills/code-intelligence/SKILL.md"] + detached = canonical.replace( + "the proxy still calls `polaris_codegraph_explore` and returns " + "`UNKNOWN`/`TREAT_AS_STALE`", + "the proxy returns `UNKNOWN`/`TREAT_AS_STALE`", + ) + " The proxy still calls `polaris_codegraph_explore` after current status." + for anchor in ( + "safe repository identity", + "still calls `polaris_codegraph_explore`", + "UNKNOWN`/`TREAT_AS_STALE", + ): + self.assertIn(anchor, detached) + with self.assertRaises(AssertionError): + assert_contract(detached, ROOT / "skills/code-intelligence/SKILL.md") def test_documentation_sync_uses_one_proxy_query(self) -> None: """Documentation Sync uses one bounded changed-path/symbol proxy query.""" From bcd2653dafeeca1797204c7eb30cb0f1b1a95421 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 18:13:59 +0800 Subject: [PATCH 10/13] chore: advance Polaris protocol to 0.1.22 --- README.md | 4 +- README.zh-CN.md | 4 +- VERSION | 2 +- docs/USAGE.md | 9 ++-- plan.md | 4 +- pyproject.toml | 2 +- scripts/internal/migration_protocol.py | 34 ++++++++------ templates/project.json | 2 +- templates/task-sources/state.json | 2 +- templates/task/state.json | 2 +- tests/test_codegraph.py | 63 ++++++++++++++++++++++++-- tests/test_core.py | 13 ++++++ workflow/migrations.json | 9 ++++ 13 files changed, 118 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 5999a29..56c5edb 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ English | [简体中文](README.zh-CN.md) -> Current protocol version: `0.1.21` (in development); workflow version: `0.1.3` +> Current protocol version: `0.1.22` (in development); workflow version: `0.1.3` Polaris is a repo-native engineering workflow for coding agent hosts. It stores requirements, plans, implementation results, independent reviews, validation evidence, and task state in Git, then uses deterministic gates to prevent requirement drift, stale evidence, and agents declaring their own work complete. @@ -97,7 +97,7 @@ When status cannot be verified but the project has a safe repository identity, t The repository owner, not Polaris, owns CodeGraph installation, initialization, configuration, raw MCP registration, watcher, daemon, and every full `codegraph index` rebuild. Polaris never starts, configures, reconfigures, waits for, or manages them. Raw `codegraph_explore` or `codegraph explore` remains available out-of-band but cannot back `CURRENT` Polaris evidence. New records are v3 projections of the retained proxy bundle and completed fallbacks; v1/v2 are historical only. CodeGraph remains optional and never becomes a workflow gate. -Protocol `0.1.21` adds the project-scoped Polaris CodeGraph proxy, host adapter v3 registration, and auditable Code Intelligence record v3 while leaving Workflow at `0.1.3`. Record v1 and v2 are immutable historical evidence only; new evidence is projected from a retained proxy bundle into v3. +Protocol `0.1.22` keeps Workflow at `0.1.3` and adds an explicit version-only migration from `0.1.21` that neither inventories nor rewrites Code Intelligence record v3 evidence. Protocol `0.1.21` introduced the project-scoped Polaris CodeGraph proxy, host adapter v3 registration, and auditable record v3; record v1 and v2 remain immutable historical evidence only. ## v0.1 scope diff --git a/README.zh-CN.md b/README.zh-CN.md index 53b9e0c..0585080 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,7 +2,7 @@ [English](README.md) | 简体中文 -> 当前协议版本:`0.1.21`(开发中);Workflow 版本:`0.1.3` +> 当前协议版本:`0.1.22`(开发中);Workflow 版本:`0.1.3` Polaris 是运行在 Coding Agent 宿主上的仓库原生工程工作流。它把需求、计划、实现、独立审查、验证和任务状态保存在 Git 仓库中,并通过确定性门禁防止需求漂移、证据过期和 Agent 自行宣布完成。 @@ -97,7 +97,7 @@ Polaris 阶段只调用 `polaris_codegraph_explore`。代理先检查 status, CodeGraph 的安装、初始化、配置、raw MCP 注册、watcher、daemon 与每次全量 `codegraph index` 重建都归仓库所有者,而不是 Polaris;全量重建始终由用户主动执行。Polaris 绝不启动、配置、重新配置、等待或管理这些能力。raw `codegraph_explore` 或 `codegraph explore` 仍可作为带外工具使用,但不能支持 Polaris 的 `CURRENT` 证据。新 record 必须由保留的代理 bundle 与已完成回退投影为 v3;v1/v2 仅供历史读取。CodeGraph 始终可选,永远不是 Workflow 门禁。 -协议 `0.1.21` 新增项目级 Polaris CodeGraph 代理、Host Adapter v3 注册和可审计的 Code Intelligence record v3,Workflow 仍为 `0.1.3`。record v1/v2 仅作为不可变历史证据读取;新证据必须由保留的代理 bundle 投影为 v3。 +协议 `0.1.22` 保持 Workflow `0.1.3`,并新增从 `0.1.21` 出发的显式纯版本迁移;该迁移不清点也不重写 Code Intelligence record v3 证据。协议 `0.1.21` 引入项目级 Polaris CodeGraph 代理、Host Adapter v3 注册和可审计的 record v3;record v1/v2 仍仅作为不可变历史证据读取。 ## v0.1 边界 diff --git a/VERSION b/VERSION index 7906299..7e72641 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.21 +0.1.22 diff --git a/docs/USAGE.md b/docs/USAGE.md index 8c8fd5e..3659232 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -2,7 +2,7 @@ 本文面向希望在受支持 Coding Agent 宿主中使用 Polaris 管理软件工程任务的项目成员。当前内置 Codex 与 Claude Code 适配器;本文从首次接入讲到日常提出需求、独立 Implementation、进度查询、Review、验证、恢复与升级。 -> 当前协议版本:v0.1.21;Workflow 版本:v0.1.3。Polaris v0.1 是仓库原生的 Skills、宿主 worker 定义与 Python 脚本集合,并提供一个只分发到这些脚本的 `polaris` CLI;不提供后台服务或图形界面。 +> 当前协议版本:v0.1.22;Workflow 版本:v0.1.3。Polaris v0.1 是仓库原生的 Skills、宿主 worker 定义与 Python 脚本集合,并提供一个只分发到这些脚本的 `polaris` CLI;不提供后台服务或图形界面。 ## 1. 先理解 Polaris 保存什么 @@ -613,8 +613,9 @@ polaris migrate --repo . 2. 注册步骤同时绑定源/目标 `polaris_version` 与 `workflow_version`。Migration protocol v2 支持仅更新版本,也支持显式替换冻结 workflow 并映射任务状态。 3. `0.1.19 → 0.1.20` 使用 `replace_version_and_workflow` 与 `append_mapped_workflow_event`:冻结 workflow 更新到 `0.1.3`,旧 `IMPLEMENTED` / `DOCS_SYNCED` 映射到 `IMPLEMENTING`,旧 `REVIEWED` 映射到 `VALIDATING`;旧 R0/R1 `VERIFIED` 也映射回 `VALIDATING`,以便通过 `PASS_AND_CLOSE` 重新提交关闭产物,R2 `VERIFIED` 保持不变。迁移事件记录源/目标状态及旧版本;旧 `events.jsonl` 行不可修改。 4. `0.1.20 → 0.1.21` 只替换协议版本,Workflow 保持 `0.1.3`。迁移会校验并清点 canonical v1/v2 Code Intelligence 历史记录的路径与 SHA-256,保持原字节不变;中断恢复前会重算清单,任何变化都会拒绝继续。v1/v2 此后仅可作为历史证据读取。 -5. `.polaris/migrations/MIG--to-.json` 先写为 `IN_PROGRESS`,全部投影更新后改为 `COMPLETED`。迁移锁会记录迁移/任务身份、主机名和 PID;若进程在中间终止,同一主机重新执行命令会接管已死亡的同迁移锁、验证并复用已经追加的事件,不会重复迁移。活跃进程、其他迁移或来源不明的锁不会被自动删除。 -6. 迁移完成后脚本自动运行项目校验;`validate_project.py` 会拒绝未完成记录、缺失/伪造的任务迁移事件或版本不一致。 +5. `0.1.21 → 0.1.22` 只替换协议版本,Workflow 保持 `0.1.3`。迁移记录中的 `retired_code_intelligence_records` 固定为空列表,不重新清点或重写任何 record v3 历史证据。 +6. `.polaris/migrations/MIG--to-.json` 先写为 `IN_PROGRESS`,全部投影更新后改为 `COMPLETED`。迁移锁会记录迁移/任务身份、主机名和 PID;若进程在中间终止,同一主机重新执行命令会接管已死亡的同迁移锁、验证并复用已经追加的事件,不会重复迁移。活跃进程、其他迁移或来源不明的锁不会被自动删除。 +7. 迁移完成后脚本自动运行项目校验;`validate_project.py` 会拒绝未完成记录、缺失/伪造的任务迁移事件或版本不一致。 没有注册路径时不要手改版本号。应先取得包含所需相邻步骤的 Polaris 版本,逐级完成并分别提交;任何失败都先保留 `.polaris/migrations/` 和事件现场,修复原因后重跑同一迁移命令。 @@ -640,6 +641,8 @@ v0.1.20 / Workflow v0.1.3 删除没有独立治理边界的中间状态和事件 v0.1.21 新增项目级 Polaris CodeGraph MCP 代理、Host Adapter v3 注册与 Code Intelligence record v3;Workflow 仍为 v0.1.3,CodeGraph 仍为可选且不参与门禁。v1/v2 record 仅作为不可变历史证据读取。 +v0.1.22 新增 `0.1.21 → 0.1.22` 显式相邻迁移;Workflow 仍为 v0.1.3。该迁移不重新清点或重写 record v3,迁移记录中的 retirement inventory 固定为空。 + ## 13. 失败探索与卡点 如果一个技术方向被证据否定,不要让结论只留在聊天中。记录任务内探索: diff --git a/plan.md b/plan.md index 227f77a..b10eb1e 100644 --- a/plan.md +++ b/plan.md @@ -2,7 +2,7 @@ > 状态:Implementation underway > 目标版本:v0.1 -> 当前协议:`0.1.21`;Workflow:`0.1.3` +> 当前协议:`0.1.22`;Workflow:`0.1.3` > 产品形态:Repo-native Skill System > 宿主 Runtime:声明式可扩展;v0.1 内置 Codex、Claude Code > @@ -452,7 +452,7 @@ v0.1 不设置 `FAILED`:可修复失败通过治理回路处理,外部阻塞 `.polaris/workflow.json` 保存当前项目实际使用且版本锁定的节点、边、依赖和门禁 ID;`tools/polaris/workflow/default-workflow.json` 只用于初始化。`transition_task.py` 只接受图中边并先运行对应 validators,Skill 不直接编辑 `state` 字段。v0.1 遇到 `polaris_version` 或 `workflow_version` 不匹配时拒绝正常执行,不做隐式迁移。 -版本升级必须先 vendoring 目标协议,再显式运行 vendored `migrate_project.py`。`workflow/migrations.json` 是迁移路径唯一且 append-only 的注册表,一次只执行一个从当前版本到目标版本的相邻步骤;历史步骤必须保留以校验已提交记录。Migration protocol v2 保留 `replace_version` / `append_version_event`,并增加 `replace_version_and_workflow` / `append_mapped_workflow_event`。`0.1.19 → 0.1.20` 原子替换冻结 workflow 为 `0.1.3`,追加带源/目标状态及旧版本字段的迁移事件;旧 `IMPLEMENTED`、`DOCS_SYNCED` 映射到 `IMPLEMENTING`,旧 `REVIEWED` 映射到 `VALIDATING`,旧 R0/R1 `VERIFIED` 映射到 `VALIDATING` 以重新提交 `PASS_AND_CLOSE`,仅 R2 保持 `VERIFIED`。`0.1.20 → 0.1.21` 保持 Workflow `0.1.3`,新增项目级 CodeGraph 代理、Host Adapter v3 与 record v3,并把 canonical v1/v2 record 作为仅可读取的不可变历史证据按路径和 SHA-256 清点;迁移恢复前必须重算并比对清单。迁移以 `.polaris/migrations/MIG-*.json` 记录 `IN_PROGRESS/COMPLETED`、各任务 sequence 和状态映射;重跑必须可恢复且不得重复事件。未知路径、跨版本跳跃、未声明的 workflow 变化、任务集合并发变化和不完整记录都必须机械拒绝。 +版本升级必须先 vendoring 目标协议,再显式运行 vendored `migrate_project.py`。`workflow/migrations.json` 是迁移路径唯一且 append-only 的注册表,一次只执行一个从当前版本到目标版本的相邻步骤;历史步骤必须保留以校验已提交记录。Migration protocol v2 保留 `replace_version` / `append_version_event`,并增加 `replace_version_and_workflow` / `append_mapped_workflow_event`。`0.1.19 → 0.1.20` 原子替换冻结 workflow 为 `0.1.3`,追加带源/目标状态及旧版本字段的迁移事件;旧 `IMPLEMENTED`、`DOCS_SYNCED` 映射到 `IMPLEMENTING`,旧 `REVIEWED` 映射到 `VALIDATING`,旧 R0/R1 `VERIFIED` 映射到 `VALIDATING` 以重新提交 `PASS_AND_CLOSE`,仅 R2 保持 `VERIFIED`。`0.1.20 → 0.1.21` 保持 Workflow `0.1.3`,新增项目级 CodeGraph 代理、Host Adapter v3 与 record v3,并把 canonical v1/v2 record 作为仅可读取的不可变历史证据按路径和 SHA-256 清点;迁移恢复前必须重算并比对清单。`0.1.21 → 0.1.22` 同样保持 Workflow `0.1.3`,但 retirement inventory 固定为空,不重新清点或重写任何 record v3 证据。迁移以 `.polaris/migrations/MIG-*.json` 记录 `IN_PROGRESS/COMPLETED`、各任务 sequence 和状态映射;重跑必须可恢复且不得重复事件。未知路径、跨版本跳跃、未声明的 workflow 变化、任务集合并发变化和不完整记录都必须机械拒绝。 迁移占用任务转换锁时必须写入结构化 owner:迁移 ID、任务 ID、主机名、PID 和创建时间。重跑只允许接管同一迁移在同一主机上、且原 PID 已确认不存在的锁;活跃 PID、其他迁移、其他主机、空锁或损坏锁一律拒绝。这样既能从进程崩溃或机器重启恢复,又不把真实并发误判为遗留锁。 diff --git a/pyproject.toml b/pyproject.toml index e8fb9c0..b58db4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "corona-polaris" -version = "0.1.21" +version = "0.1.22" description = "Repo-native AI engineering workflow command dispatcher" requires-python = ">=3.10" dependencies = [] diff --git a/scripts/internal/migration_protocol.py b/scripts/internal/migration_protocol.py index 1477666..a8cea3e 100644 --- a/scripts/internal/migration_protocol.py +++ b/scripts/internal/migration_protocol.py @@ -38,6 +38,10 @@ MIGRATIONS_ROOT = Path(".polaris/migrations") +RETIREMENT_INVENTORY_MIGRATIONS = { + "0.1.19-to-0.1.20", + "0.1.20-to-0.1.21", +} LEGACY_STATUS_MAP = { "DRAFT": "DRAFT", @@ -359,11 +363,12 @@ def _new_record( else state["status"], } ) - retired_code_intelligence_records.extend( - _retired_code_intelligence_records( - repo, task_id, directory, protocol_root, step + if step["migration_id"] in RETIREMENT_INVENTORY_MIGRATIONS: + retired_code_intelligence_records.extend( + _retired_code_intelligence_records( + repo, task_id, directory, protocol_root, step + ) ) - ) return { "record_version": 2, "migration_id": step["migration_id"], @@ -452,17 +457,18 @@ def migrate_project(repo: Path, protocol_root: Path) -> dict[str, Any]: raise RuleFailure("project task list changed during migration") if incomplete is not None: current_inventory: list[dict[str, str]] = [] - for item in record["tasks"]: - directory = task_dir(repo, item["task_id"]) - current_inventory.extend( - _retired_code_intelligence_records( - repo, - item["task_id"], - directory, - protocol_root, - step, + if step["migration_id"] in RETIREMENT_INVENTORY_MIGRATIONS: + for item in record["tasks"]: + directory = task_dir(repo, item["task_id"]) + current_inventory.extend( + _retired_code_intelligence_records( + repo, + item["task_id"], + directory, + protocol_root, + step, + ) ) - ) if record.get("retired_code_intelligence_records", []) != current_inventory: raise RuleFailure( "retired Code Intelligence record inventory changed during migration" diff --git a/templates/project.json b/templates/project.json index e914021..124869f 100644 --- a/templates/project.json +++ b/templates/project.json @@ -1,6 +1,6 @@ { "project_id": "PROJECT_ID", - "polaris_version": "0.1.21", + "polaris_version": "0.1.22", "workflow_version": "0.1.3", "active_tasks": [] } diff --git a/templates/task-sources/state.json b/templates/task-sources/state.json index 7f63d16..256dcb7 100644 --- a/templates/task-sources/state.json +++ b/templates/task-sources/state.json @@ -1,6 +1,6 @@ { "task_id": "TASK-0001", - "polaris_version": "0.1.21", + "polaris_version": "0.1.22", "workflow_version": "0.1.3", "current_revision": 1, "status": "DRAFT", diff --git a/templates/task/state.json b/templates/task/state.json index 7f63d16..256dcb7 100644 --- a/templates/task/state.json +++ b/templates/task/state.json @@ -1,6 +1,6 @@ { "task_id": "TASK-0001", - "polaris_version": "0.1.21", + "polaris_version": "0.1.22", "workflow_version": "0.1.3", "current_revision": 1, "status": "DRAFT", diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 1ae6565..18c752b 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -177,7 +177,7 @@ def test_managed_surfaces_only_name_the_official_codegraph(self) -> None: self.assertIn(official, path.read_text(encoding="utf-8"), path.relative_to(ROOT).as_posix()) for path in [ROOT / "README.md", ROOT / "README.zh-CN.md"]: text = path.read_text(encoding="utf-8") - self.assertIn("0.1.21", text, path.relative_to(ROOT).as_posix()) + self.assertIn("0.1.22", text, path.relative_to(ROOT).as_posix()) self.assertIn("0.1.3", text, path.relative_to(ROOT).as_posix()) def test_authority_surfaces_publish_workflow_013(self) -> None: @@ -188,7 +188,7 @@ def test_authority_surfaces_publish_workflow_013(self) -> None: ROOT / "plan.md", ]: text = path.read_text(encoding="utf-8") - self.assertIn("0.1.21", text, path.relative_to(ROOT).as_posix()) + self.assertIn("0.1.22", text, path.relative_to(ROOT).as_posix()) self.assertIn("0.1.3", text, path.relative_to(ROOT).as_posix()) def test_readmes_keep_codegraph_operational_boundaries(self) -> None: @@ -2028,7 +2028,8 @@ def test_migration_inventories_frozen_v2_records_without_rewriting_them( ) -> None: """0.1.21 inventories current/prior v2 evidence and preserves Workflow 0.1.3.""" frozen = self.prepare_v2_migration_records() - vendor(ROOT, self.repo, False) + with protocol_source_at("0.1.21") as source: + vendor(source, self.repo, False) result = migrate_project(self.repo) @@ -2061,7 +2062,8 @@ def test_migration_inventories_frozen_v2_records_without_rewriting_them( def test_migration_resume_rejects_mutated_frozen_v2_inventory(self) -> None: """中断迁移重跑前会重算 v2 清单,拒绝已经变化的历史证据。""" frozen = self.prepare_v2_migration_records() - vendor(ROOT, self.repo, False) + with protocol_source_at("0.1.21") as source: + vendor(source, self.repo, False) with mock.patch( "internal.migration_protocol.append_jsonl", side_effect=OSError("injected migration interruption"), @@ -2076,6 +2078,59 @@ def test_migration_resume_rejects_mutated_frozen_v2_inventory(self) -> None: with self.assertRaisesRegex(RuleFailure, "inventory changed"): migrate_project(self.repo) + def test_0122_migration_preserves_v3_code_intelligence_records(self) -> None: + recorded, _query = self.record_current_v3_fixture() + actual_path = ( + self.repo + / ".polaris/tasks/TASK-0001/code-intelligence/r001/planning.json" + ) + self.assertEqual( + json.loads(actual_path.read_text(encoding="utf-8")), recorded + ) + before = actual_path.read_bytes() + self.set_protocol_version("0.1.21") + + with protocol_source_at("0.1.22") as source: + vendor(source, self.repo, False) + result = migrate_project(self.repo) + + self.assertEqual(result["from"], "0.1.21") + self.assertEqual(result["to"], "0.1.22") + self.assertEqual(actual_path.read_bytes(), before) + migration = json.loads(Path(result["record"]).read_text(encoding="utf-8")) + self.assertEqual(migration["retired_code_intelligence_records"], []) + + def test_0122_migration_resume_rejects_nonempty_retirement_inventory( + self, + ) -> None: + self.record_current_v3_fixture() + self.set_protocol_version("0.1.21") + with protocol_source_at("0.1.22") as source: + vendor(source, self.repo, False) + with mock.patch( + "internal.migration_protocol.append_jsonl", + side_effect=OSError("injected migration interruption"), + ): + with self.assertRaisesRegex(OSError, "injected migration interruption"): + migrate_project(self.repo) + + migration_path = ( + self.repo + / ".polaris/migrations/MIG-0.1.21-to-0.1.22.json" + ) + migration = json.loads(migration_path.read_text(encoding="utf-8")) + migration["retired_code_intelligence_records"] = [ + { + "task_id": "TASK-0001", + "path": "code-intelligence/r001/planning.json", + "sha256": "0" * 64, + } + ] + write_json_atomic(migration_path, migration) + + with self.assertRaisesRegex(RuleFailure, "inventory changed"): + migrate_project(self.repo) + def test_legacy_v1_records_remain_readable_but_cannot_be_written(self) -> None: self.initialize_task() protocol = importlib.import_module("internal.code_intelligence_protocol") diff --git a/tests/test_core.py b/tests/test_core.py index ea23960..d8f010d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -2605,6 +2605,19 @@ def test_version_only_migration_rejects_a_workflow_version_change(self) -> None: ): migrate_project(self.repo) + def test_0122_version_only_migration_rejects_workflow_change(self) -> None: + self.set_protocol_version("0.1.21") + with protocol_source_at("0.1.22") as source: + migrations_path = source / "workflow/migrations.json" + migrations = read_json(migrations_path) + migrations["steps"][-1]["to_workflow_version"] = "0.1.4" + write_json_atomic(migrations_path, migrations) + vendor(source, self.repo, False) + with self.assertRaisesRegex( + RuleFailure, "workflow migration requires replacement" + ): + migrate_project(self.repo) + def test_code_intelligence_auto_detects_available_operations_and_can_be_disabled(self) -> None: """已初始化的可选代码情报按 MCP 工具能力发现;缺失或禁用时不产生硬依赖。""" (self.repo / ".codegraph").mkdir() diff --git a/workflow/migrations.json b/workflow/migrations.json index ab69d72..d97f961 100644 --- a/workflow/migrations.json +++ b/workflow/migrations.json @@ -108,6 +108,15 @@ "to_workflow_version": "0.1.3", "project_strategy": "replace_version", "task_strategy": "append_version_event" + }, + { + "migration_id": "0.1.21-to-0.1.22", + "from_polaris_version": "0.1.21", + "to_polaris_version": "0.1.22", + "from_workflow_version": "0.1.3", + "to_workflow_version": "0.1.3", + "project_strategy": "replace_version", + "task_strategy": "append_version_event" } ] } From 03b88baa1b140806c05ab2b412355f5258379cc4 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 18:24:20 +0800 Subject: [PATCH 11/13] test: verify CodeGraph freshness hardening end to end --- tests/test_codegraph.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 18c752b..aa0e712 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -1447,8 +1447,11 @@ def test_vendored_mcp_proxy_runs_one_auditable_fake_cli_window(self) -> None: self.assertEqual([response["id"] for response in responses], [1, 2]) tool_result = responses[1]["result"] self.assertFalse(tool_result["isError"]) + first_content = tool_result["content"][0]["text"] + self.assertTrue(first_content.startswith("[POLARIS_CODEGRAPH_FRESHNESS]\n")) + self.assertIn("freshness: VERIFIED_AT_CHECK", first_content) self.assertTrue( - tool_result["content"][0]["text"].startswith( + first_content.startswith( "[POLARIS_CODEGRAPH_FRESHNESS]\nstate: CURRENT\n" ) ) @@ -1488,7 +1491,14 @@ def test_vendored_mcp_proxy_runs_one_auditable_fake_cli_window(self) -> None: ["status", "--json"], ], ) - self.assertTrue(all(entry["cwd"] == str(repo.resolve()) for entry in calls)) + self.assertEqual( + [entry["argv"][0] for entry in calls], + ["status", "sync", "status", "explore", "status"], + ) + self.assertTrue( + all(Path(entry["cwd"]).resolve() == repo.resolve() for entry in calls) + ) + self.assertNotIn("index", [arg for entry in calls for arg in entry["argv"]]) annotations_path = fixture_root / "annotations.json" write_json_atomic( @@ -1522,6 +1532,7 @@ def test_vendored_mcp_proxy_runs_one_auditable_fake_cli_window(self) -> None: record_value = json.loads( Path(record_result["path"]).read_text(encoding="utf-8") ) + self.assertEqual(record_value["record_version"], 3) self.assertEqual( record_value["proxy"]["evidence_bundle_sha256"], file_sha256(bundle_path), From befb54c955dee77465937572b59fab13bf73929b Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 19:06:17 +0800 Subject: [PATCH 12/13] fix: close CodeGraph freshness safety gaps --- .../internal/code_intelligence_protocol.py | 14 +- scripts/internal/code_intelligence_proxy.py | 187 +++++++-- scripts/internal/codegraph_adapter.py | 354 ++++++++++++----- tests/test_codegraph.py | 365 +++++++++++++++++- 4 files changed, 775 insertions(+), 145 deletions(-) diff --git a/scripts/internal/code_intelligence_protocol.py b/scripts/internal/code_intelligence_protocol.py index 4750bfb..0e17d2d 100644 --- a/scripts/internal/code_intelligence_protocol.py +++ b/scripts/internal/code_intelligence_protocol.py @@ -1090,12 +1090,13 @@ def _validate_v3_record_value( }[state] if delivery["record_status"] != expected_record_status: raise RuleFailure("v3 delivery state contradicts its record freshness status") - if value["status"] != { + expected_value_status = { "CURRENT": "USED", - "STALE": "USED", + "STALE": "USED" if query["status"] == "SUCCESS" else "FAILED", "UNKNOWN": "FAILED", "UNAVAILABLE": "UNAVAILABLE", - }[state]: + }[state] + if value["status"] != expected_value_status: raise RuleFailure("v3 record status contradicts proxy delivery") if state == "CURRENT": if ( @@ -1116,13 +1117,14 @@ def _validate_v3_record_value( raise RuleFailure("CURRENT v3 evidence lacks a complete zero-pending window") elif state == "STALE": if ( - query["status"] != "SUCCESS" + query["status"] not in {"SUCCESS", "FAILED"} or delivery["usage"] != "NAVIGATION_ONLY" or delivery["required_fallback"] == "NONE" or not any( point["reason"] != "STATUS_UNREADABLE" for point in delivery["stale_points"] ) + or (query["status"] == "FAILED" and not delivery["error"]) ): raise RuleFailure("STALE v3 evidence lacks an explicit stale reason") elif state == "UNKNOWN": @@ -1337,7 +1339,9 @@ def record_proxy_bundle( "target": context["target"], "status": { "CURRENT": "USED", - "STALE": "USED", + "STALE": ( + "USED" if query["status"] == "SUCCESS" else "FAILED" + ), "UNKNOWN": "FAILED", "UNAVAILABLE": "UNAVAILABLE", }[delivery["state"]], diff --git a/scripts/internal/code_intelligence_proxy.py b/scripts/internal/code_intelligence_proxy.py index b82255e..0317829 100644 --- a/scripts/internal/code_intelligence_proxy.py +++ b/scripts/internal/code_intelligence_proxy.py @@ -263,7 +263,16 @@ def _deduplicate(items: list[dict[str, Any]]) -> list[dict[str, Any]]: return result +def _response_identity_mismatch(classification: dict[str, Any] | None) -> bool: + return classification is not None and any( + point.get("reason") == "WORKTREE_MISMATCH" + for point in classification.get("stale_points", []) + ) + + def _unsafe_response(classification: dict[str, Any]) -> bool: + if _response_identity_mismatch(classification): + return True error = str(classification.get("error") or "").lower() return classification.get("classification") == "NOT_VERIFIED" and any( token in error @@ -292,6 +301,42 @@ def _pre_status_blocks_query(observation: dict[str, Any]) -> bool: )) +def _clean_status_observation(observation: dict[str, Any] | None) -> bool: + if observation is None: + return False + pending = observation.get("pending_changes") + return ( + observation.get("status") == "CURRENT_AT_CHECK" + and observation.get("needs_sync") is False + and isinstance(pending, dict) + and set(pending) == {"added", "modified", "removed"} + and all(type(value) is int and value == 0 for value in pending.values()) + and observation.get("basis") + in (["STATUS_JSON"], ["STATUS_JSON", "SYNC_ACKNOWLEDGED"]) + and observation.get("stale_points") == [] + and observation.get("error") is None + and isinstance(observation.get("status_response_sha256"), str) + and re.fullmatch( + r"[0-9a-f]{64}", observation["status_response_sha256"] + ) is not None + ) + + +def _neutral_response_classification( + classification: dict[str, Any] | None, +) -> bool: + return ( + classification is not None + and classification.get("classification") == "NONE" + and classification.get("basis") == ["RESPONSE_BANNER"] + and classification.get("stale_points") == [] + and classification.get("error") is None + and isinstance(classification.get("response_sha256"), str) + and re.fullmatch(r"[0-9a-f]{64}", classification["response_sha256"]) + is not None + ) + + def _delivery( effective_pre: dict[str, Any], query_result: dict[str, Any], @@ -316,24 +361,42 @@ def _delivery( post_status_blocks_query = ( post_status is not None and _pre_status_blocks_query(post_status) ) - pre_status_unknown = _is_unknown(effective_pre) - known_stale = not ( - pre_status_blocks_query or post_status_blocks_query or pre_status_unknown - ) and (any( - point.get("reason") != "STATUS_UNREADABLE" for point in points - ) or effective_pre.get("status") in {"PARTIAL_STALE", "INDEX_STALE"} or ( - post_status is not None - and post_status.get("status") in {"PARTIAL_STALE", "INDEX_STALE"} - ) or (classification or {}).get("classification") in {"PARTIAL_STALE", "INDEX_STALE"}) - unknown = ( + response_identity_mismatch = _response_identity_mismatch(classification) + safety_failure = ( pre_status_blocks_query or post_status_blocks_query + or response_identity_mismatch or forced_unknown is not None + ) + pre_status_unknown = _is_unknown(effective_pre) + known_stale = ( + any(point.get("reason") != "STATUS_UNREADABLE" for point in points) + or effective_pre.get("status") in {"PARTIAL_STALE", "INDEX_STALE"} + or ( + post_status is not None + and post_status.get("status") in {"PARTIAL_STALE", "INDEX_STALE"} + ) + or (classification or {}).get("classification") + in {"PARTIAL_STALE", "INDEX_STALE"} + ) + clean_window = ( + _clean_status_observation(effective_pre) + and query_result.get("status") == "SUCCESS" + and query_result.get("error") is None + and _neutral_response_classification(classification) + and query_result.get("response_sha256") + == (classification or {}).get("response_sha256") + and _clean_status_observation(post_status) + and not safety_failure + ) + unknown = ( + safety_failure or query_result.get("status") != "SUCCESS" or pre_status_unknown or post_status is None or _is_unknown(post_status) or (classification or {}).get("classification") == "NOT_VERIFIED" + or not clean_window ) errors = [ forced_unknown, @@ -343,7 +406,30 @@ def _delivery( (post_status or {}).get("error"), ] error = next((str(item)[:240] for item in errors if item), None) - if known_stale: + + if safety_failure: + state = "UNKNOWN" + record_status = "NOT_VERIFIED" + if response_identity_mismatch: + reason = "WORKTREE_MISMATCH" + elif pre_status_blocks_query or post_status_blocks_query: + identity_observation = ( + effective_pre if pre_status_blocks_query else post_status + ) + assert identity_observation is not None + reason = ( + "WORKTREE_MISMATCH" + if any( + point.get("reason") == "WORKTREE_MISMATCH" + for point in identity_observation.get("stale_points", []) + ) + else "PROJECT_MISMATCH" + ) + else: + reason = "RESPONSE_INTEGRITY_UNVERIFIED" + required_fallback = "SEARCH_SOURCE" + usage = "NAVIGATION_ONLY" + elif known_stale: index_points = [point for point in points if point.get("scope") == "INDEX"] state = "STALE" record_status = "INDEX_STALE" if index_points else "PARTIAL_STALE" @@ -365,22 +451,7 @@ def _delivery( elif unknown: state = "UNKNOWN" record_status = "NOT_VERIFIED" - if pre_status_blocks_query or post_status_blocks_query: - identity_observation = ( - effective_pre if pre_status_blocks_query else post_status - ) - assert identity_observation is not None - reason = ( - "WORKTREE_MISMATCH" - if any( - point.get("reason") == "WORKTREE_MISMATCH" - for point in identity_observation.get("stale_points", []) - ) - else "PROJECT_MISMATCH" - ) - elif forced_unknown: - reason = "RESPONSE_INTEGRITY_UNVERIFIED" - elif pre_status_unknown: + if pre_status_unknown: reason = ( "PROJECT_MISMATCH" if "different project" in str(effective_pre.get("error", "")).lower() @@ -394,12 +465,14 @@ def _delivery( reason = "POST_STATUS_UNREADABLE" required_fallback = "SEARCH_SOURCE" usage = "NAVIGATION_ONLY" - else: + elif clean_window: state = "CURRENT" record_status = "CURRENT_AT_CHECK" reason = "VERIFIED_WINDOW" required_fallback = "NONE" usage = "NON_AUTHORITATIVE_CONTEXT" + else: + raise AssertionError("CodeGraph delivery state was not classified") return { "state": state, "record_status": record_status, @@ -529,6 +602,26 @@ def execute_proxy_query( pre_status = inspect_status(repo, descriptor, runner=runner) bundle["pre_status"] = pre_status effective_pre = pre_status + if _pre_status_blocks_query(pre_status): + blocked_error = pre_status.get("error") or ( + "CodeGraph status reports a worktree mismatch" + ) + bundle["query"]["status"] = "FAILED" + bundle["query"]["error"] = blocked_error + bundle["delivery"] = _delivery( + effective_pre, + bundle["query"], + None, + None, + ) + _write_bundle(bundle_path, bundle) + return { + "bundle": bundle, + "bundle_path": bundle_path, + "response": None, + "envelope": render_freshness_envelope(bundle), + } + if pre_status.get("needs_sync"): synchronized = synchronize_observed_status( repo, descriptor, pre_status, runner=runner @@ -538,7 +631,10 @@ def execute_proxy_query( effective_pre = synchronized["freshness"] if ( bundle["post_sync_status"] is not None - and _pre_status_blocks_query(bundle["post_sync_status"]) + and ( + _pre_status_blocks_query(bundle["post_sync_status"]) + or bundle["post_sync_status"].get("status") == "UNAVAILABLE" + ) and bundle["post_sync_status"].get("error") ): effective_pre = { @@ -546,6 +642,30 @@ def execute_proxy_query( "error": bundle["post_sync_status"]["error"], } + post_sync_status = bundle["post_sync_status"] + if post_sync_status is not None and ( + _pre_status_blocks_query(post_sync_status) + or post_sync_status.get("status") == "UNAVAILABLE" + ): + blocked_error = post_sync_status.get("error") or ( + "CodeGraph post-sync status is unsafe or unavailable" + ) + bundle["query"]["status"] = "FAILED" + bundle["query"]["error"] = blocked_error + bundle["delivery"] = _delivery( + effective_pre, + bundle["query"], + None, + None, + ) + _write_bundle(bundle_path, bundle) + return { + "bundle": bundle, + "bundle_path": bundle_path, + "response": None, + "envelope": render_freshness_envelope(bundle), + } + if effective_pre["status"] == "UNAVAILABLE": bundle["query"]["status"] = "UNAVAILABLE" bundle["query"]["error"] = effective_pre.get("error") @@ -614,7 +734,14 @@ def execute_proxy_query( forced_unknown = "CodeGraph classification digest mismatch" response = None elif _unsafe_response(classification): - forced_unknown = "CodeGraph response contains an unsafe repository path" + forced_unknown = ( + "CodeGraph response reports a worktree mismatch" + if _response_identity_mismatch(classification) + else "CodeGraph response contains an unsafe repository path" + ) + if response_path.exists(): + response_path.unlink() + bundle["response_path"] = None response = None else: response_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/scripts/internal/codegraph_adapter.py b/scripts/internal/codegraph_adapter.py index 11b34d3..2be57af 100644 --- a/scripts/internal/codegraph_adapter.py +++ b/scripts/internal/codegraph_adapter.py @@ -53,10 +53,21 @@ r"(?:since last index sync|on disk after the last index sync)" ) _DRIFTED_PROJECT_TAIL_PREFIX = "> ⚠ Changed on disk after the last index sync:" -_SUSPICIOUS_FRESHNESS_SIGNAL = re.compile( - r"(?:⚠|\bwarning\b|\bstale\b|\bpending(?:[- ]sync)?\b|\bout[- ]of[- ]date\b)", +_PROJECT_PENDING_FOOTER = re.compile( + r"^\(Note: (?P[1-9][0-9]*) file\(s\) elsewhere in this project " + r"are pending index sync but were not referenced above:$" +) +_PROJECT_PENDING_ROW = re.compile(r"^ - .+ \(edited [0-9]+ms ago\)$") +_PROJECT_PENDING_MORE = re.compile(r"^ - …and (?P[1-9][0-9]*) more$") +_FILE_SECTION_HEADER = re.compile(r"^\*\*`[^`]+`\*\*(?: — .*)?$") +_EXPLICIT_WARNING_FRAMING = re.compile( + r"(?:⚠|^\s*warning\s*:|^\s+pending[- ]sync(?:\s*:|\s+required\b))", re.IGNORECASE, ) +_PARTIAL_HEADER_VARIANTS = tuple( + tuple(value.rstrip("\n").splitlines()) + for value in (_PARTIAL_BANNER_HEADER, _LEGACY_PARTIAL_BANNER_HEADER) +) def _checked_at() -> str: @@ -150,11 +161,20 @@ def _response_result( } -def _response_not_verified(checked_at: str, error: BaseException | str) -> dict[str, Any]: +def _response_not_verified( + checked_at: str, + error: BaseException | str, + *, + stale_points: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + points = list(stale_points or []) + unreadable = _index_point("STATUS_UNREADABLE") + if unreadable not in points: + points.append(unreadable) return _response_result( "NOT_VERIFIED", checked_at, - stale_points=[_index_point("STATUS_UNREADABLE")], + stale_points=points, error=_error_summary(error), ) @@ -193,8 +213,8 @@ def _response_file_point(repo: Path, raw_path: str) -> dict[str, Any]: } -def _framing_lines(response: str) -> list[str]: - """Return response lines outside Markdown code fences.""" +def _framing_lines(response: str) -> tuple[list[str], bool]: + """Return response lines outside fences and whether every fence closed.""" lines: list[str] = [] inside_fence = False for line in response.splitlines(): @@ -203,7 +223,7 @@ def _framing_lines(response: str) -> list[str]: continue if not inside_fence: lines.append(line) - return lines + return lines, not inside_fence def _with_response_sha256(result: dict[str, Any], response_sha256: str) -> dict[str, Any]: @@ -211,13 +231,37 @@ def _with_response_sha256(result: dict[str, Any], response_sha256: str) -> dict[ return result +def _partial_header_length(lines: list[str], index: int) -> int: + for header in _PARTIAL_HEADER_VARIANTS: + if tuple(lines[index : index + len(header)]) == header: + return len(header) + return 0 + + +def _append_unique_point( + points: list[dict[str, Any]], point: dict[str, Any] +) -> None: + if point not in points: + points.append(point) + + +def _last_paragraph_start(lines: list[str]) -> int: + end = len(lines) + while end > 0 and lines[end - 1] == "": + end -= 1 + start = end + while start > 0 and lines[start - 1] != "": + start -= 1 + return start + + def classify_response( repo: Path, response: str, *, checked_at: str | None = None, ) -> dict[str, Any]: - """Classify documented freshness framing outside source-code fences. + """Classify recognized freshness regions outside source-code fences. Leading whitespace and a UTF-8 BOM are not accepted as an official banner. """ @@ -226,104 +270,188 @@ def classify_response( return _response_not_verified(checked_at, "CodeGraph response is not text") response_sha256 = hashlib.sha256(response.encode("utf-8")).hexdigest() normalized = response.replace("\r\n", "\n").replace("\r", "\n") - if normalized.startswith(_WORKTREE_BANNER_PREFIX): - return _with_response_sha256( - _response_result( - "INDEX_STALE", - checked_at, - stale_points=[_index_point("WORKTREE_MISMATCH")], - ), - response_sha256, - ) - if normalized.startswith(_DISABLED_BANNER_PREFIX): - return _with_response_sha256( - _response_result( - "INDEX_STALE", - checked_at, - stale_points=[_index_point("AUTO_SYNC_DISABLED")], - ), - response_sha256, - ) - - header = next( + framing, fences_balanced = _framing_lines(normalized) + stale_points: list[dict[str, Any]] = [] + parse_error: str | None = None + + # Parse only consecutive recognized banners at the top. CodeGraph composes + # the pending/degraded wrapper around the worktree notice, so more than one + # banner can legitimately precede the first graph-result line. + index = 0 + while index < len(framing): + partial_header_length = _partial_header_length(framing, index) + if partial_header_length: + header_index = index + index += partial_header_length + matches: list[re.Match[str]] = [] + while index < len(framing): + match = _PARTIAL_BANNER_ROW.fullmatch(framing[index]) + if match is None: + break + matches.append(match) + index += 1 + footer_present = ( + index < len(framing) + and framing[index].startswith(_PARTIAL_BANNER_FOOTER) + ) + if not matches or not footer_present: + parse_error = parse_error or "malformed CodeGraph stale banner" + index = header_index + partial_header_length + while index < len(framing) and framing[index] != "": + index += 1 + else: + index += 1 + for match in matches: + try: + _append_unique_point( + stale_points, + _response_file_point(repo, match["path"]), + ) + except (InputFailure, RuleFailure, OSError, ValueError) as error: + parse_error = parse_error or _error_summary(error) + elif framing[index].startswith(_DISABLED_BANNER_PREFIX): + _append_unique_point( + stale_points, _index_point("AUTO_SYNC_DISABLED") + ) + index += 1 + if index < len(framing) and framing[index].startswith(" Reason: "): + index += 1 + elif framing[index].startswith(_WORKTREE_BANNER_PREFIX): + _append_unique_point( + stale_points, _index_point("WORKTREE_MISMATCH") + ) + index += 1 + else: + break + while index < len(framing) and framing[index] == "": + index += 1 + + # A known banner outside the top region is malformed framing, not ordinary + # prose. This retains the legacy wrapped-banner safety behavior. + for line_index in range(index, len(framing)): + line = framing[line_index] + if ( + _partial_header_length(framing, line_index) + or line.startswith(_DISABLED_BANNER_PREFIX) + or line.startswith(_WORKTREE_BANNER_PREFIX) + ): + parse_error = parse_error or "misplaced CodeGraph freshness banner" + + # Recognized per-file headers may occur between source fences throughout an + # explore response. Generic words in prose and source are intentionally not + # inspected. + for line in framing: + drifted = _DRIFTED_FILE_HEADER.match(line) + if drifted is not None: + try: + _append_unique_point( + stale_points, + _response_file_point(repo, drifted["path"]), + ) + except (InputFailure, RuleFailure, OSError, ValueError) as error: + parse_error = parse_error or _error_summary(error) + elif ( + _FILE_SECTION_HEADER.fullmatch(line) + and _EXPLICIT_WARNING_FRAMING.search(line) + ): + parse_error = parse_error or "unrecognized CodeGraph file warning" + + # The current project-level pending footer is a final parenthesized region. + # Validate its rows and count so similar prose cannot masquerade as framing. + pending_footer_lines: set[int] = set() + for footer_index, line in enumerate(framing): + footer_match = _PROJECT_PENDING_FOOTER.fullmatch(line) + if footer_match is None: + continue + tail_end = len(framing) + while tail_end > footer_index + 1 and framing[tail_end - 1] == "": + tail_end -= 1 + rows = framing[footer_index + 1 : tail_end] + valid = bool(rows) and rows[-1].endswith(")") + normalized_rows = list(rows) + if valid: + normalized_rows[-1] = normalized_rows[-1][:-1] + observed_count = 0 + saw_more = False + for row_index, row in enumerate(normalized_rows): + if _PROJECT_PENDING_ROW.fullmatch(row): + if saw_more: + valid = False + break + observed_count += 1 + continue + more = _PROJECT_PENDING_MORE.fullmatch(row) + if more is None or row_index != len(normalized_rows) - 1: + valid = False + break + saw_more = True + observed_count += int(more["count"]) + valid = valid and observed_count == int(footer_match["count"]) + if not valid: + parse_error = parse_error or "malformed CodeGraph pending footer" + continue + pending_footer_lines.update(range(footer_index, tail_end)) + _append_unique_point(stale_points, _index_point("PENDING_CHANGES")) + + footer_start = _last_paragraph_start(framing) + for line_index, line in enumerate(framing): + if line.startswith(_DRIFTED_PROJECT_TAIL_PREFIX): + if line_index < footer_start: + parse_error = parse_error or "misplaced CodeGraph freshness footer" + else: + _append_unique_point( + stale_points, _index_point("PENDING_CHANGES") + ) + + # Unknown warning-like syntax is conservative only in framing positions: + # the first top content line, recognized file headers, and a distinct final + # epilogue paragraph. Ordinary non-framing prose is not keyword-scanned. + first_content = next( ( - candidate - for candidate in (_PARTIAL_BANNER_HEADER, _LEGACY_PARTIAL_BANNER_HEADER) - if normalized.startswith(candidate) + line_index + for line_index in range(index, len(framing)) + if framing[line_index] != "" ), None, ) - if header is not None: - listed = normalized[len(header) :] - footer_index = listed.find(_PARTIAL_BANNER_FOOTER) - if footer_index < 0: - return _with_response_sha256( - _response_not_verified(checked_at, "malformed CodeGraph stale banner"), - response_sha256, - ) - rows = listed[:footer_index].splitlines() - matches = [_PARTIAL_BANNER_ROW.fullmatch(row) for row in rows] - if not rows or any(match is None for match in matches): - return _with_response_sha256( - _response_not_verified(checked_at, "malformed CodeGraph stale banner"), - response_sha256, - ) - try: - stale_points = [ - _response_file_point(repo, match["path"]) - for match in matches - if match is not None - ] - except (InputFailure, RuleFailure, OSError, ValueError) as error: - return _with_response_sha256( - _response_not_verified(checked_at, error), response_sha256 - ) - return _with_response_sha256( - _response_result("PARTIAL_STALE", checked_at, stale_points=stale_points), - response_sha256, + if first_content is not None: + line = framing[first_content] + if ( + _DRIFTED_FILE_HEADER.match(line) is None + and _PROJECT_PENDING_FOOTER.fullmatch(line) is None + and not line.startswith(_DRIFTED_PROJECT_TAIL_PREFIX) + and _EXPLICIT_WARNING_FRAMING.search(line) + ): + parse_error = parse_error or "unrecognized CodeGraph freshness warning" + if footer_start > 0: + for line_index in range(footer_start, len(framing)): + line = framing[line_index] + if ( + line_index not in pending_footer_lines + and _DRIFTED_FILE_HEADER.match(line) is None + and not line.startswith(_DRIFTED_PROJECT_TAIL_PREFIX) + and _EXPLICIT_WARNING_FRAMING.search(line) + ): + parse_error = parse_error or "unrecognized CodeGraph freshness warning" + + if not fences_balanced: + parse_error = parse_error or "unclosed CodeGraph Markdown fence" + + if parse_error is not None: + result = _response_not_verified( + checked_at, parse_error, stale_points=stale_points ) - - framing = _framing_lines(normalized) - drifted_headers = [ - match - for line in framing - if (match := _DRIFTED_FILE_HEADER.match(line)) is not None - ] - if drifted_headers: - try: - stale_points = [ - _response_file_point(repo, match["path"]) - for match in drifted_headers - ] - except (InputFailure, RuleFailure, OSError, ValueError) as error: - return _with_response_sha256( - _response_not_verified(checked_at, error), response_sha256 - ) - return _with_response_sha256( - _response_result("PARTIAL_STALE", checked_at, stale_points=stale_points), - response_sha256, + elif any(point.get("scope") == "INDEX" for point in stale_points): + result = _response_result( + "INDEX_STALE", checked_at, stale_points=stale_points ) - - if any(line.startswith(_DRIFTED_PROJECT_TAIL_PREFIX) for line in framing): - return _with_response_sha256( - _response_result( - "INDEX_STALE", - checked_at, - stale_points=[_index_point("PENDING_CHANGES")], - ), - response_sha256, + elif stale_points: + result = _response_result( + "PARTIAL_STALE", checked_at, stale_points=stale_points ) - - if _SUSPICIOUS_FRESHNESS_SIGNAL.search("\n".join(framing)): - return _with_response_sha256( - _response_not_verified( - checked_at, "unrecognized CodeGraph freshness warning" - ), - response_sha256, - ) - return _with_response_sha256( - _response_result("NONE", checked_at, stale_points=[]), response_sha256 - ) + else: + result = _response_result("NONE", checked_at, stale_points=[]) + return _with_response_sha256(result, response_sha256) def _unique_items(items: list[Any]) -> list[Any]: @@ -361,7 +489,29 @@ def merge_freshness( } response_status = status if classification == "NONE" else classification - merged_status = max((status, response_status), key=FRESHNESS_ORDER.__getitem__) + merged_points = _unique_items( + [ + *status_result.get("stale_points", []), + *response_result.get("stale_points", []), + ] + ) + explicit_stale = any( + point.get("reason") != "STATUS_UNREADABLE" for point in merged_points + ) + if explicit_stale: + merged_status = ( + "INDEX_STALE" + if ( + status == "INDEX_STALE" + or classification == "INDEX_STALE" + or any(point.get("scope") == "INDEX" for point in merged_points) + ) + else "PARTIAL_STALE" + ) + else: + merged_status = max( + (status, response_status), key=FRESHNESS_ORDER.__getitem__ + ) status_basis = status_result.get("basis", []) response_basis = response_result.get("basis", []) include_response_basis = merged_status != "UNAVAILABLE" and ( @@ -379,9 +529,7 @@ def merge_freshness( "checked_at": response_result.get("checked_at") or status_result.get("checked_at"), "basis": basis, - "stale_points": _unique_items( - [*status_result.get("stale_points", []), *response_result.get("stale_points", [])] - ), + "stale_points": merged_points, "response_sha256": ( response_result.get("response_sha256") if include_response_basis else None ), diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index aa0e712..a4b828d 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -556,6 +556,41 @@ def runner(command, **_kwargs): "full_rebuild": "USER_ONLY", }) + def test_proxy_gates_initial_worktree_mismatch_before_sync(self) -> None: + self.qualify_task() + (self.repo / ".codegraph").mkdir() + mismatch = json.loads(healthy_status(self.repo)) + mismatch["pendingChanges"]["modified"] = 1 + mismatch["worktreeMismatch"] = { + "worktreeRoot": str(self.repo), + "indexRoot": str(self.repo / "other-worktree"), + } + calls: list[list[str]] = [] + + def runner( + command: list[str], **_kwargs: object + ) -> subprocess.CompletedProcess[str]: + calls.append(command) + return completed(json.dumps(mismatch)) + + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + result = self.proxy_module().execute_proxy_query( + self.repo, "TASK-0001", "PLANNING", "CIQ-001", + "locate A", "symbol A", runner=runner, + ) + + self.assertEqual([item[1] for item in calls], ["status"]) + self.assertIsNone(result["response"]) + self.assertIsNone(result["bundle"]["sync"]) + self.assertEqual(result["bundle"]["query"]["status"], "FAILED") + self.assertEqual(result["bundle"]["delivery"]["state"], "UNKNOWN") + self.assertEqual( + result["bundle"]["delivery"]["reason"], "WORKTREE_MISMATCH" + ) + def test_proxy_blocks_post_sync_project_mismatch_before_explore(self) -> None: self.qualify_task() (self.repo / ".codegraph").mkdir() @@ -605,6 +640,43 @@ def runner(command, **_kwargs): ["STATUS_UNREADABLE", "SYNC_FAILED"], ) + def test_proxy_blocks_post_sync_unavailable_before_explore(self) -> None: + self.qualify_task() + marker = self.repo / ".codegraph" + marker.mkdir() + pending = json.loads(healthy_status(self.repo)) + pending["pendingChanges"]["modified"] = 1 + calls: list[list[str]] = [] + + def runner( + command: list[str], **_kwargs: object + ) -> subprocess.CompletedProcess[str]: + calls.append(command) + if command[1] == "status": + return completed(json.dumps(pending)) + if command[1] == "sync": + marker.rmdir() + return completed("synced\n") + return completed("graph bytes must not be queried\n") + + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + result = self.proxy_module().execute_proxy_query( + self.repo, "TASK-0001", "PLANNING", "CIQ-001", + "locate A", "symbol A", runner=runner, + ) + + self.assertEqual([item[1] for item in calls], ["status", "sync"]) + self.assertEqual( + result["bundle"]["post_sync_status"]["status"], "UNAVAILABLE" + ) + self.assertEqual(result["bundle"]["query"]["status"], "FAILED") + self.assertEqual(result["bundle"]["delivery"]["state"], "STALE") + self.assertIsNone(result["response"]) + self.assertIsNone(result["bundle"]["response_path"]) + def test_proxy_discards_response_after_post_query_project_mismatch(self) -> None: self.qualify_task() (self.repo / ".codegraph").mkdir() @@ -646,6 +718,94 @@ def runner(command, **_kwargs): result["bundle"]["delivery"]["reason"], "PROJECT_MISMATCH" ) + def test_proxy_discards_response_classified_as_worktree_mismatch(self) -> None: + self.qualify_task() + (self.repo / ".codegraph").mkdir() + response = ( + "⚠ CodeGraph results below come from a different git worktree " + "(/tmp/main), not where you're working (/tmp/wt) — they may reflect " + "another branch.\n\n" + "graph bytes must be discarded\n" + ) + responses = [ + completed(healthy_status(self.repo)), + completed(response), + completed(healthy_status(self.repo)), + ] + calls: list[list[str]] = [] + + def runner( + command: list[str], **_kwargs: object + ) -> subprocess.CompletedProcess[str]: + calls.append(command) + return responses.pop(0) + + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + result = self.proxy_module().execute_proxy_query( + self.repo, "TASK-0001", "PLANNING", "CIQ-001", + "locate A", "symbol A", runner=runner, + ) + + response_path = ( + self.repo + / ".polaris/tasks/TASK-0001/runtime/code-intelligence/planning" + / "CIQ-001.response.txt" + ) + self.assertEqual( + [item[1] for item in calls], ["status", "explore", "status"] + ) + self.assertEqual( + result["bundle"]["response_classification"]["classification"], + "INDEX_STALE", + ) + self.assertIsNone(result["response"]) + self.assertIsNone(result["bundle"]["response_path"]) + self.assertFalse(response_path.exists()) + self.assertEqual(result["bundle"]["delivery"]["state"], "UNKNOWN") + self.assertEqual( + result["bundle"]["delivery"]["reason"], "WORKTREE_MISMATCH" + ) + + def test_proxy_post_query_unavailable_cannot_become_current(self) -> None: + self.qualify_task() + marker = self.repo / ".codegraph" + marker.mkdir() + calls: list[list[str]] = [] + + def runner( + command: list[str], **_kwargs: object + ) -> subprocess.CompletedProcess[str]: + calls.append(command) + if command[1] == "status": + return completed(healthy_status(self.repo)) + if command[1] == "explore": + marker.rmdir() + return completed("graph bytes\n") + raise AssertionError(f"unexpected CodeGraph command: {command}") + + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + result = self.proxy_module().execute_proxy_query( + self.repo, "TASK-0001", "PLANNING", "CIQ-001", + "locate A", "symbol A", runner=runner, + ) + + self.assertEqual([item[1] for item in calls], ["status", "explore"]) + self.assertEqual( + result["bundle"]["post_query_status"]["status"], "UNAVAILABLE" + ) + self.assertEqual(result["bundle"]["delivery"]["state"], "UNKNOWN") + self.assertEqual( + result["bundle"]["delivery"]["record_status"], "NOT_VERIFIED" + ) + self.assertEqual(result["response"], "graph bytes\n") + self.assertIsNotNone(result["bundle"]["response_path"]) + def test_proxy_queries_unknown_pre_status_and_treats_result_as_stale(self) -> None: self.qualify_task() (self.repo / ".codegraph").mkdir() @@ -674,7 +834,7 @@ def runner(command, **_kwargs): self.assertEqual(result["bundle"]["delivery"]["state"], "UNKNOWN") self.assertIn("freshness: TREAT_AS_STALE", result["envelope"]) - def test_proxy_unknown_pre_status_overrides_later_stale_signals(self) -> None: + def test_proxy_known_stale_overrides_unknown_pre_status(self) -> None: cases = [ ( "post_pending", @@ -721,17 +881,18 @@ def runner(command, **_kwargs): [item[1] for item in calls], ["status", "explore", "status"] ) self.assertEqual(result["response"], response) - self.assertEqual(result["bundle"]["delivery"]["state"], "UNKNOWN") + self.assertEqual(result["bundle"]["delivery"]["state"], "STALE") self.assertEqual( - result["bundle"]["delivery"]["reason"], "STATUS_UNREADABLE" + result["bundle"]["delivery"]["reason"], stale_reason ) - self.assertIn( - stale_reason, - [ + self.assertEqual( + { point["reason"] for point in result["bundle"]["delivery"]["stale_points"] - ], + }, + {"STATUS_UNREADABLE", stale_reason}, ) + self.assertIsNotNone(result["bundle"]["delivery"]["error"]) self.assertIn("freshness: TREAT_AS_STALE", result["envelope"]) def test_proxy_does_not_query_a_different_project_index(self) -> None: @@ -1782,6 +1943,73 @@ def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess self.assertEqual(recorded["query"]["response_sha256"], None) self.assertEqual(recorded["delivery"]["stale_points"], []) + def test_failed_explore_with_known_stale_projects_to_failed_v3(self) -> None: + self.qualify_task() + (self.repo / ".codegraph").mkdir() + source = self.repo / "src/a.py" + source.parent.mkdir() + source.write_text("class A:\n pass\n", encoding="utf-8") + stale = json.loads(healthy_status(self.repo)) + stale["index"]["state"] = "partial" + responses = [ + completed(json.dumps(stale)), + completed("failed explore output\n", returncode=1), + ] + + def runner( + command: list[str], **_kwargs: object + ) -> subprocess.CompletedProcess[str]: + return responses.pop(0) + + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + query = self.proxy_module().execute_proxy_query( + self.repo, "TASK-0001", "PLANNING", "CIQ-001", + "locate A in a partial index", "symbol A", runner=runner, + ) + + self.assertEqual(query["bundle"]["delivery"]["state"], "STALE") + self.assertEqual(query["bundle"]["query"]["status"], "FAILED") + self.assertIsNone(query["bundle"]["response_path"]) + protocol = importlib.import_module("internal.code_intelligence_protocol") + try: + result = protocol.record_proxy_bundle( + self.repo, + "TASK-0001", + query["bundle_path"], + { + "summary": "Explore failed; verified current source instead.", + "symbols": [], + "source_fallbacks": [{ + "action": "SEARCH_SOURCE", + "path": None, + "observed_sha256": None, + "base_commit": None, + "head_commit": None, + "diff_hash": None, + "purpose": "locate A in current source", + "result_paths": [{ + "path": "src/a.py", + "observed_sha256": file_sha256(source), + }], + }], + }, + ROOT, + ) + except RuleFailure as error: + self.fail(f"known-stale failed query must remain projectable: {error}") + recorded = json.loads(Path(result["path"]).read_text(encoding="utf-8")) + self.assertEqual(recorded["record_version"], 3) + self.assertEqual(recorded["delivery"]["state"], "STALE") + self.assertEqual(recorded["status"], "FAILED") + self.assertEqual(recorded["query"]["status"], "FAILED") + self.assertIn( + "INDEX_PARTIAL", + [point["reason"] for point in recorded["delivery"]["stale_points"]], + ) + def test_failed_sync_proxy_bundle_preserves_only_observed_post_status(self) -> None: self.qualify_task() (self.repo / ".codegraph").mkdir() @@ -4002,6 +4230,12 @@ def test_current_codegraph_freshness_framing_is_classified(self) -> None: "source below is current; the symbol list may be outdated\n", "PARTIAL_STALE", ), + "drifted_omitted_source": ( + "**`src/a.py`** — ⚠ changed on disk after the last index sync — " + "source omitted (indexed line ranges no longer match, so a slice " + "could show the wrong code).\n", + "PARTIAL_STALE", + ), "worktree": ( "⚠ CodeGraph results below come from a different git worktree " "(/tmp/main), not where you're working (/tmp/wt) — they may reflect " @@ -4015,6 +4249,88 @@ def test_current_codegraph_freshness_framing_is_classified(self) -> None: self.classify_response(response)["classification"], expected ) + def test_combined_response_framing_preserves_worktree_mismatch(self) -> None: + source = self.repo / "src/a.py" + source.parent.mkdir() + source.write_text("value = 1\n", encoding="utf-8") + worktree = ( + "⚠ CodeGraph results below come from a different git worktree " + "(/tmp/main), not where you're working (/tmp/wt) — they may reflect " + "another branch, and symbols changed only here are missing.\n" + ) + partial = ( + "⚠️ Some files referenced below were edited since the last index sync — " + "their codegraph entries may be stale:\n" + " - src/a.py (edited 12ms ago, pending sync)\n" + "For accurate content of those specific files, Read them directly. " + "The rest of this response is fresh.\n\n" + ) + disabled = ( + "⚠️ CodeGraph auto-sync is DISABLED — live file watching stopped, so " + "the index is frozen and any file edited since then is stale here. " + "Read files directly to confirm current content before relying on it.\n\n" + ) + cases = { + "partial_and_worktree": ( + partial + worktree + "\ngraph result\n", + {"PENDING_SYNC", "WORKTREE_MISMATCH"}, + ), + "disabled_and_worktree": ( + disabled + worktree + "\ngraph result\n", + {"AUTO_SYNC_DISABLED", "WORKTREE_MISMATCH"}, + ), + } + + for name, (response, expected_reasons) in cases.items(): + with self.subTest(name=name): + result = self.classify_response(response) + self.assertEqual(result["classification"], "INDEX_STALE") + self.assertEqual( + {point["reason"] for point in result["stale_points"]}, + expected_reasons, + ) + + def test_current_project_pending_footer_is_index_stale(self) -> None: + response = ( + "Graph result with no referenced stale files.\n\n" + "(Note: 2 file(s) elsewhere in this project are pending index sync " + "but were not referenced above:\n" + " - src/a.py (edited 12ms ago)\n" + " - src/b.py (edited 20ms ago))\n" + ) + + result = self.classify_response(response) + + self.assertEqual(result["classification"], "INDEX_STALE") + self.assertEqual(result["stale_points"], [{ + "scope": "INDEX", + "path": None, + "reason": "PENDING_CHANGES", + "fallback": "SEARCH_SOURCE", + "observed_sha256": None, + }]) + + def test_unclosed_source_fence_is_not_verified(self) -> None: + response = ( + "**`src/a.py`** — A(function)\n\n" + "```python\n" + "def A():\n" + " return 'ordinary source'\n" + ) + + result = self.classify_response(response) + + self.assertEqual(result["classification"], "NOT_VERIFIED") + self.assertIn("fence", str(result["error"]).lower()) + + def test_warning_words_in_ordinary_prose_do_not_change_freshness(self) -> None: + response = ( + "The pending request emits a warning when its cached value becomes stale.\n" + "This sentence is returned program prose, not CodeGraph freshness framing.\n" + ) + + self.assertEqual(self.classify_response(response)["classification"], "NONE") + def test_warning_words_inside_verbatim_source_do_not_change_freshness(self) -> None: response = ( "**`src/a.py`** — A(function)\n\n" @@ -4225,6 +4541,41 @@ def test_merge_freshness_uses_conservative_status_and_ordered_evidence(self) -> self.assertEqual(result["stale_points"], response["stale_points"]) self.assertEqual(result["status_response_sha256"], "status-sha") + def test_merge_freshness_keeps_explicit_stale_above_verification_failure(self) -> None: + merger = getattr(self.adapter_module(), "merge_freshness", None) + self.assertTrue(callable(merger), "CodeGraph freshness merger must exist") + status = { + "status": "NOT_VERIFIED", + "checked_at": "2026-08-18T00:00:00Z", + "basis": ["STATUS_JSON"], + "stale_points": [{ + "scope": "INDEX", + "path": None, + "reason": "STATUS_UNREADABLE", + "fallback": "SEARCH_SOURCE", + "observed_sha256": None, + }], + "status_response_sha256": None, + "error": "status JSON was unreadable", + "needs_sync": False, + "pending_changes": None, + } + response = self.classify_response( + "⚠️ Some files referenced below were edited since the last index sync — " + "their codegraph entries may be stale:\n" + " - src/deleted.py (edited 800ms ago, pending sync)\n" + "For accurate content of those specific files, Read them directly.\n" + ) + + result = merger(status, response) + + self.assertEqual(result["status"], "INDEX_STALE") + self.assertEqual( + {point["reason"] for point in result["stale_points"]}, + {"STATUS_UNREADABLE", "PENDING_SYNC"}, + ) + self.assertEqual(result["error"], "status JSON was unreadable") + def test_none_response_does_not_upgrade_unverified_status(self) -> None: merger = getattr(self.adapter_module(), "merge_freshness", None) self.assertTrue(callable(merger), "CodeGraph freshness merger must exist") From b823f1d9664867865e0110e4e928cbc0214316e4 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 19:21:13 +0800 Subject: [PATCH 13/13] fix: preserve misplaced worktree mismatch evidence --- scripts/internal/codegraph_adapter.py | 7 +++- tests/test_codegraph.py | 60 +++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/scripts/internal/codegraph_adapter.py b/scripts/internal/codegraph_adapter.py index 2be57af..5096a12 100644 --- a/scripts/internal/codegraph_adapter.py +++ b/scripts/internal/codegraph_adapter.py @@ -330,10 +330,15 @@ def classify_response( # prose. This retains the legacy wrapped-banner safety behavior. for line_index in range(index, len(framing)): line = framing[line_index] + worktree_mismatch = line.startswith(_WORKTREE_BANNER_PREFIX) + if worktree_mismatch: + _append_unique_point( + stale_points, _index_point("WORKTREE_MISMATCH") + ) if ( _partial_header_length(framing, line_index) or line.startswith(_DISABLED_BANNER_PREFIX) - or line.startswith(_WORKTREE_BANNER_PREFIX) + or worktree_mismatch ): parse_error = parse_error or "misplaced CodeGraph freshness banner" diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index a4b828d..22594ae 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -769,6 +769,66 @@ def runner( result["bundle"]["delivery"]["reason"], "WORKTREE_MISMATCH" ) + def test_proxy_discards_misplaced_worktree_mismatch_banner(self) -> None: + self.qualify_task() + (self.repo / ".codegraph").mkdir() + response = ( + "Ordinary graph content appears before the safety framing.\n\n" + "⚠ CodeGraph results below come from a different git worktree " + "(/tmp/main), not where you're working (/tmp/wt) — they may reflect " + "another branch.\n\n" + "graph bytes must be discarded\n" + ) + responses = [ + completed(healthy_status(self.repo)), + completed(response), + completed(healthy_status(self.repo)), + ] + calls: list[list[str]] = [] + + def runner( + command: list[str], **_kwargs: object + ) -> subprocess.CompletedProcess[str]: + calls.append(command) + return responses.pop(0) + + with mock.patch( + "internal.code_intelligence_proxy.shutil.which", + return_value="/bin/codegraph", + ): + result = self.proxy_module().execute_proxy_query( + self.repo, "TASK-0001", "PLANNING", "CIQ-001", + "locate A", "symbol A", runner=runner, + ) + + response_path = ( + self.repo + / ".polaris/tasks/TASK-0001/runtime/code-intelligence/planning" + / "CIQ-001.response.txt" + ) + classification = result["bundle"]["response_classification"] + self.assertEqual( + [item[1] for item in calls], ["status", "explore", "status"] + ) + self.assertEqual(classification["classification"], "NOT_VERIFIED") + self.assertEqual( + {point["reason"] for point in classification["stale_points"]}, + {"WORKTREE_MISMATCH", "STATUS_UNREADABLE"}, + ) + self.assertIn("misplaced", classification["error"]) + self.assertIsNone(result["response"]) + self.assertIsNone(result["bundle"]["response_path"]) + self.assertFalse(response_path.exists()) + self.assertEqual( + result["bundle"]["post_query_status"]["status"], + "CURRENT_AT_CHECK", + ) + self.assertEqual(result["bundle"]["delivery"]["state"], "UNKNOWN") + self.assertEqual(result["bundle"]["delivery"]["usage"], "NAVIGATION_ONLY") + self.assertEqual( + result["bundle"]["delivery"]["reason"], "WORKTREE_MISMATCH" + ) + def test_proxy_post_query_unavailable_cannot_become_current(self) -> None: self.qualify_task() marker = self.repo / ".codegraph"