From 091a2b19ede9d30829aca5e45039a004d21daa33 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 13:49:51 +0800 Subject: [PATCH 01/41] docs: design CodeGraph freshness integration --- .../2026-08-18-codegraph-freshness-design.md | 351 ++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-18-codegraph-freshness-design.md diff --git a/docs/superpowers/specs/2026-08-18-codegraph-freshness-design.md b/docs/superpowers/specs/2026-08-18-codegraph-freshness-design.md new file mode 100644 index 0000000..e0e2f34 --- /dev/null +++ b/docs/superpowers/specs/2026-08-18-codegraph-freshness-design.md @@ -0,0 +1,351 @@ +# CodeGraph 实时新鲜度与源码回退设计 + +## 状态 + +- 日期:2026-08-18 +- 状态:已在对话中确认,等待书面规格复核 +- 适用版本:Polaris v0.1 的下一协议版本 +- 产品 authority:`plan.md` +- 唯一正式 CodeGraph Provider:[`colbymchenry/codegraph`](https://github.com/colbymchenry/codegraph) + +## 背景 + +Polaris 已有可选 Code Intelligence Provider 协议,但当前 `codegraph` descriptor 指向另一个同名且协议不兼容的产品。目标 Provider 实际应为 `colbymchenry/codegraph`。它默认向 MCP 暴露单一高价值入口 `codegraph_explore`,并提供 `codegraph explore`、`codegraph status --json` 和 `codegraph sync` CLI。 + +目标 CodeGraph 通过三层机制保持索引接近当前工作树:MCP 文件 watcher 增量同步、响应级逐文件 stale banner,以及连接时 reconciliation。Polaris 不复制这些机制,也不建设 daemon;Polaris 只增加阶段边界检查、必要时的一次增量同步、失效点记录和确定性源码回退。 + +## 目标 + +1. `codegraph` Provider ID 只代表 `colbymchenry/codegraph`。 +2. Agent 在已初始化仓库中优先通过 `codegraph_explore` 获取源码、调用路径和影响范围。 +3. 正常编辑依赖 CodeGraph watcher;Polaris 只在阶段入口、已知索引冻结或最终交付前按需执行 `codegraph sync`。 +4. CodeGraph 返回局部失效信息时,明确记录具体文件并要求 Agent 直接读取这些文件。 +5. 整体索引无法信任时,明确标记索引级失效并让 Agent 回退到仓库搜索、源码读取或 Git diff。 +6. 新鲜度表达必须是一次检查时的有限结论,不能宣称索引与 Git commit 永久或严格一致。 +7. Provider 不可用、状态不可解析或同步失败时,Polaris 工作流继续运行;Code Intelligence 永远不是门禁或验收证据。 +8. 所有运行时代码继续只依赖 Python 标准库,并在 Windows、macOS 和 Linux 上使用相同协议语义。 + +## 非目标 + +- Polaris 不安装 CodeGraph。 +- Polaris 不执行 `codegraph init`,因为建立 `.codegraph/` 索引是用户决定。 +- Polaris 不启动、停止或管理 CodeGraph daemon,不修改宿主 MCP 配置。 +- Polaris 不通过 sleep 等待 watcher,不持续轮询状态,不实现第二套文件 watcher。 +- Polaris 不把 CodeGraph 结果当作源码、Git、构建、测试、Validation 或独立 Review 的替代品。 +- Polaris 不保存完整图或完整 MCP 响应到 Git。 +- 本改动不增加第二个正式 Code Intelligence Provider。 + +## 方案选择 + +采用“Provider 原生 watcher + Polaris 按需 sync + 精确失效回退”。 + +没有采用每次查询前强制同步,因为这会增加延迟和索引锁竞争,并重复 CodeGraph watcher 已完成的工作。没有采用完全被动信任 watcher,因为 watcher 被禁用、索引部分损坏、worktree 不匹配或连接失败时,Polaris 将无法给出可审计的新鲜度结论。 + +## 架构 + +### Provider descriptor + +`providers/code-intelligence/codegraph.json` 是唯一正式 descriptor,并升级 descriptor 版本。它声明: + +- 实现标识:`github.com/colbymchenry/codegraph` +- 项目激活标记:`.codegraph/` +- MCP 主入口:`codegraph_explore` +- MCP 可选健康入口:`codegraph_status` +- CLI executable:`codegraph` +- CLI 查询:`explore` +- CLI 健康检查:`status --json` +- CLI 增量同步:`sync --quiet` +- CodeGraph 支持的源码扩展名 + +Planning 的 context、dependency、call-path 和 impact 目的,Implementation 的 edit context,Review 的 subject impact 都映射为不同 query purpose,但最终调用同一个 `codegraph_explore`。Polaris 不再假设 Provider 有多个窄工具。 + +descriptor Schema 只描述通用的 marker、MCP operation 和 CLI capability;CodeGraph JSON 输出和 banner 的具体解释封装在 adapter 内,不能散落到核心 workflow 或阶段 Skills。 + +### 协议层 + +`scripts/internal/code_intelligence_protocol.py` 保留以下 Provider-neutral 职责: + +- 加载 `.polaris/code-intelligence.json`。 +- 加载并验证正式 descriptor。 +- 根据配置、`.codegraph/` marker 和宿主暴露的工具选择 Provider。 +- 验证 v2 Code Intelligence record 与当前任务 revision、subject 和仓库路径一致。 +- 写入不可变的精简 record。 + +旧的 `refresh_files` / `refresh_workspace` 逻辑删除。目标 CodeGraph 的 `sync` 本身是增量 reconciliation,并统一处理新增、修改、删除和重命名。 + +### CodeGraph adapter + +新增聚焦的内部 adapter,负责: + +- 使用参数数组和显式工作目录调用 `codegraph status --json` 与 `codegraph sync --quiet`,不得经过 shell。 +- 对外部进程设置有限超时,并把超时、非零退出码、编码错误和 malformed JSON 转为通用失败状态。 +- 验证 status 的 `initialized`、`projectPath`、`pendingChanges`、`worktreeMismatch`、`index.state`、`index.pendingRefs` 和 `index.reindexRecommended`。 +- 将官方逐文件 stale banner 解析为受仓库边界约束的文件失效点。 +- 将官方 auto-sync-disabled banner 解析为索引级失效点。 +- 在允许同步的调用中最多执行一次 `sync`,随后最多执行一次 status 复查。 +- 返回 Provider-neutral 的 freshness、stale points 和命令证据摘要。 + +adapter 不安装、初始化或启动 Provider。`.codegraph/` 不存在时不得调用 MCP 或 CLI。 + +### 内部运行脚本 + +新增一个不暴露到用户 `polaris` CLI 命令面的内部脚本,提供三个动作: + +- `status`:读取一次 status 并输出通用 freshness JSON。 +- `sync-if-needed`:先检查状态,只在需要时同步一次并复查一次。 +- `classify-response`:读取任务 runtime 下的原始 explore 响应,提取 stale banner,并输出通用 freshness JSON。 + +精确 Provider 响应只保存在任务的 ignored `runtime/code-intelligence/` 下。Git 中只保存响应 SHA-256、有限摘要、失效点和源码回退证据。 + +## 新鲜度模型 + +每个 v2 Code Intelligence record 包含一个 `freshness` 对象: + +```json +{ + "status": "PARTIAL_STALE", + "checked_at": "2026-08-18T12:00:00Z", + "basis": [ + "STATUS_JSON", + "RESPONSE_BANNER" + ], + "stale_points": [ + { + "scope": "FILE", + "path": "src/widget.py", + "reason": "PENDING_SYNC", + "fallback": "READ_SOURCE", + "observed_sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + } + ] +} +``` + +### Freshness status + +- `CURRENT_AT_CHECK`:检查时没有已知失效信号。该名称明确限制结论时效,不表示 commit-exact。 +- `PARTIAL_STALE`:响应引用的一个或多个具体文件处于 pending sync,其他未列出的响应内容仍可作为 CodeGraph 线索使用。 +- `INDEX_STALE`:整个索引不可直接信任。 +- `NOT_VERIFIED`:无法取得或解析充分健康状态,不能静默视为 current。 +- `UNAVAILABLE`:Provider 被禁用、`.codegraph/` 缺失,或 MCP/CLI 查询通道均不可用。 + +`basis` 只允许: + +- `CONNECT_RECONCILIATION` +- `STATUS_JSON` +- `SYNC_ACKNOWLEDGED` +- `RESPONSE_BANNER` +- `NONE` + +连接 reconciliation 只有在 Provider 响应明确确认时才能记录,不能由 Polaris 猜测。 + +### Stale point + +文件级失效点: + +- `scope` 为 `FILE`。 +- `path` 必须是仓库相对 POSIX 路径。 +- `reason` 为 `PENDING_SYNC`。 +- 文件存在时 `fallback` 必须为 `READ_SOURCE`,且 `observed_sha256` 必须等于记录时当前普通文件的 SHA-256。 +- 文件已删除时 `fallback` 必须为 `INSPECT_GIT_DIFF`,且 `observed_sha256` 必须为 `null`。 + +索引级失效点: + +- `scope` 为 `INDEX`。 +- `path` 必须为 `null`。 +- `fallback` 必须为 `SEARCH_SOURCE`。 +- `observed_sha256` 必须为 `null`。 +- `reason` 允许:`AUTO_SYNC_DISABLED`、`WORKTREE_MISMATCH`、`INDEX_PARTIAL`、`INDEX_INDEXING`、`INDEX_FAILED`、`PENDING_REFERENCES`、`REINDEX_RECOMMENDED`、`SYNC_FAILED`、`STATUS_UNREADABLE`。 + +一致性规则: + +- `CURRENT_AT_CHECK` 不得包含 stale point。 +- `PARTIAL_STALE` 至少包含一个文件级 stale point,且不得包含索引级 stale point。 +- `INDEX_STALE` 至少包含一个索引级 stale point。 +- `NOT_VERIFIED` 必须包含 `STATUS_UNREADABLE`,或记录查询通道没有提供可验证状态。 +- `UNAVAILABLE` 不得伪造 status、sync 或 response 成功证据。 +- 所有非 current 状态都必须给出 Agent 下一步回退动作。 + +### Source fallback + +`source_fallbacks` 记录 Agent 对失效范围实际采用的权威来源: + +- `READ_SOURCE`:直接读取当前普通文件并绑定 SHA-256。 +- `INSPECT_GIT_DIFF`:用于已删除路径,绑定 subject base/head 和 diff hash。 +- `SEARCH_SOURCE`:用于整个索引失效或未知影响范围,记录实际使用的仓库搜索目的与有限路径结果。 + +Validator 能验证路径 confinement、文件类型、当前 SHA-256 和 subject diff hash。它不能证明模型理解了内容,因此这些字段是可审计的输入证据,不是完成门禁。 + +## 运行流程 + +### Provider 激活 + +1. 加载项目 Code Intelligence 配置。 +2. 配置为 `disabled` 时记录 `UNAVAILABLE`,不尝试 Provider。 +3. `.codegraph/` 不存在时记录 `UNAVAILABLE`。当前会话对该项目停止调用 CodeGraph,并提示用户可自行运行 `codegraph init`。 +4. 优先使用宿主暴露的 `codegraph_explore`。 +5. MCP explore 不可用而 `codegraph` executable 可用时,使用 `codegraph explore`。 +6. 两者均不可用时立即回退源码。 + +### 阶段入口 + +1. 调用 `status`。 +2. status healthy 且 pending change 总数为零时记录 `CURRENT_AT_CHECK`。 +3. 有 pending change 时执行一次 `sync`,然后复查一次。 +4. 复查 healthy 时以 `SYNC_ACKNOWLEDGED` 作为 basis。 +5. 复查仍 pending 或出现索引级异常时记录 `INDEX_STALE`,本阶段不循环同步。 + +Healthy status 必须同时满足: + +- `initialized` 为 `true`。 +- `projectPath` 解析后等于当前仓库根目录。 +- `pendingChanges.added`、`modified`、`removed` 均为零。 +- `worktreeMismatch` 为 `null`。 +- `index.state` 为 `complete`,或为旧版本缺省值 `null` 且没有其他失效信号。 +- `index.pendingRefs` 为零。 +- `index.reindexRecommended` 为 `false`。 + +### Explore 查询 + +1. 查询必须受冻结 Work Item、Working Set、registered subject 或已确认依赖约束。 +2. 保存原始响应到 runtime,并记录 SHA-256。 +3. 解析 stale banner。 +4. 没有 banner 且入口 status healthy 时,结果可作为 `CURRENT_AT_CHECK` 的非权威线索。 +5. 逐文件 banner 产生 `PARTIAL_STALE`:立即直接读取列出的文件;这些文件相关的图关系只能用于导航,不能直接形成编辑或 Review 结论。官方明确未列出的响应部分仍可使用。 +6. auto-sync-disabled banner 产生 `INDEX_STALE`:允许同步时执行一次 sync 并重试一次查询;无法恢复时直接读取返回的全部路径,并通过仓库搜索补齐依赖。 +7. malformed response 产生 `NOT_VERIFIED` 并回退源码。 + +### Implementation 中途查询 + +正常编辑不主动同步,也不等待 debounce。只有后续声明步骤确实依赖刚修改代码的新调用关系时才再次查询。查询返回 stale banner 时直接读取 stale 文件;不能通过 sleep 或重复轮询让 banner 消失。 + +### Documentation Sync + +最终代码 subject 存在受支持源码变化时执行一次 `sync-if-needed`。没有受支持源码变化时记录 `SKIPPED`。同步失败只影响 Code Intelligence freshness record,不阻止 docs check、Validation 或任务状态转换。 + +### Review + +Reviewer 使用 registered subject 独立查询,不能复用 Implementer 的查询结论。任何 stale point 都必须在 Reviewer 自己的 record 中重新处理。CodeGraph 不能决定 Review verdict。 + +### Validation + +Validation 不调用 CodeGraph。验收只依赖源码、Git、构建、测试、静态检查和 Human Check。 + +## 错误处理 + +- 外部进程超时、非零退出、无法解码或 JSON malformed:`NOT_VERIFIED` 或 `INDEX_STALE`,附有限错误摘要,立即回退。 +- `.codegraph/` 缺失:`UNAVAILABLE`,不自动初始化。 +- executable 缺失但 MCP 可用:继续使用 MCP;不得因为 CLI status 缺失声称 `CURRENT_AT_CHECK`,响应 banner 仍可提供局部 freshness。 +- MCP 缺失但 CLI 可用:使用 CLI explore/status/sync。 +- marker、status project path 或返回路径越过仓库边界:拒绝该证据并按 `INDEX_STALE` 回退。 +- 同步成功但复查仍 pending:记录 `SYNC_FAILED` 或保留更具体的 status reason,不再同步第二次。 +- CodeGraph lock、watcher 或 daemon 错误:Polaris 不管理进程;记录错误并给出用户可执行的 Provider 修复提示。 +- 原始响应可能包含源码,不得写入 Git artifact;只保存 ignored runtime 文件及其哈希。 + +## Agent 指令 + +vendored `AGENTS.md` 和宿主 worker 指令必须包含同一组条件规则: + +1. 只有仓库根存在 `.codegraph/` 才优先使用 CodeGraph。 +2. MCP 可用时先用 `codegraph_explore`;非 MCP worker 使用 `codegraph explore`。 +3. 响应列出的 stale 文件必须直接读取;不要放弃整个仍可用的图响应。 +4. auto-sync disabled 或整体索引失效时,图只作为提示,必须从源码和 Git 确认。 +5. 项目未初始化时停止对本项目调用 CodeGraph;可以提示 `codegraph init`,但不得代用户运行。 +6. CodeGraph 结果不能扩展冻结 scope,也不能替代测试和门禁。 + +这些规则由 Polaris vendoring 负责提供给 Implementer 和 Reviewer。若 CodeGraph 官方 installer 已在仓库 instructions 中写入 marker-fenced 规则,内容可以并存,但 Polaris 规则不得修改或删除 installer 管理的 marker block。 + +## 版本和迁移 + +本改动升级 Polaris 协议版本和 workflow 版本,因为 descriptor、record Schema 和阶段行为均改变。 + +迁移规则: + +- 新任务只能写 v2 Code Intelligence record,并绑定新版 `colbymchenry/codegraph` descriptor。 +- 已提交的 v1 record 不删除、不改写,继续作为 immutable historical evidence 审计。 +- v1 record 通过独立的 legacy validation path 读取,不再根据当前 descriptor 宣称 Provider capability 或 freshness。 +- 迁移记录将 v1 Code Intelligence evidence 标记为 `retired_provider_evidence`;它不能被新阶段复用,也不能支持任何新鲜度结论。 +- 活跃任务在迁移后的下一 Code Intelligence 阶段必须生成新的 v2 record。 +- 旧产品的 MCP 工具名、descriptor 映射、刷新规划、测试断言和用户文档全部删除;legacy record Schema 使用逻辑 operation 名,不保存旧产品工具名。 + +## 安全与跨平台 + +- 所有 CLI 调用使用 `subprocess` 参数列表、显式 `cwd`、UTF-8 文本模式和有限 timeout。 +- 不使用 shell command string,不依赖 Bash、PowerShell 或 PATH 分隔符语义。 +- status `projectPath`、banner 路径和 source fallback 路径都必须经过现有 confinement 与 regular-file 检查。 +- symlink 解析后越出仓库即拒绝。 +- `.codegraph/` 只作为存在性 marker,不读取或修改其内部数据库。 +- Windows 路径在进入 artifact 前标准化为仓库相对 POSIX 路径。 +- 同步命令的 stdout/stderr 只进入 ignored runtime;Git record 只保存哈希与有限错误摘要。 + +## 文件改动范围 + +预计修改: + +- `providers/code-intelligence/codegraph.json` +- `schemas/code-intelligence-provider.schema.json` +- `schemas/code-intelligence-record.schema.json` +- `scripts/internal/code_intelligence_protocol.py` +- `scripts/record_code_intelligence.py` +- `skills/code-intelligence/SKILL.md` +- `skills/architecture-planning/SKILL.md` +- `skills/implementation/SKILL.md` +- `skills/adversarial-review/SKILL.md` +- `skills/documentation-sync/SKILL.md` +- `templates/AGENTS.md` +- Code Intelligence record 与相关 artifact 模板 +- `README.md` +- `docs/USAGE.md` +- `plan.md` +- `VERSION` +- workflow version、migration 声明与对应模板 +- `tests/test_core.py`,或按现有测试组织拆出的聚焦测试文件 + +预计新增: + +- 一个内部 CodeGraph adapter 模块 +- 一个内部 Code Intelligence runtime 脚本 +- v1 record legacy Schema 或等价的只读 legacy validator + +预计删除: + +- 旧同名产品的所有 MCP 工具映射 +- `refresh_files` / `refresh_workspace` Provider 操作和刷新规划分支 +- 对旧映射的测试与文档说明 + +## 测试策略 + +所有行为改动使用 TDD,并覆盖: + +1. 正式 descriptor 只指向 `github.com/colbymchenry/codegraph`。 +2. 仓库受管代码、测试和文档中不存在旧 MCP 工具名。 +3. `.codegraph/` 缺失时不调用外部工具并返回 `UNAVAILABLE`。 +4. MCP explore 优先;MCP 缺失时使用 CLI explore。 +5. healthy status 映射为 `CURRENT_AT_CHECK`。 +6. pending changes 只触发一次 sync 和一次复查。 +7. sync 后仍 pending 映射为 `INDEX_STALE`,且不循环。 +8. worktree mismatch、partial/indexing/failed index、pending refs 和 reindex recommended 分别映射为准确 stale reason。 +9. 多文件 stale banner 生成多个受限文件路径和 `PARTIAL_STALE`。 +10. auto-sync-disabled banner 生成索引级 stale point。 +11. malformed status、malformed response、timeout 和非零退出不会静默标记 current。 +12. `READ_SOURCE` fallback 必须绑定当前普通文件 SHA-256。 +13. 删除文件只能使用 `INSPECT_GIT_DIFF`;索引级失效使用 `SEARCH_SOURCE`。 +14. `../`、绝对路径、错误 SHA 和越界 symlink 被拒绝。 +15. v2 record 状态与 stale point 的组合满足所有一致性规则。 +16. v1 record 在迁移后可审计但不能成为新阶段活跃 evidence。 +17. Codex、Claude Code 和 vendored template 都包含相同回退语义。 +18. Windows、macOS 和 Linux subprocess/path 行为通过平台无关模拟覆盖。 +19. 真实 CodeGraph smoke test 仅在本机已有 CLI 时运行;CI 缺少 CLI 时明确 `SKIP`,不得误报失败。 +20. 全量现有测试通过,vendored target 自包含校验继续通过。 + +## 验收标准 + +1. Polaris 代码库只存在一个正式 `codegraph` descriptor,且实现来源为 `colbymchenry/codegraph`。 +2. 旧同名产品的具体工具名与刷新模型不再出现在受管实现、测试或用户文档中。 +3. 已初始化且健康的仓库能通过 MCP 或 CLI 使用 CodeGraph。 +4. 有 pending changes 时最多自动同步一次;没有固定等待或轮询。 +5. CodeGraph 响应列出的 stale 文件会出现在 v2 record,并绑定源码或 Git diff 回退证据。 +6. 整体索引异常会明确标记为 `INDEX_STALE` 或 `NOT_VERIFIED`,Agent 不会静默使用它作结论。 +7. Provider 缺失、失败或未初始化不阻塞任何 Polaris workflow gate。 +8. 新实现只使用 Python 标准库,并通过跨平台自动化测试。 +9. 旧 v1 evidence 保持不可变和可审计,但不能被新阶段复用。 From 39cbec7c9fc0ef7b6617f36ef4ef2d215f15ab4c Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 13:54:28 +0800 Subject: [PATCH 02/41] docs: keep CodeGraph workflow version stable --- .../specs/2026-08-18-codegraph-freshness-design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-08-18-codegraph-freshness-design.md b/docs/superpowers/specs/2026-08-18-codegraph-freshness-design.md index e0e2f34..5f63fc2 100644 --- a/docs/superpowers/specs/2026-08-18-codegraph-freshness-design.md +++ b/docs/superpowers/specs/2026-08-18-codegraph-freshness-design.md @@ -257,7 +257,7 @@ vendored `AGENTS.md` 和宿主 worker 指令必须包含同一组条件规则: ## 版本和迁移 -本改动升级 Polaris 协议版本和 workflow 版本,因为 descriptor、record Schema 和阶段行为均改变。 +本改动升级 Polaris 协议版本,但 workflow 版本继续保持 `0.1.2`。descriptor、record Schema 和阶段 Skill 行为属于 Polaris 协议资产;节点、边、gate ID 和 rigor 图均未改变,因此不应扩展冻结 workflow 的迁移策略。 迁移规则: @@ -298,7 +298,7 @@ vendored `AGENTS.md` 和宿主 worker 指令必须包含同一组条件规则: - `docs/USAGE.md` - `plan.md` - `VERSION` -- workflow version、migration 声明与对应模板 +- Polaris version、相邻 migration 声明与对应模板;workflow version 保持 `0.1.2` - `tests/test_core.py`,或按现有测试组织拆出的聚焦测试文件 预计新增: From aa87615bcf5be7d4ac3e2e62ff13ca6afe2d8025 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 14:04:52 +0800 Subject: [PATCH 03/41] docs: plan CodeGraph freshness integration --- .../plans/2026-08-18-codegraph-freshness.md | 893 ++++++++++++++++++ 1 file changed, 893 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-18-codegraph-freshness.md diff --git a/docs/superpowers/plans/2026-08-18-codegraph-freshness.md b/docs/superpowers/plans/2026-08-18-codegraph-freshness.md new file mode 100644 index 0000000..d055214 --- /dev/null +++ b/docs/superpowers/plans/2026-08-18-codegraph-freshness.md @@ -0,0 +1,893 @@ +# CodeGraph Freshness Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `colbymchenry/codegraph` the only formal `codegraph` Provider, keep its graph current with watcher-aware one-shot sync, and record precise stale points that force bounded source fallback. + +**Architecture:** Keep Provider selection and immutable evidence Provider-neutral, while a focused CodeGraph adapter owns the official CLI/status/banner contract. Stage Skills use `codegraph_explore` first, CLI as fallback, and persist v2 freshness records; legacy v1 records remain read-only historical evidence. + +**Tech Stack:** Python 3.10+ standard library, JSON artifacts and Schemas, `unittest`, Git, optional external `codegraph` CLI/MCP. + +**Spec:** `docs/superpowers/specs/2026-08-18-codegraph-freshness-design.md` + +## Global Constraints + +- `plan.md` remains the v0.1 product and implementation authority. +- Runtime code must remain dependency-free beyond the Python standard library. +- `codegraph` means only `https://github.com/colbymchenry/codegraph`. +- Polaris may run `codegraph sync` once when needed, but must not install CodeGraph, run `codegraph init`, start a daemon, or modify MCP configuration. +- `.codegraph/` creation remains a user decision; its absence is a non-blocking `UNAVAILABLE` result. +- CodeGraph evidence never replaces source, Git, builds, tests, Validation, Review, or Human gates. +- Polaris workflow version remains exactly `0.1.2`; only the Polaris protocol version advances from `0.1.18` to `0.1.19`. +- Every subprocess call must use an argument list, explicit `cwd`, finite timeout, and no shell. +- Repository paths stored in artifacts use POSIX separators and pass existing confinement/regular-file checks. +- Agents never write `VERIFIED` or `CLOSED`; all state transitions continue through `transition_task.py`. +- Generated `templates/task/` files are refreshed only through `python scripts/materialize_task_layout.py`. + +## File Structure + +- `providers/code-intelligence/codegraph.json`: the sole formal descriptor for the official Provider. +- `scripts/internal/codegraph_adapter.py`: CodeGraph-specific status, one-shot sync, banner classification, and freshness merge logic. +- `scripts/internal/code_intelligence_protocol.py`: Provider-neutral config, selection, v2/legacy record validation, immutable record writes. +- `scripts/code_intelligence_runtime.py`: internal `status`, `sync-if-needed`, and `classify-response` dispatcher used by Skills. +- `scripts/record_code_intelligence.py`: immutable record CLI only; old refresh planning flags are removed. +- `schemas/code-intelligence-provider.schema.json`: descriptor v2 structure. +- `schemas/code-intelligence-record.schema.json`: writable v2 evidence. +- `schemas/code-intelligence-record-v1.schema.json`: frozen read-only legacy evidence shape. +- `schemas/migration-record.schema.json`: optional retired-evidence inventory on new migration records. +- `templates/task-sources/code-intelligence-record.json`: canonical v2 template source. +- `templates/task/code-intelligence/r001/planning.json`: generated sample projection. +- `tests/test_codegraph.py`: focused Provider/adapter/runtime/record tests. +- `tests/test_core.py`: existing migration, vendoring, materialization, and whole-protocol assertions. +- `skills/*/SKILL.md` and `templates/AGENTS.md`: stage usage and source-fallback rules. +- `VERSION`, `pyproject.toml`, `templates/project.json`, task state templates, `workflow/migrations.json`: protocol `0.1.19` migration assets; workflow remains `0.1.2`. +- `README.md`, `docs/USAGE.md`, `plan.md`: official Provider and operational documentation. + +--- + +### Task 1: Replace the Provider descriptor and require an initialized graph + +**Files:** +- Modify: `providers/code-intelligence/codegraph.json` +- Modify: `schemas/code-intelligence-provider.schema.json` +- Modify: `scripts/internal/code_intelligence_protocol.py:73-183` +- Modify: `scripts/record_code_intelligence.py:23-56` +- Create: `tests/test_codegraph.py` + +**Interfaces:** +- Consumes: `.polaris/code-intelligence.json`, `.codegraph/`, host-exposed tool names, executable names discovered by the caller. +- Produces: `select_provider(repo: Path, available_tools: Iterable[str], root: Path | None = None, available_executables: Iterable[str] = ()) -> dict[str, Any] | None`. +- Produces selected descriptor fields: `provider_id`, `provider_version`, `transport`, `operations`, `cli_available`. + +- [ ] **Step 1: Add failing descriptor and activation tests** + +Create `tests/test_codegraph.py` with a small standard-library fixture and these concrete assertions: + +```python +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +from init_project import initialize as init_project # noqa: E402 +from internal.code_intelligence_protocol import ( # noqa: E402 + load_providers, + select_provider, +) + + +class CodeGraphTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(prefix="polaris-codegraph-") + self.repo = Path(self.temp.name) + subprocess.run(["git", "init", "-q"], cwd=self.repo, check=True) + init_project(self.repo, "codegraph-test") + + def tearDown(self) -> None: + self.temp.cleanup() + + def test_official_descriptor_uses_explore_status_and_sync(self) -> None: + descriptor = load_providers(ROOT)["codegraph"] + self.assertEqual(descriptor["provider_version"], 2) + self.assertEqual( + descriptor["implementation"], + "https://github.com/colbymchenry/codegraph", + ) + self.assertEqual(descriptor["project_marker"], ".codegraph") + self.assertEqual( + descriptor["operations"], + {"explore": "codegraph_explore", "status": "codegraph_status"}, + ) + self.assertEqual(descriptor["cli"]["sync_args"], ["sync", "--quiet"]) + + def test_provider_requires_marker_and_accepts_mcp_or_cli(self) -> None: + self.assertIsNone( + select_provider(self.repo, ["codegraph_explore"], ROOT) + ) + (self.repo / ".codegraph").mkdir() + selected = select_provider(self.repo, ["codegraph_explore"], ROOT) + self.assertEqual(selected["operations"], {"explore": "codegraph_explore"}) + cli = select_provider( + self.repo, [], ROOT, available_executables=["codegraph"] + ) + self.assertTrue(cli["cli_available"]) + + def test_old_product_tool_names_are_absent_from_descriptor(self) -> None: + text = (ROOT / "providers/code-intelligence/codegraph.json").read_text( + encoding="utf-8" + ) + for fragment in ("get_ai_context", "index_files", "reindex_workspace"): + self.assertNotIn(fragment, text) +``` + +- [ ] **Step 2: Run the focused tests and verify RED** + +Run: + +```bash +python -m unittest tests.test_codegraph.CodeGraphTests.test_official_descriptor_uses_explore_status_and_sync tests.test_codegraph.CodeGraphTests.test_provider_requires_marker_and_accepts_mcp_or_cli -v +``` + +Expected: FAIL because descriptor v1 lacks `implementation`, `project_marker`, `cli`, and selection does not require `.codegraph/`. + +- [ ] **Step 3: Implement descriptor v2 and marker-aware selection** + +Change the descriptor to this shape, using the target Provider's documented extension set (case-insensitive matching means one `.r` entry covers both `.R` and `.r`): + +```json +{ + "provider_version": 2, + "provider_id": "codegraph", + "display_name": "CodeGraph", + "implementation": "https://github.com/colbymchenry/codegraph", + "project_marker": ".codegraph", + "transport": "mcp", + "file_extensions": [ + ".ts", ".tsx", ".js", ".jsx", ".mjs", ".py", ".go", ".rs", + ".java", ".cs", ".php", ".rb", ".c", ".h", ".cpp", ".hpp", + ".cc", ".m", ".mm", ".swift", ".kt", ".kts", ".scala", ".sc", + ".dart", ".svelte", ".vue", ".astro", ".liquid", ".pas", ".dpr", + ".dpk", ".lpr", ".lua", ".r", ".luau" + ], + "operations": { + "explore": "codegraph_explore", + "status": "codegraph_status" + }, + "cli": { + "executable": "codegraph", + "explore_args": ["explore"], + "status_args": ["status", "--json"], + "sync_args": ["sync", "--quiet"] + } +} +``` + +Update the Provider Schema so `provider_version` is `2`, all new fields are required, CLI argument arrays have `minItems: 1`, and every item is a non-empty string. Replace `OPERATIONS` with `{"explore", "status", "sync"}`. Add a helper that rejects absolute, multi-component, or symlink markers and make selection require a regular `.codegraph/` directory. Preserve `add_provider()` as configuration-only and update its message to say initialization remains the user's decision. + +Remove `--plan-refresh`, `--provider`, `--subject-base`, and `--subject-head` from `record_code_intelligence.py`; runtime sync moves to Task 3. Add repeatable `--available-executable` beside `--available-tool` and pass both lists to `select_provider()`, so a non-MCP worker can select the initialized Provider without probing or executing it during selection. + +- [ ] **Step 4: Run focused tests and verify GREEN** + +Run: + +```bash +python -m unittest tests.test_codegraph -v +``` + +Expected: all Task 1 tests PASS. + +- [ ] **Step 5: Run static configuration regression tests** + +Run: + +```bash +python -m unittest tests.test_core.PolarisCoreTests.test_code_intelligence_add_enables_prioritizes_and_preserves_scope tests.test_core.PolarisCoreTests.test_code_intelligence_add_rejects_unknown_provider_without_writing -v +``` + +Expected: PASS after updating only assertions that describe the new initialization message; config scope remains unchanged. + +- [ ] **Step 6: Commit Task 1** + +```bash +git add providers/code-intelligence/codegraph.json schemas/code-intelligence-provider.schema.json scripts/internal/code_intelligence_protocol.py scripts/record_code_intelligence.py tests/test_codegraph.py tests/test_core.py +git commit -m "feat: select the official CodeGraph provider" +``` + +--- + +### Task 2: Add deterministic status inspection and one-shot sync + +**Files:** +- Create: `scripts/internal/codegraph_adapter.py` +- Modify: `tests/test_codegraph.py` + +**Interfaces:** +- Consumes: descriptor v2 CLI fields and official `codegraph status --json` output. +- Produces: `inspect_status(repo: Path, descriptor: dict[str, Any], *, runner: Runner = subprocess.run, timeout_seconds: int = 15) -> dict[str, Any]`. +- Produces: `sync_if_needed(repo: Path, descriptor: dict[str, Any], *, runner: Runner = subprocess.run, status_timeout_seconds: int = 15, sync_timeout_seconds: int = 120) -> dict[str, Any]`. +- Inspection result keys: `status`, `checked_at`, `basis`, `stale_points`, `status_response_sha256`, `error`, `needs_sync`, `pending_changes`. +- Sync result keys: `freshness`, `sync` where sync has `status`, `response_sha256`, `error`. + +- [ ] **Step 1: Add failing healthy and pending-sync tests** + +Add `importlib`, a completed-process factory, and a test helper that first asserts `scripts/internal/codegraph_adapter.py` exists, then imports it inside the test. This makes RED an assertion failure instead of a collection/import error. Use the helper to obtain `inspect_status` and `sync_if_needed`, then add these tests: + +```python +def completed(stdout: str, returncode: int = 0, stderr: str = "") -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess([], returncode, stdout, stderr) + +def healthy_status(project: Path) -> str: + return json.dumps({ + "initialized": True, + "projectPath": str(project), + "pendingChanges": {"added": 0, "modified": 0, "removed": 0}, + "worktreeMismatch": None, + "index": { + "state": "complete", + "pendingRefs": 0, + "reindexRecommended": False, + }, + }) + +def test_healthy_status_is_current_at_check(self) -> None: + (self.repo / ".codegraph").mkdir() + descriptor = load_providers(ROOT)["codegraph"] + result = inspect_status( + self.repo, descriptor, runner=lambda *args, **kwargs: completed(healthy_status(self.repo)) + ) + self.assertEqual(result["status"], "CURRENT_AT_CHECK") + self.assertEqual(result["basis"], ["STATUS_JSON"]) + self.assertEqual(result["stale_points"], []) + +def test_pending_changes_sync_once_and_recheck_once(self) -> None: + (self.repo / ".codegraph").mkdir() + pending = json.loads(healthy_status(self.repo)) + pending["pendingChanges"]["modified"] = 1 + responses = iter([ + completed(json.dumps(pending)), + completed("Synced 1 changed file\n"), + completed(healthy_status(self.repo)), + ]) + calls: list[list[str]] = [] + def runner(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + calls.append(command) + return next(responses) + result = sync_if_needed(self.repo, load_providers(ROOT)["codegraph"], runner=runner) + self.assertEqual([call[1] for call in calls], ["status", "sync", "status"]) + self.assertEqual(result["sync"]["status"], "SUCCESS") + self.assertEqual(result["freshness"]["status"], "CURRENT_AT_CHECK") + self.assertIn("SYNC_ACKNOWLEDGED", result["freshness"]["basis"]) +``` + +- [ ] **Step 2: Add failing table tests for every index-wide stale reason** + +Use `subTest` cases for: + +```python +cases = [ + ({"worktreeMismatch": {"worktreeRoot": "/a", "indexRoot": "/b"}}, "WORKTREE_MISMATCH"), + ({"index": {"state": "partial", "pendingRefs": 0, "reindexRecommended": False}}, "INDEX_PARTIAL"), + ({"index": {"state": "indexing", "pendingRefs": 0, "reindexRecommended": False}}, "INDEX_INDEXING"), + ({"index": {"state": "failed", "pendingRefs": 0, "reindexRecommended": False}}, "INDEX_FAILED"), + ({"index": {"state": "complete", "pendingRefs": 2, "reindexRecommended": False}}, "PENDING_REFERENCES"), + ({"index": {"state": "complete", "pendingRefs": 0, "reindexRecommended": True}}, "REINDEX_RECOMMENDED"), +] +``` + +Assert every result is `INDEX_STALE`, has one `scope: INDEX` point with the expected reason, `fallback: SEARCH_SOURCE`, and does not call sync when the status is structurally unsafe rather than merely pending. + +- [ ] **Step 3: Run Task 2 tests and verify RED** + +```bash +python -m unittest tests.test_codegraph -v +``` + +Expected: FAIL on the explicit missing-adapter assertion. + +- [ ] **Step 4: Implement status normalization and one-shot sync** + +Implement the public functions around these internal boundaries: + +```python +Runner = Callable[..., subprocess.CompletedProcess[str]] + +def _run_cli( + repo: Path, + descriptor: dict[str, Any], + args_key: str, + timeout_seconds: int, + runner: Runner, +) -> subprocess.CompletedProcess[str]: + command = [descriptor["cli"]["executable"], *descriptor["cli"][args_key]] + return runner( + command, + cwd=repo, + check=False, + text=True, + encoding="utf-8", + capture_output=True, + timeout=timeout_seconds, + ) +``` + +Normalize paths with `Path(...).resolve()` and compare them to `repo.resolve()`. Convert timeout, missing executable, Unicode errors, nonzero exit, malformed JSON, wrong project, and invalid field types to `STATUS_UNREADABLE` or the more specific index reason. Compute response hashes with `hashlib.sha256(raw.encode("utf-8")).hexdigest()`. A structurally healthy status with nonzero pending counts returns `needs_sync: true` and preserves the counts in `pending_changes`; this intermediate inspection is not written as a final freshness record. + +`sync_if_needed()` must return immediately for healthy status, call sync only when pending counts are nonzero, and never recurse. A nonzero sync or unhealthy second status produces `INDEX_STALE` with `SYNC_FAILED` while preserving more specific second-status points. + +- [ ] **Step 5: Run focused tests and verify GREEN** + +```bash +python -m unittest tests.test_codegraph -v +``` + +Expected: all adapter status/sync tests PASS and recorded command sequence is exactly `status`, `sync`, `status`. + +- [ ] **Step 6: Commit Task 2** + +```bash +git add scripts/internal/codegraph_adapter.py tests/test_codegraph.py +git commit -m "feat: inspect and sync CodeGraph freshness" +``` + +--- + +### Task 3: Classify stale banners and expose the internal runtime script + +**Files:** +- Modify: `scripts/internal/codegraph_adapter.py` +- Create: `scripts/code_intelligence_runtime.py` +- Modify: `tests/test_codegraph.py` + +**Interfaces:** +- Produces: `classify_response(repo: Path, response: str, *, checked_at: str | None = None) -> dict[str, Any]` with `classification` equal to `NONE`, `PARTIAL_STALE`, `INDEX_STALE`, or `NOT_VERIFIED`. +- Produces: `merge_freshness(status_result: dict[str, Any], response_result: dict[str, Any]) -> dict[str, Any]`. +- Runtime commands: + - `python scripts/code_intelligence_runtime.py status --repo PATH --json` + - `python scripts/code_intelligence_runtime.py sync-if-needed --repo PATH --json` + - `python scripts/code_intelligence_runtime.py classify-response TASK-0001 --input PATH --repo PATH --json` + +- [ ] **Step 1: Add failing partial and frozen banner tests** + +Obtain `classify_response` with `getattr(adapter_module, "classify_response", None)` and assert it is callable before invoking it, so the missing behavior produces a clean RED assertion. + +```python +def test_response_banner_marks_only_named_files_stale(self) -> None: + source = self.repo / "src/widget.py" + source.parent.mkdir() + source.write_text("def widget():\n return 1\n", encoding="utf-8") + response = """⚠️ Some files referenced below were edited since the last index sync — +their codegraph entries may be stale: + - src/widget.py (edited 800ms ago, pending sync) +For accurate content of those specific files, Read them directly. +""" + result = classify_response(self.repo, response, checked_at="2026-08-18T00:00:00Z") + self.assertEqual(result["classification"], "PARTIAL_STALE") + self.assertEqual(result["stale_points"][0]["path"], "src/widget.py") + self.assertEqual(result["stale_points"][0]["fallback"], "READ_SOURCE") + self.assertEqual(result["stale_points"][0]["observed_sha256"], file_sha256(source)) + +def test_disabled_banner_freezes_the_whole_index(self) -> None: + result = classify_response( + self.repo, + "⚠️ CodeGraph auto-sync is DISABLED — the index is frozen.\n", + checked_at="2026-08-18T00:00:00Z", + ) + self.assertEqual(result["classification"], "INDEX_STALE") + self.assertEqual(result["stale_points"][0]["reason"], "AUTO_SYNC_DISABLED") +``` + +Add rejection cases for `../outside.py`, absolute paths, a symlink resolving outside the repository, and a missing listed path mapping to `INSPECT_GIT_DIFF` with `observed_sha256: null`. + +- [ ] **Step 2: Add failing runtime confinement and JSON-output tests** + +Create a response file below `.polaris/tasks/TASK-0001/runtime/code-intelligence/`, call the script through `subprocess.run`, and assert envelope status `PASS` plus classification `PARTIAL_STALE`. Pass an input outside that runtime directory and assert exit code `2` with an input error. + +- [ ] **Step 3: Run Task 3 tests and verify RED** + +```bash +python -m unittest tests.test_codegraph -v +``` + +Expected: missing classifier/runtime failures. + +- [ ] **Step 4: Implement exact banner parsing and freshness merge** + +Match only the official leading sentences and list rows shaped as `- PATH (edited ..., pending sync)`. Resolve each candidate through `resolve_repo_reference`; existing paths must be regular files, while missing safe paths become deletion fallbacks. Do not treat arbitrary warning text as a valid banner. + +Use this merge precedence: + +```python +FRESHNESS_ORDER = { + "CURRENT_AT_CHECK": 0, + "PARTIAL_STALE": 1, + "NOT_VERIFIED": 2, + "INDEX_STALE": 3, + "UNAVAILABLE": 4, +} +``` + +The more conservative status wins; combine unique basis values and stale points without changing their original order. Classification `NONE` is neutral: it preserves the status baseline and adds `RESPONSE_BANNER` only when the baseline already came from a successful status check. It must not upgrade a `NOT_VERIFIED` baseline. A malformed recognized banner returns classification `NOT_VERIFIED` and therefore downgrades the merged result. + +Implement the runtime script as a thin `argparse` dispatcher over adapter functions. Before `classify-response`, resolve the task with `task_dir()`, require the input to be a regular file inside `code_intelligence_runtime_dir()`, and read UTF-8 text. Use `run_main()` for stable exit codes and JSON envelopes. + +- [ ] **Step 5: Run focused tests and verify GREEN** + +```bash +python -m unittest tests.test_codegraph -v +``` + +Expected: banner parsing, path security, merge precedence, and runtime CLI tests PASS. + +- [ ] **Step 6: Commit Task 3** + +```bash +git add scripts/internal/codegraph_adapter.py scripts/code_intelligence_runtime.py tests/test_codegraph.py +git commit -m "feat: classify stale CodeGraph responses" +``` + +--- + +### Task 4: Introduce writable v2 records and read-only v1 history + +**Files:** +- Create: `schemas/code-intelligence-record-v1.schema.json` +- Modify: `schemas/code-intelligence-record.schema.json` +- Modify: `templates/task-sources/code-intelligence-record.json` +- Regenerate: `templates/task/code-intelligence/r001/planning.json` +- Modify: `scripts/internal/code_intelligence_protocol.py:293-446` +- Modify: `scripts/record_code_intelligence.py` +- Modify: `tests/test_codegraph.py` +- Modify: `tests/test_core.py:1568-1663,3320-3505` + +**Interfaces:** +- Produces: `validate_legacy_record_value(repo: Path, task_id: str, value: dict[str, Any], root: Path | None = None) -> dict[str, Any]`. +- `validate_record_value(...)` accepts v1 for historical reads and v2 for current reads. +- `record(...)` writes only `record_version == 2`. +- v2 replaces `refresh` with `sync`, `freshness`, and `source_fallbacks`. + +- [ ] **Step 1: Add failing legacy-read and legacy-write-block tests** + +Import `internal.code_intelligence_protocol` as a module, obtain `validate_legacy_record_value` with `getattr(module, "validate_legacy_record_value", None)`, and first assert it is callable; this produces an assertion failure rather than an import error. The same test then loads the current v1 template through that callable. Add a second test that expects `record()` to reject the v1 value with `InputFailure("new Code Intelligence records must use record_version 2")`. The first RED run must fail because the legacy validator and frozen v1 Schema do not exist; do not create either production asset before observing that failure. + +- [ ] **Step 2: Add failing v2 consistency tests** + +Build v2 values from the template and test these exact rules: + +```python +def test_partial_stale_record_requires_matching_source_fallback(self) -> None: + source = self.repo / "src/widget.py" + source.parent.mkdir() + source.write_text("value = 1\n", encoding="utf-8") + value = self.v2_record() + digest = file_sha256(source) + value["freshness"] = { + "status": "PARTIAL_STALE", + "checked_at": "2026-08-18T00:00:00Z", + "basis": ["RESPONSE_BANNER"], + "stale_points": [{ + "scope": "FILE", + "path": "src/widget.py", + "reason": "PENDING_SYNC", + "fallback": "READ_SOURCE", + "observed_sha256": digest, + }], + } + with self.assertRaisesRegex(RuleFailure, "matching source fallback"): + validate_record_value(self.repo, "TASK-0001", value, ROOT) + value["source_fallbacks"] = [{ + "action": "READ_SOURCE", + "path": "src/widget.py", + "observed_sha256": digest, + "base_commit": None, + "head_commit": None, + "diff_hash": None, + "purpose": "confirm pending CodeGraph content", + }] + self.assertEqual( + validate_record_value(self.repo, "TASK-0001", value, ROOT)["record_version"], + 2, + ) +``` + +Also assert: + +- `CURRENT_AT_CHECK` rejects stale points. +- `PARTIAL_STALE` rejects index points. +- `INDEX_STALE` requires an index point and a `SEARCH_SOURCE` fallback. +- `UNAVAILABLE` uses basis `NONE` and has no attempted query/sync. +- `READ_SOURCE` requires a current SHA. +- `INSPECT_GIT_DIFF` requires target base/head/diff hash and a null SHA. +- `SEARCH_SOURCE` requires a non-empty purpose. +- `sync.status == SUCCESS` requires a response hash and `SYNC_ACKNOWLEDGED` basis. +- Provider descriptor version is `2` and available operations are a subset of `explore`, `status`, `sync`. + +- [ ] **Step 3: Run v2 tests and verify RED** + +```bash +python -m unittest tests.test_codegraph -v +``` + +Expected: Schema and validator failures because the writable record is still v1. + +- [ ] **Step 4: Implement the v2 Schema, template, and validator dispatch** + +Make the writable template start with: + +```json +{ + "record_version": 2, + "provider": null, + "status": "UNAVAILABLE", + "queries": [], + "sync": null, + "freshness": { + "status": "UNAVAILABLE", + "checked_at": "1970-01-01T00:00:00Z", + "basis": ["NONE"], + "stale_points": [] + }, + "source_fallbacks": [] +} +``` + +Retain the existing task/revision/target fields. Limit query operation to `explore`. Define `sync` statuses `SUCCESS`, `FAILED`, `SKIPPED`, `UNAVAILABLE`. Define the exact freshness, stale-point, and source-fallback enums from the Spec. + +First add `schemas/code-intelligence-record-v1.schema.json` with the pre-change writable Schema content, then replace the writable Schema with v2. Split common task/target validation from version-specific validation. `record_reference()` chooses v1 or v2 by `record_version`; `record()` rejects v1 before selecting a destination. Legacy validation checks Schema, task/revision, subject commit/diff, canonical record name, response hashes, and confined symbol paths, but does not compare old capability sets to descriptor v2 or claim freshness. + +- [ ] **Step 5: Regenerate and validate the task template tree** + +```bash +python scripts/materialize_task_layout.py +python -m unittest tests.test_core.PolarisCoreTests.test_task_layout_is_single_source_and_templates_mirror_it -v +``` + +Expected: materializer exits 0 and template projection test PASS. + +- [ ] **Step 6: Run record and artifact-reference regressions** + +```bash +python -m unittest tests.test_codegraph tests.test_core.PolarisCoreTests.test_code_intelligence_record_is_compact_safe_and_immutable tests.test_core.PolarisCoreTests.test_implementation_and_final_documentation_subjects_are_bound tests.test_core.PolarisCoreTests.test_full_r1_flow_closes_only_after_review_and_validation -v +``` + +Expected: PASS after updating current-record fixtures to v2 while leaving explicit legacy fixtures at v1. + +- [ ] **Step 7: Commit Task 4** + +```bash +git add schemas/code-intelligence-record.schema.json schemas/code-intelligence-record-v1.schema.json templates/task-sources/code-intelligence-record.json templates/task/code-intelligence/r001/planning.json scripts/internal/code_intelligence_protocol.py scripts/record_code_intelligence.py tests/test_codegraph.py tests/test_core.py +git commit -m "feat: record CodeGraph freshness and fallbacks" +``` + +--- + +### Task 5: Update stage Skills and every Agent instruction surface + +**Files:** +- Modify: `skills/code-intelligence/SKILL.md` +- Modify: `skills/architecture-planning/SKILL.md` +- Modify: `skills/implementation/SKILL.md` +- Modify: `skills/adversarial-review/SKILL.md` +- Modify: `skills/documentation-sync/SKILL.md` +- Modify: `templates/AGENTS.md` +- Modify: `tests/test_codegraph.py` +- Modify: `tests/test_core.py:680-760` + +**Interfaces:** +- Consumes: `code_intelligence_runtime.py` actions and v2 record format. +- Produces: identical conditional usage semantics in Codex-rendered Skills, Claude Code-rendered Skills, Implementer/Reviewer instructions, and vendored `AGENTS.md`. + +- [ ] **Step 1: Add failing instruction-contract tests** + +Read each source Skill and render it through every host adapter. Assert the resulting text contains all of these exact semantic anchors: + +```python +required_fragments = ( + ".codegraph/", + "codegraph_explore", + "codegraph explore", + "codegraph sync", + "PARTIAL_STALE", + "INDEX_STALE", + "directly read", + "never run `codegraph init`", +) +``` + +Assert Validation still says not to invoke Code Intelligence. Construct forbidden legacy tokens in the test with string concatenation, for example `"refresh" + "_files"`, so the retired names do not remain literally in the repository. Assert no rendered Skill contains either retired refresh token or any retired narrow operation token. Assert `templates/AGENTS.md` says to stop CodeGraph calls for the session when `.codegraph/` is absent and to preserve any installer-managed marker block. + +- [ ] **Step 2: Run instruction tests and verify RED** + +```bash +python -m unittest tests.test_codegraph.CodeGraphTests.test_all_agent_surfaces_share_codegraph_fallback_rules -v +``` + +Expected: FAIL because current Skills still describe the old multi-operation refresh model. + +- [ ] **Step 3: Rewrite the internal Code Intelligence stage procedure** + +Make `skills/code-intelligence/SKILL.md` direct the caller to: + +1. Check project policy and `.codegraph/`. +2. Run internal `status` or `sync-if-needed` at the declared stage boundary. +3. Use only `codegraph_explore`, with `codegraph explore` as non-MCP fallback. +4. Save the raw response under task runtime and run `classify-response`. +5. Directly read every `PARTIAL_STALE` file and record its current SHA. +6. For `INDEX_STALE` or `NOT_VERIFIED`, use source search/Git fallback and stop repeated graph calls for that stage. +7. Never initialize, install, start, authenticate, or reconfigure CodeGraph. +8. Finalize a v2 record; never use it as a gate. + +Update each stage Skill with its bounded query purpose. Implementation queries mid-stage only if a later declared step depends on new relationships. Documentation Sync runs `sync-if-needed` only for supported source changes. Reviewer queries independently. Validation remains graph-free. + +- [ ] **Step 4: Update shared Agent rules without owning installer markers** + +Append a normal Polaris-owned section to `templates/AGENTS.md`. Do not add CodeGraph's marker-fence syntax, because its installer owns that block. State the same marker, stale-file, frozen-index, and no-init rules. Keep generic Polaris repository rules unchanged. + +- [ ] **Step 5: Run host rendering and vendoring tests** + +```bash +python -m unittest tests.test_codegraph.CodeGraphTests.test_all_agent_surfaces_share_codegraph_fallback_rules tests.test_core.PolarisCoreTests.test_vendored_target_is_self_contained tests.test_core.PolarisCoreTests.test_host_adapters_render_from_one_host_neutral_skill_source -v +``` + +Expected: PASS for Codex and Claude Code outputs. + +- [ ] **Step 6: Commit Task 5** + +```bash +git add skills/code-intelligence/SKILL.md skills/architecture-planning/SKILL.md skills/implementation/SKILL.md skills/adversarial-review/SKILL.md skills/documentation-sync/SKILL.md templates/AGENTS.md tests/test_codegraph.py tests/test_core.py +git commit -m "feat: guide agents through stale CodeGraph data" +``` + +--- + +### Task 6: Retire v1 evidence during the 0.1.19 migration + +**Files:** +- Modify: `VERSION` +- Modify: `pyproject.toml` +- Modify: `templates/project.json` +- Modify: `templates/task-sources/state.json` +- Regenerate: `templates/task/state.json` +- Modify: `workflow/migrations.json` +- Modify: `schemas/migration-record.schema.json` +- Modify: `scripts/internal/migration_protocol.py:16-364` +- Modify: `tests/test_core.py:1388-1505` +- Modify: `tests/test_codegraph.py` + +**Interfaces:** +- Adds migration route `0.1.18-to-0.1.19`, workflow `0.1.2` to `0.1.2`. +- New migration records include `retired_code_intelligence_records` entries with `task_id`, canonical task-relative `path`, and SHA-256. +- Existing migration records without that optional field remain valid. + +- [ ] **Step 1: Add a failing migration-retirement test** + +In an initialized fixture, write a valid v1 record to `code-intelligence/r001/planning.json`, set project/task/event versions to `0.1.18`, vendor the new protocol, migrate, and assert: + +```python +self.assertEqual(result["from"], "0.1.18") +self.assertEqual(result["to"], "0.1.19") +self.assertEqual( + read_json(self.repo / ".polaris/project.json")["workflow_version"], + "0.1.2", +) +migration = read_json( + self.repo / ".polaris/migrations/MIG-0.1.18-to-0.1.19.json" +) +self.assertEqual( + migration["retired_code_intelligence_records"], + [{ + "task_id": "TASK-0001", + "path": "code-intelligence/r001/planning.json", + "sha256": file_sha256(legacy_path), + }], +) +self.assertEqual(read_json(legacy_path)["record_version"], 1) +``` + +Also assert a newly generated v2 record can coexist at the next canonical stage path and that the v1 bytes do not change during migration. + +- [ ] **Step 2: Run migration tests and verify RED** + +```bash +python -m unittest tests.test_codegraph.CodeGraphTests.test_migration_retires_v1_records_without_rewriting_them tests.test_core.PolarisCoreTests.test_explicit_migration_appends_task_event_and_records_completion -v +``` + +Expected: FAIL because version `0.1.19`, route, and retirement inventory do not exist. + +- [ ] **Step 3: Implement the adjacent migration and retirement inventory** + +Advance only the Polaris version fields to `0.1.19`; keep every workflow field at `0.1.2`. Add the adjacent migration JSON step using existing `replace_version` and `append_version_event` strategies. + +Add optional Schema property: + +```json +"retired_code_intelligence_records": { + "type": "array", + "items": { + "type": "object", + "required": ["task_id", "path", "sha256"], + "additionalProperties": false + } +} +``` + +In `_new_record()`, scan only canonical task Code Intelligence record paths, validate each v1 record through the legacy path, and append sorted inventory entries. Reject symlinks and noncanonical paths. Do not edit record files or artifact references. Existing older migration records without the optional field continue to validate. + +- [ ] **Step 4: Regenerate templates and run migration recovery regressions** + +```bash +python scripts/materialize_task_layout.py +python -m unittest tests.test_core.PolarisCoreTests.test_explicit_migration_appends_task_event_and_records_completion tests.test_core.PolarisCoreTests.test_migration_resumes_after_event_append_without_duplication tests.test_core.PolarisCoreTests.test_migration_reclaims_only_its_own_dead_process_lock tests.test_codegraph.CodeGraphTests.test_migration_retires_v1_records_without_rewriting_them -v +``` + +Expected: all PASS; workflow version assertions remain `0.1.2`. + +- [ ] **Step 5: Run package and template version consistency checks** + +```bash +python -m unittest tests.test_core.PolarisCoreTests.test_cli_packaging_declares_no_runtime_dependencies tests.test_core.PolarisCoreTests.test_project_version_mismatch_is_rejected tests.test_core.PolarisCoreTests.test_task_layout_is_single_source_and_templates_mirror_it -v +``` + +Expected: PASS with protocol `0.1.19` and workflow `0.1.2`. + +- [ ] **Step 6: Commit Task 6** + +```bash +git add VERSION pyproject.toml templates/project.json templates/task-sources/state.json templates/task/state.json workflow/migrations.json schemas/migration-record.schema.json scripts/internal/migration_protocol.py tests/test_core.py tests/test_codegraph.py +git commit -m "feat: migrate CodeGraph evidence to protocol 0.1.19" +``` + +--- + +### Task 7: Update product authority and user documentation + +**Files:** +- Modify: `plan.md:489-517,650-677` +- Modify: `README.md:1-125,180-245` +- Modify: `docs/USAGE.md:1-45,180-220,600-635` +- Modify: `tests/test_codegraph.py` + +**Interfaces:** +- Documents the only supported Provider, initialization ownership, freshness states, sync boundary, and fallback behavior. +- Removes old multi-operation and per-file/workspace refresh claims. + +- [ ] **Step 1: Add a failing repository-content contract test** + +Add a test that scans managed implementation, tests, Skills, templates, README, usage docs, and `plan.md`. Exclude `.git/`, `docs/superpowers/specs/`, `docs/superpowers/plans/`, and the frozen v1 JSON Schema. Build every forbidden legacy token through concatenated fragments so the test itself does not preserve a literal retired name. Assert those tokens are absent and the official repository URL appears in the descriptor, README, usage guide, and `plan.md`. + +- [ ] **Step 2: Run the content test and verify RED** + +```bash +python -m unittest tests.test_codegraph.CodeGraphTests.test_managed_surfaces_only_name_the_official_codegraph -v +``` + +Expected: FAIL on current README, usage guide, plan, tests, and old protocol wording. + +- [ ] **Step 3: Rewrite the Code Intelligence authority section in `plan.md`** + +Replace narrow logical tool and two-refresh-operation statements with: + +- only `colbymchenry/codegraph` is formal in v0.1; +- `.codegraph/` is user-created; +- watcher and connect reconciliation are primary; +- one-shot sync is allowed at bounded points; +- freshness states and stale-point source fallbacks are persisted; +- Validation remains graph-free and Provider failures remain non-blocking. + +Update the implementation checklist item without claiming daemon ownership or commit-exact freshness. + +- [ ] **Step 4: Update README and the usage guide** + +Set current version to `0.1.19`. Document the external setup sequence as user-run commands: + +```text +codegraph install +codegraph init +polaris code-intelligence add codegraph --repo . +``` + +Clearly say Polaris only performs `status`, `explore`, and bounded `sync`; it never runs the first two setup commands. Add examples for `PARTIAL_STALE`, `INDEX_STALE`, and direct source fallback. Add the `0.1.19` migration note and state workflow remains `0.1.2`. + +- [ ] **Step 5: Run documentation/content checks** + +```bash +python -m unittest tests.test_codegraph.CodeGraphTests.test_managed_surfaces_only_name_the_official_codegraph -v +git diff --check +``` + +Expected: PASS and no whitespace errors. + +- [ ] **Step 6: Commit Task 7** + +```bash +git add plan.md README.md docs/USAGE.md tests/test_codegraph.py +git commit -m "docs: document live CodeGraph freshness" +``` + +--- + +### Task 8: Add the optional real-CLI smoke test and verify the whole repository + +**Files:** +- Modify: `tests/test_codegraph.py` + +**Interfaces:** +- Optional integration test consumes an already installed `codegraph` executable and creates an index only inside a disposable temporary repository. +- The normal suite has no CodeGraph dependency and reports `SKIP` when the executable is absent. + +- [ ] **Step 1: Add the guarded real-CLI smoke test** + +```python +import shutil + +@unittest.skipUnless(shutil.which("codegraph"), "codegraph CLI is not installed") +def test_real_codegraph_status_shape_when_cli_is_available(self) -> None: + source = self.repo / "sample.py" + source.write_text("def sample():\n return 1\n", encoding="utf-8") + subprocess.run( + ["codegraph", "init", str(self.repo)], + cwd=self.repo, + check=True, + text=True, + encoding="utf-8", + capture_output=True, + timeout=120, + ) + descriptor = load_providers(ROOT)["codegraph"] + result = inspect_status(self.repo, descriptor, timeout_seconds=30) + self.assertEqual(result["status"], "CURRENT_AT_CHECK") +``` + +The test may initialize only `self.repo`, which is a validated `TemporaryDirectory`; it must never run against the workspace. + +- [ ] **Step 2: Run the focused suite** + +```bash +python -m unittest tests.test_codegraph -v +``` + +Expected: all deterministic tests PASS; real smoke PASS when CodeGraph is installed or explicitly reports `SKIP` otherwise. + +- [ ] **Step 3: Rebuild generated assets and run the full suite** + +```bash +python scripts/materialize_task_layout.py +python tests/run_tests.py +``` + +Expected: every Polaris scenario PASS; only the optional real CodeGraph test may be `SKIP`. + +- [ ] **Step 4: Verify removal, versions, and clean generated state** + +```bash +rg -n "codegraph_(get_ai_context|get_dependency_graph|get_call_graph|analyze_impact|pr_context|index_files|reindex_workspace)|refresh_files|refresh_workspace" providers scripts schemas skills templates tests README.md docs/USAGE.md plan.md --glob '!code-intelligence-record-v1.schema.json' +git diff --check +git status --short +``` + +Expected: `rg` exits 1 with no matches; `git diff --check` is silent; `git status --short` lists only intentional Task 8/test or regenerated changes before commit. + +- [ ] **Step 5: Commit Task 8** + +```bash +git add tests/test_codegraph.py templates/task +git commit -m "test: verify CodeGraph integration end to end" +``` + +- [ ] **Step 6: Capture final verification evidence** + +Run again after the commit: + +```bash +python tests/run_tests.py +git status --short +``` + +Expected: full suite PASS with the optional documented `SKIP`, and working tree clean. From 5af4f80e1aba770e70da866d5bd41f9a3372ddc2 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 14:07:24 +0800 Subject: [PATCH 04/41] chore: ignore local worktrees --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 89a216e..f85aa7c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ dist/ .transition.lock .polaris-host-smoke/ .polaris/tasks/*/runtime/ +.worktrees/ From 5ece08f2a0adbe8ceeb65ff345d0dec03cb0636b Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 14:18:16 +0800 Subject: [PATCH 05/41] feat: select the official CodeGraph provider --- providers/code-intelligence/codegraph.json | 49 +++++---------- .../code-intelligence-provider.schema.json | 52 +++++++++++++--- .../internal/code_intelligence_protocol.py | 51 ++++++++++----- scripts/record_code_intelligence.py | 21 ++----- tests/test_codegraph.py | 62 +++++++++++++++++++ tests/test_core.py | 15 ++--- 6 files changed, 169 insertions(+), 81 deletions(-) create mode 100644 tests/test_codegraph.py diff --git a/providers/code-intelligence/codegraph.json b/providers/code-intelligence/codegraph.json index e4f4d02..90ba8e0 100644 --- a/providers/code-intelligence/codegraph.json +++ b/providers/code-intelligence/codegraph.json @@ -1,42 +1,25 @@ { - "provider_version": 1, + "provider_version": 2, "provider_id": "codegraph", "display_name": "CodeGraph", + "implementation": "https://github.com/colbymchenry/codegraph", + "project_marker": ".codegraph", "transport": "mcp", "file_extensions": [ - ".c", - ".cc", - ".cpp", - ".cxx", - ".h", - ".hh", - ".hpp", - ".hxx", - ".m", - ".mm", - ".py", - ".rs", - ".go", - ".java", - ".kt", - ".kts", - ".js", - ".jsx", - ".ts", - ".tsx", - ".cs", - ".swift", - ".rb", - ".php" + ".ts", ".tsx", ".js", ".jsx", ".mjs", ".py", ".go", ".rs", + ".java", ".cs", ".php", ".rb", ".c", ".h", ".cpp", ".hpp", + ".cc", ".m", ".mm", ".swift", ".kt", ".kts", ".scala", ".sc", + ".dart", ".svelte", ".vue", ".astro", ".liquid", ".pas", ".dpr", + ".dpk", ".lpr", ".lua", ".r", ".luau" ], "operations": { - "symbol_search": "codegraph_symbol_search", - "context": "codegraph_get_ai_context", - "dependencies": "codegraph_get_dependency_graph", - "call_graph": "codegraph_get_call_graph", - "impact": "codegraph_analyze_impact", - "review_context": "codegraph_pr_context", - "refresh_files": "codegraph_index_files", - "refresh_workspace": "codegraph_reindex_workspace" + "explore": "codegraph_explore", + "status": "codegraph_status" + }, + "cli": { + "executable": "codegraph", + "explore_args": ["explore"], + "status_args": ["status", "--json"], + "sync_args": ["sync", "--quiet"] } } diff --git a/schemas/code-intelligence-provider.schema.json b/schemas/code-intelligence-provider.schema.json index bbc0713..e698d5c 100644 --- a/schemas/code-intelligence-provider.schema.json +++ b/schemas/code-intelligence-provider.schema.json @@ -6,14 +6,17 @@ "provider_version", "provider_id", "display_name", + "implementation", + "project_marker", "transport", "file_extensions", - "operations" + "operations", + "cli" ], "additionalProperties": false, "properties": { "provider_version": { - "const": 1 + "const": 2 }, "provider_id": { "type": "string", @@ -23,6 +26,14 @@ "type": "string", "minLength": 1 }, + "implementation": { + "type": "string", + "minLength": 1 + }, + "project_marker": { + "type": "string", + "minLength": 1 + }, "transport": { "const": "mcp" }, @@ -39,14 +50,35 @@ "type": "object", "additionalProperties": false, "properties": { - "symbol_search": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_.:-]*$"}, - "context": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_.:-]*$"}, - "dependencies": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_.:-]*$"}, - "call_graph": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_.:-]*$"}, - "impact": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_.:-]*$"}, - "review_context": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_.:-]*$"}, - "refresh_files": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_.:-]*$"}, - "refresh_workspace": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_.:-]*$"} + "explore": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_.:-]*$"}, + "status": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_.:-]*$"}, + "sync": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_.:-]*$"} + } + }, + "cli": { + "type": "object", + "required": ["executable", "explore_args", "status_args", "sync_args"], + "additionalProperties": false, + "properties": { + "executable": { + "type": "string", + "minLength": 1 + }, + "explore_args": { + "type": "array", + "minItems": 1, + "items": {"type": "string", "minLength": 1} + }, + "status_args": { + "type": "array", + "minItems": 1, + "items": {"type": "string", "minLength": 1} + }, + "sync_args": { + "type": "array", + "minItems": 1, + "items": {"type": "string", "minLength": 1} + } } } } diff --git a/scripts/internal/code_intelligence_protocol.py b/scripts/internal/code_intelligence_protocol.py index 37e33c5..9848720 100644 --- a/scripts/internal/code_intelligence_protocol.py +++ b/scripts/internal/code_intelligence_protocol.py @@ -27,14 +27,9 @@ CONFIG_PATH = Path(".polaris/code-intelligence.json") OPERATIONS = { - "symbol_search", - "context", - "dependencies", - "call_graph", - "impact", - "review_context", - "refresh_files", - "refresh_workspace", + "explore", + "status", + "sync", } STAGE_NAMES = { "PLANNING": "planning", @@ -95,8 +90,21 @@ def load_providers(root: Path) -> dict[str, dict[str, Any]]: return result +def _project_marker_path(repo: Path, marker: str) -> Path: + path = Path(marker) + if path.is_absolute() or len(path.parts) != 1 or path.parts[0] in {".", ".."}: + raise RuleFailure(f"unsafe Code Intelligence project marker: {marker}") + target = repo / path + if target.is_symlink(): + raise RuleFailure(f"Code Intelligence project marker must not be a symlink: {target}") + return target + + def select_provider( - repo: Path, available_tools: Iterable[str], root: Path | None = None + repo: Path, + available_tools: Iterable[str], + root: Path | None = None, + available_executables: Iterable[str] = (), ) -> dict[str, Any] | None: root = protocol_root(repo) if root is None else root config = load_config(repo, root) @@ -110,21 +118,26 @@ def select_provider( if provider_id not in configured_priority ] available = set(available_tools) + executables = set(available_executables) for provider_id in priority: descriptor = providers.get(provider_id) if descriptor is None: raise RuleFailure(f"unknown configured Code Intelligence provider: {provider_id}") + if not _project_marker_path(repo, descriptor["project_marker"]).is_dir(): + continue operations = { operation: tool for operation, tool in descriptor["operations"].items() if tool in available } - if operations: + cli_available = descriptor["cli"]["executable"] in executables + if operations or cli_available: return { "provider_id": provider_id, "provider_version": descriptor["provider_version"], "transport": descriptor["transport"], "operations": operations, + "cli_available": cli_available, } return None @@ -149,7 +162,7 @@ def validate_static_configuration(repo: Path, root: Path | None = None) -> dict[ def add_provider( repo: Path, provider_id: str, root: Path | None = None ) -> dict[str, Any]: - """Enable and prioritize one installed Provider without probing its runtime.""" + """Configure one Provider without probing or initializing its runtime.""" root = protocol_root(repo) if root is None else root validate_static_configuration(repo, root) providers = load_providers(root) @@ -182,7 +195,8 @@ def add_provider( return { "message": ( f"added {providers[provider_id]['display_name']} to Polaris Code Intelligence; " - "runtime MCP availability will be checked by the next workflow; " + "Provider initialization remains the user's decision; " + "runtime availability will be checked by the next workflow; " "fallback remains enabled" ), "provider": provider_id, @@ -326,10 +340,15 @@ def validate_record_value( descriptor = descriptors.get(provider["id"]) if ( descriptor is None - or provider["descriptor_version"] != descriptor["provider_version"] - or provider["transport"] != descriptor["transport"] - or not set(provider["available_operations"]).issubset( - descriptor["operations"] + or ( + value["record_version"] != 1 + and ( + provider["descriptor_version"] != descriptor["provider_version"] + or provider["transport"] != descriptor["transport"] + or not set(provider["available_operations"]).issubset( + descriptor["operations"] + ) + ) ) ): raise RuleFailure("Code Intelligence record names an invalid provider capability set") diff --git a/scripts/record_code_intelligence.py b/scripts/record_code_intelligence.py index bf89857..cc723eb 100644 --- a/scripts/record_code_intelligence.py +++ b/scripts/record_code_intelligence.py @@ -8,12 +8,10 @@ from pathlib import Path from internal.code_intelligence_protocol import ( - plan_refresh, record, select_provider, ) from internal.polaris_core import ( - InputFailure, read_json, require_protocol_compatible, run_main, @@ -26,11 +24,8 @@ def main() -> int: parser.add_argument("--repo", type=Path, default=Path.cwd()) parser.add_argument("--input", type=Path) parser.add_argument("--available-tool", action="append", default=[]) + parser.add_argument("--available-executable", action="append", default=[]) parser.add_argument("--select-provider", action="store_true") - parser.add_argument("--plan-refresh", action="store_true") - parser.add_argument("--provider") - parser.add_argument("--subject-base") - parser.add_argument("--subject-head") parser.add_argument("--json", action="store_true") args = parser.parse_args() repo = args.repo.resolve() @@ -38,16 +33,12 @@ def main() -> int: def execute() -> dict[str, object]: require_protocol_compatible(repo) if args.select_provider: - selected = select_provider(repo, args.available_tool) - return {"selected": selected, "available": selected is not None} - if args.plan_refresh: - if not args.provider or not args.subject_base or not args.subject_head: - raise InputFailure( - "--plan-refresh requires --provider, --subject-base, and --subject-head" - ) - return plan_refresh( - repo, args.subject_base, args.subject_head, args.provider + selected = select_provider( + repo, + args.available_tool, + available_executables=args.available_executable, ) + return {"selected": selected, "available": selected is not None} if args.task_id is None or args.input is None: raise InputFailure("recording requires task_id and --input") input_path = args.input if args.input.is_absolute() else repo / args.input diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py new file mode 100644 index 0000000..e3276ea --- /dev/null +++ b/tests/test_codegraph.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +from init_project import initialize as init_project # noqa: E402 +from internal.code_intelligence_protocol import ( # noqa: E402 + load_providers, + select_provider, +) + + +class CodeGraphTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(prefix="polaris-codegraph-") + self.repo = Path(self.temp.name) + subprocess.run(["git", "init", "-q"], cwd=self.repo, check=True) + init_project(self.repo, "codegraph-test") + + def tearDown(self) -> None: + self.temp.cleanup() + + def test_official_descriptor_uses_explore_status_and_sync(self) -> None: + descriptor = load_providers(ROOT)["codegraph"] + self.assertEqual(descriptor["provider_version"], 2) + self.assertEqual( + descriptor["implementation"], + "https://github.com/colbymchenry/codegraph", + ) + self.assertEqual(descriptor["project_marker"], ".codegraph") + self.assertEqual( + descriptor["operations"], + {"explore": "codegraph_explore", "status": "codegraph_status"}, + ) + self.assertEqual(descriptor["cli"]["sync_args"], ["sync", "--quiet"]) + + def test_provider_requires_marker_and_accepts_mcp_or_cli(self) -> None: + self.assertIsNone( + select_provider(self.repo, ["codegraph_explore"], ROOT) + ) + (self.repo / ".codegraph").mkdir() + selected = select_provider(self.repo, ["codegraph_explore"], ROOT) + self.assertEqual(selected["operations"], {"explore": "codegraph_explore"}) + cli = select_provider( + self.repo, [], ROOT, available_executables=["codegraph"] + ) + self.assertTrue(cli["cli_available"]) + + def test_old_product_tool_names_are_absent_from_descriptor(self) -> None: + text = (ROOT / "providers/code-intelligence/codegraph.json").read_text( + encoding="utf-8" + ) + for fragment in ("get_ai_context", "index_files", "reindex_workspace"): + self.assertNotIn(fragment, text) diff --git a/tests/test_core.py b/tests/test_core.py index acdae1a..29e4916 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1541,18 +1541,19 @@ def test_migration_rejects_an_undeclared_version_jump(self) -> None: ) def test_code_intelligence_auto_detects_available_operations_and_can_be_disabled(self) -> None: - """可选代码情报按 MCP 工具能力发现;缺失或禁用时不产生硬依赖。""" + """已初始化的可选代码情报按 MCP 工具能力发现;缺失或禁用时不产生硬依赖。""" + (self.repo / ".codegraph").mkdir() selected = select_provider( self.repo, - ["codegraph_symbol_search", "codegraph_analyze_impact"], + ["codegraph_explore", "codegraph_status"], ROOT, ) self.assertEqual(selected["provider_id"], "codegraph") self.assertEqual( selected["operations"], { - "symbol_search": "codegraph_symbol_search", - "impact": "codegraph_analyze_impact", + "explore": "codegraph_explore", + "status": "codegraph_status", }, ) self.assertIsNone(select_provider(self.repo, [], ROOT)) @@ -1561,14 +1562,14 @@ def test_code_intelligence_auto_detects_available_operations_and_can_be_disabled config["mode"] = "disabled" write_json_atomic(self.repo / ".polaris" / "code-intelligence.json", config) self.assertIsNone( - select_provider(self.repo, ["codegraph_symbol_search"], ROOT) + select_provider(self.repo, ["codegraph_explore"], ROOT) ) self.assertEqual( validate_static_configuration(self.repo, ROOT)["mode"], "disabled" ) - def test_code_intelligence_record_is_compact_safe_and_immutable(self) -> None: - """精简记录绑定任务与提交,拒绝越界符号路径并且写入后不可覆盖。""" + def test_code_intelligence_v1_record_is_compact_safe_and_immutable(self) -> None: + """v1 精简记录升级后仍可读取,并绑定任务、提交和安全路径。""" base = run_git(self.repo, "rev-parse", "HEAD") value = read_json( ROOT / "templates" / "task-sources" / "code-intelligence-record.json" From 778132d91233afd0e4bfeed3f92e5b0fd67d89d9 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 14:21:55 +0800 Subject: [PATCH 06/41] fix: preserve CodeGraph input validation --- scripts/record_code_intelligence.py | 1 + tests/test_codegraph.py | 34 +++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/scripts/record_code_intelligence.py b/scripts/record_code_intelligence.py index cc723eb..a20bda2 100644 --- a/scripts/record_code_intelligence.py +++ b/scripts/record_code_intelligence.py @@ -12,6 +12,7 @@ select_provider, ) from internal.polaris_core import ( + InputFailure, read_json, require_protocol_compatible, run_main, diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index e3276ea..993741c 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -13,9 +13,11 @@ from init_project import initialize as init_project # noqa: E402 from internal.code_intelligence_protocol import ( # noqa: E402 + _project_marker_path, load_providers, select_provider, ) +from internal.polaris_core import RuleFailure # noqa: E402 class CodeGraphTests(unittest.TestCase): @@ -54,6 +56,38 @@ def test_provider_requires_marker_and_accepts_mcp_or_cli(self) -> None: ) self.assertTrue(cli["cli_available"]) + def test_project_marker_rejects_unsafe_paths_and_symlinks(self) -> None: + for marker in ("/absolute", ".codegraph/cache"): + with self.subTest(marker=marker): + with self.assertRaisesRegex(RuleFailure, "unsafe"): + _project_marker_path(self.repo, marker) + + target = self.repo / "marker-target" + target.mkdir() + (self.repo / ".codegraph").symlink_to(target, target_is_directory=True) + with self.assertRaisesRegex(RuleFailure, "must not be a symlink"): + _project_marker_path(self.repo, ".codegraph") + + def test_record_cli_requires_task_id_and_input(self) -> None: + completed = subprocess.run( + [ + sys.executable, + SCRIPTS / "record_code_intelligence.py", + "--repo", + self.repo, + "--json", + ], + cwd=ROOT, + capture_output=True, + check=False, + text=True, + ) + + self.assertEqual(completed.returncode, 2) + payload = json.loads(completed.stdout) + self.assertEqual(payload["status"], "ERROR") + self.assertEqual(payload["message"], "recording requires task_id and --input") + def test_old_product_tool_names_are_absent_from_descriptor(self) -> None: text = (ROOT / "providers/code-intelligence/codegraph.json").read_text( encoding="utf-8" From f48b0c9b3707395ca8bb12b68d627ca4f270c47f Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 14:30:00 +0800 Subject: [PATCH 07/41] feat: inspect and sync CodeGraph freshness --- scripts/internal/codegraph_adapter.py | 326 ++++++++++++++++++++++++++ tests/test_codegraph.py | 280 ++++++++++++++++++++++ 2 files changed, 606 insertions(+) create mode 100644 scripts/internal/codegraph_adapter.py diff --git a/scripts/internal/codegraph_adapter.py b/scripts/internal/codegraph_adapter.py new file mode 100644 index 0000000..91525ff --- /dev/null +++ b/scripts/internal/codegraph_adapter.py @@ -0,0 +1,326 @@ +"""Normalize CodeGraph CLI status and perform one bounded synchronization.""" + +from __future__ import annotations + +import hashlib +import json +import subprocess +from collections.abc import Callable, Mapping +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +Runner = Callable[..., subprocess.CompletedProcess[str]] + +_PENDING_KEYS = ("added", "modified", "removed") +_INDEX_REASONS = { + "partial": "INDEX_PARTIAL", + "indexing": "INDEX_INDEXING", + "failed": "INDEX_FAILED", +} + + +def _checked_at() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds").replace( + "+00:00", "Z" + ) + + +def _index_point(reason: str) -> dict[str, Any]: + return { + "scope": "INDEX", + "path": None, + "reason": reason, + "fallback": "SEARCH_SOURCE", + "observed_sha256": None, + } + + +def _error_summary(error: BaseException | str) -> str: + message = str(error).strip() + return message[:240] if message else type(error).__name__ + + +def _freshness( + status: str, + checked_at: str, + *, + basis: list[str], + stale_points: list[dict[str, Any]], + status_response_sha256: str | None, + error: str | None, + needs_sync: bool, + pending_changes: dict[str, int] | None, +) -> dict[str, Any]: + return { + "status": status, + "checked_at": checked_at, + "basis": basis, + "stale_points": stale_points, + "status_response_sha256": status_response_sha256, + "error": error, + "needs_sync": needs_sync, + "pending_changes": pending_changes, + } + + +def _not_verified( + checked_at: str, + error: BaseException | str, + response_sha256: str | None = None, +) -> dict[str, Any]: + return _freshness( + "NOT_VERIFIED", + checked_at, + basis=["STATUS_JSON"], + stale_points=[_index_point("STATUS_UNREADABLE")], + status_response_sha256=response_sha256, + error=_error_summary(error), + needs_sync=False, + pending_changes=None, + ) + + +def _unavailable(checked_at: str, error: str) -> dict[str, Any]: + return _freshness( + "UNAVAILABLE", + checked_at, + basis=["NONE"], + stale_points=[], + status_response_sha256=None, + error=error, + needs_sync=False, + pending_changes=None, + ) + + +def _marker_path(repo: Path, descriptor: dict[str, Any]) -> Path | None: + marker = descriptor.get("project_marker") + if not isinstance(marker, str): + return None + marker_path = Path(marker) + if ( + marker_path.is_absolute() + or len(marker_path.parts) != 1 + or marker_path.parts[0] in {".", ".."} + ): + return None + return repo / marker_path + + +def _run_cli( + repo: Path, + descriptor: dict[str, Any], + args_key: str, + timeout_seconds: int, + runner: Runner, +) -> subprocess.CompletedProcess[str]: + command = [descriptor["cli"]["executable"], *descriptor["cli"][args_key]] + return runner( + command, + cwd=repo, + check=False, + text=True, + encoding="utf-8", + capture_output=True, + timeout=timeout_seconds, + ) + + +def _stdout_and_hash(completed: subprocess.CompletedProcess[str]) -> tuple[str, str]: + raw = completed.stdout + if not isinstance(raw, str): + raise UnicodeError("CodeGraph status output was not UTF-8 text") + return raw, hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def _pending_changes(value: Any) -> dict[str, int]: + if not isinstance(value, Mapping): + raise ValueError("pendingChanges must be an object") + result: dict[str, int] = {} + for key in _PENDING_KEYS: + count = value.get(key) + if isinstance(count, bool) or not isinstance(count, int) or count < 0: + raise ValueError(f"pendingChanges.{key} must be a non-negative integer") + result[key] = count + return result + + +def _status_result( + repo: Path, + payload: Any, + checked_at: str, + response_sha256: str, +) -> dict[str, Any]: + if not isinstance(payload, Mapping): + raise ValueError("CodeGraph status JSON must be an object") + if payload.get("initialized") is not True: + raise ValueError("CodeGraph status is not initialized") + + project_path = payload.get("projectPath") + if not isinstance(project_path, str) or not project_path: + raise ValueError("CodeGraph status projectPath must be a path") + try: + if Path(project_path).resolve() != repo.resolve(): + raise ValueError("CodeGraph status belongs to a different project") + except (OSError, RuntimeError) as error: + raise ValueError("CodeGraph status projectPath could not be resolved") from error + + pending = _pending_changes(payload.get("pendingChanges")) + worktree_mismatch = payload.get("worktreeMismatch") + if worktree_mismatch is not None and not isinstance(worktree_mismatch, Mapping): + raise ValueError("worktreeMismatch must be null or an object") + + index = payload.get("index") + if not isinstance(index, Mapping): + raise ValueError("index must be an object") + state = index.get("state") + if state is not None and not isinstance(state, str): + raise ValueError("index.state must be a string or null") + pending_refs = index.get("pendingRefs") + if ( + isinstance(pending_refs, bool) + or not isinstance(pending_refs, int) + or pending_refs < 0 + ): + raise ValueError("index.pendingRefs must be a non-negative integer") + reindex_recommended = index.get("reindexRecommended") + if not isinstance(reindex_recommended, bool): + raise ValueError("index.reindexRecommended must be a boolean") + + stale_reasons: list[str] = [] + if worktree_mismatch is not None: + stale_reasons.append("WORKTREE_MISMATCH") + if state in _INDEX_REASONS: + stale_reasons.append(_INDEX_REASONS[state]) + elif state not in {None, "complete"}: + raise ValueError("index.state is not recognized") + if pending_refs: + stale_reasons.append("PENDING_REFERENCES") + if reindex_recommended: + stale_reasons.append("REINDEX_RECOMMENDED") + + if stale_reasons: + return _freshness( + "INDEX_STALE", + checked_at, + basis=["STATUS_JSON"], + stale_points=[_index_point(reason) for reason in stale_reasons], + status_response_sha256=response_sha256, + error=None, + needs_sync=False, + pending_changes=pending, + ) + + return _freshness( + "CURRENT_AT_CHECK", + checked_at, + basis=["STATUS_JSON"], + stale_points=[], + status_response_sha256=response_sha256, + error=None, + needs_sync=any(pending.values()), + pending_changes=pending, + ) + + +def inspect_status( + repo: Path, + descriptor: dict[str, Any], + *, + runner: Runner = subprocess.run, + timeout_seconds: int = 15, +) -> dict[str, Any]: + """Inspect one CodeGraph status response without changing the graph.""" + checked_at = _checked_at() + marker = _marker_path(repo, descriptor) + if marker is None: + return _not_verified(checked_at, "CodeGraph descriptor has an unsafe project marker") + if not marker.is_dir() or marker.is_symlink(): + return _unavailable(checked_at, "CodeGraph project marker is unavailable") + try: + completed = _run_cli(repo, descriptor, "status_args", timeout_seconds, runner) + raw, response_sha256 = _stdout_and_hash(completed) + except (OSError, subprocess.TimeoutExpired, UnicodeError) as error: + return _not_verified(checked_at, error) + except (KeyError, TypeError, ValueError) as error: + return _not_verified(checked_at, error) + if completed.returncode != 0: + return _not_verified( + checked_at, + f"CodeGraph status exited with {completed.returncode}", + response_sha256, + ) + try: + return _status_result(repo, json.loads(raw), checked_at, response_sha256) + except (json.JSONDecodeError, OSError, RuntimeError, TypeError, ValueError) as error: + return _not_verified(checked_at, error, response_sha256) + + +def _sync_result(status: str, response_sha256: str | None, error: str | None) -> dict[str, Any]: + return { + "status": status, + "response_sha256": response_sha256, + "error": error, + } + + +def _sync_failed( + freshness: dict[str, Any], + sync: dict[str, Any], +) -> dict[str, Any]: + points = [*freshness["stale_points"], _index_point("SYNC_FAILED")] + return { + "freshness": { + **freshness, + "status": "INDEX_STALE", + "stale_points": points, + "needs_sync": False, + "error": freshness["error"] or sync["error"], + }, + "sync": sync, + } + + +def sync_if_needed( + repo: Path, + descriptor: dict[str, Any], + *, + runner: Runner = subprocess.run, + status_timeout_seconds: int = 15, + sync_timeout_seconds: int = 120, +) -> dict[str, Any]: + """Synchronize at most once, then inspect status at most once more.""" + initial = inspect_status( + repo, descriptor, runner=runner, timeout_seconds=status_timeout_seconds + ) + skipped = _sync_result("SKIPPED", None, None) + if not initial["needs_sync"]: + return {"freshness": initial, "sync": skipped} + + try: + completed = _run_cli(repo, descriptor, "sync_args", sync_timeout_seconds, runner) + _, response_sha256 = _stdout_and_hash(completed) + except (OSError, subprocess.TimeoutExpired, UnicodeError) as error: + return _sync_failed(initial, _sync_result("FAILED", None, _error_summary(error))) + except (KeyError, TypeError, ValueError) as error: + return _sync_failed(initial, _sync_result("FAILED", None, _error_summary(error))) + if completed.returncode != 0: + return _sync_failed( + initial, + _sync_result( + "FAILED", + response_sha256, + f"CodeGraph sync exited with {completed.returncode}", + ), + ) + + sync = _sync_result("SUCCESS", response_sha256, None) + rechecked = inspect_status( + repo, descriptor, runner=runner, timeout_seconds=status_timeout_seconds + ) + if rechecked["status"] != "CURRENT_AT_CHECK" or rechecked["needs_sync"]: + return _sync_failed(rechecked, sync) + rechecked["basis"] = [*rechecked["basis"], "SYNC_ACKNOWLEDGED"] + return {"freshness": rechecked, "sync": sync} diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 993741c..2c2ca21 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -1,5 +1,6 @@ from __future__ import annotations +import importlib import json import subprocess import sys @@ -20,6 +21,28 @@ from internal.polaris_core import RuleFailure # noqa: E402 +def completed( + stdout: str, returncode: int = 0, stderr: str = "" +) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess([], returncode, stdout, stderr) + + +def healthy_status(project: Path) -> str: + return json.dumps( + { + "initialized": True, + "projectPath": str(project), + "pendingChanges": {"added": 0, "modified": 0, "removed": 0}, + "worktreeMismatch": None, + "index": { + "state": "complete", + "pendingRefs": 0, + "reindexRecommended": False, + }, + } + ) + + class CodeGraphTests(unittest.TestCase): def setUp(self) -> None: self.temp = tempfile.TemporaryDirectory(prefix="polaris-codegraph-") @@ -30,6 +53,15 @@ def setUp(self) -> None: def tearDown(self) -> None: self.temp.cleanup() + def adapter_functions(self) -> tuple[object, object]: + adapter_path = SCRIPTS / "internal" / "codegraph_adapter.py" + self.assertTrue( + adapter_path.is_file(), + "CodeGraph adapter must exist before it can inspect status", + ) + module = importlib.import_module("internal.codegraph_adapter") + return module.inspect_status, module.sync_if_needed + def test_official_descriptor_uses_explore_status_and_sync(self) -> None: descriptor = load_providers(ROOT)["codegraph"] self.assertEqual(descriptor["provider_version"], 2) @@ -94,3 +126,251 @@ def test_old_product_tool_names_are_absent_from_descriptor(self) -> None: ) for fragment in ("get_ai_context", "index_files", "reindex_workspace"): self.assertNotIn(fragment, text) + + def test_healthy_status_is_current_at_check(self) -> None: + inspect_status, _ = self.adapter_functions() + (self.repo / ".codegraph").mkdir() + + result = inspect_status( + self.repo, + load_providers(ROOT)["codegraph"], + runner=lambda *args, **kwargs: completed(healthy_status(self.repo)), + ) + + self.assertEqual(result["status"], "CURRENT_AT_CHECK") + self.assertEqual(result["basis"], ["STATUS_JSON"]) + self.assertEqual(result["stale_points"], []) + self.assertFalse(result["needs_sync"]) + self.assertEqual( + result["pending_changes"], {"added": 0, "modified": 0, "removed": 0} + ) + + def test_pending_changes_sync_once_and_recheck_once(self) -> None: + _, sync_if_needed = self.adapter_functions() + (self.repo / ".codegraph").mkdir() + pending = json.loads(healthy_status(self.repo)) + pending["pendingChanges"]["modified"] = 1 + responses = iter( + [ + completed(json.dumps(pending)), + completed("Synced 1 changed file\n"), + completed(healthy_status(self.repo)), + ] + ) + calls: list[list[str]] = [] + + def runner( + command: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[str]: + calls.append(command) + return next(responses) + + result = sync_if_needed( + self.repo, load_providers(ROOT)["codegraph"], runner=runner + ) + + self.assertEqual([call[1] for call in calls], ["status", "sync", "status"]) + self.assertEqual(result["sync"]["status"], "SUCCESS") + self.assertEqual(result["freshness"]["status"], "CURRENT_AT_CHECK") + self.assertIn("SYNC_ACKNOWLEDGED", result["freshness"]["basis"]) + + def test_index_wide_stale_reasons_do_not_sync(self) -> None: + inspect_status, sync_if_needed = self.adapter_functions() + (self.repo / ".codegraph").mkdir() + cases = [ + ( + {"worktreeMismatch": {"worktreeRoot": "/a", "indexRoot": "/b"}}, + "WORKTREE_MISMATCH", + ), + ( + { + "index": { + "state": "partial", + "pendingRefs": 0, + "reindexRecommended": False, + } + }, + "INDEX_PARTIAL", + ), + ( + { + "index": { + "state": "indexing", + "pendingRefs": 0, + "reindexRecommended": False, + } + }, + "INDEX_INDEXING", + ), + ( + { + "index": { + "state": "failed", + "pendingRefs": 0, + "reindexRecommended": False, + } + }, + "INDEX_FAILED", + ), + ( + { + "index": { + "state": "complete", + "pendingRefs": 2, + "reindexRecommended": False, + } + }, + "PENDING_REFERENCES", + ), + ( + { + "index": { + "state": "complete", + "pendingRefs": 0, + "reindexRecommended": True, + } + }, + "REINDEX_RECOMMENDED", + ), + ] + descriptor = load_providers(ROOT)["codegraph"] + for override, expected_reason in cases: + with self.subTest(reason=expected_reason): + status = json.loads(healthy_status(self.repo)) + status.update(override) + calls: list[list[str]] = [] + + def runner( + command: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[str]: + calls.append(command) + return completed(json.dumps(status)) + + inspection = inspect_status(self.repo, descriptor, runner=runner) + result = sync_if_needed(self.repo, descriptor, runner=runner) + + self.assertEqual(inspection["status"], "INDEX_STALE") + self.assertEqual(result["freshness"]["status"], "INDEX_STALE") + self.assertEqual( + inspection["stale_points"], + [ + { + "scope": "INDEX", + "path": None, + "reason": expected_reason, + "fallback": "SEARCH_SOURCE", + "observed_sha256": None, + } + ], + ) + self.assertEqual(calls, [["codegraph", "status", "--json"]] * 2) + self.assertEqual(result["sync"]["status"], "SKIPPED") + + def test_unreadable_statuses_fall_back_without_sync(self) -> None: + _, sync_if_needed = self.adapter_functions() + (self.repo / ".codegraph").mkdir() + descriptor = load_providers(ROOT)["codegraph"] + wrong_project = json.loads(healthy_status(self.repo)) + wrong_project["projectPath"] = str(self.repo / "other") + invalid_counts = json.loads(healthy_status(self.repo)) + invalid_counts["pendingChanges"]["added"] = True + cases = [ + (completed("not json"), "malformed JSON"), + (completed(healthy_status(self.repo), returncode=1, stderr="failed"), "nonzero"), + (completed(json.dumps(wrong_project)), "wrong project"), + (completed(json.dumps(invalid_counts)), "invalid counts"), + ] + for response, name in cases: + with self.subTest(name=name): + calls: list[list[str]] = [] + + def runner( + command: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[str]: + calls.append(command) + return response + + result = sync_if_needed(self.repo, descriptor, runner=runner) + self.assertEqual(result["freshness"]["status"], "NOT_VERIFIED") + self.assertEqual( + result["freshness"]["stale_points"][0]["reason"], + "STATUS_UNREADABLE", + ) + self.assertEqual(result["sync"]["status"], "SKIPPED") + self.assertEqual(calls, [["codegraph", "status", "--json"]]) + + def test_unsafe_marker_does_not_run_codegraph(self) -> None: + inspect_status, _ = self.adapter_functions() + descriptor = dict(load_providers(ROOT)["codegraph"]) + descriptor["project_marker"] = ".." + + result = inspect_status( + self.repo, + descriptor, + runner=lambda *args, **kwargs: self.fail("runner must not be called"), + ) + + self.assertEqual(result["status"], "NOT_VERIFIED") + self.assertEqual(result["stale_points"][0]["reason"], "STATUS_UNREADABLE") + + def test_failed_sync_marks_the_index_stale_without_retrying(self) -> None: + _, sync_if_needed = self.adapter_functions() + (self.repo / ".codegraph").mkdir() + pending = json.loads(healthy_status(self.repo)) + pending["pendingChanges"]["added"] = 1 + responses = iter( + [completed(json.dumps(pending)), completed("sync failed", returncode=1)] + ) + calls: list[list[str]] = [] + + def runner( + command: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[str]: + calls.append(command) + return next(responses) + + result = sync_if_needed( + self.repo, load_providers(ROOT)["codegraph"], runner=runner + ) + + self.assertEqual([call[1] for call in calls], ["status", "sync"]) + self.assertEqual(result["sync"]["status"], "FAILED") + self.assertEqual(result["freshness"]["status"], "INDEX_STALE") + self.assertEqual( + result["freshness"]["stale_points"][-1]["reason"], "SYNC_FAILED" + ) + + def test_unhealthy_recheck_preserves_index_reason_and_adds_sync_failure(self) -> None: + _, sync_if_needed = self.adapter_functions() + (self.repo / ".codegraph").mkdir() + pending = json.loads(healthy_status(self.repo)) + pending["pendingChanges"]["removed"] = 1 + unhealthy = json.loads(healthy_status(self.repo)) + unhealthy["index"]["state"] = "failed" + responses = iter( + [ + completed(json.dumps(pending)), + completed("Synced 1 changed file\n"), + completed(json.dumps(unhealthy)), + ] + ) + calls: list[list[str]] = [] + + def runner( + command: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[str]: + calls.append(command) + return next(responses) + + result = sync_if_needed( + self.repo, load_providers(ROOT)["codegraph"], runner=runner + ) + + self.assertEqual([call[1] for call in calls], ["status", "sync", "status"]) + self.assertEqual(result["sync"]["status"], "SUCCESS") + self.assertEqual(result["freshness"]["status"], "INDEX_STALE") + self.assertEqual( + [point["reason"] for point in result["freshness"]["stale_points"]], + ["INDEX_FAILED", "SYNC_FAILED"], + ) + self.assertFalse(result["freshness"]["needs_sync"]) From a47e9a578a6ca44d509e526a6a241f312fc5f018 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 14:34:47 +0800 Subject: [PATCH 08/41] fix: validate CodeGraph timeouts --- scripts/internal/codegraph_adapter.py | 42 +++++++++++--- tests/test_codegraph.py | 80 +++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 9 deletions(-) diff --git a/scripts/internal/codegraph_adapter.py b/scripts/internal/codegraph_adapter.py index 91525ff..67baff2 100644 --- a/scripts/internal/codegraph_adapter.py +++ b/scripts/internal/codegraph_adapter.py @@ -4,6 +4,7 @@ import hashlib import json +import math import subprocess from collections.abc import Callable, Mapping from datetime import datetime, timezone @@ -109,13 +110,26 @@ def _marker_path(repo: Path, descriptor: dict[str, Any]) -> Path | None: return repo / marker_path +def _validated_timeout(timeout_seconds: Any) -> float: + if isinstance(timeout_seconds, bool) or not isinstance(timeout_seconds, (int, float)): + raise ValueError("CodeGraph timeout must be a positive finite number") + try: + timeout = float(timeout_seconds) + except OverflowError as error: + raise ValueError("CodeGraph timeout must be a positive finite number") from error + if not math.isfinite(timeout) or timeout <= 0: + raise ValueError("CodeGraph timeout must be a positive finite number") + return timeout + + def _run_cli( repo: Path, descriptor: dict[str, Any], args_key: str, - timeout_seconds: int, + timeout_seconds: float, runner: Runner, ) -> subprocess.CompletedProcess[str]: + timeout = _validated_timeout(timeout_seconds) command = [descriptor["cli"]["executable"], *descriptor["cli"][args_key]] return runner( command, @@ -124,7 +138,7 @@ def _run_cli( text=True, encoding="utf-8", capture_output=True, - timeout=timeout_seconds, + timeout=timeout, ) @@ -230,17 +244,21 @@ def inspect_status( descriptor: dict[str, Any], *, runner: Runner = subprocess.run, - timeout_seconds: int = 15, + timeout_seconds: float = 15, ) -> dict[str, Any]: """Inspect one CodeGraph status response without changing the graph.""" checked_at = _checked_at() + try: + timeout = _validated_timeout(timeout_seconds) + except ValueError as error: + return _not_verified(checked_at, error) marker = _marker_path(repo, descriptor) if marker is None: return _not_verified(checked_at, "CodeGraph descriptor has an unsafe project marker") if not marker.is_dir() or marker.is_symlink(): return _unavailable(checked_at, "CodeGraph project marker is unavailable") try: - completed = _run_cli(repo, descriptor, "status_args", timeout_seconds, runner) + completed = _run_cli(repo, descriptor, "status_args", timeout, runner) raw, response_sha256 = _stdout_and_hash(completed) except (OSError, subprocess.TimeoutExpired, UnicodeError) as error: return _not_verified(checked_at, error) @@ -288,19 +306,25 @@ def sync_if_needed( descriptor: dict[str, Any], *, runner: Runner = subprocess.run, - status_timeout_seconds: int = 15, - sync_timeout_seconds: int = 120, + status_timeout_seconds: float = 15, + sync_timeout_seconds: float = 120, ) -> dict[str, Any]: """Synchronize at most once, then inspect status at most once more.""" + try: + status_timeout = _validated_timeout(status_timeout_seconds) + sync_timeout = _validated_timeout(sync_timeout_seconds) + except ValueError as error: + freshness = _not_verified(_checked_at(), error) + return {"freshness": freshness, "sync": _sync_result("SKIPPED", None, None)} initial = inspect_status( - repo, descriptor, runner=runner, timeout_seconds=status_timeout_seconds + repo, descriptor, runner=runner, timeout_seconds=status_timeout ) skipped = _sync_result("SKIPPED", None, None) if not initial["needs_sync"]: return {"freshness": initial, "sync": skipped} try: - completed = _run_cli(repo, descriptor, "sync_args", sync_timeout_seconds, runner) + completed = _run_cli(repo, descriptor, "sync_args", sync_timeout, runner) _, response_sha256 = _stdout_and_hash(completed) except (OSError, subprocess.TimeoutExpired, UnicodeError) as error: return _sync_failed(initial, _sync_result("FAILED", None, _error_summary(error))) @@ -318,7 +342,7 @@ def sync_if_needed( sync = _sync_result("SUCCESS", response_sha256, None) rechecked = inspect_status( - repo, descriptor, runner=runner, timeout_seconds=status_timeout_seconds + repo, descriptor, runner=runner, timeout_seconds=status_timeout ) if rechecked["status"] != "CURRENT_AT_CHECK" or rechecked["needs_sync"]: return _sync_failed(rechecked, sync) diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 2c2ca21..7624551 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -374,3 +374,83 @@ def runner( ["INDEX_FAILED", "SYNC_FAILED"], ) self.assertFalse(result["freshness"]["needs_sync"]) + + def test_invalid_status_timeouts_do_not_run_codegraph(self) -> None: + inspect_status, _ = self.adapter_functions() + (self.repo / ".codegraph").mkdir() + descriptor = load_providers(ROOT)["codegraph"] + invalid_timeouts = [ + (None, "none"), + (float("nan"), "nan"), + (float("inf"), "positive infinity"), + (float("-inf"), "negative infinity"), + (0, "zero"), + (-1, "negative"), + ("15", "string"), + (True, "boolean"), + ] + for timeout, name in invalid_timeouts: + with self.subTest(timeout=name): + calls: list[list[str]] = [] + + def runner( + command: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[str]: + calls.append(command) + return completed(healthy_status(self.repo)) + + result = inspect_status( + self.repo, + descriptor, + runner=runner, + timeout_seconds=timeout, + ) + + self.assertEqual(result["status"], "NOT_VERIFIED") + self.assertEqual( + result["stale_points"][0]["reason"], "STATUS_UNREADABLE" + ) + self.assertEqual(calls, []) + + def test_invalid_sync_timeouts_do_not_run_codegraph(self) -> None: + _, sync_if_needed = self.adapter_functions() + (self.repo / ".codegraph").mkdir() + descriptor = load_providers(ROOT)["codegraph"] + invalid_timeouts = [ + ("status_timeout_seconds", None, "none"), + ("status_timeout_seconds", float("nan"), "nan"), + ("status_timeout_seconds", float("inf"), "positive infinity"), + ("status_timeout_seconds", float("-inf"), "negative infinity"), + ("status_timeout_seconds", 0, "zero"), + ("status_timeout_seconds", -1, "negative"), + ("status_timeout_seconds", "15", "string"), + ("sync_timeout_seconds", None, "none"), + ("sync_timeout_seconds", float("nan"), "nan"), + ("sync_timeout_seconds", float("inf"), "positive infinity"), + ("sync_timeout_seconds", float("-inf"), "negative infinity"), + ("sync_timeout_seconds", 0, "zero"), + ("sync_timeout_seconds", -1, "negative"), + ("sync_timeout_seconds", "120", "string"), + ] + for parameter, timeout, name in invalid_timeouts: + with self.subTest(parameter=parameter, timeout=name): + calls: list[list[str]] = [] + + def runner(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + calls.append(command) + self.fail("runner must not be called for an invalid timeout") + + arguments: dict[str, object] = { + "runner": runner, + parameter: timeout, + } + + result = sync_if_needed(self.repo, descriptor, **arguments) + + self.assertEqual(result["freshness"]["status"], "NOT_VERIFIED") + self.assertEqual( + result["freshness"]["stale_points"][0]["reason"], + "STATUS_UNREADABLE", + ) + self.assertEqual(result["sync"]["status"], "SKIPPED") + self.assertEqual(calls, []) From e0c0ca53c86ec6c4a92acc2334254ebdba377278 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 14:42:49 +0800 Subject: [PATCH 09/41] feat: classify stale CodeGraph responses --- scripts/code_intelligence_runtime.py | 70 ++++++++ scripts/internal/codegraph_adapter.py | 167 +++++++++++++++++++ tests/test_codegraph.py | 221 +++++++++++++++++++++++++- 3 files changed, 457 insertions(+), 1 deletion(-) create mode 100644 scripts/code_intelligence_runtime.py diff --git a/scripts/code_intelligence_runtime.py b/scripts/code_intelligence_runtime.py new file mode 100644 index 0000000..72d6e61 --- /dev/null +++ b/scripts/code_intelligence_runtime.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Run bounded CodeGraph freshness checks for Polaris stage Skills.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path +from typing import Any + +from internal.code_intelligence_protocol import load_providers +from internal.codegraph_adapter import classify_response, inspect_status, sync_if_needed +from internal.polaris_core import InputFailure, protocol_root, require_protocol_compatible, run_main, task_dir +from internal.task_layout import code_intelligence_runtime_dir + + +def _runtime_input(repo: Path, task_id: str, value: Path) -> Path: + directory = task_dir(repo, task_id) + runtime = code_intelligence_runtime_dir(directory).resolve() + candidate = value if value.is_absolute() else repo / value + if candidate.is_symlink(): + raise InputFailure("CodeGraph response input must not be a symlink") + candidate = candidate.resolve() + try: + relative = candidate.relative_to(runtime) + except ValueError as error: + raise InputFailure("CodeGraph response input must be inside task runtime") from error + cursor = runtime + for part in relative.parts: + cursor /= part + if cursor.is_symlink(): + raise InputFailure("CodeGraph response input must not cross a symlink") + if not candidate.is_file(): + raise InputFailure("CodeGraph response input must be a regular runtime file") + return candidate + + +def main() -> int: + parser = argparse.ArgumentParser() + commands = parser.add_subparsers(dest="command", required=True) + common = argparse.ArgumentParser(add_help=False) + common.add_argument("--repo", type=Path, default=Path.cwd()) + common.add_argument("--json", action="store_true") + commands.add_parser("status", parents=[common]) + commands.add_parser("sync-if-needed", parents=[common]) + classify = commands.add_parser("classify-response", parents=[common]) + classify.add_argument("task_id") + classify.add_argument("--input", type=Path, required=True) + args = parser.parse_args() + repo = args.repo.resolve() + + def execute() -> dict[str, Any]: + require_protocol_compatible(repo) + if args.command == "classify-response": + input_path = _runtime_input(repo, args.task_id, args.input) + try: + response = input_path.read_text(encoding="utf-8") + except UnicodeDecodeError as error: + raise InputFailure("CodeGraph response input is not UTF-8") from error + return classify_response(repo, response) + descriptor = load_providers(protocol_root(repo))["codegraph"] + if args.command == "status": + return inspect_status(repo, descriptor) + return sync_if_needed(repo, descriptor) + + return run_main(execute, args.json) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/internal/codegraph_adapter.py b/scripts/internal/codegraph_adapter.py index 67baff2..cffe25d 100644 --- a/scripts/internal/codegraph_adapter.py +++ b/scripts/internal/codegraph_adapter.py @@ -5,12 +5,16 @@ import hashlib import json import math +import re import subprocess from collections.abc import Callable, Mapping from datetime import datetime, timezone from pathlib import Path from typing import Any +from .polaris_core import InputFailure, RuleFailure, file_sha256 +from .task_location_protocol import resolve_repo_reference + Runner = Callable[..., subprocess.CompletedProcess[str]] @@ -20,6 +24,25 @@ "indexing": "INDEX_INDEXING", "failed": "INDEX_FAILED", } +FRESHNESS_ORDER = { + "CURRENT_AT_CHECK": 0, + "PARTIAL_STALE": 1, + "NOT_VERIFIED": 2, + "INDEX_STALE": 3, + "UNAVAILABLE": 4, +} + +_PARTIAL_BANNER_HEADER = ( + "⚠️ Some files referenced below were edited since the last index sync —\n" + "their codegraph entries may be stale:\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\)$" +) +_DISABLED_BANNER = "⚠️ CodeGraph auto-sync is DISABLED — the index is frozen." def _checked_at() -> str: @@ -96,6 +119,150 @@ def _unavailable(checked_at: str, error: str) -> dict[str, Any]: ) +def _response_result( + classification: str, + checked_at: str, + *, + stale_points: list[dict[str, Any]], + error: str | None = None, +) -> dict[str, Any]: + return { + "classification": classification, + "checked_at": checked_at, + "basis": ["RESPONSE_BANNER"], + "stale_points": stale_points, + "response_sha256": None, + "error": error, + } + + +def _response_not_verified(checked_at: str, error: BaseException | str) -> dict[str, Any]: + return _response_result( + "NOT_VERIFIED", + checked_at, + stale_points=[_index_point("STATUS_UNREADABLE")], + error=_error_summary(error), + ) + + +def _response_file_point(repo: Path, raw_path: str) -> dict[str, Any]: + target = resolve_repo_reference(repo, raw_path) + if target.exists(): + if target.is_symlink() or not target.is_file(): + raise ValueError(f"CodeGraph stale path is not a regular file: {raw_path}") + fallback = "READ_SOURCE" + observed_sha256: str | None = file_sha256(target) + else: + fallback = "INSPECT_GIT_DIFF" + observed_sha256 = None + return { + "scope": "FILE", + "path": raw_path, + "reason": "PENDING_SYNC", + "fallback": fallback, + "observed_sha256": observed_sha256, + } + + +def classify_response( + repo: Path, + response: str, + *, + checked_at: str | None = None, +) -> dict[str, Any]: + """Classify only documented CodeGraph freshness banners in an explore response.""" + checked_at = _checked_at() if checked_at is None else checked_at + if not isinstance(response, str): + 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 _DISABLED_BANNER in normalized: + result = _response_result( + "INDEX_STALE", + checked_at, + stale_points=[_index_point("AUTO_SYNC_DISABLED")], + ) + result["response_sha256"] = response_sha256 + return result + + header_index = normalized.find(_PARTIAL_BANNER_HEADER) + if header_index < 0: + result = _response_result("NONE", checked_at, stale_points=[]) + result["response_sha256"] = response_sha256 + return result + + listed = normalized[header_index + 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 + + +def _unique_items(items: list[Any]) -> list[Any]: + result: list[Any] = [] + for item in items: + if item not in result: + result.append(item) + return result + + +def merge_freshness( + status_result: dict[str, Any], response_result: dict[str, Any] +) -> dict[str, Any]: + """Merge an inspected status with one explore-response freshness conclusion.""" + status = status_result.get("status") + classification = response_result.get("classification") + if status not in FRESHNESS_ORDER or classification not in { + "NONE", + "PARTIAL_STALE", + "INDEX_STALE", + "NOT_VERIFIED", + }: + raise ValueError("unrecognized CodeGraph freshness result") + + response_status = status if classification == "NONE" else classification + 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 = classification != "NONE" or ( + status not in {"NOT_VERIFIED", "UNAVAILABLE"} + and "STATUS_JSON" in status_basis + ) + basis = _unique_items( + [*status_basis, *(response_basis if include_response_basis else [])] + ) + return { + **status_result, + "status": merged_status, + "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", [])] + ), + "response_sha256": response_result.get("response_sha256"), + "error": status_result.get("error") or response_result.get("error"), + } + + def _marker_path(repo: Path, descriptor: dict[str, Any]) -> Path | None: marker = descriptor.get("project_marker") if not isinstance(marker, str): diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 7624551..f9b7c5b 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -13,12 +13,13 @@ sys.path.insert(0, str(SCRIPTS)) from init_project import initialize as init_project # noqa: E402 +from init_task import initialize as init_task # noqa: E402 from internal.code_intelligence_protocol import ( # noqa: E402 _project_marker_path, load_providers, select_provider, ) -from internal.polaris_core import RuleFailure # noqa: E402 +from internal.polaris_core import RuleFailure, file_sha256 # noqa: E402 def completed( @@ -62,6 +63,16 @@ def adapter_functions(self) -> tuple[object, object]: module = importlib.import_module("internal.codegraph_adapter") return module.inspect_status, module.sync_if_needed + def adapter_module(self) -> object: + return importlib.import_module("internal.codegraph_adapter") + + def classify_response( + self, response: str, *, checked_at: str = "2026-08-18T00:00:00Z" + ) -> dict[str, object]: + classifier = getattr(self.adapter_module(), "classify_response", None) + self.assertTrue(callable(classifier), "CodeGraph response classifier must exist") + return classifier(self.repo, response, checked_at=checked_at) + def test_official_descriptor_uses_explore_status_and_sync(self) -> None: descriptor = load_providers(ROOT)["codegraph"] self.assertEqual(descriptor["provider_version"], 2) @@ -454,3 +465,211 @@ def runner(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[ ) self.assertEqual(result["sync"]["status"], "SKIPPED") self.assertEqual(calls, []) + + def test_response_banner_marks_only_named_files_stale(self) -> None: + source = self.repo / "src/widget.py" + source.parent.mkdir() + source.write_text("def widget():\n return 1\n", encoding="utf-8") + response = """⚠️ Some files referenced below were edited since the last index sync — +their codegraph entries may be stale: + - src/widget.py (edited 800ms ago, pending sync) +For accurate content of those specific files, Read them directly. +""" + + result = self.classify_response(response) + + self.assertEqual(result["classification"], "PARTIAL_STALE") + self.assertEqual(result["basis"], ["RESPONSE_BANNER"]) + self.assertEqual(result["stale_points"][0]["path"], "src/widget.py") + self.assertEqual(result["stale_points"][0]["fallback"], "READ_SOURCE") + self.assertEqual( + result["stale_points"][0]["observed_sha256"], file_sha256(source) + ) + + def test_disabled_banner_freezes_the_whole_index(self) -> None: + result = self.classify_response( + "⚠️ CodeGraph auto-sync is DISABLED — the index is frozen.\n" + ) + + self.assertEqual(result["classification"], "INDEX_STALE") + self.assertEqual(result["basis"], ["RESPONSE_BANNER"]) + self.assertEqual(result["stale_points"][0]["reason"], "AUTO_SYNC_DISABLED") + self.assertEqual(result["stale_points"][0]["fallback"], "SEARCH_SOURCE") + + def test_response_banner_rejects_unsafe_and_symlink_paths(self) -> None: + outside = self.repo.parent / "outside.py" + outside.write_text("outside\n", encoding="utf-8") + link = self.repo / "src/link.py" + link.parent.mkdir() + link.symlink_to(outside) + prefix = """⚠️ Some files referenced below were edited since the last index sync — +their codegraph entries may be stale: +""" + suffix = "For accurate content of those specific files, Read them directly.\n" + for listed_path in ("../outside.py", outside.as_posix(), "src/link.py"): + with self.subTest(listed_path=listed_path): + result = self.classify_response( + f"{prefix} - {listed_path} (edited 800ms ago, pending sync)\n{suffix}" + ) + self.assertEqual(result["classification"], "NOT_VERIFIED") + self.assertEqual( + result["stale_points"][0]["reason"], "STATUS_UNREADABLE" + ) + + def test_response_banner_missing_file_requires_git_diff(self) -> None: + response = """⚠️ Some files referenced below were edited since the last index sync — +their codegraph entries may be stale: + - src/deleted.py (edited 800ms ago, pending sync) +For accurate content of those specific files, Read them directly. +""" + + result = self.classify_response(response) + + self.assertEqual(result["classification"], "PARTIAL_STALE") + self.assertEqual(result["stale_points"][0]["fallback"], "INSPECT_GIT_DIFF") + self.assertIsNone(result["stale_points"][0]["observed_sha256"]) + + def test_arbitrary_warning_is_not_a_codegraph_banner(self) -> None: + result = self.classify_response("⚠️ maybe stale: src/widget.py\n") + + self.assertEqual(result["classification"], "NONE") + self.assertEqual(result["stale_points"], []) + + def test_merge_freshness_uses_conservative_status_and_ordered_evidence(self) -> None: + merger = getattr(self.adapter_module(), "merge_freshness", None) + self.assertTrue(callable(merger), "CodeGraph freshness merger must exist") + status = { + "status": "CURRENT_AT_CHECK", + "checked_at": "2026-08-18T00:00:00Z", + "basis": ["STATUS_JSON"], + "stale_points": [], + "status_response_sha256": "status-sha", + "error": None, + "needs_sync": False, + "pending_changes": {"added": 0, "modified": 0, "removed": 0}, + } + response = self.classify_response( + """⚠️ Some files referenced below were edited since the last index sync — +their codegraph entries may be stale: + - src/deleted.py (edited 800ms ago, pending sync) +For accurate content of those specific files, Read them directly. +""" + ) + + result = merger(status, response) + + self.assertEqual(result["status"], "PARTIAL_STALE") + self.assertEqual(result["basis"], ["STATUS_JSON", "RESPONSE_BANNER"]) + self.assertEqual(result["stale_points"], response["stale_points"]) + self.assertEqual(result["status_response_sha256"], "status-sha") + + 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") + status = { + "status": "NOT_VERIFIED", + "checked_at": "2026-08-18T00:00:00Z", + "basis": ["STATUS_JSON"], + "stale_points": [{"reason": "STATUS_UNREADABLE"}], + } + + result = merger(status, self.classify_response("normal response\n")) + + self.assertEqual(result["status"], "NOT_VERIFIED") + self.assertEqual(result["basis"], ["STATUS_JSON"]) + + def test_none_response_records_banner_check_after_successful_stale_status(self) -> None: + merger = getattr(self.adapter_module(), "merge_freshness", None) + self.assertTrue(callable(merger), "CodeGraph freshness merger must exist") + status = { + "status": "INDEX_STALE", + "checked_at": "2026-08-18T00:00:00Z", + "basis": ["STATUS_JSON"], + "stale_points": [{"reason": "INDEX_FAILED"}], + } + + result = merger(status, self.classify_response("normal response\n")) + + self.assertEqual(result["status"], "INDEX_STALE") + self.assertEqual(result["basis"], ["STATUS_JSON", "RESPONSE_BANNER"]) + + def test_malformed_recognized_banner_downgrades_status(self) -> None: + merger = getattr(self.adapter_module(), "merge_freshness", None) + self.assertTrue(callable(merger), "CodeGraph freshness merger must exist") + status = { + "status": "CURRENT_AT_CHECK", + "checked_at": "2026-08-18T00:00:00Z", + "basis": ["STATUS_JSON"], + "stale_points": [], + } + response = self.classify_response( + "⚠️ Some files referenced below were edited since the last index sync —\n" + "their codegraph entries may be stale:\n" + ) + + result = merger(status, response) + + self.assertEqual(response["classification"], "NOT_VERIFIED") + self.assertEqual(result["status"], "NOT_VERIFIED") + + def test_runtime_classify_response_confines_input_and_emits_json(self) -> None: + (self.repo / "README.md").write_text("test repository\n", encoding="utf-8") + subprocess.run(["git", "add", "README.md"], cwd=self.repo, check=True) + subprocess.run( + [ + "git", + "-c", + "user.name=Polaris Test", + "-c", + "user.email=polaris@example.test", + "commit", + "-qm", + "initialize test repository", + ], + cwd=self.repo, + check=True, + ) + init_task(self.repo, "TASK-0001", "R1") + runtime = self.repo / ".polaris/tasks/TASK-0001/runtime/code-intelligence" + runtime.mkdir() + response_path = runtime / "response.txt" + response_path.write_text( + """⚠️ Some files referenced below were edited since the last index sync — +their codegraph entries may be stale: + - src/deleted.py (edited 800ms ago, pending sync) +For accurate content of those specific files, Read them directly. +""", + encoding="utf-8", + ) + command = [ + sys.executable, + SCRIPTS / "code_intelligence_runtime.py", + "classify-response", + "TASK-0001", + "--input", + response_path, + "--repo", + self.repo, + "--json", + ] + + completed_process = subprocess.run( + command, cwd=ROOT, capture_output=True, text=True, check=False + ) + + self.assertEqual(completed_process.returncode, 0, completed_process.stderr) + payload = json.loads(completed_process.stdout) + self.assertEqual(payload["status"], "PASS") + self.assertEqual(payload["classification"], "PARTIAL_STALE") + + outside = self.repo / "response.txt" + outside.write_text("normal response\n", encoding="utf-8") + outside_command = [*command] + outside_command[outside_command.index(response_path)] = outside + rejected = subprocess.run( + outside_command, cwd=ROOT, capture_output=True, text=True, check=False + ) + self.assertEqual(rejected.returncode, 2) + rejected_payload = json.loads(rejected.stdout) + self.assertEqual(rejected_payload["status"], "ERROR") + self.assertIn("runtime", rejected_payload["message"]) From 945a2ba7ba4a724d52eb245d74016e09a14cc6ab Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 14:48:45 +0800 Subject: [PATCH 10/41] fix: harden CodeGraph response boundaries --- scripts/code_intelligence_runtime.py | 14 +++++- scripts/internal/codegraph_adapter.py | 12 +++-- tests/test_codegraph.py | 68 +++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 6 deletions(-) diff --git a/scripts/code_intelligence_runtime.py b/scripts/code_intelligence_runtime.py index 72d6e61..4bddebb 100644 --- a/scripts/code_intelligence_runtime.py +++ b/scripts/code_intelligence_runtime.py @@ -14,9 +14,21 @@ from internal.task_layout import code_intelligence_runtime_dir +def _runtime_directory(directory: Path) -> Path: + """Return the lexical runtime directory only after rejecting symlink hops.""" + runtime = code_intelligence_runtime_dir(directory) + runtime_parent = runtime.parent + for path in (runtime_parent, runtime): + if path.is_symlink(): + raise InputFailure("CodeGraph response runtime must not cross a symlink") + if not runtime.is_dir(): + raise InputFailure("CodeGraph response runtime directory is unavailable") + return runtime.resolve() + + def _runtime_input(repo: Path, task_id: str, value: Path) -> Path: directory = task_dir(repo, task_id) - runtime = code_intelligence_runtime_dir(directory).resolve() + runtime = _runtime_directory(directory) candidate = value if value.is_absolute() else repo / value if candidate.is_symlink(): raise InputFailure("CodeGraph response input must not be a symlink") diff --git a/scripts/internal/codegraph_adapter.py b/scripts/internal/codegraph_adapter.py index cffe25d..cfbda62 100644 --- a/scripts/internal/codegraph_adapter.py +++ b/scripts/internal/codegraph_adapter.py @@ -170,13 +170,16 @@ def classify_response( *, checked_at: str | None = None, ) -> dict[str, Any]: - """Classify only documented CodeGraph freshness banners in an explore response.""" + """Classify only documented banners beginning at response byte zero. + + Leading whitespace and a UTF-8 BOM are not accepted as an official banner. + """ checked_at = _checked_at() if checked_at is None else checked_at if not isinstance(response, str): 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 _DISABLED_BANNER in normalized: + if normalized.startswith(_DISABLED_BANNER): result = _response_result( "INDEX_STALE", checked_at, @@ -185,13 +188,12 @@ def classify_response( result["response_sha256"] = response_sha256 return result - header_index = normalized.find(_PARTIAL_BANNER_HEADER) - if header_index < 0: + if not normalized.startswith(_PARTIAL_BANNER_HEADER): result = _response_result("NONE", checked_at, stale_points=[]) result["response_sha256"] = response_sha256 return result - listed = normalized[header_index + len(_PARTIAL_BANNER_HEADER) :] + 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") diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index f9b7c5b..01bf651 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -535,6 +535,24 @@ def test_arbitrary_warning_is_not_a_codegraph_banner(self) -> None: self.assertEqual(result["classification"], "NONE") self.assertEqual(result["stale_points"], []) + def test_prefixed_or_quoted_official_banner_is_not_recognized(self) -> None: + banner = """⚠️ Some files referenced below were edited since the last index sync — +their codegraph entries may be stale: + - src/deleted.py (edited 800ms ago, pending sync) +For accurate content of those specific files, Read them directly. +""" + for response in ( + f"context before banner\n{banner}", + f"> {banner}", + f"quoted response: {banner}", + f" {banner}", + f"\ufeff{banner}", + ): + with self.subTest(response=response[:20]): + result = self.classify_response(response) + self.assertEqual(result["classification"], "NONE") + self.assertEqual(result["stale_points"], []) + def test_merge_freshness_uses_conservative_status_and_ordered_evidence(self) -> None: merger = getattr(self.adapter_module(), "merge_freshness", None) self.assertTrue(callable(merger), "CodeGraph freshness merger must exist") @@ -673,3 +691,53 @@ def test_runtime_classify_response_confines_input_and_emits_json(self) -> None: rejected_payload = json.loads(rejected.stdout) self.assertEqual(rejected_payload["status"], "ERROR") self.assertIn("runtime", rejected_payload["message"]) + + def test_runtime_rejects_symlinked_runtime_directory_before_reading(self) -> None: + (self.repo / "README.md").write_text("test repository\n", encoding="utf-8") + subprocess.run(["git", "add", "README.md"], cwd=self.repo, check=True) + subprocess.run( + [ + "git", + "-c", + "user.name=Polaris Test", + "-c", + "user.email=polaris@example.test", + "commit", + "-qm", + "initialize test repository", + ], + cwd=self.repo, + check=True, + ) + init_task(self.repo, "TASK-0001", "R1") + runtime = self.repo / ".polaris/tasks/TASK-0001/runtime" + outside = self.repo / "outside-runtime" + external_runtime = outside / "code-intelligence" + external_runtime.mkdir(parents=True) + response_path = external_runtime / "response.txt" + response_path.write_text("normal response\n", encoding="utf-8") + runtime.rmdir() + runtime.symlink_to(outside, target_is_directory=True) + + completed_process = subprocess.run( + [ + sys.executable, + SCRIPTS / "code_intelligence_runtime.py", + "classify-response", + "TASK-0001", + "--input", + runtime / "code-intelligence/response.txt", + "--repo", + self.repo, + "--json", + ], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + + self.assertEqual(completed_process.returncode, 2) + payload = json.loads(completed_process.stdout) + self.assertEqual(payload["status"], "ERROR") + self.assertIn("symlink", payload["message"]) From f5d0dbf2010b14b3999eeadfb115e74225557f57 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 15:00:17 +0800 Subject: [PATCH 11/41] feat: record CodeGraph freshness and fallbacks --- .../code-intelligence-record-v1.schema.json | 116 +++++ schemas/code-intelligence-record.schema.json | 396 ++++++++++++++++-- .../internal/code_intelligence_protocol.py | 215 +++++++++- scripts/record_code_intelligence.py | 2 +- .../code-intelligence-record.json | 11 +- .../task/code-intelligence/r001/planning.json | 11 +- tests/test_codegraph.py | 197 ++++++++- tests/test_core.py | 16 +- 8 files changed, 887 insertions(+), 77 deletions(-) create mode 100644 schemas/code-intelligence-record-v1.schema.json diff --git a/schemas/code-intelligence-record-v1.schema.json b/schemas/code-intelligence-record-v1.schema.json new file mode 100644 index 0000000..bf94792 --- /dev/null +++ b/schemas/code-intelligence-record-v1.schema.json @@ -0,0 +1,116 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Polaris Code Intelligence record", + "type": "object", + "required": [ + "record_version", + "task_id", + "work_item_revision", + "stage", + "artifact_attempt", + "reviewer_slot", + "provider", + "target", + "status", + "queries", + "refresh", + "recorded_at" + ], + "additionalProperties": false, + "properties": { + "record_version": {"const": 1}, + "task_id": {"type": "string", "pattern": "^TASK-[0-9]{4}$"}, + "work_item_revision": {"type": "integer", "minimum": 1}, + "stage": { + "type": "string", + "enum": ["PLANNING", "IMPLEMENTATION", "DOCUMENTATION_SYNC", "REVIEW"] + }, + "artifact_attempt": {"type": ["integer", "null"], "minimum": 1}, + "reviewer_slot": {"type": ["integer", "null"], "minimum": 1}, + "provider": { + "type": ["object", "null"], + "required": ["id", "descriptor_version", "transport", "available_operations"], + "additionalProperties": false, + "properties": { + "id": {"type": "string", "pattern": "^[a-z][a-z0-9-]*$"}, + "descriptor_version": {"const": 1}, + "transport": {"const": "mcp"}, + "available_operations": { + "type": "array", + "uniqueItems": true, + "items": {"type": "string", "enum": ["symbol_search", "context", "dependencies", "call_graph", "impact", "review_context", "refresh_files", "refresh_workspace"]} + } + } + }, + "target": { + "type": "object", + "required": ["base_commit", "head_commit", "diff_hash"], + "additionalProperties": false, + "properties": { + "base_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "head_commit": {"type": ["string", "null"], "pattern": "^[0-9a-f]{40}$"}, + "diff_hash": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"} + } + }, + "status": { + "type": "string", + "enum": ["USED", "UNAVAILABLE", "FAILED", "SKIPPED"] + }, + "queries": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "operation", "purpose", "status", "summary", "symbols", "response_sha256", "error"], + "additionalProperties": false, + "properties": { + "id": {"type": "string", "pattern": "^CIQ-[0-9]{3}$"}, + "operation": {"type": "string", "enum": ["symbol_search", "context", "dependencies", "call_graph", "impact", "review_context", "refresh_files", "refresh_workspace"]}, + "purpose": {"type": "string", "minLength": 1}, + "status": {"type": "string", "enum": ["SUCCESS", "EMPTY", "FAILED", "UNAVAILABLE"]}, + "summary": {"type": "string"}, + "symbols": { + "type": "array", + "items": { + "type": "object", + "required": ["path", "line", "name"], + "additionalProperties": false, + "properties": { + "path": {"type": "string", "minLength": 1}, + "line": {"type": ["integer", "null"], "minimum": 1}, + "name": {"type": "string", "minLength": 1} + } + } + }, + "response_sha256": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"}, + "error": {"type": ["string", "null"]} + } + } + }, + "refresh": { + "type": ["object", "null"], + "required": ["operation", "paths", "status", "freshness", "response_sha256", "error"], + "additionalProperties": false, + "properties": { + "operation": {"type": "string", "enum": ["refresh_files", "refresh_workspace"]}, + "paths": { + "type": "array", + "items": { + "type": "object", + "required": ["path", "change", "sha256"], + "additionalProperties": false, + "properties": { + "path": {"type": "string", "minLength": 1}, + "change": {"type": "string", "enum": ["ADDED", "MODIFIED", "DELETED", "RENAMED"]}, + "sha256": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"} + } + } + }, + "status": {"type": "string", "enum": ["SUCCESS", "FAILED", "SKIPPED", "UNAVAILABLE"]}, + "freshness": {"type": "string", "enum": ["refresh_acknowledged", "spot_checked", "not_verified"]}, + "response_sha256": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"}, + "error": {"type": ["string", "null"]} + } + }, + "recorded_at": {"type": "string", "minLength": 1} + } +} diff --git a/schemas/code-intelligence-record.schema.json b/schemas/code-intelligence-record.schema.json index bf94792..428747e 100644 --- a/schemas/code-intelligence-record.schema.json +++ b/schemas/code-intelligence-record.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "Polaris Code Intelligence record", + "title": "Polaris Code Intelligence record v2", "type": "object", "required": [ "record_version", @@ -13,104 +13,408 @@ "target", "status", "queries", - "refresh", + "sync", + "freshness", + "source_fallbacks", "recorded_at" ], "additionalProperties": false, "properties": { - "record_version": {"const": 1}, - "task_id": {"type": "string", "pattern": "^TASK-[0-9]{4}$"}, - "work_item_revision": {"type": "integer", "minimum": 1}, + "record_version": { + "const": 2 + }, + "task_id": { + "type": "string", + "pattern": "^TASK-[0-9]{4}$" + }, + "work_item_revision": { + "type": "integer", + "minimum": 1 + }, "stage": { "type": "string", - "enum": ["PLANNING", "IMPLEMENTATION", "DOCUMENTATION_SYNC", "REVIEW"] + "enum": [ + "PLANNING", + "IMPLEMENTATION", + "DOCUMENTATION_SYNC", + "REVIEW" + ] + }, + "artifact_attempt": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "reviewer_slot": { + "type": [ + "integer", + "null" + ], + "minimum": 1 }, - "artifact_attempt": {"type": ["integer", "null"], "minimum": 1}, - "reviewer_slot": {"type": ["integer", "null"], "minimum": 1}, "provider": { - "type": ["object", "null"], - "required": ["id", "descriptor_version", "transport", "available_operations"], + "type": [ + "object", + "null" + ], + "required": [ + "id", + "descriptor_version", + "transport", + "available_operations" + ], "additionalProperties": false, "properties": { - "id": {"type": "string", "pattern": "^[a-z][a-z0-9-]*$"}, - "descriptor_version": {"const": 1}, - "transport": {"const": "mcp"}, + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "descriptor_version": { + "const": 2 + }, + "transport": { + "const": "mcp" + }, "available_operations": { "type": "array", "uniqueItems": true, - "items": {"type": "string", "enum": ["symbol_search", "context", "dependencies", "call_graph", "impact", "review_context", "refresh_files", "refresh_workspace"]} + "items": { + "type": "string", + "enum": [ + "explore", + "status", + "sync" + ] + } } } }, "target": { "type": "object", - "required": ["base_commit", "head_commit", "diff_hash"], + "required": [ + "base_commit", + "head_commit", + "diff_hash" + ], "additionalProperties": false, "properties": { - "base_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, - "head_commit": {"type": ["string", "null"], "pattern": "^[0-9a-f]{40}$"}, - "diff_hash": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"} + "base_commit": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "head_commit": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{40}$" + }, + "diff_hash": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{64}$" + } } }, "status": { "type": "string", - "enum": ["USED", "UNAVAILABLE", "FAILED", "SKIPPED"] + "enum": [ + "USED", + "UNAVAILABLE", + "FAILED", + "SKIPPED" + ] }, "queries": { "type": "array", "items": { "type": "object", - "required": ["id", "operation", "purpose", "status", "summary", "symbols", "response_sha256", "error"], + "required": [ + "id", + "operation", + "purpose", + "status", + "summary", + "symbols", + "response_sha256", + "error" + ], "additionalProperties": false, "properties": { - "id": {"type": "string", "pattern": "^CIQ-[0-9]{3}$"}, - "operation": {"type": "string", "enum": ["symbol_search", "context", "dependencies", "call_graph", "impact", "review_context", "refresh_files", "refresh_workspace"]}, - "purpose": {"type": "string", "minLength": 1}, - "status": {"type": "string", "enum": ["SUCCESS", "EMPTY", "FAILED", "UNAVAILABLE"]}, - "summary": {"type": "string"}, + "id": { + "type": "string", + "pattern": "^CIQ-[0-9]{3}$" + }, + "operation": { + "const": "explore" + }, + "purpose": { + "type": "string", + "minLength": 1 + }, + "status": { + "type": "string", + "enum": [ + "SUCCESS", + "EMPTY", + "FAILED", + "UNAVAILABLE" + ] + }, + "summary": { + "type": "string" + }, "symbols": { "type": "array", "items": { "type": "object", - "required": ["path", "line", "name"], + "required": [ + "path", + "line", + "name" + ], "additionalProperties": false, "properties": { - "path": {"type": "string", "minLength": 1}, - "line": {"type": ["integer", "null"], "minimum": 1}, - "name": {"type": "string", "minLength": 1} + "path": { + "type": "string", + "minLength": 1 + }, + "line": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "name": { + "type": "string", + "minLength": 1 + } } } }, - "response_sha256": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"}, - "error": {"type": ["string", "null"]} + "response_sha256": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{64}$" + }, + "error": { + "type": [ + "string", + "null" + ] + } } } }, - "refresh": { - "type": ["object", "null"], - "required": ["operation", "paths", "status", "freshness", "response_sha256", "error"], + "sync": { + "type": [ + "object", + "null" + ], + "required": [ + "status", + "response_sha256", + "error" + ], "additionalProperties": false, "properties": { - "operation": {"type": "string", "enum": ["refresh_files", "refresh_workspace"]}, - "paths": { + "status": { + "type": "string", + "enum": [ + "SUCCESS", + "FAILED", + "SKIPPED", + "UNAVAILABLE" + ] + }, + "response_sha256": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{64}$" + }, + "error": { + "type": [ + "string", + "null" + ] + } + } + }, + "freshness": { + "type": "object", + "required": [ + "status", + "checked_at", + "basis", + "stale_points" + ], + "additionalProperties": false, + "properties": { + "status": { + "type": "string", + "enum": [ + "CURRENT_AT_CHECK", + "PARTIAL_STALE", + "INDEX_STALE", + "NOT_VERIFIED", + "UNAVAILABLE" + ] + }, + "checked_at": { + "type": "string", + "minLength": 1 + }, + "basis": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "CONNECT_RECONCILIATION", + "STATUS_JSON", + "SYNC_ACKNOWLEDGED", + "RESPONSE_BANNER", + "NONE" + ] + } + }, + "stale_points": { "type": "array", "items": { "type": "object", - "required": ["path", "change", "sha256"], + "required": [ + "scope", + "path", + "reason", + "fallback", + "observed_sha256" + ], "additionalProperties": false, "properties": { - "path": {"type": "string", "minLength": 1}, - "change": {"type": "string", "enum": ["ADDED", "MODIFIED", "DELETED", "RENAMED"]}, - "sha256": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"} + "scope": { + "type": "string", + "enum": [ + "FILE", + "INDEX" + ] + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "reason": { + "type": "string", + "enum": [ + "PENDING_SYNC", + "AUTO_SYNC_DISABLED", + "WORKTREE_MISMATCH", + "INDEX_PARTIAL", + "INDEX_INDEXING", + "INDEX_FAILED", + "PENDING_REFERENCES", + "REINDEX_RECOMMENDED", + "SYNC_FAILED", + "STATUS_UNREADABLE" + ] + }, + "fallback": { + "type": "string", + "enum": [ + "READ_SOURCE", + "INSPECT_GIT_DIFF", + "SEARCH_SOURCE" + ] + }, + "observed_sha256": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{64}$" + } } } - }, - "status": {"type": "string", "enum": ["SUCCESS", "FAILED", "SKIPPED", "UNAVAILABLE"]}, - "freshness": {"type": "string", "enum": ["refresh_acknowledged", "spot_checked", "not_verified"]}, - "response_sha256": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"}, - "error": {"type": ["string", "null"]} + } } }, - "recorded_at": {"type": "string", "minLength": 1} + "source_fallbacks": { + "type": "array", + "items": { + "type": "object", + "required": [ + "action", + "path", + "observed_sha256", + "base_commit", + "head_commit", + "diff_hash", + "purpose" + ], + "additionalProperties": false, + "properties": { + "action": { + "type": "string", + "enum": [ + "READ_SOURCE", + "INSPECT_GIT_DIFF", + "SEARCH_SOURCE" + ] + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "observed_sha256": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{64}$" + }, + "base_commit": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{40}$" + }, + "head_commit": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{40}$" + }, + "diff_hash": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{64}$" + }, + "purpose": { + "type": "string" + } + } + } + }, + "recorded_at": { + "type": "string", + "minLength": 1 + } } } diff --git a/scripts/internal/code_intelligence_protocol.py b/scripts/internal/code_intelligence_protocol.py index 9848720..a61ed73 100644 --- a/scripts/internal/code_intelligence_protocol.py +++ b/scripts/internal/code_intelligence_protocol.py @@ -304,14 +304,14 @@ def _record_name(value: dict[str, Any]) -> str: ) -def validate_record_value( +def validate_legacy_record_value( repo: Path, task_id: str, value: dict[str, Any], root: Path | None = None, ) -> dict[str, Any]: root = protocol_root(repo) if root is None else root - schema = read_json(root / "schemas" / "code-intelligence-record.schema.json") + schema = read_json(root / "schemas" / "code-intelligence-record-v1.schema.json") errors = validate_schema(value, schema) if errors: raise RuleFailure( @@ -335,23 +335,6 @@ def validate_record_value( if value["status"] in {"USED", "FAILED"} and value["provider"] is None: raise RuleFailure("used or failed Code Intelligence record requires a provider") provider = value["provider"] - if provider is not None: - descriptors = load_providers(root) - descriptor = descriptors.get(provider["id"]) - if ( - descriptor is None - or ( - value["record_version"] != 1 - and ( - provider["descriptor_version"] != descriptor["provider_version"] - or provider["transport"] != descriptor["transport"] - or not set(provider["available_operations"]).issubset( - descriptor["operations"] - ) - ) - ) - ): - raise RuleFailure("Code Intelligence record names an invalid provider capability set") query_ids = [item["id"] for item in value["queries"]] expected_ids = [f"CIQ-{index:03d}" for index in range(1, len(query_ids) + 1)] if query_ids != expected_ids: @@ -422,6 +405,198 @@ def validate_record_value( return value +def _validate_record_identity( + repo: Path, task_id: str, value: dict[str, Any] +) -> tuple[Path, str, str | None]: + directory = task_dir(repo, task_id) + state = read_json(state_path(directory)) + if value["task_id"] != task_id or value["work_item_revision"] != state["current_revision"]: + raise RuleFailure("Code Intelligence record targets the wrong task revision") + _record_name(value) + target = value["target"] + base = full_commit(repo, target["base_commit"]) + head = target["head_commit"] + if head is None: + if target["diff_hash"] is not None: + raise RuleFailure("base-only Code Intelligence target cannot have a diff hash") + else: + head = full_commit(repo, head) + if target["diff_hash"] != subject_diff_hash(repo, base, head): + raise RuleFailure("Code Intelligence target diff hash is stale") + return directory, base, head + + +def _matching_fallback( + fallbacks: list[dict[str, Any]], action: str, path: str | None, digest: str | None +) -> dict[str, Any] | None: + for fallback in fallbacks: + if ( + fallback["action"] == action + and fallback["path"] == path + and fallback["observed_sha256"] == digest + ): + return fallback + return None + + +def _validate_source_fallbacks( + repo: Path, + value: dict[str, Any], + base: str, + head: str | None, +) -> None: + target = value["target"] + for fallback in value["source_fallbacks"]: + action = fallback["action"] + path = fallback["path"] + digest = fallback["observed_sha256"] + if not fallback["purpose"]: + raise RuleFailure("source fallback requires a non-empty purpose") + if action == "READ_SOURCE": + if not isinstance(path, str) or digest is None: + raise RuleFailure("READ_SOURCE fallback requires a current SHA") + resolved = resolve_repo_reference(repo, path) + if not resolved.is_file() or file_sha256(resolved) != digest: + raise RuleFailure(f"READ_SOURCE fallback hash is stale: {path}") + if any(item is not None for item in (fallback["base_commit"], fallback["head_commit"], fallback["diff_hash"])): + raise RuleFailure("READ_SOURCE fallback cannot claim a Git diff target") + elif action == "INSPECT_GIT_DIFF": + if not isinstance(path, str) or digest is not None: + raise RuleFailure("INSPECT_GIT_DIFF fallback requires a null SHA") + resolve_repo_reference(repo, path) + if head is None or ( + fallback["base_commit"] != base + or fallback["head_commit"] != head + or fallback["diff_hash"] != target["diff_hash"] + ): + raise RuleFailure("INSPECT_GIT_DIFF fallback requires the target base/head/diff hash") + else: + if path is not None or digest is not None: + raise RuleFailure("SEARCH_SOURCE fallback cannot name a path or SHA") + if any(item is not None for item in (fallback["base_commit"], fallback["head_commit"], fallback["diff_hash"])): + raise RuleFailure("SEARCH_SOURCE fallback cannot claim a Git diff target") + + +def _validate_v2_freshness( + repo: Path, value: dict[str, Any], base: str, head: str | None +) -> None: + freshness = value["freshness"] + points = freshness["stale_points"] + fallbacks = value["source_fallbacks"] + file_points = [point for point in points if point["scope"] == "FILE"] + index_points = [point for point in points if point["scope"] == "INDEX"] + for point in file_points: + if ( + not isinstance(point["path"], str) + or point["reason"] != "PENDING_SYNC" + or point["fallback"] not in {"READ_SOURCE", "INSPECT_GIT_DIFF"} + ): + raise RuleFailure("file stale point is invalid") + if point["fallback"] == "READ_SOURCE": + if point["observed_sha256"] is None: + raise RuleFailure("READ_SOURCE stale point requires a current SHA") + resolved = resolve_repo_reference(repo, point["path"]) + if not resolved.is_file() or file_sha256(resolved) != point["observed_sha256"]: + raise RuleFailure(f"stale file hash is stale: {point['path']}") + elif point["observed_sha256"] is not None: + raise RuleFailure("INSPECT_GIT_DIFF stale point requires a null SHA") + if _matching_fallback( + fallbacks, point["fallback"], point["path"], point["observed_sha256"] + ) is None: + raise RuleFailure("stale point requires a matching source fallback") + for point in index_points: + if ( + point["path"] is not None + or point["fallback"] != "SEARCH_SOURCE" + or point["observed_sha256"] is not None + or point["reason"] == "PENDING_SYNC" + ): + raise RuleFailure("index stale point is invalid") + if _matching_fallback(fallbacks, "SEARCH_SOURCE", None, None) is None: + raise RuleFailure("stale point requires a matching source fallback") + status = freshness["status"] + if status == "CURRENT_AT_CHECK" and points: + raise RuleFailure("CURRENT_AT_CHECK cannot contain stale points") + if status == "PARTIAL_STALE" and (not file_points or index_points): + raise RuleFailure("PARTIAL_STALE requires only file stale points") + if status == "INDEX_STALE" and not index_points: + raise RuleFailure("INDEX_STALE requires an index stale point") + if status == "NOT_VERIFIED" and not any( + point["reason"] == "STATUS_UNREADABLE" for point in index_points + ): + raise RuleFailure("NOT_VERIFIED requires STATUS_UNREADABLE") + if status == "UNAVAILABLE": + if freshness["basis"] != ["NONE"] or points: + raise RuleFailure("UNAVAILABLE freshness must use NONE with no stale points") + if value["sync"] is not None or any( + item["status"] != "UNAVAILABLE" for item in value["queries"] + ): + raise RuleFailure("UNAVAILABLE record contains an attempted query or sync") + + +def _validate_v2_record_value( + repo: Path, task_id: str, value: dict[str, Any], root: Path +) -> dict[str, Any]: + errors = validate_schema( + value, read_json(root / "schemas" / "code-intelligence-record.schema.json") + ) + if errors: + raise RuleFailure("Code Intelligence record failed schema validation:\n- " + "\n- ".join(errors)) + _, base, head = _validate_record_identity(repo, task_id, value) + provider = value["provider"] + if value["status"] in {"USED", "FAILED"} and provider is None: + raise RuleFailure("used or failed Code Intelligence record requires a provider") + if provider is not None: + descriptor = load_providers(root).get(provider["id"]) + if ( + descriptor is None + or provider["descriptor_version"] != descriptor["provider_version"] + or provider["transport"] != descriptor["transport"] + or not set(provider["available_operations"]).issubset(OPERATIONS) + ): + raise RuleFailure("Code Intelligence record names an invalid provider capability set") + query_ids = [item["id"] for item in value["queries"]] + expected_ids = [f"CIQ-{index:03d}" for index in range(1, len(query_ids) + 1)] + if query_ids != expected_ids: + raise RuleFailure("Code Intelligence query IDs must be sequential") + for query in value["queries"]: + if provider is not None and query["status"] != "UNAVAILABLE" and "explore" not in provider["available_operations"]: + raise RuleFailure(f"query used an unavailable provider operation: {query['id']}") + if query["status"] == "SUCCESS" and query["response_sha256"] is None: + raise RuleFailure(f"successful query lacks response hash: {query['id']}") + if query["status"] == "FAILED" and not query["error"]: + raise RuleFailure(f"failed query lacks error: {query['id']}") + for symbol in query["symbols"]: + path = resolve_repo_reference(repo, symbol["path"]) + if not path.is_file(): + raise RuleFailure(f"Code Intelligence symbol path is not a file: {symbol['path']}") + sync = value["sync"] + if sync is not None: + if sync["status"] == "SUCCESS": + if sync["response_sha256"] is None or "SYNC_ACKNOWLEDGED" not in value["freshness"]["basis"]: + raise RuleFailure("successful Code Intelligence sync requires a response hash and SYNC_ACKNOWLEDGED basis") + if sync["status"] == "FAILED" and not sync["error"]: + raise RuleFailure("failed Code Intelligence sync lacks error") + _validate_source_fallbacks(repo, value, base, head) + _validate_v2_freshness(repo, value, base, head) + observed_statuses = {item["status"] for item in value["queries"]} + sync_status = sync["status"] if sync is not None else None + if value["status"] == "FAILED" and "FAILED" not in observed_statuses and sync_status != "FAILED": + raise RuleFailure("failed Code Intelligence record lacks a failed operation") + if value["status"] == "USED" and not observed_statuses.intersection({"SUCCESS", "EMPTY"}) and sync_status != "SUCCESS": + raise RuleFailure("used Code Intelligence record lacks a successful operation") + return value + + +def validate_record_value( + repo: Path, task_id: str, value: dict[str, Any], root: Path | None = None +) -> dict[str, Any]: + root = protocol_root(repo) if root is None else root + if value.get("record_version") == 1: + return validate_legacy_record_value(repo, task_id, value, root) + return _validate_v2_record_value(repo, task_id, value, root) + + def record( repo: Path, task_id: str, @@ -429,6 +604,8 @@ def record( root: Path | None = None, ) -> dict[str, Any]: root = protocol_root(repo) if root is None else root + if value.get("record_version") == 1: + raise InputFailure("new Code Intelligence records must use record_version 2") value = validate_record_value(repo, task_id, value, root) directory = task_dir(repo, task_id) destination = code_intelligence_record_path( diff --git a/scripts/record_code_intelligence.py b/scripts/record_code_intelligence.py index a20bda2..28afb60 100644 --- a/scripts/record_code_intelligence.py +++ b/scripts/record_code_intelligence.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Record compact optional Code Intelligence evidence or plan an index refresh.""" +"""Record compact optional Code Intelligence evidence.""" from __future__ import annotations diff --git a/templates/task-sources/code-intelligence-record.json b/templates/task-sources/code-intelligence-record.json index 9533806..c171a6a 100644 --- a/templates/task-sources/code-intelligence-record.json +++ b/templates/task-sources/code-intelligence-record.json @@ -1,5 +1,5 @@ { - "record_version": 1, + "record_version": 2, "task_id": "TASK-0001", "work_item_revision": 1, "stage": "PLANNING", @@ -13,6 +13,13 @@ }, "status": "UNAVAILABLE", "queries": [], - "refresh": null, + "sync": null, + "freshness": { + "status": "UNAVAILABLE", + "checked_at": "1970-01-01T00:00:00Z", + "basis": ["NONE"], + "stale_points": [] + }, + "source_fallbacks": [], "recorded_at": "1970-01-01T00:00:00Z" } diff --git a/templates/task/code-intelligence/r001/planning.json b/templates/task/code-intelligence/r001/planning.json index 9533806..c171a6a 100644 --- a/templates/task/code-intelligence/r001/planning.json +++ b/templates/task/code-intelligence/r001/planning.json @@ -1,5 +1,5 @@ { - "record_version": 1, + "record_version": 2, "task_id": "TASK-0001", "work_item_revision": 1, "stage": "PLANNING", @@ -13,6 +13,13 @@ }, "status": "UNAVAILABLE", "queries": [], - "refresh": null, + "sync": null, + "freshness": { + "status": "UNAVAILABLE", + "checked_at": "1970-01-01T00:00:00Z", + "basis": ["NONE"], + "stale_points": [] + }, + "source_fallbacks": [], "recorded_at": "1970-01-01T00:00:00Z" } diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 01bf651..ddc9647 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -17,9 +17,11 @@ from internal.code_intelligence_protocol import ( # noqa: E402 _project_marker_path, load_providers, + record, select_provider, + validate_record_value, ) -from internal.polaris_core import RuleFailure, file_sha256 # noqa: E402 +from internal.polaris_core import InputFailure, RuleFailure, file_sha256 # noqa: E402 def completed( @@ -50,6 +52,12 @@ def setUp(self) -> None: self.repo = Path(self.temp.name) subprocess.run(["git", "init", "-q"], cwd=self.repo, check=True) init_project(self.repo, "codegraph-test") + subprocess.run(["git", "add", "."], cwd=self.repo, check=True) + subprocess.run( + ["git", "commit", "-q", "-m", "initialize test project"], + cwd=self.repo, + check=True, + ) def tearDown(self) -> None: self.temp.cleanup() @@ -73,6 +81,193 @@ def classify_response( self.assertTrue(callable(classifier), "CodeGraph response classifier must exist") return classifier(self.repo, response, checked_at=checked_at) + def v2_record(self) -> dict[str, object]: + value = json.loads( + (ROOT / "templates" / "task-sources" / "code-intelligence-record.json").read_text( + encoding="utf-8" + ) + ) + value["target"]["base_commit"] = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=self.repo, + capture_output=True, + check=True, + encoding="utf-8", + text=True, + ).stdout.strip() + return value + + def initialize_task(self) -> None: + init_task(self.repo, "TASK-0001", "R1") + + def test_legacy_v1_records_remain_readable_but_cannot_be_written(self) -> None: + self.initialize_task() + protocol = importlib.import_module("internal.code_intelligence_protocol") + legacy_validator = getattr(protocol, "validate_legacy_record_value", None) + self.assertTrue(callable(legacy_validator)) + legacy = json.loads( + (ROOT / "schemas" / "code-intelligence-record-v1.schema.json").read_text( + encoding="utf-8" + ) + ) + self.assertEqual(legacy["properties"]["record_version"]["const"], 1) + value = { + "record_version": 1, + "task_id": "TASK-0001", + "work_item_revision": 1, + "stage": "PLANNING", + "artifact_attempt": None, + "reviewer_slot": None, + "provider": None, + "target": { + "base_commit": subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=self.repo, + capture_output=True, + check=True, + encoding="utf-8", + text=True, + ).stdout.strip(), + "head_commit": None, + "diff_hash": None, + }, + "status": "UNAVAILABLE", + "queries": [], + "refresh": None, + "recorded_at": "1970-01-01T00:00:00Z", + } + self.assertEqual(legacy_validator(self.repo, "TASK-0001", value, ROOT)["record_version"], 1) + with self.assertRaisesRegex( + InputFailure, "new Code Intelligence records must use record_version 2" + ): + record(self.repo, "TASK-0001", value, ROOT) + + def test_partial_stale_record_requires_matching_source_fallback(self) -> None: + self.initialize_task() + source = self.repo / "src/widget.py" + source.parent.mkdir() + source.write_text("value = 1\n", encoding="utf-8") + value = self.v2_record() + digest = file_sha256(source) + value["freshness"] = { + "status": "PARTIAL_STALE", + "checked_at": "2026-08-18T00:00:00Z", + "basis": ["RESPONSE_BANNER"], + "stale_points": [{ + "scope": "FILE", + "path": "src/widget.py", + "reason": "PENDING_SYNC", + "fallback": "READ_SOURCE", + "observed_sha256": digest, + }], + } + with self.assertRaisesRegex(RuleFailure, "matching source fallback"): + validate_record_value(self.repo, "TASK-0001", value, ROOT) + value["source_fallbacks"] = [{ + "action": "READ_SOURCE", + "path": "src/widget.py", + "observed_sha256": digest, + "base_commit": None, + "head_commit": None, + "diff_hash": None, + "purpose": "confirm pending CodeGraph content", + }] + self.assertEqual( + validate_record_value(self.repo, "TASK-0001", value, ROOT)["record_version"], + 2, + ) + + def test_v2_freshness_statuses_require_consistent_stale_points(self) -> None: + self.initialize_task() + value = self.v2_record() + index_point = { + "scope": "INDEX", + "path": None, + "reason": "INDEX_FAILED", + "fallback": "SEARCH_SOURCE", + "observed_sha256": None, + } + search = { + "action": "SEARCH_SOURCE", + "path": None, + "observed_sha256": None, + "base_commit": None, + "head_commit": None, + "diff_hash": None, + "purpose": "find affected source", + } + value["status"] = "SKIPPED" + value["freshness"] = { + "status": "CURRENT_AT_CHECK", + "checked_at": "2026-08-18T00:00:00Z", + "basis": ["STATUS_JSON"], + "stale_points": [index_point], + } + value["source_fallbacks"] = [search] + with self.assertRaisesRegex(RuleFailure, "CURRENT_AT_CHECK"): + validate_record_value(self.repo, "TASK-0001", value, ROOT) + value["freshness"]["status"] = "PARTIAL_STALE" + with self.assertRaisesRegex(RuleFailure, "PARTIAL_STALE"): + validate_record_value(self.repo, "TASK-0001", value, ROOT) + value["freshness"]["status"] = "INDEX_STALE" + self.assertEqual( + validate_record_value(self.repo, "TASK-0001", value, ROOT)["record_version"], + 2, + ) + + def test_v2_fallback_and_sync_rules_are_auditable(self) -> None: + self.initialize_task() + source = self.repo / "src/widget.py" + source.parent.mkdir() + source.write_text("value = 1\n", encoding="utf-8") + value = self.v2_record() + digest = file_sha256(source) + value["status"] = "SKIPPED" + value["freshness"] = { + "status": "PARTIAL_STALE", + "checked_at": "2026-08-18T00:00:00Z", + "basis": ["RESPONSE_BANNER"], + "stale_points": [{ + "scope": "FILE", "path": "src/widget.py", "reason": "PENDING_SYNC", + "fallback": "READ_SOURCE", "observed_sha256": digest, + }], + } + value["source_fallbacks"] = [{ + "action": "READ_SOURCE", "path": "src/widget.py", "observed_sha256": "0" * 64, + "base_commit": None, "head_commit": None, "diff_hash": None, "purpose": "read source", + }] + with self.assertRaisesRegex(RuleFailure, "READ_SOURCE fallback hash is stale"): + validate_record_value(self.repo, "TASK-0001", value, ROOT) + value["source_fallbacks"][0]["observed_sha256"] = digest + value["source_fallbacks"][0]["action"] = "INSPECT_GIT_DIFF" + value["source_fallbacks"][0]["observed_sha256"] = None + with self.assertRaisesRegex(RuleFailure, "INSPECT_GIT_DIFF fallback requires"): + validate_record_value(self.repo, "TASK-0001", value, ROOT) + value = self.v2_record() + value["sync"] = {"status": "SUCCESS", "response_sha256": None, "error": None} + with self.assertRaisesRegex(RuleFailure, "successful Code Intelligence sync requires"): + validate_record_value(self.repo, "TASK-0001", value, ROOT) + value["sync"]["response_sha256"] = "0" * 64 + with self.assertRaisesRegex(RuleFailure, "SYNC_ACKNOWLEDGED"): + validate_record_value(self.repo, "TASK-0001", value, ROOT) + + def test_v2_unavailable_and_provider_capabilities_are_restricted(self) -> None: + self.initialize_task() + value = self.v2_record() + value["queries"] = [{ + "id": "CIQ-001", "operation": "explore", "purpose": "query", "status": "SUCCESS", + "summary": "result", "symbols": [], "response_sha256": "0" * 64, "error": None, + }] + with self.assertRaisesRegex(RuleFailure, "UNAVAILABLE record contains"): + validate_record_value(self.repo, "TASK-0001", value, ROOT) + value = self.v2_record() + value["provider"] = { + "id": "codegraph", "descriptor_version": 2, "transport": "mcp", + "available_operations": ["symbol_search"], + } + with self.assertRaises(RuleFailure): + validate_record_value(self.repo, "TASK-0001", value, ROOT) + def test_official_descriptor_uses_explore_status_and_sync(self) -> None: descriptor = load_providers(ROOT)["codegraph"] self.assertEqual(descriptor["provider_version"], 2) diff --git a/tests/test_core.py b/tests/test_core.py index 29e4916..ad07160 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1568,19 +1568,23 @@ def test_code_intelligence_auto_detects_available_operations_and_can_be_disabled validate_static_configuration(self.repo, ROOT)["mode"], "disabled" ) - def test_code_intelligence_v1_record_is_compact_safe_and_immutable(self) -> None: + def test_code_intelligence_v1_record_is_read_only_historical_evidence(self) -> None: """v1 精简记录升级后仍可读取,并绑定任务、提交和安全路径。""" base = run_git(self.repo, "rev-parse", "HEAD") value = read_json( ROOT / "templates" / "task-sources" / "code-intelligence-record.json" ) + value["record_version"] = 1 + value.pop("sync") + value.pop("freshness") + value.pop("source_fallbacks") + value["refresh"] = None value["target"]["base_commit"] = base - result = record_code_intelligence( - self.repo, "TASK-0001", value, ROOT + self.assertEqual( + validate_record_value(self.repo, "TASK-0001", value, ROOT)["status"], + "UNAVAILABLE", ) - self.assertEqual(result["status"], "UNAVAILABLE") - self.assertTrue(Path(result["path"]).is_file()) - with self.assertRaises(InputFailure): + with self.assertRaisesRegex(InputFailure, "record_version 2"): record_code_intelligence(self.repo, "TASK-0001", value, ROOT) invalid = copy.deepcopy(value) From d03a1ece7b06041b20da77fb121386af0ed64ce7 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 15:05:58 +0800 Subject: [PATCH 12/41] fix: constrain CodeGraph fallback evidence --- .../internal/code_intelligence_protocol.py | 7 +- tests/test_codegraph.py | 105 +++++++++++++++++- 2 files changed, 109 insertions(+), 3 deletions(-) diff --git a/scripts/internal/code_intelligence_protocol.py b/scripts/internal/code_intelligence_protocol.py index a61ed73..1bd5544 100644 --- a/scripts/internal/code_intelligence_protocol.py +++ b/scripts/internal/code_intelligence_protocol.py @@ -463,7 +463,8 @@ def _validate_source_fallbacks( elif action == "INSPECT_GIT_DIFF": if not isinstance(path, str) or digest is not None: raise RuleFailure("INSPECT_GIT_DIFF fallback requires a null SHA") - resolve_repo_reference(repo, path) + if resolve_repo_reference(repo, path).is_file(): + raise RuleFailure("INSPECT_GIT_DIFF fallback cannot describe an existing file") if head is None or ( fallback["base_commit"] != base or fallback["head_commit"] != head @@ -572,6 +573,10 @@ def _validate_v2_record_value( raise RuleFailure(f"Code Intelligence symbol path is not a file: {symbol['path']}") sync = value["sync"] if sync is not None: + if sync["status"] != "UNAVAILABLE" and ( + provider is None or "sync" not in provider["available_operations"] + ): + raise RuleFailure("Code Intelligence sync evidence requires sync capability") if sync["status"] == "SUCCESS": if sync["response_sha256"] is None or "SYNC_ACKNOWLEDGED" not in value["freshness"]["basis"]: raise RuleFailure("successful Code Intelligence sync requires a response hash and SYNC_ACKNOWLEDGED basis") diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index ddc9647..d253d81 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -21,7 +21,12 @@ select_provider, validate_record_value, ) -from internal.polaris_core import InputFailure, RuleFailure, file_sha256 # noqa: E402 +from internal.polaris_core import ( # noqa: E402 + InputFailure, + RuleFailure, + file_sha256, + subject_diff_hash, +) def completed( @@ -241,9 +246,13 @@ def test_v2_fallback_and_sync_rules_are_auditable(self) -> None: value["source_fallbacks"][0]["observed_sha256"] = digest value["source_fallbacks"][0]["action"] = "INSPECT_GIT_DIFF" value["source_fallbacks"][0]["observed_sha256"] = None - with self.assertRaisesRegex(RuleFailure, "INSPECT_GIT_DIFF fallback requires"): + with self.assertRaisesRegex(RuleFailure, "cannot describe an existing file"): validate_record_value(self.repo, "TASK-0001", value, ROOT) value = self.v2_record() + value["provider"] = { + "id": "codegraph", "descriptor_version": 2, "transport": "mcp", + "available_operations": ["sync"], + } value["sync"] = {"status": "SUCCESS", "response_sha256": None, "error": None} with self.assertRaisesRegex(RuleFailure, "successful Code Intelligence sync requires"): validate_record_value(self.repo, "TASK-0001", value, ROOT) @@ -268,6 +277,98 @@ def test_v2_unavailable_and_provider_capabilities_are_restricted(self) -> None: with self.assertRaises(RuleFailure): validate_record_value(self.repo, "TASK-0001", value, ROOT) + def test_inspect_git_diff_is_only_valid_for_missing_stale_files(self) -> None: + self.initialize_task() + base = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=self.repo, capture_output=True, + check=True, encoding="utf-8", text=True, + ).stdout.strip() + source = self.repo / "src/widget.py" + source.parent.mkdir() + source.write_text("value = 1\n", encoding="utf-8") + subprocess.run(["git", "add", "src/widget.py"], cwd=self.repo, check=True) + subprocess.run( + ["git", "commit", "-q", "-m", "add widget"], cwd=self.repo, check=True + ) + added_head = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=self.repo, capture_output=True, + check=True, encoding="utf-8", text=True, + ).stdout.strip() + value = self.v2_record() + value.update({ + "status": "SKIPPED", + "target": { + "base_commit": base, + "head_commit": added_head, + "diff_hash": subject_diff_hash(self.repo, base, added_head), + }, + "freshness": { + "status": "PARTIAL_STALE", + "checked_at": "2026-08-18T00:00:00Z", + "basis": ["RESPONSE_BANNER"], + "stale_points": [{ + "scope": "FILE", "path": "src/widget.py", "reason": "PENDING_SYNC", + "fallback": "INSPECT_GIT_DIFF", "observed_sha256": None, + }], + }, + "source_fallbacks": [{ + "action": "INSPECT_GIT_DIFF", "path": "src/widget.py", + "observed_sha256": None, "base_commit": base, "head_commit": added_head, + "diff_hash": subject_diff_hash(self.repo, base, added_head), "purpose": "inspect change", + }], + }) + with self.assertRaisesRegex(RuleFailure, "existing file"): + validate_record_value(self.repo, "TASK-0001", value, ROOT) + source.unlink() + subprocess.run(["git", "add", "-u"], cwd=self.repo, check=True) + subprocess.run( + ["git", "commit", "-q", "-m", "delete widget"], cwd=self.repo, check=True + ) + deleted_head = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=self.repo, capture_output=True, + check=True, encoding="utf-8", text=True, + ).stdout.strip() + value["target"] = { + "base_commit": base, + "head_commit": deleted_head, + "diff_hash": subject_diff_hash(self.repo, base, deleted_head), + } + value["source_fallbacks"][0].update({ + "base_commit": base, + "head_commit": deleted_head, + "diff_hash": subject_diff_hash(self.repo, base, deleted_head), + }) + self.assertEqual( + validate_record_value(self.repo, "TASK-0001", value, ROOT)["record_version"], 2 + ) + + def test_sync_evidence_requires_advertised_sync_capability(self) -> None: + self.initialize_task() + value = self.v2_record() + value.update({ + "status": "USED", + "provider": { + "id": "codegraph", "descriptor_version": 2, "transport": "mcp", + "available_operations": ["status"], + }, + "sync": {"status": "SUCCESS", "response_sha256": "0" * 64, "error": None}, + "freshness": { + "status": "CURRENT_AT_CHECK", + "checked_at": "2026-08-18T00:00:00Z", + "basis": ["SYNC_ACKNOWLEDGED"], + "stale_points": [], + }, + }) + with self.assertRaisesRegex(RuleFailure, "sync capability"): + validate_record_value(self.repo, "TASK-0001", value, ROOT) + value["provider"] = { + "id": "codegraph", "descriptor_version": 2, "transport": "mcp", + "available_operations": ["sync"], + } + self.assertEqual( + validate_record_value(self.repo, "TASK-0001", value, ROOT)["record_version"], 2 + ) + def test_official_descriptor_uses_explore_status_and_sync(self) -> None: descriptor = load_providers(ROOT)["codegraph"] self.assertEqual(descriptor["provider_version"], 2) From fe6ed7de87ffe57b0d77ce4c9adad1394cbb6922 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 15:10:15 +0800 Subject: [PATCH 13/41] fix: require CodeGraph freshness evidence --- .../internal/code_intelligence_protocol.py | 21 ++++++++++ tests/test_codegraph.py | 38 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/scripts/internal/code_intelligence_protocol.py b/scripts/internal/code_intelligence_protocol.py index 1bd5544..2995b2f 100644 --- a/scripts/internal/code_intelligence_protocol.py +++ b/scripts/internal/code_intelligence_protocol.py @@ -516,8 +516,29 @@ def _validate_v2_freshness( if _matching_fallback(fallbacks, "SEARCH_SOURCE", None, None) is None: raise RuleFailure("stale point requires a matching source fallback") status = freshness["status"] + basis = freshness["basis"] + if status != "UNAVAILABLE" and "NONE" in basis: + raise RuleFailure("freshness basis NONE is reserved for UNAVAILABLE") if status == "CURRENT_AT_CHECK" and points: raise RuleFailure("CURRENT_AT_CHECK cannot contain stale points") + if status == "CURRENT_AT_CHECK" and "STATUS_JSON" not in basis: + connect_response = any( + query["status"] == "SUCCESS" and query["response_sha256"] is not None + for query in value["queries"] + ) + sync_acknowledged = ( + value["sync"] is not None + and value["sync"]["status"] == "SUCCESS" + and "SYNC_ACKNOWLEDGED" in basis + ) + if not ( + sync_acknowledged + or ("CONNECT_RECONCILIATION" in basis and connect_response) + ): + raise RuleFailure( + "CURRENT_AT_CHECK requires STATUS_JSON, successful sync acknowledgement, " + "or confirmed connection response evidence" + ) if status == "PARTIAL_STALE" and (not file_points or index_points): raise RuleFailure("PARTIAL_STALE requires only file stale points") if status == "INDEX_STALE" and not index_points: diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index d253d81..14b8863 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -369,6 +369,44 @@ def test_sync_evidence_requires_advertised_sync_capability(self) -> None: validate_record_value(self.repo, "TASK-0001", value, ROOT)["record_version"], 2 ) + def test_current_freshness_requires_a_real_non_none_evidence_path(self) -> None: + self.initialize_task() + value = self.v2_record() + value.update({ + "status": "SKIPPED", + "freshness": { + "status": "CURRENT_AT_CHECK", + "checked_at": "2026-08-18T00:00:00Z", + "basis": ["NONE"], + "stale_points": [], + }, + }) + with self.assertRaisesRegex(RuleFailure, "basis NONE is reserved"): + validate_record_value(self.repo, "TASK-0001", value, ROOT) + value["freshness"]["basis"] = ["CONNECT_RECONCILIATION"] + with self.assertRaisesRegex(RuleFailure, "CURRENT_AT_CHECK requires"): + validate_record_value(self.repo, "TASK-0001", value, ROOT) + value["freshness"]["basis"] = ["STATUS_JSON"] + self.assertEqual( + validate_record_value(self.repo, "TASK-0001", value, ROOT)["record_version"], 2 + ) + value.update({ + "status": "USED", + "provider": { + "id": "codegraph", "descriptor_version": 2, "transport": "mcp", + "available_operations": ["explore"], + }, + "queries": [{ + "id": "CIQ-001", "operation": "explore", "purpose": "confirm connection", + "status": "SUCCESS", "summary": "connected", "symbols": [], + "response_sha256": "0" * 64, "error": None, + }], + }) + value["freshness"]["basis"] = ["CONNECT_RECONCILIATION"] + self.assertEqual( + validate_record_value(self.repo, "TASK-0001", value, ROOT)["record_version"], 2 + ) + def test_official_descriptor_uses_explore_status_and_sync(self) -> None: descriptor = load_providers(ROOT)["codegraph"] self.assertEqual(descriptor["provider_version"], 2) From 080e0c85452c307d40b88304be96c16bbf238866 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 15:14:46 +0800 Subject: [PATCH 14/41] fix: require auditable CodeGraph freshness --- scripts/internal/code_intelligence_protocol.py | 12 ++---------- tests/test_codegraph.py | 5 ++--- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/scripts/internal/code_intelligence_protocol.py b/scripts/internal/code_intelligence_protocol.py index 2995b2f..3cc8391 100644 --- a/scripts/internal/code_intelligence_protocol.py +++ b/scripts/internal/code_intelligence_protocol.py @@ -522,22 +522,14 @@ def _validate_v2_freshness( if status == "CURRENT_AT_CHECK" and points: raise RuleFailure("CURRENT_AT_CHECK cannot contain stale points") if status == "CURRENT_AT_CHECK" and "STATUS_JSON" not in basis: - connect_response = any( - query["status"] == "SUCCESS" and query["response_sha256"] is not None - for query in value["queries"] - ) sync_acknowledged = ( value["sync"] is not None and value["sync"]["status"] == "SUCCESS" and "SYNC_ACKNOWLEDGED" in basis ) - if not ( - sync_acknowledged - or ("CONNECT_RECONCILIATION" in basis and connect_response) - ): + if not sync_acknowledged: raise RuleFailure( - "CURRENT_AT_CHECK requires STATUS_JSON, successful sync acknowledgement, " - "or confirmed connection response evidence" + "CURRENT_AT_CHECK requires STATUS_JSON or successful sync acknowledgement" ) if status == "PARTIAL_STALE" and (not file_points or index_points): raise RuleFailure("PARTIAL_STALE requires only file stale points") diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 14b8863..430dd53 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -403,9 +403,8 @@ def test_current_freshness_requires_a_real_non_none_evidence_path(self) -> None: }], }) value["freshness"]["basis"] = ["CONNECT_RECONCILIATION"] - self.assertEqual( - validate_record_value(self.repo, "TASK-0001", value, ROOT)["record_version"], 2 - ) + with self.assertRaisesRegex(RuleFailure, "CURRENT_AT_CHECK requires"): + validate_record_value(self.repo, "TASK-0001", value, ROOT) def test_official_descriptor_uses_explore_status_and_sync(self) -> None: descriptor = load_providers(ROOT)["codegraph"] From f62e3af21431b68ed139bc717da5c201c471897a Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 15:24:08 +0800 Subject: [PATCH 15/41] fix: bind CodeGraph status evidence --- schemas/code-intelligence-record.schema.json | 45 ++++++++++ .../internal/code_intelligence_protocol.py | 28 +++++++ .../code-intelligence-record.json | 1 + .../task/code-intelligence/r001/planning.json | 1 + tests/test_codegraph.py | 82 ++++++++++++++++--- tests/test_core.py | 1 + 6 files changed, 148 insertions(+), 10 deletions(-) diff --git a/schemas/code-intelligence-record.schema.json b/schemas/code-intelligence-record.schema.json index 428747e..3e5ecb8 100644 --- a/schemas/code-intelligence-record.schema.json +++ b/schemas/code-intelligence-record.schema.json @@ -13,6 +13,7 @@ "target", "status", "queries", + "status_check", "sync", "freshness", "source_fallbacks", @@ -213,6 +214,50 @@ } } }, + "status_check": { + "type": [ + "object", + "null" + ], + "required": [ + "status", + "phase", + "response_sha256", + "error" + ], + "additionalProperties": false, + "properties": { + "status": { + "type": "string", + "enum": [ + "SUCCESS", + "FAILED", + "SKIPPED", + "UNAVAILABLE" + ] + }, + "phase": { + "type": "string", + "enum": [ + "STAGE_ENTRY", + "POST_SYNC" + ] + }, + "response_sha256": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{64}$" + }, + "error": { + "type": [ + "string", + "null" + ] + } + } + }, "sync": { "type": [ "object", diff --git a/scripts/internal/code_intelligence_protocol.py b/scripts/internal/code_intelligence_protocol.py index 3cc8391..081a4b6 100644 --- a/scripts/internal/code_intelligence_protocol.py +++ b/scripts/internal/code_intelligence_protocol.py @@ -584,6 +584,21 @@ def _validate_v2_record_value( path = resolve_repo_reference(repo, symbol["path"]) if not path.is_file(): raise RuleFailure(f"Code Intelligence symbol path is not a file: {symbol['path']}") + status_check = value["status_check"] + if status_check is not None: + status_check_status = status_check["status"] + if status_check_status == "SUCCESS": + if status_check["response_sha256"] is None or status_check["error"] is not None: + raise RuleFailure("successful Code Intelligence status check requires a response hash and no error") + if provider is None or "status" not in provider["available_operations"]: + raise RuleFailure("successful Code Intelligence status check requires status capability") + elif status_check_status == "FAILED": + if not status_check["error"]: + raise RuleFailure("failed Code Intelligence status check lacks error") + if provider is None or "status" not in provider["available_operations"]: + raise RuleFailure("failed Code Intelligence status check requires status capability") + elif status_check["response_sha256"] is not None: + raise RuleFailure("non-successful Code Intelligence status check cannot have a response hash") sync = value["sync"] if sync is not None: if sync["status"] != "UNAVAILABLE" and ( @@ -595,6 +610,19 @@ def _validate_v2_record_value( raise RuleFailure("successful Code Intelligence sync requires a response hash and SYNC_ACKNOWLEDGED basis") if sync["status"] == "FAILED" and not sync["error"]: raise RuleFailure("failed Code Intelligence sync lacks error") + freshness = value["freshness"] + status_check_success = status_check is not None and status_check["status"] == "SUCCESS" + if freshness["status"] != "UNAVAILABLE" and "NONE" in freshness["basis"]: + raise RuleFailure("freshness basis NONE is reserved for UNAVAILABLE") + if "STATUS_JSON" in freshness["basis"] and not status_check_success: + raise RuleFailure("STATUS_JSON basis requires a successful status check") + if freshness["status"] == "CURRENT_AT_CHECK": + if not status_check_success: + raise RuleFailure("CURRENT_AT_CHECK requires a successful status check") + if sync is not None and sync["status"] == "SUCCESS" and status_check["phase"] != "POST_SYNC": + raise RuleFailure("successful sync currentness requires a POST_SYNC status check") + if value["status"] == "UNAVAILABLE" and status_check_success: + raise RuleFailure("unavailable Code Intelligence record cannot claim a successful status check") _validate_source_fallbacks(repo, value, base, head) _validate_v2_freshness(repo, value, base, head) observed_statuses = {item["status"] for item in value["queries"]} diff --git a/templates/task-sources/code-intelligence-record.json b/templates/task-sources/code-intelligence-record.json index c171a6a..7452bf6 100644 --- a/templates/task-sources/code-intelligence-record.json +++ b/templates/task-sources/code-intelligence-record.json @@ -13,6 +13,7 @@ }, "status": "UNAVAILABLE", "queries": [], + "status_check": null, "sync": null, "freshness": { "status": "UNAVAILABLE", diff --git a/templates/task/code-intelligence/r001/planning.json b/templates/task/code-intelligence/r001/planning.json index c171a6a..7452bf6 100644 --- a/templates/task/code-intelligence/r001/planning.json +++ b/templates/task/code-intelligence/r001/planning.json @@ -13,6 +13,7 @@ }, "status": "UNAVAILABLE", "queries": [], + "status_check": null, "sync": null, "freshness": { "status": "UNAVAILABLE", diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 430dd53..64d0804 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -209,6 +209,14 @@ def test_v2_freshness_statuses_require_consistent_stale_points(self) -> None: "stale_points": [index_point], } value["source_fallbacks"] = [search] + value["provider"] = { + "id": "codegraph", "descriptor_version": 2, "transport": "mcp", + "available_operations": ["status"], + } + value["status_check"] = { + "status": "SUCCESS", "phase": "STAGE_ENTRY", + "response_sha256": "0" * 64, "error": None, + } with self.assertRaisesRegex(RuleFailure, "CURRENT_AT_CHECK"): validate_record_value(self.repo, "TASK-0001", value, ROOT) value["freshness"]["status"] = "PARTIAL_STALE" @@ -270,6 +278,17 @@ def test_v2_unavailable_and_provider_capabilities_are_restricted(self) -> None: with self.assertRaisesRegex(RuleFailure, "UNAVAILABLE record contains"): validate_record_value(self.repo, "TASK-0001", value, ROOT) value = self.v2_record() + value["provider"] = { + "id": "codegraph", "descriptor_version": 2, "transport": "mcp", + "available_operations": ["status"], + } + value["status_check"] = { + "status": "SUCCESS", "phase": "STAGE_ENTRY", + "response_sha256": "0" * 64, "error": None, + } + with self.assertRaisesRegex(RuleFailure, "unavailable Code Intelligence record"): + validate_record_value(self.repo, "TASK-0001", value, ROOT) + value = self.v2_record() value["provider"] = { "id": "codegraph", "descriptor_version": 2, "transport": "mcp", "available_operations": ["symbol_search"], @@ -351,6 +370,10 @@ def test_sync_evidence_requires_advertised_sync_capability(self) -> None: "id": "codegraph", "descriptor_version": 2, "transport": "mcp", "available_operations": ["status"], }, + "status_check": { + "status": "SUCCESS", "phase": "POST_SYNC", + "response_sha256": "1" * 64, "error": None, + }, "sync": {"status": "SUCCESS", "response_sha256": "0" * 64, "error": None}, "freshness": { "status": "CURRENT_AT_CHECK", @@ -363,7 +386,7 @@ def test_sync_evidence_requires_advertised_sync_capability(self) -> None: validate_record_value(self.repo, "TASK-0001", value, ROOT) value["provider"] = { "id": "codegraph", "descriptor_version": 2, "transport": "mcp", - "available_operations": ["sync"], + "available_operations": ["sync", "status"], } self.assertEqual( validate_record_value(self.repo, "TASK-0001", value, ROOT)["record_version"], 2 @@ -386,25 +409,64 @@ def test_current_freshness_requires_a_real_non_none_evidence_path(self) -> None: value["freshness"]["basis"] = ["CONNECT_RECONCILIATION"] with self.assertRaisesRegex(RuleFailure, "CURRENT_AT_CHECK requires"): validate_record_value(self.repo, "TASK-0001", value, ROOT) - value["freshness"]["basis"] = ["STATUS_JSON"] + + def test_current_freshness_requires_hashed_status_check_evidence(self) -> None: + self.initialize_task() + value = self.v2_record() + value.update({ + "status": "SKIPPED", + "freshness": { + "status": "CURRENT_AT_CHECK", + "checked_at": "2026-08-18T00:00:00Z", + "basis": ["STATUS_JSON"], + "stale_points": [], + }, + }) + with self.assertRaisesRegex(RuleFailure, "successful status check"): + validate_record_value(self.repo, "TASK-0001", value, ROOT) + value["status_check"] = { + "status": "SUCCESS", "phase": "STAGE_ENTRY", + "response_sha256": "0" * 64, "error": None, + } + with self.assertRaisesRegex(RuleFailure, "status capability"): + validate_record_value(self.repo, "TASK-0001", value, ROOT) + value["provider"] = { + "id": "codegraph", "descriptor_version": 2, "transport": "mcp", + "available_operations": ["status"], + } self.assertEqual( validate_record_value(self.repo, "TASK-0001", value, ROOT)["record_version"], 2 ) + + def test_sync_currentness_requires_successful_post_sync_status_check(self) -> None: + self.initialize_task() + value = self.v2_record() value.update({ "status": "USED", "provider": { "id": "codegraph", "descriptor_version": 2, "transport": "mcp", - "available_operations": ["explore"], + "available_operations": ["sync", "status"], + }, + "sync": {"status": "SUCCESS", "response_sha256": "0" * 64, "error": None}, + "freshness": { + "status": "CURRENT_AT_CHECK", + "checked_at": "2026-08-18T00:00:00Z", + "basis": ["SYNC_ACKNOWLEDGED"], + "stale_points": [], }, - "queries": [{ - "id": "CIQ-001", "operation": "explore", "purpose": "confirm connection", - "status": "SUCCESS", "summary": "connected", "symbols": [], - "response_sha256": "0" * 64, "error": None, - }], }) - value["freshness"]["basis"] = ["CONNECT_RECONCILIATION"] - with self.assertRaisesRegex(RuleFailure, "CURRENT_AT_CHECK requires"): + with self.assertRaisesRegex(RuleFailure, "successful status check"): validate_record_value(self.repo, "TASK-0001", value, ROOT) + value["status_check"] = { + "status": "SUCCESS", "phase": "STAGE_ENTRY", + "response_sha256": "1" * 64, "error": None, + } + with self.assertRaisesRegex(RuleFailure, "POST_SYNC status check"): + validate_record_value(self.repo, "TASK-0001", value, ROOT) + value["status_check"]["phase"] = "POST_SYNC" + self.assertEqual( + validate_record_value(self.repo, "TASK-0001", value, ROOT)["record_version"], 2 + ) def test_official_descriptor_uses_explore_status_and_sync(self) -> None: descriptor = load_providers(ROOT)["codegraph"] diff --git a/tests/test_core.py b/tests/test_core.py index ad07160..96c83a7 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1578,6 +1578,7 @@ def test_code_intelligence_v1_record_is_read_only_historical_evidence(self) -> N value.pop("sync") value.pop("freshness") value.pop("source_fallbacks") + value.pop("status_check") value["refresh"] = None value["target"]["base_commit"] = base self.assertEqual( From 32479bcb4d9e3165041e738011ed367cf72ead70 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 15:32:12 +0800 Subject: [PATCH 16/41] fix: validate CodeGraph status failures --- .../internal/code_intelligence_protocol.py | 21 +++- tests/test_codegraph.py | 109 +++++++++++++++++- 2 files changed, 124 insertions(+), 6 deletions(-) diff --git a/scripts/internal/code_intelligence_protocol.py b/scripts/internal/code_intelligence_protocol.py index 081a4b6..81eb543 100644 --- a/scripts/internal/code_intelligence_protocol.py +++ b/scripts/internal/code_intelligence_protocol.py @@ -612,22 +612,33 @@ def _validate_v2_record_value( raise RuleFailure("failed Code Intelligence sync lacks error") freshness = value["freshness"] status_check_success = status_check is not None and status_check["status"] == "SUCCESS" + status_check_evidence = status_check is not None and status_check["status"] in { + "SUCCESS", "FAILED" + } if freshness["status"] != "UNAVAILABLE" and "NONE" in freshness["basis"]: raise RuleFailure("freshness basis NONE is reserved for UNAVAILABLE") - if "STATUS_JSON" in freshness["basis"] and not status_check_success: - raise RuleFailure("STATUS_JSON basis requires a successful status check") + if "STATUS_JSON" in freshness["basis"] and not status_check_evidence: + raise RuleFailure("STATUS_JSON basis requires structured status check evidence") if freshness["status"] == "CURRENT_AT_CHECK": if not status_check_success: raise RuleFailure("CURRENT_AT_CHECK requires a successful status check") - if sync is not None and sync["status"] == "SUCCESS" and status_check["phase"] != "POST_SYNC": - raise RuleFailure("successful sync currentness requires a POST_SYNC status check") + if sync is not None and sync["status"] == "SUCCESS": + if not status_check_success or status_check["phase"] != "POST_SYNC": + raise RuleFailure("successful sync requires a successful POST_SYNC status check") + elif status_check is not None and status_check["phase"] == "POST_SYNC": + raise RuleFailure("POST_SYNC status check requires successful sync") if value["status"] == "UNAVAILABLE" and status_check_success: raise RuleFailure("unavailable Code Intelligence record cannot claim a successful status check") _validate_source_fallbacks(repo, value, base, head) _validate_v2_freshness(repo, value, base, head) observed_statuses = {item["status"] for item in value["queries"]} sync_status = sync["status"] if sync is not None else None - if value["status"] == "FAILED" and "FAILED" not in observed_statuses and sync_status != "FAILED": + if ( + value["status"] == "FAILED" + and "FAILED" not in observed_statuses + and sync_status != "FAILED" + and (status_check is None or status_check["status"] != "FAILED") + ): raise RuleFailure("failed Code Intelligence record lacks a failed operation") if value["status"] == "USED" and not observed_statuses.intersection({"SUCCESS", "EMPTY"}) and sync_status != "SUCCESS": raise RuleFailure("used Code Intelligence record lacks a successful operation") diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 64d0804..1c46a5b 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -422,7 +422,7 @@ def test_current_freshness_requires_hashed_status_check_evidence(self) -> None: "stale_points": [], }, }) - with self.assertRaisesRegex(RuleFailure, "successful status check"): + with self.assertRaisesRegex(RuleFailure, "structured status check evidence"): validate_record_value(self.repo, "TASK-0001", value, ROOT) value["status_check"] = { "status": "SUCCESS", "phase": "STAGE_ENTRY", @@ -468,6 +468,113 @@ def test_sync_currentness_requires_successful_post_sync_status_check(self) -> No validate_record_value(self.repo, "TASK-0001", value, ROOT)["record_version"], 2 ) + def test_failed_status_check_records_adapter_not_verified_results(self) -> None: + self.initialize_task() + (self.repo / ".codegraph").mkdir() + inspect_status, _ = self.adapter_functions() + descriptor = load_providers(ROOT)["codegraph"] + for raw, returncode in (("{not json", 0), ("status failed", 3)): + with self.subTest(returncode=returncode): + result = inspect_status( + self.repo, + descriptor, + runner=lambda *args, **kwargs: completed(raw, returncode), + ) + self.assertEqual(result["status"], "NOT_VERIFIED") + self.assertEqual(result["basis"], ["STATUS_JSON"]) + self.assertEqual(result["stale_points"][0]["reason"], "STATUS_UNREADABLE") + self.assertTrue(result["error"]) + self.assertIsNotNone(result["status_response_sha256"]) + value = self.v2_record() + value.update({ + "status": "FAILED", + "provider": { + "id": "codegraph", "descriptor_version": 2, "transport": "mcp", + "available_operations": ["status"], + }, + "status_check": { + "status": "FAILED", "phase": "STAGE_ENTRY", + "response_sha256": result["status_response_sha256"], + "error": result["error"], + }, + "freshness": { + "status": result["status"], "checked_at": result["checked_at"], + "basis": result["basis"], "stale_points": result["stale_points"], + }, + "source_fallbacks": [{ + "action": "SEARCH_SOURCE", "path": None, + "observed_sha256": None, "base_commit": None, + "head_commit": None, "diff_hash": None, + "purpose": "recover unreadable CodeGraph status", + }], + }) + self.assertEqual( + validate_record_value(self.repo, "TASK-0001", value, ROOT)["record_version"], 2 + ) + + def test_sync_success_requires_post_sync_check_for_every_freshness_status(self) -> None: + self.initialize_task() + fallback = { + "action": "SEARCH_SOURCE", "path": None, "observed_sha256": None, + "base_commit": None, "head_commit": None, "diff_hash": None, + "purpose": "recover stale CodeGraph index", + } + for status, reason in (("INDEX_STALE", "INDEX_FAILED"), ("NOT_VERIFIED", "STATUS_UNREADABLE")): + with self.subTest(status=status): + value = self.v2_record() + value.update({ + "status": "USED", + "provider": { + "id": "codegraph", "descriptor_version": 2, "transport": "mcp", + "available_operations": ["sync", "status"], + }, + "status_check": { + "status": "SUCCESS", "phase": "STAGE_ENTRY", + "response_sha256": "1" * 64, "error": None, + }, + "sync": { + "status": "SUCCESS", "response_sha256": "0" * 64, "error": None, + }, + "freshness": { + "status": status, "checked_at": "2026-08-18T00:00:00Z", + "basis": ["STATUS_JSON", "SYNC_ACKNOWLEDGED"], + "stale_points": [{ + "scope": "INDEX", "path": None, "reason": reason, + "fallback": "SEARCH_SOURCE", "observed_sha256": None, + }], + }, + "source_fallbacks": [fallback], + }) + with self.assertRaisesRegex(RuleFailure, "POST_SYNC status check"): + validate_record_value(self.repo, "TASK-0001", value, ROOT) + value["status_check"]["phase"] = "POST_SYNC" + self.assertEqual( + validate_record_value(self.repo, "TASK-0001", value, ROOT)["record_version"], 2 + ) + + value = self.v2_record() + value.update({ + "status": "SKIPPED", + "provider": { + "id": "codegraph", "descriptor_version": 2, "transport": "mcp", + "available_operations": ["status"], + }, + "status_check": { + "status": "SUCCESS", "phase": "POST_SYNC", + "response_sha256": "0" * 64, "error": None, + }, + "freshness": { + "status": "INDEX_STALE", "checked_at": "2026-08-18T00:00:00Z", + "basis": ["STATUS_JSON"], "stale_points": [{ + "scope": "INDEX", "path": None, "reason": "INDEX_FAILED", + "fallback": "SEARCH_SOURCE", "observed_sha256": None, + }], + }, + "source_fallbacks": [fallback], + }) + with self.assertRaisesRegex(RuleFailure, "POST_SYNC status check requires successful sync"): + validate_record_value(self.repo, "TASK-0001", value, ROOT) + def test_official_descriptor_uses_explore_status_and_sync(self) -> None: descriptor = load_providers(ROOT)["codegraph"] self.assertEqual(descriptor["provider_version"], 2) From a59d5232837971cbae6efe8c7017b78f9a488e63 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 15:43:43 +0800 Subject: [PATCH 17/41] fix: require healthy post-sync CodeGraph status --- .../internal/code_intelligence_protocol.py | 20 +- scripts/internal/codegraph_adapter.py | 11 +- tests/test_codegraph.py | 172 +++++++++++++++++- 3 files changed, 193 insertions(+), 10 deletions(-) diff --git a/scripts/internal/code_intelligence_protocol.py b/scripts/internal/code_intelligence_protocol.py index 81eb543..97424f6 100644 --- a/scripts/internal/code_intelligence_protocol.py +++ b/scripts/internal/code_intelligence_protocol.py @@ -623,10 +623,24 @@ def _validate_v2_record_value( if not status_check_success: raise RuleFailure("CURRENT_AT_CHECK requires a successful status check") if sync is not None and sync["status"] == "SUCCESS": - if not status_check_success or status_check["phase"] != "POST_SYNC": + if ( + freshness["status"] != "CURRENT_AT_CHECK" + or not status_check_success + or status_check["phase"] != "POST_SYNC" + ): raise RuleFailure("successful sync requires a successful POST_SYNC status check") - elif status_check is not None and status_check["phase"] == "POST_SYNC": - raise RuleFailure("POST_SYNC status check requires successful sync") + if sync is not None and sync["status"] == "FAILED": + if freshness["status"] != "INDEX_STALE" or not any( + point["reason"] == "SYNC_FAILED" for point in freshness["stale_points"] + ): + raise RuleFailure("failed sync requires INDEX_STALE freshness with SYNC_FAILED") + if "SYNC_ACKNOWLEDGED" in freshness["basis"]: + raise RuleFailure("failed sync cannot claim SYNC_ACKNOWLEDGED") + if status_check is not None and status_check["phase"] == "POST_SYNC": + if sync is None or sync["status"] not in {"SUCCESS", "FAILED"}: + raise RuleFailure("POST_SYNC status check requires an attempted sync") + if sync["status"] == "FAILED" and sync["response_sha256"] is None: + raise RuleFailure("POST_SYNC status check requires a hashed sync attempt") if value["status"] == "UNAVAILABLE" and status_check_success: raise RuleFailure("unavailable Code Intelligence record cannot claim a successful status check") _validate_source_fallbacks(repo, value, base, head) diff --git a/scripts/internal/codegraph_adapter.py b/scripts/internal/codegraph_adapter.py index cfbda62..742e3b5 100644 --- a/scripts/internal/codegraph_adapter.py +++ b/scripts/internal/codegraph_adapter.py @@ -464,7 +464,7 @@ def _sync_failed( "status": "INDEX_STALE", "stale_points": points, "needs_sync": False, - "error": freshness["error"] or sync["error"], + "error": sync["error"] or freshness["error"], }, "sync": sync, } @@ -514,6 +514,13 @@ def sync_if_needed( repo, descriptor, runner=runner, timeout_seconds=status_timeout ) if rechecked["status"] != "CURRENT_AT_CHECK" or rechecked["needs_sync"]: - return _sync_failed(rechecked, sync) + return _sync_failed( + rechecked, + _sync_result( + "FAILED", + response_sha256, + "CodeGraph post-sync status is not current", + ), + ) rechecked["basis"] = [*rechecked["basis"], "SYNC_ACKNOWLEDGED"] return {"freshness": rechecked, "sync": sync} diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 1c46a5b..b867a26 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -512,14 +512,161 @@ def test_failed_status_check_records_adapter_not_verified_results(self) -> None: validate_record_value(self.repo, "TASK-0001", value, ROOT)["record_version"], 2 ) - def test_sync_success_requires_post_sync_check_for_every_freshness_status(self) -> None: + def test_sync_results_project_to_v2_records_without_false_success(self) -> None: + """A sync command is successful only after a healthy post-sync status.""" + self.initialize_task() + (self.repo / ".codegraph").mkdir() + _, sync_if_needed = self.adapter_functions() + descriptor = load_providers(ROOT)["codegraph"] + pending = json.loads(healthy_status(self.repo)) + pending["pendingChanges"]["modified"] = 1 + + def runner_for( + responses: list[subprocess.CompletedProcess[str]], + ) -> object: + iterator = iter(responses) + return lambda *args, **kwargs: next(iterator) + + def project( + result: dict[str, object], status_check: dict[str, object] + ) -> dict[str, object]: + freshness = result["freshness"] + sync = result["sync"] + value = self.v2_record() + value.update({ + "status": "USED" if sync["status"] == "SUCCESS" else "FAILED", + "provider": { + "id": "codegraph", "descriptor_version": 2, "transport": "mcp", + "available_operations": ["status", "sync"], + }, + "status_check": status_check, + "sync": sync, + "freshness": { + "status": freshness["status"], "checked_at": freshness["checked_at"], + "basis": freshness["basis"], "stale_points": freshness["stale_points"], + }, + "source_fallbacks": ([{ + "action": "SEARCH_SOURCE", "path": None, + "observed_sha256": None, "base_commit": None, + "head_commit": None, "diff_hash": None, + "purpose": "recover stale CodeGraph evidence", + }] if freshness["stale_points"] else []), + }) + return value + + malformed_post = sync_if_needed( + self.repo, + descriptor, + runner=runner_for([ + completed(json.dumps(pending)), + completed("synced\n"), + completed("{not json"), + ]), + ) + self.assertEqual(malformed_post["sync"]["status"], "FAILED") + self.assertEqual(malformed_post["freshness"]["status"], "INDEX_STALE") + self.assertEqual(malformed_post["freshness"]["error"], "CodeGraph post-sync status is not current") + self.assertIn("SYNC_FAILED", [ + point["reason"] for point in malformed_post["freshness"]["stale_points"] + ]) + self.assertEqual( + validate_record_value( + self.repo, + "TASK-0001", + project(malformed_post, { + "status": "FAILED", "phase": "POST_SYNC", + "response_sha256": malformed_post["freshness"]["status_response_sha256"], + "error": malformed_post["freshness"]["error"], + }), + ROOT, + )["record_version"], + 2, + ) + + unhealthy = json.loads(healthy_status(self.repo)) + unhealthy["index"]["state"] = "failed" + unhealthy_post = sync_if_needed( + self.repo, + descriptor, + runner=runner_for([ + completed(json.dumps(pending)), + completed("synced\n"), + completed(json.dumps(unhealthy)), + ]), + ) + self.assertEqual(unhealthy_post["sync"]["status"], "FAILED") + self.assertEqual(unhealthy_post["freshness"]["status"], "INDEX_STALE") + self.assertEqual(unhealthy_post["freshness"]["error"], "CodeGraph post-sync status is not current") + self.assertEqual( + validate_record_value( + self.repo, + "TASK-0001", + project(unhealthy_post, { + "status": "SUCCESS", "phase": "POST_SYNC", + "response_sha256": unhealthy_post["freshness"]["status_response_sha256"], + "error": None, + }), + ROOT, + )["record_version"], + 2, + ) + + healthy_post = sync_if_needed( + self.repo, + descriptor, + runner=runner_for([ + completed(json.dumps(pending)), + completed("synced\n"), + completed(healthy_status(self.repo)), + ]), + ) + self.assertEqual(healthy_post["sync"]["status"], "SUCCESS") + self.assertEqual( + validate_record_value( + self.repo, + "TASK-0001", + project(healthy_post, { + "status": "SUCCESS", "phase": "POST_SYNC", + "response_sha256": healthy_post["freshness"]["status_response_sha256"], + "error": None, + }), + ROOT, + )["record_version"], + 2, + ) + + raw_failure = sync_if_needed( + self.repo, + descriptor, + runner=runner_for([ + completed(json.dumps(pending)), + completed("sync failed", returncode=1), + ]), + ) + self.assertEqual(raw_failure["sync"]["status"], "FAILED") + self.assertEqual(raw_failure["freshness"]["status"], "INDEX_STALE") + self.assertEqual( + validate_record_value( + self.repo, + "TASK-0001", + project(raw_failure, { + "status": "SUCCESS", "phase": "STAGE_ENTRY", + "response_sha256": raw_failure["freshness"]["status_response_sha256"], + "error": None, + }), + ROOT, + )["record_version"], + 2, + ) + + def test_noncurrent_post_sync_check_downgrades_sync_evidence(self) -> None: self.initialize_task() fallback = { "action": "SEARCH_SOURCE", "path": None, "observed_sha256": None, "base_commit": None, "head_commit": None, "diff_hash": None, "purpose": "recover stale CodeGraph index", } - for status, reason in (("INDEX_STALE", "INDEX_FAILED"), ("NOT_VERIFIED", "STATUS_UNREADABLE")): + for status, reason in (("INDEX_STALE", "INDEX_FAILED"),): with self.subTest(status=status): value = self.v2_record() value.update({ @@ -548,6 +695,18 @@ def test_sync_success_requires_post_sync_check_for_every_freshness_status(self) with self.assertRaisesRegex(RuleFailure, "POST_SYNC status check"): validate_record_value(self.repo, "TASK-0001", value, ROOT) value["status_check"]["phase"] = "POST_SYNC" + with self.assertRaisesRegex(RuleFailure, "successful sync requires"): + validate_record_value(self.repo, "TASK-0001", value, ROOT) + value["sync"] = { + "status": "FAILED", "response_sha256": "0" * 64, + "error": "CodeGraph post-sync status is not current", + } + value["status"] = "FAILED" + value["freshness"]["basis"] = ["STATUS_JSON"] + value["freshness"]["stale_points"].append({ + "scope": "INDEX", "path": None, "reason": "SYNC_FAILED", + "fallback": "SEARCH_SOURCE", "observed_sha256": None, + }) self.assertEqual( validate_record_value(self.repo, "TASK-0001", value, ROOT)["record_version"], 2 ) @@ -572,7 +731,7 @@ def test_sync_success_requires_post_sync_check_for_every_freshness_status(self) }, "source_fallbacks": [fallback], }) - with self.assertRaisesRegex(RuleFailure, "POST_SYNC status check requires successful sync"): + with self.assertRaisesRegex(RuleFailure, "POST_SYNC status check requires an attempted sync"): validate_record_value(self.repo, "TASK-0001", value, ROOT) def test_official_descriptor_uses_explore_status_and_sync(self) -> None: @@ -853,7 +1012,7 @@ def runner( result["freshness"]["stale_points"][-1]["reason"], "SYNC_FAILED" ) - def test_unhealthy_recheck_preserves_index_reason_and_adds_sync_failure(self) -> None: + def test_unhealthy_recheck_downgrades_sync_and_preserves_index_reason(self) -> None: _, sync_if_needed = self.adapter_functions() (self.repo / ".codegraph").mkdir() pending = json.loads(healthy_status(self.repo)) @@ -880,7 +1039,10 @@ def runner( ) self.assertEqual([call[1] for call in calls], ["status", "sync", "status"]) - self.assertEqual(result["sync"]["status"], "SUCCESS") + self.assertEqual(result["sync"]["status"], "FAILED") + self.assertEqual( + result["sync"]["error"], "CodeGraph post-sync status is not current" + ) self.assertEqual(result["freshness"]["status"], "INDEX_STALE") self.assertEqual( [point["reason"] for point in result["freshness"]["stale_points"]], From e1f6fa303690145b20e6cb7af66e0aad3aa15d1a Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 15:49:06 +0800 Subject: [PATCH 18/41] fix: represent unavailable CodeGraph syncs --- .../internal/code_intelligence_protocol.py | 9 ++- scripts/internal/codegraph_adapter.py | 3 + tests/test_codegraph.py | 76 +++++++++++++++++++ 3 files changed, 87 insertions(+), 1 deletion(-) diff --git a/scripts/internal/code_intelligence_protocol.py b/scripts/internal/code_intelligence_protocol.py index 97424f6..6d1c35d 100644 --- a/scripts/internal/code_intelligence_protocol.py +++ b/scripts/internal/code_intelligence_protocol.py @@ -542,7 +542,10 @@ def _validate_v2_freshness( if status == "UNAVAILABLE": if freshness["basis"] != ["NONE"] or points: raise RuleFailure("UNAVAILABLE freshness must use NONE with no stale points") - if value["sync"] is not None or any( + if ( + value["sync"] is not None + and value["sync"]["status"] != "UNAVAILABLE" + ) or any( item["status"] != "UNAVAILABLE" for item in value["queries"] ): raise RuleFailure("UNAVAILABLE record contains an attempted query or sync") @@ -610,6 +613,10 @@ def _validate_v2_record_value( raise RuleFailure("successful Code Intelligence sync requires a response hash and SYNC_ACKNOWLEDGED basis") if sync["status"] == "FAILED" and not sync["error"]: raise RuleFailure("failed Code Intelligence sync lacks error") + if sync["status"] == "UNAVAILABLE" and ( + sync["response_sha256"] is not None or sync["error"] is not None + ): + raise RuleFailure("unavailable Code Intelligence sync cannot contain response or error evidence") freshness = value["freshness"] status_check_success = status_check is not None and status_check["status"] == "SUCCESS" status_check_evidence = status_check is not None and status_check["status"] in { diff --git a/scripts/internal/codegraph_adapter.py b/scripts/internal/codegraph_adapter.py index 742e3b5..d1f1e87 100644 --- a/scripts/internal/codegraph_adapter.py +++ b/scripts/internal/codegraph_adapter.py @@ -489,6 +489,9 @@ def sync_if_needed( repo, descriptor, runner=runner, timeout_seconds=status_timeout ) skipped = _sync_result("SKIPPED", None, None) + unavailable = _sync_result("UNAVAILABLE", None, None) + if initial["status"] == "UNAVAILABLE" or _marker_path(repo, descriptor) is None: + return {"freshness": initial, "sync": unavailable} if not initial["needs_sync"]: return {"freshness": initial, "sync": skipped} diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index b867a26..e000aad 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -296,6 +296,82 @@ def test_v2_unavailable_and_provider_capabilities_are_restricted(self) -> None: with self.assertRaises(RuleFailure): validate_record_value(self.repo, "TASK-0001", value, ROOT) + for sync in ( + {"status": "SKIPPED", "response_sha256": None, "error": None}, + {"status": "SUCCESS", "response_sha256": "0" * 64, "error": None}, + {"status": "FAILED", "response_sha256": None, "error": "sync failed"}, + {"status": "UNAVAILABLE", "response_sha256": "0" * 64, "error": None}, + {"status": "UNAVAILABLE", "response_sha256": None, "error": "unavailable"}, + ): + with self.subTest(sync=sync["status"]): + value = self.v2_record() + value["sync"] = sync + with self.assertRaises(RuleFailure): + validate_record_value(self.repo, "TASK-0001", value, ROOT) + + def test_unavailable_sync_results_project_to_v2_records(self) -> None: + self.initialize_task() + _, sync_if_needed = self.adapter_functions() + descriptor = load_providers(ROOT)["codegraph"] + + unavailable = sync_if_needed( + self.repo, + descriptor, + runner=lambda *args, **kwargs: self.fail("runner must not be called"), + ) + self.assertEqual(unavailable["freshness"]["status"], "UNAVAILABLE") + self.assertEqual(unavailable["sync"], { + "status": "UNAVAILABLE", "response_sha256": None, "error": None, + }) + value = self.v2_record() + value["sync"] = unavailable["sync"] + value["freshness"] = { + key: unavailable["freshness"][key] + for key in ("status", "checked_at", "basis", "stale_points") + } + self.assertEqual( + validate_record_value(self.repo, "TASK-0001", value, ROOT)["record_version"], 2 + ) + + unsafe_descriptor = dict(descriptor) + unsafe_descriptor["project_marker"] = ".." + unsafe = sync_if_needed( + self.repo, + unsafe_descriptor, + runner=lambda *args, **kwargs: self.fail("runner must not be called"), + ) + self.assertEqual(unsafe["freshness"]["status"], "NOT_VERIFIED") + self.assertEqual(unsafe["sync"], { + "status": "UNAVAILABLE", "response_sha256": None, "error": None, + }) + value = self.v2_record() + value.update({ + "status": "FAILED", + "provider": { + "id": "codegraph", "descriptor_version": 2, "transport": "mcp", + "available_operations": ["status"], + }, + "status_check": { + "status": "FAILED", "phase": "STAGE_ENTRY", + "response_sha256": unsafe["freshness"]["status_response_sha256"], + "error": unsafe["freshness"]["error"], + }, + "sync": unsafe["sync"], + "freshness": { + key: unsafe["freshness"][key] + for key in ("status", "checked_at", "basis", "stale_points") + }, + "source_fallbacks": [{ + "action": "SEARCH_SOURCE", "path": None, + "observed_sha256": None, "base_commit": None, + "head_commit": None, "diff_hash": None, + "purpose": "recover unavailable CodeGraph evidence", + }], + }) + self.assertEqual( + validate_record_value(self.repo, "TASK-0001", value, ROOT)["record_version"], 2 + ) + def test_inspect_git_diff_is_only_valid_for_missing_stale_files(self) -> None: self.initialize_task() base = subprocess.run( From 2cad76019ff1dd3faf6d283a1fa99058167c0861 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 15:53:55 +0800 Subject: [PATCH 19/41] fix: represent unavailable CodeGraph status checks --- .../internal/code_intelligence_protocol.py | 13 +++++- scripts/internal/codegraph_adapter.py | 2 +- tests/test_codegraph.py | 40 +++++++++++++------ 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/scripts/internal/code_intelligence_protocol.py b/scripts/internal/code_intelligence_protocol.py index 6d1c35d..1f6c82c 100644 --- a/scripts/internal/code_intelligence_protocol.py +++ b/scripts/internal/code_intelligence_protocol.py @@ -600,6 +600,13 @@ def _validate_v2_record_value( raise RuleFailure("failed Code Intelligence status check lacks error") if provider is None or "status" not in provider["available_operations"]: raise RuleFailure("failed Code Intelligence status check requires status capability") + elif status_check_status == "UNAVAILABLE": + if ( + status_check["phase"] != "STAGE_ENTRY" + or status_check["response_sha256"] is not None + or status_check["error"] is not None + ): + raise RuleFailure("unavailable Code Intelligence status check cannot contain response or error evidence") elif status_check["response_sha256"] is not None: raise RuleFailure("non-successful Code Intelligence status check cannot have a response hash") sync = value["sync"] @@ -648,8 +655,10 @@ def _validate_v2_record_value( raise RuleFailure("POST_SYNC status check requires an attempted sync") if sync["status"] == "FAILED" and sync["response_sha256"] is None: raise RuleFailure("POST_SYNC status check requires a hashed sync attempt") - if value["status"] == "UNAVAILABLE" and status_check_success: - raise RuleFailure("unavailable Code Intelligence record cannot claim a successful status check") + if value["status"] == "UNAVAILABLE" and ( + status_check is not None and status_check["status"] != "UNAVAILABLE" + ): + raise RuleFailure("unavailable Code Intelligence record cannot claim a status check") _validate_source_fallbacks(repo, value, base, head) _validate_v2_freshness(repo, value, base, head) observed_statuses = {item["status"] for item in value["queries"]} diff --git a/scripts/internal/codegraph_adapter.py b/scripts/internal/codegraph_adapter.py index d1f1e87..87d2c81 100644 --- a/scripts/internal/codegraph_adapter.py +++ b/scripts/internal/codegraph_adapter.py @@ -423,7 +423,7 @@ def inspect_status( return _not_verified(checked_at, error) marker = _marker_path(repo, descriptor) if marker is None: - return _not_verified(checked_at, "CodeGraph descriptor has an unsafe project marker") + return _unavailable(checked_at, "CodeGraph descriptor has an unsafe project marker") if not marker.is_dir() or marker.is_symlink(): return _unavailable(checked_at, "CodeGraph project marker is unavailable") try: diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index e000aad..4124080 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -296,6 +296,29 @@ def test_v2_unavailable_and_provider_capabilities_are_restricted(self) -> None: with self.assertRaises(RuleFailure): validate_record_value(self.repo, "TASK-0001", value, ROOT) + for status_check in ( + {"status": "FAILED", "phase": "STAGE_ENTRY", "response_sha256": None, "error": "status failed"}, + {"status": "SKIPPED", "phase": "STAGE_ENTRY", "response_sha256": None, "error": None}, + ): + with self.subTest(status_check=status_check["status"]): + value = self.v2_record() + value["provider"] = { + "id": "codegraph", "descriptor_version": 2, "transport": "mcp", + "available_operations": ["status"], + } + value["status_check"] = status_check + with self.assertRaisesRegex(RuleFailure, "unavailable Code Intelligence record"): + validate_record_value(self.repo, "TASK-0001", value, ROOT) + + value = self.v2_record() + value["status_check"] = { + "status": "UNAVAILABLE", "phase": "STAGE_ENTRY", + "response_sha256": None, "error": None, + } + self.assertEqual( + validate_record_value(self.repo, "TASK-0001", value, ROOT)["record_version"], 2 + ) + for sync in ( {"status": "SKIPPED", "response_sha256": None, "error": None}, {"status": "SUCCESS", "response_sha256": "0" * 64, "error": None}, @@ -340,22 +363,12 @@ def test_unavailable_sync_results_project_to_v2_records(self) -> None: unsafe_descriptor, runner=lambda *args, **kwargs: self.fail("runner must not be called"), ) - self.assertEqual(unsafe["freshness"]["status"], "NOT_VERIFIED") + self.assertEqual(unsafe["freshness"]["status"], "UNAVAILABLE") self.assertEqual(unsafe["sync"], { "status": "UNAVAILABLE", "response_sha256": None, "error": None, }) value = self.v2_record() value.update({ - "status": "FAILED", - "provider": { - "id": "codegraph", "descriptor_version": 2, "transport": "mcp", - "available_operations": ["status"], - }, - "status_check": { - "status": "FAILED", "phase": "STAGE_ENTRY", - "response_sha256": unsafe["freshness"]["status_response_sha256"], - "error": unsafe["freshness"]["error"], - }, "sync": unsafe["sync"], "freshness": { key: unsafe["freshness"][key] @@ -1058,8 +1071,9 @@ def test_unsafe_marker_does_not_run_codegraph(self) -> None: runner=lambda *args, **kwargs: self.fail("runner must not be called"), ) - self.assertEqual(result["status"], "NOT_VERIFIED") - self.assertEqual(result["stale_points"][0]["reason"], "STATUS_UNREADABLE") + self.assertEqual(result["status"], "UNAVAILABLE") + self.assertEqual(result["basis"], ["NONE"]) + self.assertEqual(result["stale_points"], []) def test_failed_sync_marks_the_index_stale_without_retrying(self) -> None: _, sync_if_needed = self.adapter_functions() From 917fcefd70fde56b2bd7c8dfdfbd8b0c12097b01 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 16:01:43 +0800 Subject: [PATCH 20/41] feat: guide agents through stale CodeGraph data --- skills/adversarial-review/SKILL.md | 6 ++-- skills/architecture-planning/SKILL.md | 4 ++- skills/code-intelligence/SKILL.md | 25 ++++++------- skills/documentation-sync/SKILL.md | 4 ++- skills/implementation/SKILL.md | 6 ++-- templates/AGENTS.md | 8 +++++ tests/test_codegraph.py | 51 +++++++++++++++++++++++++++ 7 files changed, 86 insertions(+), 18 deletions(-) diff --git a/skills/adversarial-review/SKILL.md b/skills/adversarial-review/SKILL.md index 1b6beae..b6f36c0 100644 --- a/skills/adversarial-review/SKILL.md +++ b/skills/adversarial-review/SKILL.md @@ -10,14 +10,16 @@ For R1/R2, run only in the fresh Reviewer context defined by the active host ada 1. Run `recover_task.py --repo . --json` and require state `REVIEWING`. 2. Require an explicit Reviewer slot and registered `review_handoff` path from the dispatcher. Load only that handoff and its package paths. Do not use implementer explanations, prior chat, another Reviewer's artifact, or an expected verdict. 3. Verify handoff hashes, task revision, Review attempt, exact subject commits/diff hash, and the required isolation mode. -4. Assign a reviewer session ID distinct from the implementer for R1/R2. Attest truthfully to isolation and chat-history inheritance; do not fabricate independence. Invoke `{{skill:code-intelligence}}` for optional independent `review_context` and impact observations derived only from the registered subject. Do not reuse Implementer query conclusions. Missing or failing provider operations immediately fall back to the original frozen-package and source review. +4. Assign a reviewer session ID distinct from the implementer for R1/R2. Attest truthfully to isolation and chat-history inheritance; do not fabricate independence. At the Review boundary invoke `{{skill:code-intelligence}}` for optional independent registered-subject relationships, first using `status` or `sync-if-needed`. Use CodeGraph only with an existing `.codegraph/` directory: prefer `codegraph_explore`, with `codegraph explore` as the non-MCP fallback. A bounded `codegraph sync` is non-blocking. Do not reuse Implementer query conclusions. Missing or failing Provider output immediately falls back to the original frozen-package and source review. 5. Check specification compliance first: correct problem, scope, exclusions, constraints, and every acceptance criterion. 6. Check engineering quality second: correctness, failure paths, lifetime, concurrency, security, performance, compatibility, maintainability, test gaps, and counterexamples. 7. Preserve every prior Finding ID in a follow-up Review. Read the registered author response, recheck the entire new patch, and record a concrete `reviewer_resolution` for each carried Finding. 8. Give new Findings monotonic IDs and mark critical/high, acceptance failures, and scope violations as blocking. -9. Finalize a Review Code Intelligence record, including `UNAVAILABLE`, `FAILED`, or `SKIPPED` when appropriate. Resolve the output with `task_layout.review_path` from the handoff revision, attempt, and Reviewer slot. Write a new immutable Review JSON bound to the handoff, slot, session attestation, and optional record. Never assemble the path independently or overwrite an existing artifact. Reject while any blocking Finding remains open; Code Intelligence cannot determine the verdict. +9. Finalize an immutable v2 Review Code Intelligence record, including `UNAVAILABLE`, `FAILED`, or `SKIPPED` when appropriate. Resolve the output with `task_layout.review_path` from the handoff revision, attempt, and Reviewer slot. Write a new immutable Review JSON bound to the handoff, slot, session attestation, and optional record. Never assemble the path independently or overwrite an existing artifact. Reject while any blocking Finding remains open; Code Intelligence cannot determine the verdict. 10. Return the verdict and exact Review path to the dispatching `{{skill:engineering-task}}` context. Do not run `ACCEPT_REVIEW` or `REJECT_REVIEW`; the dispatcher validates and registers all required Review artifacts before applying the graph transition. Never modify implementation code or start another Reviewer task during Review. Return a concise structured result to the dispatcher with verdict, Review attempt, Reviewer slot, reviewer session ID, subject commits/diff hash, every Finding ID and status, and the immutable Review path. Do not emit a Polaris checkpoint marker from the child task. The dispatching context emits `[POLARIS:REVIEW_ACCEPTED]` or `[POLARIS:REVIEW_REJECTED]` with the nine fixed fields only after the corresponding transition succeeds. If isolation or handoff validation prevents review, do not write a Review; report the exact required fresh-session or handoff action to the dispatcher. Only the Reviewer context may write `ACCEPT`. + +CodeGraph fallback contract: never run `codegraph init` or manage the Provider. Save and classify each response. For `PARTIAL_STALE`, directly read each listed file and record its SHA-256. For `INDEX_STALE` or `NOT_VERIFIED`, use source search and Git evidence, then stop graph calls for this stage. Graph evidence cannot determine the Review verdict. diff --git a/skills/architecture-planning/SKILL.md b/skills/architecture-planning/SKILL.md index 47709eb..ba34a9b 100644 --- a/skills/architecture-planning/SKILL.md +++ b/skills/architecture-planning/SKILL.md @@ -6,7 +6,7 @@ description: Internal Polaris stage for an explicitly started `{{skill:engineeri # Architecture Planning 1. Read the frozen Work Item and project rules. -2. Refresh `working-set.json` with `build_working_set.py`. Invoke `{{skill:code-intelligence}}` for optional context, dependency, call-graph, and impact queries. Record a compact Planning Code Intelligence record even when no provider is available. Confirm every returned path from repository source before adding it with the query ID as `discovered_from`; provider failure immediately falls back to the original repository search path and never blocks Planning. Include `.polaris/code-intelligence.json` when it exists and the finalized Planning record in the Working Set. Record every entry as section, path, reason, and discovery source; add explicit entries only for concrete dependencies. Do not parse or create a duplicate Markdown Working Set. +2. Refresh `working-set.json` with `build_working_set.py`. At the Planning boundary invoke `{{skill:code-intelligence}}` for optional frozen-task relationship discovery, first using `status` or `sync-if-needed`. Use CodeGraph only when `.codegraph/` already exists: prefer `codegraph_explore`, with `codegraph explore` as the non-MCP fallback. A bounded `codegraph sync` is non-blocking. Record a compact v2 Planning Code Intelligence record even when unavailable. Confirm every returned path from repository source before adding it with the query ID as `discovered_from`; provider failure immediately falls back to the original repository search path and never blocks Planning. Include `.polaris/code-intelligence.json` when it exists and the finalized Planning record in the Working Set. Record every entry as section, path, reason, and discovery source; add explicit entries only for concrete dependencies. Do not parse or create a duplicate Markdown Working Set. 3. Investigate only paths justified by the task or a discovered dependency. Provider observations cannot expand frozen scope. 4. Write `PLAN.md` as a delta from `base_commit`, including alternatives, risks, affected invariants, and expected documentation changes. Keep rationale in Markdown; do not use it as decision authority. 5. Map every acceptance criterion to a planned validation command or Human check. Code Intelligence observations are not acceptance evidence. @@ -20,3 +20,5 @@ description: Internal Polaris stage for an explicitly started `{{skill:engineeri After the transition succeeds, reload state and emit `[POLARIS:PLAN_READY]` with the nine fixed `{{skill:engineering-task}}` status fields. Put the Plan, Plan decision register, Working Set, acceptance-to-validation mapping, and resolved Human decisions in the checkpoint details. Set unresolved decisions to `None`. Do not modify the frozen Work Item or start implementation from this stage. + +CodeGraph fallback contract: never run `codegraph init` or manage the Provider. Save and classify each response. For `PARTIAL_STALE`, directly read each listed file and record its SHA-256. For `INDEX_STALE` or `NOT_VERIFIED`, use source search and Git evidence, then stop graph calls for this stage. Graph evidence cannot expand scope or act as a gate. diff --git a/skills/code-intelligence/SKILL.md b/skills/code-intelligence/SKILL.md index db10938..3e1a0d6 100644 --- a/skills/code-intelligence/SKILL.md +++ b/skills/code-intelligence/SKILL.md @@ -1,24 +1,25 @@ --- name: code-intelligence -description: Internal optional Polaris stage support for querying and refreshing a repository Code Intelligence Provider. Invoke only from an active `{{skill:engineering-task}}` workflow when Planning, Implementation, Documentation Sync, or Review can benefit from indexed code relationships; never activate from ordinary requests or make provider availability a workflow gate. +description: Internal optional Polaris stage support for bounded CodeGraph relationship queries. Invoke only from an active `{{skill:engineering-task}}` workflow when Planning, Implementation, Documentation Sync, or Review can benefit from indexed code relationships; never activate from ordinary requests or make provider availability a workflow gate. --- # Code Intelligence Treat Code Intelligence as read-only, best-effort evidence. Source, Git, builds, tests, and frozen Polaris artifacts remain authority. -1. Load `.polaris/code-intelligence.json` when present; otherwise use the protocol default `auto_optional` policy. `disabled` skips all provider work. -2. Inspect the tools exposed by the current host and compare them with descriptors under the protocol root's `providers/code-intelligence/` directory. Select the first configured provider with at least one operation available. Never install, start, authenticate, or reconfigure a provider. -3. Use only the operation requested by the calling stage. Missing operations, tool errors, empty indexes, timeouts, and malformed responses are non-blocking. Record `UNAVAILABLE`, `FAILED`, `EMPTY`, or `SKIPPED`, then immediately continue with the stage's original repository search and source-reading path. -4. Bound every query to the frozen Work Item, Working Set, changed subject, or a dependency discovered from them. Confirm returned paths against the repository before using them. Provider results cannot expand frozen scope, authorize a change, satisfy acceptance, or determine a Review verdict. -5. Keep exact provider responses only below the task's ignored `runtime/code-intelligence/` directory. Finalize a compact immutable record with `record_code_intelligence.py`; store only purpose, operation, status, bounded symbol/path references, response hash, and refresh evidence. -6. For refresh planning, run `record_code_intelligence.py ... --plan-refresh` against the final subject commits. Use `refresh_files` for eligible added or modified files. Use `refresh_workspace` when eligible files were deleted or renamed. Skip refresh when no eligible code changed. -7. Report freshness only as `refresh_acknowledged` or `spot_checked`. Never claim that an external index is commit-exact. +1. Load `.polaris/code-intelligence.json` and project rules. If the policy disables Code Intelligence, record `UNAVAILABLE` and use the stage's source path. Use CodeGraph only when the repository root has an existing `.codegraph/` directory. When it is absent, record `UNAVAILABLE`, stop CodeGraph calls for this project for the session, and tell the user they may choose to initialize it; never run `codegraph init`. +2. At the calling stage's declared boundary, run `code_intelligence_runtime.py status` or `sync-if-needed`. The latter may run one bounded `codegraph sync` only when status reports pending changes; it never loops, waits for a watcher, or treats a successful command as a gate. +3. For an allowed frozen-scope relationship query, use only `codegraph_explore` when MCP exposes it. If MCP is unavailable and the executable is available, use `codegraph explore` as the non-MCP fallback. Do not select retired narrow operations. Bound the query to the Work Item, Working Set, registered subject, or a confirmed dependency; graph output cannot expand frozen scope, authorize change, satisfy acceptance, or determine a Review verdict. +4. Save each raw explore response only below the task's ignored `runtime/code-intelligence/` directory, then run `code_intelligence_runtime.py classify-response` for it. Final records contain the response hash and finite summary, never the response itself. +5. On `PARTIAL_STALE`, directly read every listed stale file from the current worktree and record its current SHA-256 with a `READ_SOURCE` fallback. The remaining graph response may still be navigation evidence, but never a conclusion about those stale files. +6. On `INDEX_STALE` or `NOT_VERIFIED`, use repository source search and Git evidence, record the `SEARCH_SOURCE` fallback, and stop repeated graph calls for that stage. On malformed, missing, or unavailable Provider output, continue the same source fallback without blocking the stage. +7. Never initialize, install, start, authenticate, or reconfigure CodeGraph. Do not manage its watcher, daemon, lock, or host MCP settings. +8. Finalize an immutable v2 Code Intelligence record with the stage's actual freshness, stale points, and source fallbacks. Code Intelligence is never a workflow gate. Stage policy: -- Planning: prefer `context`, `dependencies`, `call_graph`, and `impact`; add a returned path to the Working Set only after repository confirmation and with its query ID as `discovered_from`. -- Implementation: prefer `context` and `impact` before editing. Refresh mid-implementation only when a later declared step needs relationships from newly changed code. -- Documentation Sync: attempt the final subject refresh after the documentation checkpoint and before completing live progress. -- Review: query the frozen subject independently with `review_context` and `impact`; do not reuse Implementer conclusions. Provider observations do not violate handoff isolation when they are derived only from the registered subject. +- Planning: at the Planning boundary, request only frozen-task relationship discovery needed to justify Working Set entries; confirm every returned path in repository source and record its query ID as `discovered_from`. +- Implementation: before editing, request only handoff-scoped edit relationships. Query again mid-stage only when a later declared implementation step depends on relationships changed by the current subject. +- Documentation Sync: run `sync-if-needed` once only when the final subject changed supported source files; otherwise record `SKIPPED`. +- Review: independently request only registered-subject impact relationships. Do not reuse Implementer query conclusions. - Validation: do not invoke this Skill; use builds, tests, static checks, and Human Checks as the acceptance evidence. diff --git a/skills/documentation-sync/SKILL.md b/skills/documentation-sync/SKILL.md index da9bfdf..0db0c88 100644 --- a/skills/documentation-sync/SKILL.md +++ b/skills/documentation-sync/SKILL.md @@ -12,10 +12,12 @@ 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. Invoke `{{skill:code-intelligence}}` to plan refresh from the final subject diff. Attempt `refresh_files` for eligible additions/modifications and `refresh_workspace` for eligible deletions/renames. Record `SKIPPED`, `UNAVAILABLE`, or `FAILED` and continue when refresh cannot run. Reference the immutable Documentation Sync Code Intelligence record from the Knowledge Delta; never claim commit-exact freshness. +8. When the final subject includes supported source changes, invoke `{{skill:code-intelligence}}` once at the Documentation Sync boundary with `sync-if-needed`; otherwise record `SKIPPED`. Use CodeGraph only with an existing `.codegraph/` directory: prefer `codegraph_explore`, with `codegraph explore` as the non-MCP fallback. A bounded `codegraph sync` is non-blocking. Record `UNAVAILABLE` or `FAILED` and continue when CodeGraph cannot run. Reference the immutable v2 Documentation Sync Code Intelligence record from the Knowledge Delta; never claim commit-exact freshness. 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, 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. Do not run `SYNC_DOCS` or emit a Polaris checkpoint marker. The main `{{skill:engineering-task}}` validates and registers the artifact, advances the graph, reloads state, and emits `[POLARIS:DOCS_SYNCED]`. Do not edit Review, Validation, Result, event, or state artifacts directly. + +CodeGraph fallback contract: never run `codegraph init` or manage the Provider. Save and classify each response. For `PARTIAL_STALE`, directly read each listed file and record its SHA-256. For `INDEX_STALE` or `NOT_VERIFIED`, use source search and Git evidence, then stop graph calls for this stage. Graph evidence never gates documentation checks or state changes. diff --git a/skills/implementation/SKILL.md b/skills/implementation/SKILL.md index 41497df..7d4c859 100644 --- a/skills/implementation/SKILL.md +++ b/skills/implementation/SKILL.md @@ -6,7 +6,7 @@ description: Internal Polaris worker stage for an explicitly started `{{skill:en # Implementation 1. Require the task ID and registered Implementation handoff path returned by the main task. Load only that handoff and its package as task context; read `state.json` only to verify registration. Use paths carried by the handoff or resolved by `task_layout.py`; never reconstruct them from prose. Do not read the main conversation or infer unstated requirements. -2. Confirm state is `IMPLEMENTING`, the handoff hash matches `state.json`, and `artifact_attempt`, revision, base commit, output path, and progress paths are current. Invoke `{{skill:code-intelligence}}` before editing for optional context and impact observations derived only from the handoff package. Missing or failing provider operations immediately fall back to the original source-reading path. Refresh during Implementation only when a later declared step needs relationships from newly changed code. +2. Confirm state is `IMPLEMENTING`, the handoff hash matches `state.json`, and `artifact_attempt`, revision, base commit, output path, and progress paths are current. At the Implementation boundary invoke `{{skill:code-intelligence}}` before editing for optional handoff-scoped edit relationships, first using `status` or `sync-if-needed`. Use CodeGraph only with an existing `.codegraph/` directory: prefer `codegraph_explore`, with `codegraph explore` as the non-MCP fallback. A bounded `codegraph sync` is non-blocking. Missing or failing Provider output immediately falls back to direct source reading. Query again during Implementation only when a later declared step depends on relationships from newly changed code. 3. Generate one stable Implementer session ID for this conversation. Before changing code, use the initialized live snapshot's `DEFINE_STEPS` event to create a non-empty ordered `implementation_steps` list. Every step receives the next `STEP-NNN` ID and must reference one or more acceptance IDs from the frozen Work Item. 4. Execute steps linearly with `START_STEP`, then `COMPLETE_STEP`, `BLOCK_STEP`, or `RESUME_STEP`; use `SKIP_STEP` only with an explicit reason. Existing step identity, title, order, and acceptance bindings are immutable. Newly discovered work may only be added at the end with `APPEND_STEP`. Never edit `progress.json` directly. 5. Change only declared subject paths and protect unrelated user changes. Work in small build/test/fix loops. @@ -14,7 +14,9 @@ description: Internal Polaris worker stage for an explicitly started `{{skill:en 7. Record Plan deviations and reasons. After Review rejection, load the handoff's prior Review, answer every open Finding once in an immutable Review Response, and bind it to the new subject. 8. Run planned local checks and append reproducible evidence with `ADD_CHECK`. Never report a made-up percentage; derive completed, current, and remaining work from the ordered steps. 9. Complete or explicitly skip every step, then create a subject checkpoint commit containing scoped code, tests, build configuration, and relevant project docs only. -10. Finalize an Implementation Code Intelligence record, including `UNAVAILABLE`, `FAILED`, or `SKIPPED` when appropriate. Write the immutable Implementation JSON at the handoff's `output_path`, reference that record, bind the handoff, subject, session, deviations, and checks, and copy the exact terminal `id`, `status`, and `result` projection into `step_results`. Code Intelligence evidence is never a gate. +10. Finalize an immutable v2 Implementation Code Intelligence record, including `UNAVAILABLE`, `FAILED`, or `SKIPPED` when appropriate. Write the immutable Implementation JSON at the handoff's `output_path`, reference that record, bind the handoff, subject, session, deviations, and checks, and copy the exact terminal `id`, `status`, and `result` projection into `step_results`. Code Intelligence evidence is never a gate. 11. Use `SET_PHASE` to enter `CHECKPOINTING` only after every step is `COMPLETED` or `SKIPPED`, then return the artifact path, session ID, subject base/head, diff hash, step results, checks, deviations, Review Response path when present, and remaining Documentation Sync work. Do not run `FINISH_IMPLEMENTATION`, Documentation Sync, Review, Validation, or any completion transition. Do not emit a Polaris checkpoint marker; the main `{{skill:engineering-task}}` validates the artifact, advances the graph, and continues this task for `{{skill:documentation-sync}}`. + +CodeGraph fallback contract: never run `codegraph init` or manage the Provider. Save and classify each response. For `PARTIAL_STALE`, directly read each listed file and record its SHA-256. For `INDEX_STALE` or `NOT_VERIFIED`, use source search and Git evidence, then stop graph calls for this stage. Graph evidence never gates implementation. diff --git a/templates/AGENTS.md b/templates/AGENTS.md index faef63b..ab25698 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -12,3 +12,11 @@ - Let only the main `engineering-task` context apply workflow transitions; Implementer and Reviewer workers only write their declared artifacts. - For R1/R2 Review, stop implementation and use the registered handoff in a fresh Review task or isolated reviewer agent. - Recover a task from repository state; do not require previous chat history. + +## Optional CodeGraph rules + +- Use CodeGraph only when the repository root already contains `.codegraph/`. When it is absent, stop CodeGraph calls for this session and use repository source and Git; a user may choose to initialize CodeGraph, but agents must never run `codegraph init`. +- Prefer MCP `codegraph_explore`; when MCP is unavailable, use `codegraph explore` as the CLI fallback. A bounded `codegraph sync` may run only through the Polaris stage boundary procedure and never gates a task. +- Save and classify every graph response in task runtime. For `PARTIAL_STALE`, directly read every listed stale file and record its current SHA-256. For `INDEX_STALE` or `NOT_VERIFIED`, treat graph output only as a lead, use source search and Git evidence, and stop repeated graph calls for that stage. +- Never install, start, authenticate, reconfigure, or manage CodeGraph, its watcher, daemon, lock, or MCP settings. CodeGraph cannot expand frozen scope or replace source, Git, builds, tests, Review, Validation, or Human gates. +- Preserve any installer-managed marker block exactly as owned by that installer; Polaris does not add, edit, or remove installer marker fences. diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 4124080..90f1575 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -21,6 +21,7 @@ select_provider, validate_record_value, ) +from internal.host_adapters import discover_skills, load_host_adapters, render_skill # noqa: E402 from internal.polaris_core import ( # noqa: E402 InputFailure, RuleFailure, @@ -837,6 +838,56 @@ def test_official_descriptor_uses_explore_status_and_sync(self) -> None: ) self.assertEqual(descriptor["cli"]["sync_args"], ["sync", "--quiet"]) + def test_all_agent_surfaces_share_codegraph_fallback_rules(self) -> None: + """Stage instructions keep CodeGraph stale-data fallbacks identical per host.""" + required_fragments = ( + ".codegraph/", + "codegraph_explore", + "codegraph explore", + "codegraph sync", + "PARTIAL_STALE", + "INDEX_STALE", + "directly read", + "never run `codegraph init`", + ) + retired_operations = ( + "symbol" + "_search", + "call" + "_graph", + "review" + "_context", + "refresh" + "_files", + "refresh" + "_workspace", + ) + stage_skills = ( + "code-intelligence", + "architecture-planning", + "implementation", + "adversarial-review", + "documentation-sync", + ) + available_skills = set(discover_skills(ROOT)) + for adapter in load_host_adapters(ROOT): + for skill_name in stage_skills: + source = (ROOT / "skills" / skill_name / "SKILL.md").read_text( + encoding="utf-8" + ) + rendered = render_skill( + source, skill_name, adapter, available_skills + ) + for fragment in required_fragments: + self.assertIn(fragment, rendered, f"{adapter['host_id']}:{skill_name}") + self.assertIn("v2", rendered, f"{adapter['host_id']}:{skill_name}") + for retired in retired_operations: + self.assertNotIn(retired, rendered, f"{adapter['host_id']}:{skill_name}") + + validation = (ROOT / "skills" / "validation" / "SKILL.md").read_text( + encoding="utf-8" + ) + self.assertIn("Do not invoke Code Intelligence", validation) + + agents = (ROOT / "templates" / "AGENTS.md").read_text(encoding="utf-8") + self.assertIn("stop CodeGraph calls for this session", agents) + self.assertIn("installer-managed marker block", agents) + def test_provider_requires_marker_and_accepts_mcp_or_cli(self) -> None: self.assertIsNone( select_provider(self.repo, ["codegraph_explore"], ROOT) From e7d19929f9fef9cc475e14e643d32cc90683bdc9 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 16:06:26 +0800 Subject: [PATCH 21/41] fix: distinguish deleted CodeGraph stale paths --- skills/adversarial-review/SKILL.md | 2 +- skills/architecture-planning/SKILL.md | 2 +- skills/code-intelligence/SKILL.md | 2 +- skills/documentation-sync/SKILL.md | 2 +- skills/implementation/SKILL.md | 2 +- templates/AGENTS.md | 2 +- tests/test_codegraph.py | 18 ++++++++++++++++++ 7 files changed, 24 insertions(+), 6 deletions(-) diff --git a/skills/adversarial-review/SKILL.md b/skills/adversarial-review/SKILL.md index b6f36c0..e0cb778 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`. -CodeGraph fallback contract: never run `codegraph init` or manage the Provider. Save and classify each response. For `PARTIAL_STALE`, directly read each listed file and record its SHA-256. For `INDEX_STALE` or `NOT_VERIFIED`, use source search and Git evidence, then stop graph calls for this stage. Graph evidence cannot determine the Review verdict. +CodeGraph fallback contract: never run `codegraph init` or manage the Provider. Save and classify each response. For `PARTIAL_STALE`, if a named path is a current confined regular file, directly read it and record `READ_SOURCE` with its current SHA-256; if a safe path is missing/deleted, inspect the registered subject Git diff and record `INSPECT_GIT_DIFF` with null observed SHA-256 and bound base/head/diff evidence; for unsafe paths, record `NOT_VERIFIED` and use source search. For `INDEX_STALE` or `NOT_VERIFIED`, use source search and Git evidence, then stop graph calls for this stage. Graph evidence cannot determine the Review verdict. diff --git a/skills/architecture-planning/SKILL.md b/skills/architecture-planning/SKILL.md index ba34a9b..9413815 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. -CodeGraph fallback contract: never run `codegraph init` or manage the Provider. Save and classify each response. For `PARTIAL_STALE`, directly read each listed file and record its SHA-256. For `INDEX_STALE` or `NOT_VERIFIED`, use source search and Git evidence, then stop graph calls for this stage. Graph evidence cannot expand scope or act as a gate. +CodeGraph fallback contract: never run `codegraph init` or manage the Provider. Save and classify each response. For `PARTIAL_STALE`, if a named path is a current confined regular file, directly read it and record `READ_SOURCE` with its current SHA-256; if a safe path is missing/deleted, inspect the registered subject Git diff and record `INSPECT_GIT_DIFF` with null observed SHA-256 and bound base/head/diff evidence; for unsafe paths, record `NOT_VERIFIED` and use source search. For `INDEX_STALE` or `NOT_VERIFIED`, use source search and Git evidence, then stop graph calls for this stage. Graph evidence cannot expand scope or act as a gate. diff --git a/skills/code-intelligence/SKILL.md b/skills/code-intelligence/SKILL.md index 3e1a0d6..099c6b9 100644 --- a/skills/code-intelligence/SKILL.md +++ b/skills/code-intelligence/SKILL.md @@ -11,7 +11,7 @@ Treat Code Intelligence as read-only, best-effort evidence. Source, Git, builds, 2. At the calling stage's declared boundary, run `code_intelligence_runtime.py status` or `sync-if-needed`. The latter may run one bounded `codegraph sync` only when status reports pending changes; it never loops, waits for a watcher, or treats a successful command as a gate. 3. For an allowed frozen-scope relationship query, use only `codegraph_explore` when MCP exposes it. If MCP is unavailable and the executable is available, use `codegraph explore` as the non-MCP fallback. Do not select retired narrow operations. Bound the query to the Work Item, Working Set, registered subject, or a confirmed dependency; graph output cannot expand frozen scope, authorize change, satisfy acceptance, or determine a Review verdict. 4. Save each raw explore response only below the task's ignored `runtime/code-intelligence/` directory, then run `code_intelligence_runtime.py classify-response` for it. Final records contain the response hash and finite summary, never the response itself. -5. On `PARTIAL_STALE`, directly read every listed stale file from the current worktree and record its current SHA-256 with a `READ_SOURCE` fallback. The remaining graph response may still be navigation evidence, but never a conclusion about those stale files. +5. On `PARTIAL_STALE`, process every named path by its current safe state. If it is a current confined regular file, directly read it and record `READ_SOURCE` with its current SHA-256. If a safe path is missing/deleted, inspect the registered subject Git diff and record `INSPECT_GIT_DIFF` with null observed SHA-256 and bound base/head/diff evidence. For unsafe paths, record `NOT_VERIFIED` and use source search. The remaining graph response may still be navigation evidence, but never a conclusion about a stale path. 6. On `INDEX_STALE` or `NOT_VERIFIED`, use repository source search and Git evidence, record the `SEARCH_SOURCE` fallback, and stop repeated graph calls for that stage. On malformed, missing, or unavailable Provider output, continue the same source fallback without blocking the stage. 7. Never initialize, install, start, authenticate, or reconfigure CodeGraph. Do not manage its watcher, daemon, lock, or host MCP settings. 8. Finalize an immutable v2 Code Intelligence record with the stage's actual freshness, stale points, and source fallbacks. Code Intelligence is never a workflow gate. diff --git a/skills/documentation-sync/SKILL.md b/skills/documentation-sync/SKILL.md index 0db0c88..cd0cb4d 100644 --- a/skills/documentation-sync/SKILL.md +++ b/skills/documentation-sync/SKILL.md @@ -20,4 +20,4 @@ Do not run `SYNC_DOCS` or emit a Polaris checkpoint marker. The main `{{skill:en Do not edit Review, Validation, Result, event, or state artifacts directly. -CodeGraph fallback contract: never run `codegraph init` or manage the Provider. Save and classify each response. For `PARTIAL_STALE`, directly read each listed file and record its SHA-256. For `INDEX_STALE` or `NOT_VERIFIED`, use source search and Git evidence, then stop graph calls for this stage. Graph evidence never gates documentation checks or state changes. +CodeGraph fallback contract: never run `codegraph init` or manage the Provider. Save and classify each response. For `PARTIAL_STALE`, if a named path is a current confined regular file, directly read it and record `READ_SOURCE` with its current SHA-256; if a safe path is missing/deleted, inspect the registered subject Git diff and record `INSPECT_GIT_DIFF` with null observed SHA-256 and bound base/head/diff evidence; for unsafe paths, record `NOT_VERIFIED` and use source search. For `INDEX_STALE` or `NOT_VERIFIED`, use source search and Git evidence, then stop graph calls for this stage. Graph evidence never gates documentation checks or state changes. diff --git a/skills/implementation/SKILL.md b/skills/implementation/SKILL.md index 7d4c859..e3b96b6 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 `FINISH_IMPLEMENTATION`, Documentation Sync, Review, Validation, or any completion transition. Do not emit a Polaris checkpoint marker; the main `{{skill:engineering-task}}` validates the artifact, advances the graph, and continues this task for `{{skill:documentation-sync}}`. -CodeGraph fallback contract: never run `codegraph init` or manage the Provider. Save and classify each response. For `PARTIAL_STALE`, directly read each listed file and record its SHA-256. For `INDEX_STALE` or `NOT_VERIFIED`, use source search and Git evidence, then stop graph calls for this stage. Graph evidence never gates implementation. +CodeGraph fallback contract: never run `codegraph init` or manage the Provider. Save and classify each response. For `PARTIAL_STALE`, if a named path is a current confined regular file, directly read it and record `READ_SOURCE` with its current SHA-256; if a safe path is missing/deleted, inspect the registered subject Git diff and record `INSPECT_GIT_DIFF` with null observed SHA-256 and bound base/head/diff evidence; for unsafe paths, record `NOT_VERIFIED` and use source search. For `INDEX_STALE` or `NOT_VERIFIED`, use source search and Git evidence, then stop graph calls for this stage. Graph evidence never gates implementation. diff --git a/templates/AGENTS.md b/templates/AGENTS.md index ab25698..d182e5b 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -17,6 +17,6 @@ - Use CodeGraph only when the repository root already contains `.codegraph/`. When it is absent, stop CodeGraph calls for this session and use repository source and Git; a user may choose to initialize CodeGraph, but agents must never run `codegraph init`. - Prefer MCP `codegraph_explore`; when MCP is unavailable, use `codegraph explore` as the CLI fallback. A bounded `codegraph sync` may run only through the Polaris stage boundary procedure and never gates a task. -- Save and classify every graph response in task runtime. For `PARTIAL_STALE`, directly read every listed stale file and record its current SHA-256. For `INDEX_STALE` or `NOT_VERIFIED`, treat graph output only as a lead, use source search and Git evidence, and stop repeated graph calls for that stage. +- Save and classify every graph response in task runtime. For `PARTIAL_STALE`, if a named path is a current confined regular file, directly read it and record `READ_SOURCE` with its current SHA-256; if a safe path is missing/deleted, inspect the registered subject Git diff and record `INSPECT_GIT_DIFF` with null observed SHA-256 and bound base/head/diff evidence; for unsafe paths, record `NOT_VERIFIED` and use source search. For `INDEX_STALE` or `NOT_VERIFIED`, treat graph output only as a lead, use source search and Git evidence, and stop repeated graph calls for that stage. - Never install, start, authenticate, reconfigure, or manage CodeGraph, its watcher, daemon, lock, or MCP settings. CodeGraph cannot expand frozen scope or replace source, Git, builds, tests, Review, Validation, or Human gates. - Preserve any installer-managed marker block exactly as owned by that installer; Polaris does not add, edit, or remove installer marker fences. diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 90f1575..0ce0848 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -850,6 +850,18 @@ def test_all_agent_surfaces_share_codegraph_fallback_rules(self) -> None: "directly read", "never run `codegraph init`", ) + partial_stale_branches = ( + "current confined regular file", + "READ_SOURCE", + "current SHA-256", + "missing/deleted", + "INSPECT_GIT_DIFF", + "null observed SHA-256", + "base/head/diff evidence", + "unsafe paths", + "NOT_VERIFIED", + "source search", + ) retired_operations = ( "symbol" + "_search", "call" + "_graph", @@ -875,7 +887,10 @@ def test_all_agent_surfaces_share_codegraph_fallback_rules(self) -> None: ) for fragment in required_fragments: self.assertIn(fragment, rendered, f"{adapter['host_id']}:{skill_name}") + for fragment in partial_stale_branches: + self.assertIn(fragment, rendered, f"{adapter['host_id']}:{skill_name}") self.assertIn("v2", rendered, f"{adapter['host_id']}:{skill_name}") + self.assertNotIn("directly read every listed stale file", rendered) for retired in retired_operations: self.assertNotIn(retired, rendered, f"{adapter['host_id']}:{skill_name}") @@ -887,6 +902,9 @@ def test_all_agent_surfaces_share_codegraph_fallback_rules(self) -> None: agents = (ROOT / "templates" / "AGENTS.md").read_text(encoding="utf-8") self.assertIn("stop CodeGraph calls for this session", agents) self.assertIn("installer-managed marker block", agents) + for fragment in partial_stale_branches: + self.assertIn(fragment, agents) + self.assertNotIn("directly read every listed stale file", agents) def test_provider_requires_marker_and_accepts_mcp_or_cli(self) -> None: self.assertIsNone( From 07b8786bd6a06464415d18429cd0e7d0f3525b7e Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 16:13:26 +0800 Subject: [PATCH 22/41] feat: migrate CodeGraph evidence to protocol 0.1.19 --- VERSION | 2 +- pyproject.toml | 2 +- schemas/migration-record.schema.json | 26 +++++++ scripts/internal/migration_protocol.py | 62 ++++++++++++++- templates/project.json | 2 +- templates/task-sources/state.json | 2 +- templates/task/state.json | 2 +- tests/test_codegraph.py | 104 +++++++++++++++++++++++++ tests/test_core.py | 30 +++---- workflow/migrations.json | 9 +++ 10 files changed, 219 insertions(+), 22 deletions(-) diff --git a/VERSION b/VERSION index f8bc4c6..d8a023e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.18 +0.1.19 diff --git a/pyproject.toml b/pyproject.toml index b81d81e..b71b7c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "corona-polaris" -version = "0.1.18" +version = "0.1.19" description = "Repo-native AI engineering workflow command dispatcher" requires-python = ">=3.10" dependencies = [] diff --git a/schemas/migration-record.schema.json b/schemas/migration-record.schema.json index fc714b3..7c96b35 100644 --- a/schemas/migration-record.schema.json +++ b/schemas/migration-record.schema.json @@ -81,6 +81,32 @@ } } } + }, + "retired_code_intelligence_records": { + "type": "array", + "items": { + "type": "object", + "required": [ + "task_id", + "path", + "sha256" + ], + "additionalProperties": false, + "properties": { + "task_id": { + "type": "string", + "pattern": "^TASK-[0-9]{4}$" + }, + "path": { + "type": "string", + "minLength": 1 + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } + } } } } diff --git a/scripts/internal/migration_protocol.py b/scripts/internal/migration_protocol.py index ade690a..f8dfffe 100644 --- a/scripts/internal/migration_protocol.py +++ b/scripts/internal/migration_protocol.py @@ -5,12 +5,18 @@ from pathlib import Path from typing import Any +from .code_intelligence_protocol import ( + _record_name, + validate_legacy_record_value, + validate_record_value, +) from .polaris_core import ( InputFailure, RuleFailure, acquire_migration_lock, append_jsonl, ensure_gitignore_rule, + file_sha256, load_events_checked, read_json, rebuild_state_value, @@ -20,9 +26,15 @@ validate_json_file, write_json_atomic, ) +from .path_security import require_regular_tree from .recovery_protocol import refresh_project_index from .task_location_protocol import initialize_task_locations -from .task_layout import ARCHIVED_RUNTIME_IGNORE_PATTERN, events_path, state_path +from .task_layout import ( + ARCHIVED_RUNTIME_IGNORE_PATTERN, + code_intelligence_record_path, + events_path, + state_path, +) MIGRATIONS_ROOT = Path(".polaris/migrations") @@ -183,13 +195,55 @@ def _step_for_record( return step +def _retired_code_intelligence_records( + repo: Path, task_id: str, directory: Path, protocol_root: Path +) -> list[dict[str, str]]: + """Inventory immutable v1 records at their canonical task-local locations.""" + records_root = directory / "code-intelligence" + if not records_root.exists(): + return [] + require_regular_tree(records_root, "Code Intelligence record directory") + retired: list[dict[str, str]] = [] + for path in sorted(records_root.rglob("*")): + if path.is_dir(): + continue + if path.suffix != ".json": + raise RuleFailure(f"Code Intelligence record path is non-canonical: {path}") + value = read_json(path) + if not isinstance(value, dict): + raise RuleFailure(f"Code Intelligence record path is non-canonical: {path}") + if value.get("record_version") == 1: + value = validate_legacy_record_value(repo, task_id, value, protocol_root) + elif value.get("record_version") == 2: + value = validate_record_value(repo, task_id, value, protocol_root) + else: + raise RuleFailure(f"Code Intelligence record path is non-canonical: {path}") + expected = code_intelligence_record_path( + directory, value["work_item_revision"], _record_name(value) + ) + if path != expected: + raise RuleFailure(f"Code Intelligence record path is non-canonical: {path}") + if value["record_version"] != 1: + continue + retired.append( + { + "task_id": task_id, + "path": path.relative_to(directory).as_posix(), + "sha256": file_sha256(path), + } + ) + return retired + + def _new_record( repo: Path, + protocol_root: Path, project: dict[str, Any], step: dict[str, Any], timestamp: str, ) -> dict[str, Any]: tasks: list[dict[str, Any]] = [] + retired_code_intelligence_records: list[dict[str, str]] = [] for task_id in sorted(project["active_tasks"]): directory = task_dir(repo, task_id) state = read_json(state_path(directory)) @@ -206,6 +260,9 @@ def _new_record( "migration_sequence": state["sequence"] + 1, } ) + retired_code_intelligence_records.extend( + _retired_code_intelligence_records(repo, task_id, directory, protocol_root) + ) return { "record_version": 1, "migration_id": step["migration_id"], @@ -217,6 +274,7 @@ def _new_record( "started_at": timestamp, "completed_at": None, "tasks": tasks, + "retired_code_intelligence_records": retired_code_intelligence_records, } @@ -280,7 +338,7 @@ def migrate_project(repo: Path, protocol_root: Path) -> dict[str, Any]: raise RuleFailure( f"completed migration cannot be replayed: {step['migration_id']}" ) - record = _new_record(repo, project, step, utc_now()) + record = _new_record(repo, protocol_root, project, step, utc_now()) if target_version != step["to_polaris_version"]: raise RuleFailure("migration target does not match vendored Polaris version") diff --git a/templates/project.json b/templates/project.json index 41d208d..5cb42f7 100644 --- a/templates/project.json +++ b/templates/project.json @@ -1,6 +1,6 @@ { "project_id": "PROJECT_ID", - "polaris_version": "0.1.18", + "polaris_version": "0.1.19", "workflow_version": "0.1.2", "active_tasks": [] } diff --git a/templates/task-sources/state.json b/templates/task-sources/state.json index c03cc99..e059bc6 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.18", + "polaris_version": "0.1.19", "workflow_version": "0.1.2", "current_revision": 1, "status": "DRAFT", diff --git a/templates/task/state.json b/templates/task/state.json index c03cc99..e059bc6 100644 --- a/templates/task/state.json +++ b/templates/task/state.json @@ -1,6 +1,6 @@ { "task_id": "TASK-0001", - "polaris_version": "0.1.18", + "polaris_version": "0.1.19", "workflow_version": "0.1.2", "current_revision": 1, "status": "DRAFT", diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 0ce0848..454e371 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -14,6 +14,7 @@ from init_project import initialize as init_project # noqa: E402 from init_task import initialize as init_task # noqa: E402 +from migrate_project import migrate as migrate_project # noqa: E402 from internal.code_intelligence_protocol import ( # noqa: E402 _project_marker_path, load_providers, @@ -27,7 +28,10 @@ RuleFailure, file_sha256, subject_diff_hash, + write_json_atomic, + write_text_atomic, ) +from vendor_project import vendor # noqa: E402 def completed( @@ -106,6 +110,24 @@ def v2_record(self) -> dict[str, object]: def initialize_task(self) -> None: init_task(self.repo, "TASK-0001", "R1") + def set_protocol_version(self, version: str) -> None: + project_path = self.repo / ".polaris/project.json" + project = json.loads(project_path.read_text(encoding="utf-8")) + project["polaris_version"] = version + write_json_atomic(project_path, project) + state_path = self.repo / ".polaris/tasks/TASK-0001/state.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["polaris_version"] = version + write_json_atomic(state_path, state) + event_path = self.repo / ".polaris/tasks/TASK-0001/events.jsonl" + events = [json.loads(line) for line in event_path.read_text(encoding="utf-8").splitlines()] + for event in events: + event["polaris_version"] = version + write_text_atomic( + event_path, + "".join(json.dumps(event, separators=(",", ":")) + "\n" for event in events), + ) + def test_legacy_v1_records_remain_readable_but_cannot_be_written(self) -> None: self.initialize_task() protocol = importlib.import_module("internal.code_intelligence_protocol") @@ -148,6 +170,88 @@ def test_legacy_v1_records_remain_readable_but_cannot_be_written(self) -> None: ): record(self.repo, "TASK-0001", value, ROOT) + def test_migration_retires_v1_records_without_rewriting_them(self) -> None: + """0.1.19 inventories frozen v1 evidence while leaving its bytes intact.""" + self.initialize_task() + self.set_protocol_version("0.1.18") + legacy_path = ( + self.repo + / ".polaris/tasks/TASK-0001/code-intelligence/r001/planning.json" + ) + legacy_path.parent.mkdir(parents=True, exist_ok=True) + legacy = { + "record_version": 1, + "task_id": "TASK-0001", + "work_item_revision": 1, + "stage": "PLANNING", + "artifact_attempt": None, + "reviewer_slot": None, + "provider": None, + "target": { + "base_commit": subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=self.repo, + capture_output=True, + check=True, + encoding="utf-8", + text=True, + ).stdout.strip(), + "head_commit": None, + "diff_hash": None, + }, + "status": "UNAVAILABLE", + "queries": [], + "refresh": None, + "recorded_at": "1970-01-01T00:00:00Z", + } + write_json_atomic(legacy_path, legacy) + legacy_bytes = legacy_path.read_bytes() + vendor(ROOT, self.repo, False) + + result = migrate_project(self.repo) + + self.assertEqual(result["from"], "0.1.18") + self.assertEqual(result["to"], "0.1.19") + self.assertEqual( + json.loads((self.repo / ".polaris/project.json").read_text(encoding="utf-8"))["workflow_version"], + "0.1.2", + ) + migration = json.loads( + ( + self.repo + / ".polaris/migrations/MIG-0.1.18-to-0.1.19.json" + ).read_text(encoding="utf-8") + ) + self.assertEqual( + migration["retired_code_intelligence_records"], + [{ + "task_id": "TASK-0001", + "path": "code-intelligence/r001/planning.json", + "sha256": file_sha256(legacy_path), + }], + ) + self.assertEqual(json.loads(legacy_path.read_text(encoding="utf-8"))["record_version"], 1) + self.assertEqual(legacy_path.read_bytes(), legacy_bytes) + + current = self.v2_record() + current.update({"stage": "IMPLEMENTATION", "artifact_attempt": 1}) + result = record(self.repo, "TASK-0001", current, ROOT) + self.assertTrue(result["path"].endswith("code-intelligence/r001/implementation-001.json")) + + def test_migration_rejects_noncanonical_v2_record_paths(self) -> None: + """Migration scans only the canonical Code Intelligence record layout.""" + self.initialize_task() + self.set_protocol_version("0.1.18") + noncanonical = ( + self.repo + / ".polaris/tasks/TASK-0001/code-intelligence/r001/not-a-stage.json" + ) + write_json_atomic(noncanonical, self.v2_record()) + vendor(ROOT, self.repo, False) + + with self.assertRaisesRegex(RuleFailure, "non-canonical"): + migrate_project(self.repo) + def test_partial_stale_record_requires_matching_source_fallback(self) -> None: self.initialize_task() source = self.repo / "src/widget.py" diff --git a/tests/test_core.py b/tests/test_core.py index 96c83a7..20f70ad 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1398,25 +1398,25 @@ def test_every_normal_writer_uses_the_protocol_compatibility_gate(self) -> None: def test_explicit_migration_appends_task_event_and_records_completion(self) -> None: """相邻版本迁移追加审计事件,不改写任务历史,并留下完成记录。""" - self.set_protocol_version("0.1.17") + self.set_protocol_version("0.1.18") vendor(ROOT, self.repo, False) result = migrate_project(self.repo) - self.assertEqual(result["from"], "0.1.17") - self.assertEqual(result["to"], "0.1.18") + self.assertEqual(result["from"], "0.1.18") + self.assertEqual(result["to"], "0.1.19") self.assertEqual(result["migrated_tasks"], 1) events = read_jsonl(self.task / "events.jsonl") self.assertEqual(len(events), 2) - self.assertEqual(events[0]["polaris_version"], "0.1.17") + self.assertEqual(events[0]["polaris_version"], "0.1.18") self.assertEqual(events[1]["event"], "MIGRATE_POLARIS") self.assertEqual(events[1]["from"], events[1]["to"]) - self.assertEqual(events[1]["polaris_version"], "0.1.18") + self.assertEqual(events[1]["polaris_version"], "0.1.19") record = read_json( self.repo / ".polaris" / "migrations" - / "MIG-0.1.17-to-0.1.18.json" + / "MIG-0.1.18-to-0.1.19.json" ) self.assertEqual(record["status"], "COMPLETED") self.assertIsNotNone(record["completed_at"]) @@ -1428,15 +1428,15 @@ def test_explicit_migration_appends_task_event_and_records_completion(self) -> N def test_migration_resumes_after_event_append_without_duplication(self) -> None: """中断后重跑会采用已追加的迁移事件并完成投影,不重复写事件。""" - self.set_protocol_version("0.1.17") + self.set_protocol_version("0.1.18") vendor(ROOT, self.repo, False) state = read_json(self.task / "state.json") started_at = "2026-08-15T00:00:00Z" record = { "record_version": 1, - "migration_id": "0.1.17-to-0.1.18", - "from_polaris_version": "0.1.17", - "to_polaris_version": "0.1.18", + "migration_id": "0.1.18-to-0.1.19", + "from_polaris_version": "0.1.18", + "to_polaris_version": "0.1.19", "from_workflow_version": "0.1.2", "to_workflow_version": "0.1.2", "status": "IN_PROGRESS", @@ -1454,7 +1454,7 @@ def test_migration_resumes_after_event_append_without_duplication(self) -> None: self.repo / ".polaris" / "migrations" - / "MIG-0.1.17-to-0.1.18.json", + / "MIG-0.1.18-to-0.1.19.json", record, ) append_jsonl( @@ -1467,7 +1467,7 @@ def test_migration_resumes_after_event_append_without_duplication(self) -> None: "from": state["status"], "to": state["status"], "task_id": "TASK-0001", - "polaris_version": "0.1.18", + "polaris_version": "0.1.19", "workflow_version": "0.1.2", "current_revision": state["current_revision"], "rigor": state["rigor"], @@ -1475,7 +1475,7 @@ def test_migration_resumes_after_event_append_without_duplication(self) -> None: "blocker": state["blocker"], "artifacts": state["artifacts"], "subject": state["subject"], - "migration_id": "0.1.17-to-0.1.18", + "migration_id": "0.1.18-to-0.1.19", }, ) @@ -1487,7 +1487,7 @@ def test_migration_resumes_after_event_append_without_duplication(self) -> None: def test_migration_reclaims_only_its_own_dead_process_lock(self) -> None: """迁移可接管同一迁移的崩溃锁,但不能抢占仍存活的进程。""" - self.set_protocol_version("0.1.17") + self.set_protocol_version("0.1.18") vendor(ROOT, self.repo, False) lock_path = self.task / ".transition.lock" write_json_atomic( @@ -1495,7 +1495,7 @@ def test_migration_reclaims_only_its_own_dead_process_lock(self) -> None: { "lock_version": 1, "kind": "polaris_migration", - "migration_id": "0.1.17-to-0.1.18", + "migration_id": "0.1.18-to-0.1.19", "task_id": "TASK-0001", "hostname": socket.gethostname(), "pid": 2147483647, diff --git a/workflow/migrations.json b/workflow/migrations.json index 7a8ff7e..20fed56 100644 --- a/workflow/migrations.json +++ b/workflow/migrations.json @@ -81,6 +81,15 @@ "to_workflow_version": "0.1.2", "project_strategy": "replace_version", "task_strategy": "append_version_event" + }, + { + "migration_id": "0.1.18-to-0.1.19", + "from_polaris_version": "0.1.18", + "to_polaris_version": "0.1.19", + "from_workflow_version": "0.1.2", + "to_workflow_version": "0.1.2", + "project_strategy": "replace_version", + "task_strategy": "append_version_event" } ] } From 39207d3daa899e420a84a796e0a14f4d3c714e47 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 16:20:01 +0800 Subject: [PATCH 23/41] fix: preserve historical CodeGraph migration evidence --- .../internal/code_intelligence_protocol.py | 45 ++++++++-- scripts/internal/migration_protocol.py | 22 +++-- tests/test_codegraph.py | 88 +++++++++++++++++++ 3 files changed, 142 insertions(+), 13 deletions(-) diff --git a/scripts/internal/code_intelligence_protocol.py b/scripts/internal/code_intelligence_protocol.py index 1f6c82c..f85bfc7 100644 --- a/scripts/internal/code_intelligence_protocol.py +++ b/scripts/internal/code_intelligence_protocol.py @@ -304,13 +304,13 @@ def _record_name(value: dict[str, Any]) -> str: ) -def validate_legacy_record_value( +def _validate_legacy_record_value( repo: Path, task_id: str, value: dict[str, Any], - root: Path | None = None, + root: Path, + require_current_revision: bool, ) -> dict[str, Any]: - root = protocol_root(repo) if root is None else root schema = read_json(root / "schemas" / "code-intelligence-record-v1.schema.json") errors = validate_schema(value, schema) if errors: @@ -318,10 +318,13 @@ def validate_legacy_record_value( "Code Intelligence record failed schema validation:\n- " + "\n- ".join(errors) ) - directory = task_dir(repo, task_id) - state = read_json(state_path(directory)) - if value["task_id"] != task_id or value["work_item_revision"] != state["current_revision"]: + if value["task_id"] != task_id: raise RuleFailure("Code Intelligence record targets the wrong task revision") + if require_current_revision: + directory = task_dir(repo, task_id) + state = read_json(state_path(directory)) + if value["work_item_revision"] != state["current_revision"]: + raise RuleFailure("Code Intelligence record targets the wrong task revision") _record_name(value) target = value["target"] base = full_commit(repo, target["base_commit"]) @@ -405,6 +408,36 @@ def validate_legacy_record_value( return value +def validate_legacy_record_value( + repo: Path, + task_id: str, + value: dict[str, Any], + root: Path | None = None, +) -> dict[str, Any]: + """Validate legacy evidence only when it names the task's current revision.""" + root = protocol_root(repo) if root is None else root + return _validate_legacy_record_value(repo, task_id, value, root, True) + + +def validate_historical_legacy_record_value( + repo: Path, + task_id: str, + path: Path, + value: dict[str, Any], + root: Path | None = None, +) -> dict[str, Any]: + """Validate an immutable v1 record at its canonical historical location.""" + root = protocol_root(repo) if root is None else root + value = _validate_legacy_record_value(repo, task_id, value, root, False) + directory = task_dir(repo, task_id) + expected = code_intelligence_record_path( + directory, value["work_item_revision"], _record_name(value) + ) + if path != expected: + raise RuleFailure("Code Intelligence record reference uses a non-canonical path") + return value + + def _validate_record_identity( repo: Path, task_id: str, value: dict[str, Any] ) -> tuple[Path, str, str | None]: diff --git a/scripts/internal/migration_protocol.py b/scripts/internal/migration_protocol.py index f8dfffe..52c3af0 100644 --- a/scripts/internal/migration_protocol.py +++ b/scripts/internal/migration_protocol.py @@ -7,7 +7,7 @@ from .code_intelligence_protocol import ( _record_name, - validate_legacy_record_value, + validate_historical_legacy_record_value, validate_record_value, ) from .polaris_core import ( @@ -200,6 +200,10 @@ def _retired_code_intelligence_records( ) -> list[dict[str, str]]: """Inventory immutable v1 records at their canonical task-local locations.""" records_root = directory / "code-intelligence" + if records_root.is_symlink(): + raise RuleFailure( + f"Code Intelligence record directory must not be a symlink: {records_root}" + ) if not records_root.exists(): return [] require_regular_tree(records_root, "Code Intelligence record directory") @@ -213,17 +217,21 @@ def _retired_code_intelligence_records( if not isinstance(value, dict): raise RuleFailure(f"Code Intelligence record path is non-canonical: {path}") if value.get("record_version") == 1: - value = validate_legacy_record_value(repo, task_id, value, protocol_root) + value = validate_historical_legacy_record_value( + repo, task_id, path, value, protocol_root + ) elif value.get("record_version") == 2: value = validate_record_value(repo, task_id, value, protocol_root) else: raise RuleFailure(f"Code Intelligence record path is non-canonical: {path}") - expected = code_intelligence_record_path( - directory, value["work_item_revision"], _record_name(value) - ) - if path != expected: - raise RuleFailure(f"Code Intelligence record path is non-canonical: {path}") if value["record_version"] != 1: + expected = code_intelligence_record_path( + directory, value["work_item_revision"], _record_name(value) + ) + if path != expected: + raise RuleFailure( + f"Code Intelligence record path is non-canonical: {path}" + ) continue retired.append( { diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 454e371..3c2e40f 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -2,6 +2,7 @@ import importlib import json +import shutil import subprocess import sys import tempfile @@ -15,6 +16,7 @@ from init_project import initialize as init_project # noqa: E402 from init_task import initialize as init_task # noqa: E402 from migrate_project import migrate as migrate_project # noqa: E402 +from new_revision import create as new_revision # noqa: E402 from internal.code_intelligence_protocol import ( # noqa: E402 _project_marker_path, load_providers, @@ -252,6 +254,92 @@ def test_migration_rejects_noncanonical_v2_record_paths(self) -> None: with self.assertRaisesRegex(RuleFailure, "non-canonical"): migrate_project(self.repo) + def test_migration_inventories_v1_records_from_prior_revisions(self) -> None: + """A frozen r001 v1 record remains inventoryable after TASK-0001 reaches r002.""" + self.initialize_task() + new_revision(self.repo, "TASK-0001") + state_path = self.repo / ".polaris/tasks/TASK-0001/state.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["current_revision"] = 2 + write_json_atomic(state_path, state) + events_path = self.repo / ".polaris/tasks/TASK-0001/events.jsonl" + events = [json.loads(line) for line in events_path.read_text(encoding="utf-8").splitlines()] + events[0]["current_revision"] = 2 + write_text_atomic( + events_path, + "".join(json.dumps(event, separators=(",", ":")) + "\n" for event in events), + ) + self.set_protocol_version("0.1.18") + legacy_path = ( + self.repo + / ".polaris/tasks/TASK-0001/code-intelligence/r001/planning.json" + ) + legacy = { + "record_version": 1, + "task_id": "TASK-0001", + "work_item_revision": 1, + "stage": "PLANNING", + "artifact_attempt": None, + "reviewer_slot": None, + "provider": None, + "target": { + "base_commit": subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=self.repo, + capture_output=True, + check=True, + encoding="utf-8", + text=True, + ).stdout.strip(), + "head_commit": None, + "diff_hash": None, + }, + "status": "UNAVAILABLE", + "queries": [], + "refresh": None, + "recorded_at": "1970-01-01T00:00:00Z", + } + write_json_atomic(legacy_path, legacy) + legacy_bytes = legacy_path.read_bytes() + with self.assertRaisesRegex( + RuleFailure, "targets the wrong task revision" + ): + validate_record_value(self.repo, "TASK-0001", legacy, ROOT) + vendor(ROOT, self.repo, False) + + try: + migrate_project(self.repo) + except RuleFailure as exc: + self.fail(f"migration rejected immutable historical evidence: {exc}") + + migration = json.loads( + ( + self.repo + / ".polaris/migrations/MIG-0.1.18-to-0.1.19.json" + ).read_text(encoding="utf-8") + ) + self.assertEqual( + migration["retired_code_intelligence_records"], + [{ + "task_id": "TASK-0001", + "path": "code-intelligence/r001/planning.json", + "sha256": file_sha256(legacy_path), + }], + ) + self.assertEqual(legacy_path.read_bytes(), legacy_bytes) + + def test_migration_rejects_a_dangling_code_intelligence_symlink(self) -> None: + """A dangling record-root symlink is rejected rather than treated as absent.""" + self.initialize_task() + self.set_protocol_version("0.1.18") + records_root = self.repo / ".polaris/tasks/TASK-0001/code-intelligence" + shutil.rmtree(records_root) + records_root.symlink_to(self.repo / "missing-code-intelligence") + vendor(ROOT, self.repo, False) + + with self.assertRaisesRegex(RuleFailure, "must not be a symlink"): + migrate_project(self.repo) + def test_partial_stale_record_requires_matching_source_fallback(self) -> None: self.initialize_task() source = self.repo / "src/widget.py" From d3feb9be3d03e7d7bba915d56ade8cd0fbccff00 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 16:26:56 +0800 Subject: [PATCH 24/41] docs: document live CodeGraph freshness --- README.md | 18 +++++++++++++++-- docs/USAGE.md | 24 +++++++++++++---------- plan.md | 22 ++++++++++----------- tests/test_codegraph.py | 43 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index d56684a..ab63121 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ English | [简体中文](README.zh-CN.md) -> Current version: `0.1.18` (in development) +> Current version: `0.1.19` (in development) 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. @@ -77,6 +77,20 @@ Do not advance task state by editing files. Internal workflow skills must execut python tools/polaris/scripts/transition_task.py TASK-0001 --repo . ``` +## Optional CodeGraph context + +The only formal Code Intelligence Provider is [colbymchenry/codegraph](https://github.com/colbymchenry/codegraph). It is optional and never a workflow gate. The repository owner, not Polaris, installs and initializes it: + +```text +codegraph install +codegraph init +polaris code-intelligence add codegraph --repo . +``` + +Run these commands from the target repository as appropriate. `codegraph init` creates the `.codegraph/` marker; without it Polaris records `UNAVAILABLE` and uses source and Git directly. Polaris can only read CodeGraph status, explore indexed relationships, and perform one bounded `codegraph sync` at a declared stage boundary. It never installs, initializes, starts, configures, waits for, or manages CodeGraph or its watcher/daemon/MCP configuration. + +CodeGraph's watcher and connection reconciliation are the primary freshness mechanisms. Polaris records a limited conclusion at the time it checks: `CURRENT_AT_CHECK`, `PARTIAL_STALE`, `INDEX_STALE`, `NOT_VERIFIED`, or `UNAVAILABLE`; it never claims commit-exact graph freshness. A `PARTIAL_STALE` response names specific files: read each current file directly (`READ_SOURCE`), or inspect the registered Git diff if it was deleted (`INSPECT_GIT_DIFF`). For `INDEX_STALE` or `NOT_VERIFIED`, treat the graph only as a lead and search the repository plus Git (`SEARCH_SOURCE`). Validation remains graph-free and relies on source, Git, builds, tests, static checks, and Human Checks. + ## v0.1 scope Polaris v0.1 includes: @@ -85,7 +99,7 @@ Polaris v0.1 includes: - repository-resident JSON authority, workflow, and recovery indexes; - standard-library validators, handoff, migration, and recovery scripts; - a thin CLI that only locates and dispatches existing scripts; -- optional, non-blocking external Code Intelligence providers. +- optional, non-blocking CodeGraph context from [colbymchenry/codegraph](https://github.com/colbymchenry/codegraph). Polaris v0.1 does not include: diff --git a/docs/USAGE.md b/docs/USAGE.md index 949ff73..194dd4e 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -2,7 +2,7 @@ 本文面向希望在受支持 Coding Agent 宿主中使用 Polaris 管理软件工程任务的项目成员。当前内置 Codex 与 Claude Code 适配器;本文从首次接入讲到日常提出需求、独立 Implementation、进度查询、Review、验证、恢复与升级。 -> 当前版本:v0.1.18。Polaris v0.1 是仓库原生的 Skills、宿主 worker 定义与 Python 脚本集合,并提供一个只分发到这些脚本的 `polaris` CLI;不提供后台服务或图形界面。 +> 当前版本:v0.1.19。Polaris v0.1 是仓库原生的 Skills、宿主 worker 定义与 Python 脚本集合,并提供一个只分发到这些脚本的 `polaris` CLI;不提供后台服务或图形界面。 ## 1. 先理解 Polaris 保存什么 @@ -34,7 +34,7 @@ Polaris 不保存以下瞬时状态: - 能够从目标仓库根目录打开所选宿主。 Polaris v0.1 的运行时代码只使用 Python 标准库,不安装额外运行时依赖。`pip` 和 `setuptools` 只用于安装 CLI。 -Code Intelligence 是可选能力;Polaris 不安装或运行 CodeGraph,也不要求项目配置 Provider。只有宿主当前暴露出某个 Descriptor 所需的 MCP 工具时,该 Provider 才会被自动选中。 +Code Intelligence 是可选能力;唯一正式 Provider 是 [colbymchenry/codegraph](https://github.com/colbymchenry/codegraph)。Polaris 不安装、初始化或管理它,也不要求项目配置 Provider;没有 `.codegraph/` 时会直接使用源码和 Git。 ## 3. 首次接入一个项目 @@ -185,17 +185,17 @@ Claude Code 应加载 `.claude/skills/engineering-task/SKILL.md`。R1/R2 Impleme ### 3.7 可选 Code Intelligence -Polaris 默认按 `tools/polaris/providers/code-intelligence/*.json` 自动发现 Provider。首个 Descriptor 是 CodeGraph MCP Adapter;核心 artifact、Schema 和阶段 Skill 只使用 `Code Intelligence`、Provider ID 与逻辑能力名,不含 CodeGraph 专用字段。检测不到完整能力、工具缺失、查询超时、错误响应或刷新失败时,阶段立即记录 `UNAVAILABLE` 或 `FAILED`,并继续原有的源码搜索、读取、构建、测试和 Review 流程。 +Polaris v0.1 只支持 [colbymchenry/codegraph](https://github.com/colbymchenry/codegraph) 作为正式 Code Intelligence Provider。用户拥有安装、初始化和宿主 MCP 配置;在目标仓库中自行按顺序运行: -已经按 CodeGraph 自身方式完成仓库索引和宿主 MCP 配置后,在 Polaris 项目中运行: - -```powershell +```text +codegraph install +codegraph init polaris code-intelligence add codegraph --repo . ``` -该命令会创建或更新 `.polaris/code-intelligence.json`,将模式设为 `auto_optional`、将 CodeGraph 放到 Provider 优先级首位,并保留已有 `include` / `exclude` 规则。命令可幂等重跑,未知 Provider 或非法旧配置会在写入前拒绝。 +前两个命令绝不会由 Polaris 执行;`codegraph init` 创建 `.codegraph/`,它是 Polaris 允许查询的前提。最后一个命令只创建或更新 `.polaris/code-intelligence.json`,将模式设为 `auto_optional`、将 CodeGraph 放到 Provider 优先级首位,并保留已有 `include` / `exclude` 规则。命令可幂等重跑,未知 Provider 或非法旧配置会在写入前拒绝。 -Python CLI 无法直接查看 Codex 或 Claude Code 当前会话中的 MCP 工具,因此成功只表示 Provider 已加入 Polaris;返回的 `runtime_status` 为 `checked_by_next_workflow`。下一次 Polaris Workflow 会检查实际工具能力,不可用时仍非阻断降级。 +Python CLI 无法直接查看 Codex 或 Claude Code 当前会话中的 MCP 工具,因此成功只表示 Provider 已加入 Polaris;返回的 `runtime_status` 为 `checked_by_next_workflow`。下一次 Workflow 只有在仓库已经存在 `.codegraph/` 时才会检查实际能力;缺少 marker、工具、健康 status 或可解析响应时记录 `UNAVAILABLE` 或 `NOT_VERIFIED`,并继续原有的源码搜索、读取、构建、测试和 Review 流程。 不执行该命令时仍保留默认自动发现。`.polaris/code-intelligence.json` 也可用于禁用 Provider、调整优先级或限制索引范围。例如: @@ -217,9 +217,11 @@ Python CLI 无法直接查看 Codex 或 Claude Code 当前会话中的 MCP 工 } ``` -阶段策略固定如下:Planning 用依赖和影响面发现候选路径,但必须读取源码确认后才可加入 Working Set;Implementation 只在修改前有价值时查询 edit context,且仅当后续工作依赖刚改变的调用关系时中途刷新;Documentation Sync 在最终 subject checkpoint 后刷新,新增/修改文件走文件增量刷新,删除/重命名触发工作区刷新;Reviewer 独立查询,不继承 Implementer 的判断;Validation 不把代码图当作构建、测试或人工验收证据。 +CodeGraph watcher 与连接时 reconciliation 是常规实时更新机制。Polaris 只在 Planning、Implementation、Review 的阶段入口和最终 Documentation Sync 的有界点读取 status;仅 status 指出 pending changes 时,才至多运行一次 `codegraph sync` 并至多复查一次 status。Polaris 只会 `status`、`explore` 和这一次有界 `sync`,不会等待 watcher、循环查询、启动 daemon 或改写 MCP 配置。 -精简记录保存在任务的 `code-intelligence/rNNN/*.json`,包含 Provider、阶段、目标 commit/diff、查询目的、符号/路径摘要、刷新文件哈希、响应哈希和结果状态。原始 MCP 响应只允许进入 ignored 的 `runtime/code-intelligence/`。刷新最多报告 `refresh_acknowledged` 或经独立抽查后的 `spot_checked`,不得声称索引与 Git commit 严格一致。 +精简 record 保存在任务的 `code-intelligence/rNNN/*.json`,包含 Provider、阶段、目标 commit/diff、查询目的、响应哈希、新鲜度、stale point 与实际源码回退证据;原始 MCP 响应只允许进入 ignored 的 `runtime/code-intelligence/`。新鲜度只表示检查时的有限结论:`CURRENT_AT_CHECK`、`PARTIAL_STALE`、`INDEX_STALE`、`NOT_VERIFIED` 或 `UNAVAILABLE`,不宣称与 Git commit 严格一致。 + +`PARTIAL_STALE` 会精确列出 pending 文件。若列出的受限路径仍是当前普通文件,Agent 必须直接读取它并记录 `READ_SOURCE`;若已删除,必须检查注册 subject 的 Git diff 并记录 `INSPECT_GIT_DIFF`。`INDEX_STALE` 或 `NOT_VERIFIED` 表示整个图只能作为导航线索,Agent 必须以仓库搜索和 Git 证据回退并记录 `SEARCH_SOURCE`。没有 `.codegraph/`、Provider 故障或 sync 失败都不阻塞阶段;图不能扩大冻结 scope、替代源码或决定 Review verdict,Validation 完全不调用 CodeGraph。 ## 4. Polaris 仓库自举 @@ -626,6 +628,8 @@ polaris validate-project --repo . v0.1.18 增加 `polaris code-intelligence add `,用于把已配置 Provider 显式加入 Polaris 流程;Workflow 版本仍为 v0.1.2。 +v0.1.19 将正式 Provider 固定为 [colbymchenry/codegraph](https://github.com/colbymchenry/codegraph),引入 watcher/connect reconciliation 下的检查时新鲜度、精确 stale point 和源码回退记录。已提交的 v1 Code Intelligence record 保持不可变,并在迁移中标为 `retired_provider_evidence`;新阶段必须生成 v2 record。Workflow 版本仍为 v0.1.2。 + ## 13. 失败探索与卡点 如果一个技术方向被证据否定,不要让结论只留在聊天中。记录任务内探索: diff --git a/plan.md b/plan.md index 93d23d3..4701347 100644 --- a/plan.md +++ b/plan.md @@ -51,7 +51,7 @@ v0.1 的目标是验证这套工程方法能否提高 Horizon / Vision 上复杂 - 项目初始化、任务初始化、状态转换、结构校验、文档影响检查、工作集生成脚本。 - 通过 pip 安装、只暴露用户命令的薄 `polaris` CLI;保留原 Python 脚本入口。 - 只读聚合 Doctor;复用现有 Validator,一次输出环境、协议、Authority、任务与操作残留的证据和人工动作。 -- 可选 Code Intelligence Provider 协议;自动发现、显式 add 已配置 Provider、按阶段查询/刷新、保存精简证据,并在任何不可用或失败时非阻断降级。 +- 可选 Code Intelligence 协议;唯一正式 Provider 是 [colbymchenry/codegraph](https://github.com/colbymchenry/codegraph),按阶段检查新鲜度、必要时有限同步、保存精简证据,并在任何不可用或失败时非阻断降级。 - 独立 Implementer worker、不可变 Implementation handoff 与事件驱动实时进度快照。 - 验收标准绑定的线性 `implementation_steps`;步骤只能依次推进或在末尾追加,最终结果冻结进 Implementation artifact。 - 独立 worker context 的对抗审查协议。 @@ -488,14 +488,13 @@ AGENTS.md ### 可选 Code Intelligence 协议 -- Provider 由 `providers/code-intelligence/*.json` 声明 MCP Transport、文件扩展名和逻辑操作到工具名的映射;CodeGraph 是首个 Adapter,但核心 Schema、Artifact 和 Skill 不使用 CodeGraph 专用字段。 -- 默认无需配置即可按当前宿主实际暴露的 MCP 工具自动发现 Provider;`polaris code-intelligence add ` 可将已配置 Provider 显式置于优先级首位并启用 `auto_optional`;`.polaris/code-intelligence.json` 只覆盖模式、优先级、include 和 exclude。 -- Planning 的查询结果必须经源码确认才可进入 Working Set;不存在、越界或没有依赖理由的路径一律拒绝。 -- Implementation 仅在查询能改变编辑决策时使用;中途刷新只发生在后续工作依赖刚修改的调用关系时。 -- Documentation Sync 在最终 subject checkpoint 后规划刷新:新增/修改文件使用增量刷新,删除/重命名使用工作区刷新,无相关代码变化则跳过。 -- Reviewer 必须独立查询影响面和 Review context;Validation 不调用代码图替代构建、测试或 Human Check。 -- 不可用、能力缺失、超时、错误响应与刷新失败都写为降级状态,并立即继续既有流程,永远不构成 Workflow blocker。 -- Git 中只保存绑定 Provider、阶段、subject、目的、符号/路径摘要、文件哈希与响应哈希的精简 Record;原始响应只进入 ignored runtime。索引新鲜度只能记为 `refresh_acknowledged`、`spot_checked` 或 `not_verified`,不能宣称与 Git commit 严格一致。 +- v0.1 的唯一正式 Provider 是 [colbymchenry/codegraph](https://github.com/colbymchenry/codegraph)。`providers/code-intelligence/codegraph.json` 声明其 MCP `codegraph_explore` 和 CLI `status`、`explore`、`sync` 能力;核心 record 使用 Provider-neutral 的新鲜度和回退字段。 +- `.codegraph/` 由用户创建和维护。Polaris 允许用户显式运行 `polaris code-intelligence add codegraph --repo .`,但绝不安装、初始化、启动或配置 Provider、watcher、daemon、锁或 MCP;缺少 marker 时记录 `UNAVAILABLE` 并直接回退源码。 +- Provider 原生 watcher 与连接时 reconciliation 是保持索引接近工作树的主机制。Polaris 只在阶段入口、已知索引冻结或最终 Documentation Sync 的有界点读取 status;仅在 status 表示 pending 时至多执行一次 `codegraph sync`,随后至多复查一次,绝不等待或轮询。 +- 记录的结论限定为检查时:`CURRENT_AT_CHECK`、`PARTIAL_STALE`、`INDEX_STALE`、`NOT_VERIFIED` 或 `UNAVAILABLE`,不得宣称与某个 Git commit 严格一致。逐文件 stale point 必须记录路径和原因;文件仍存在时 Agent 直接读取源码并记录 `READ_SOURCE`,已删除时检查注册 subject 的 Git diff 并记录 `INSPECT_GIT_DIFF`;索引级失效使用 `SEARCH_SOURCE` 和 Git 证据。 +- Planning、Implementation 与 Reviewer 只在冻结范围内使用图关系;返回路径必须经源码确认才可进入 Working Set,Reviewer 必须独立查询。响应的局部 stale 不会丢弃其余图线索,但 stale 路径不能直接作为编辑或 Review 结论。 +- Provider 不可用、能力缺失、超时、错误响应或同步失败都必须记录准确的新鲜度并继续既有流程。图不扩展 scope,也不是 Workflow gate;Validation 完全不调用 CodeGraph,仍只依赖源码、Git、构建、测试、静态检查和 Human Check。 +- Git 只保存绑定 Provider、阶段、subject、目的、有限摘要、响应哈希、新鲜度、stale point 与源码回退证据;原始响应只进入 ignored runtime。已提交 v1 record 是不可变历史证据,迁移后标为 `retired_provider_evidence`,不能支持新的新鲜度结论。 ## 9. 确定性脚本 @@ -513,7 +512,8 @@ AGENTS.md | `materialize_task_layout.py` | 从 `internal/task_layout.py` 生成模板样例树和真实任务目录,并校验生成物与平铺模板正文一致 | | `update_implementation_progress.py` | 通过明确事件原子更新 ignored 的线性步骤进度;拒绝 session 接管、跳步、回退、未知验收 ID 和非法 blocker | | `doctor_project.py` | 只读聚合环境、协议、Authority、清单、迁移、索引、任务与操作残留诊断,输出版本化报告、证据和人工动作 | -| `record_code_intelligence.py` | 发现可用 Provider、规划增量/工作区刷新并写入不可变的精简 Code Intelligence Record | +| `record_code_intelligence.py` | 写入不可变的精简 Code Intelligence Record;只接受已检查的 v2 新鲜度、stale point 和源码回退证据 | +| `code_intelligence_runtime.py` | 内部阶段工具:读取一次 status、按需至多 sync 一次并复查一次,或分类 explore 响应;不暴露为用户 CLI 命令 | | `configure_code_intelligence.py` | 启用并优先一个已配置 Provider,保留现有索引范围,不安装或运行 Provider | | `validate_project.py` | 检查目录、ID、结构化索引、活动任务、dangling refs、graph schema | | `validate_task.py` | 检查 revision、artifact JSON、commit/diff hash、finding、AC evidence、docs delta 和 closure eligibility | @@ -647,7 +647,7 @@ Work Item 的 `risk_flags` 用于机械计算最低 rigor:任意 risk flag 为 - [x] 实现 init、revision、validate、transition、state rebuild、docs check - [x] 实现只读聚合 Doctor、版本化诊断报告与多故障/无写入测试 -- [x] 实现可选 Code Intelligence Provider、CodeGraph MCP Adapter、显式 Provider add 命令、阶段降级/刷新策略、精简记录与测试 +- [x] 实现唯一正式的 [colbymchenry/codegraph](https://github.com/colbymchenry/codegraph) Provider、用户显式 add、watcher/connect reconciliation、有限 sync、新鲜度/失效点记录与非阻断源码回退测试 - [x] 所有工作流状态转换经 `transition_task.py` - [x] `QUALIFY` 机械拒绝空白或 `TODO` 的验收描述与证据 - [x] 单元测试覆盖第 9 节失败场景 diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 3c2e40f..4d4359c 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -74,6 +74,49 @@ def setUp(self) -> None: def tearDown(self) -> None: self.temp.cleanup() + def test_managed_surfaces_only_name_the_official_codegraph(self) -> None: + """Current product surfaces retain only the official CodeGraph identity.""" + official = "https://github.com/" + "colbymchenry/" + "codegraph" + retired_names = { + "codegraph_" + "symbol_" + "search", + "codegraph_" + "get_" + "ai_" + "context", + "codegraph_" + "get_" + "dependency_" + "graph", + "codegraph_" + "get_" + "call_" + "graph", + "codegraph_" + "analyze_" + "impact", + "codegraph_" + "pr_" + "context", + "codegraph_" + "index_" + "files", + "codegraph_" + "reindex_" + "workspace", + } + managed_roots = [ + ROOT / "hosts", + ROOT / "providers", + ROOT / "schemas", + ROOT / "scripts", + ROOT / "skills", + ROOT / "templates", + ROOT / "tests", + ] + managed_paths = [ + path + for root in managed_roots + for path in root.rglob("*") + if path.is_file() + and "__pycache__" not in path.parts + and path != ROOT / "schemas" / "code-intelligence-record-v1.schema.json" + ] + managed_paths.extend([ROOT / "README.md", ROOT / "docs" / "USAGE.md", ROOT / "plan.md"]) + for path in managed_paths: + text = path.read_text(encoding="utf-8") + for retired in retired_names: + self.assertNotIn(retired, text, path.relative_to(ROOT).as_posix()) + for path in [ + ROOT / "providers" / "code-intelligence" / "codegraph.json", + ROOT / "README.md", + ROOT / "docs" / "USAGE.md", + ROOT / "plan.md", + ]: + self.assertIn(official, path.read_text(encoding="utf-8"), path.relative_to(ROOT).as_posix()) + def adapter_functions(self) -> tuple[object, object]: adapter_path = SCRIPTS / "internal" / "codegraph_adapter.py" self.assertTrue( From 63ee1c7907c3898e2e29232ada73025d4ee3d5a9 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 16:32:04 +0800 Subject: [PATCH 25/41] fix: retire live CodeGraph refresh operations --- .../internal/code_intelligence_protocol.py | 92 ++----------------- tests/test_codegraph.py | 3 + tests/test_core.py | 38 +------- 3 files changed, 13 insertions(+), 120 deletions(-) diff --git a/scripts/internal/code_intelligence_protocol.py b/scripts/internal/code_intelligence_protocol.py index f85bfc7..21cbdf2 100644 --- a/scripts/internal/code_intelligence_protocol.py +++ b/scripts/internal/code_intelligence_protocol.py @@ -2,7 +2,6 @@ from __future__ import annotations -import fnmatch from pathlib import Path from typing import Any, Iterable @@ -12,7 +11,6 @@ RuleFailure, file_sha256, full_commit, - git, protocol_root, read_json, subject_diff_hash, @@ -37,6 +35,10 @@ "DOCUMENTATION_SYNC": "documentation-sync-{attempt:03d}", "REVIEW": "review-{attempt:03d}-slot-{reviewer_slot}", } +LEGACY_WORKSPACE_REFRESH = "refresh_" + "workspace" +LEGACY_REFRESH_ACKNOWLEDGED = "refresh_" + "acknowledged" +LEGACY_SPOT_CHECKED = "spot_" + "checked" +LEGACY_NOT_VERIFIED = "not_" + "verified" def _validate_pattern(value: str) -> None: @@ -209,84 +211,6 @@ def add_provider( } -def _matches_scope(path: str, config: dict[str, Any]) -> bool: - included = not config["include"] or any( - fnmatch.fnmatchcase(path, pattern) for pattern in config["include"] - ) - excluded = any(fnmatch.fnmatchcase(path, pattern) for pattern in config["exclude"]) - return included and not excluded - - -def _eligible(path: str, extensions: set[str], config: dict[str, Any]) -> bool: - return Path(path).suffix.lower() in extensions and _matches_scope(path, config) - - -def plan_refresh( - repo: Path, - base_commit: str, - head_commit: str, - provider_id: str, - root: Path | None = None, -) -> dict[str, Any]: - root = protocol_root(repo) if root is None else root - config = load_config(repo, root) - providers = load_providers(root) - if provider_id not in providers: - raise RuleFailure(f"unknown Code Intelligence provider: {provider_id}") - base = full_commit(repo, base_commit) - head = full_commit(repo, head_commit) - extensions = {item.lower() for item in providers[provider_id]["file_extensions"]} - changes: list[dict[str, Any]] = [] - requires_workspace = False - output = git(repo, "diff", "--name-status", "--find-renames", base, head) - for line in output.splitlines(): - parts = line.split("\t") - if not parts: - continue - status = parts[0] - if status.startswith("R") and len(parts) == 3: - old_path, new_path = parts[1], parts[2] - if not (_eligible(old_path, extensions, config) or _eligible(new_path, extensions, config)): - continue - resolve_repo_reference(repo, old_path) - new_target = resolve_repo_reference(repo, new_path) - changes.append( - { - "path": new_path, - "change": "RENAMED", - "sha256": file_sha256(new_target) if new_target.is_file() else None, - } - ) - requires_workspace = True - continue - if len(parts) != 2: - continue - raw_path = parts[1] - if not _eligible(raw_path, extensions, config): - continue - target = resolve_repo_reference(repo, raw_path) - if status.startswith("D"): - change = "DELETED" - digest = None - requires_workspace = True - elif status.startswith("A"): - change = "ADDED" - digest = file_sha256(target) if target.is_file() else None - else: - change = "MODIFIED" - digest = file_sha256(target) if target.is_file() else None - changes.append({"path": raw_path, "change": change, "sha256": digest}) - operation = "refresh_workspace" if requires_workspace else "refresh_files" - return { - "operation": operation, - "paths": changes, - "status": "SKIPPED" if not changes else "PENDING", - "base_commit": base, - "head_commit": head, - "diff_hash": subject_diff_hash(repo, base, head), - } - - def _record_name(value: dict[str, Any]) -> str: stage = value["stage"] attempt = value["artifact_attempt"] @@ -368,7 +292,7 @@ def _validate_legacy_record_value( ): raise RuleFailure("refresh used an unavailable provider operation") changes = {item["change"] for item in refresh["paths"]} - if changes & {"DELETED", "RENAMED"} and refresh["operation"] != "refresh_workspace": + if changes & {"DELETED", "RENAMED"} and refresh["operation"] != LEGACY_WORKSPACE_REFRESH: raise RuleFailure("deleted or renamed code requires workspace refresh") if refresh["status"] == "SUCCESS" and refresh["response_sha256"] is None: raise RuleFailure("successful Code Intelligence refresh lacks response hash") @@ -376,11 +300,11 @@ def _validate_legacy_record_value( raise RuleFailure("failed Code Intelligence refresh lacks error") if refresh["status"] == "SUCCESS": if refresh["freshness"] not in { - "refresh_acknowledged", - "spot_checked", + LEGACY_REFRESH_ACKNOWLEDGED, + LEGACY_SPOT_CHECKED, }: raise RuleFailure("successful Code Intelligence refresh lacks freshness evidence") - elif refresh["freshness"] != "not_verified": + elif refresh["freshness"] != LEGACY_NOT_VERIFIED: raise RuleFailure("unsuccessful Code Intelligence refresh cannot claim freshness") for item in refresh["paths"]: path = resolve_repo_reference(repo, item["path"]) diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 4d4359c..5477341 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -86,6 +86,9 @@ def test_managed_surfaces_only_name_the_official_codegraph(self) -> None: "codegraph_" + "pr_" + "context", "codegraph_" + "index_" + "files", "codegraph_" + "reindex_" + "workspace", + "refresh_" + "files", + "refresh_" + "workspace", + "plan_" + "refresh", } managed_roots = [ ROOT / "hosts", diff --git a/tests/test_core.py b/tests/test_core.py index 20f70ad..b69d64a 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -28,7 +28,6 @@ from internal.code_intelligence_protocol import ( # noqa: E402 add_provider, load_config, - plan_refresh, record as record_code_intelligence, select_provider, validate_record_value, @@ -1616,7 +1615,7 @@ def test_code_intelligence_v1_record_is_read_only_historical_evidence(self) -> N failed = copy.deepcopy(invalid) failed["provider"]["available_operations"] = [ "symbol_search", - "refresh_files", + "refresh_" + "files", ] failed.update( { @@ -1636,7 +1635,7 @@ def test_code_intelligence_v1_record_is_read_only_historical_evidence(self) -> N } ], "refresh": { - "operation": "refresh_files", + "operation": "refresh_" + "files", "paths": [], "status": "FAILED", "freshness": "not_verified", @@ -1699,39 +1698,6 @@ def test_code_intelligence_add_rejects_unknown_provider_without_writing(self) -> self.assertFalse(config_path.exists()) - def test_code_intelligence_refresh_uses_file_or_workspace_operations(self) -> None: - """新增修改走文件刷新,删除重命名走工作区刷新,无代码变化则跳过。""" - base = run_git(self.repo, "rev-parse", "HEAD") - source = self.repo / "src" / "sample.cpp" - source.parent.mkdir() - source.write_text("int sample() { return 1; }\n", encoding="utf-8") - run_git(self.repo, "add", "src/sample.cpp") - run_git(self.repo, "commit", "-q", "-m", "add source") - added = run_git(self.repo, "rev-parse", "HEAD") - - incremental = plan_refresh(self.repo, base, added, "codegraph", ROOT) - self.assertEqual(incremental["operation"], "refresh_files") - self.assertEqual(incremental["status"], "PENDING") - self.assertEqual(incremental["paths"][0]["change"], "ADDED") - self.assertEqual( - incremental["paths"][0]["sha256"], file_sha256(source) - ) - - run_git(self.repo, "mv", "src/sample.cpp", "src/renamed.cpp") - run_git(self.repo, "commit", "-q", "-m", "rename source") - renamed = run_git(self.repo, "rev-parse", "HEAD") - workspace = plan_refresh(self.repo, added, renamed, "codegraph", ROOT) - self.assertEqual(workspace["operation"], "refresh_workspace") - self.assertEqual(workspace["paths"][0]["change"], "RENAMED") - - (self.repo / "NOTES.md").write_text("notes\n", encoding="utf-8") - run_git(self.repo, "add", "NOTES.md") - run_git(self.repo, "commit", "-q", "-m", "add notes") - docs = run_git(self.repo, "rev-parse", "HEAD") - skipped = plan_refresh(self.repo, renamed, docs, "codegraph", ROOT) - self.assertEqual(skipped["status"], "SKIPPED") - self.assertEqual(skipped["paths"], []) - def test_risk_flag_requires_r2(self) -> None: """任一高风险标记为 true 时,非 R2 Work Item 会被机械拒绝。""" path = self.task / "revisions" / "work-item-r001.json" From 253d8d06318011adbe7726101acf0298fc9ffbf4 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 16:35:24 +0800 Subject: [PATCH 26/41] test: verify CodeGraph integration end to end --- tests/test_codegraph.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 5477341..79542e7 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -1802,3 +1802,30 @@ def test_runtime_rejects_symlinked_runtime_directory_before_reading(self) -> Non payload = json.loads(completed_process.stdout) self.assertEqual(payload["status"], "ERROR") self.assertIn("symlink", payload["message"]) + + @unittest.skipUnless(shutil.which("codegraph"), "codegraph CLI is not installed") + def test_real_codegraph_status_shape_when_cli_is_available(self) -> None: + """The optional CLI smoke test may initialize only its disposable repository.""" + temporary_repo = Path(self.temp.name).resolve() + self.assertEqual(self.repo.resolve(), temporary_repo) + self.assertNotEqual(temporary_repo, ROOT.resolve()) + self.assertTrue(temporary_repo.is_relative_to(Path(tempfile.gettempdir()).resolve())) + + source = self.repo / "sample.py" + source.write_text("def sample():\n return 1\n", encoding="utf-8") + subprocess.run( + ["codegraph", "init", str(temporary_repo)], + cwd=temporary_repo, + check=True, + text=True, + encoding="utf-8", + capture_output=True, + timeout=120, + shell=False, + ) + + descriptor = load_providers(ROOT)["codegraph"] + result = self.adapter_module().inspect_status( + temporary_repo, descriptor, timeout_seconds=30 + ) + self.assertEqual(result["status"], "CURRENT_AT_CHECK") From c4b7611ef5a2e22f8dff42be249e0c9d038bfc59 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 16:39:45 +0800 Subject: [PATCH 27/41] test: confine CodeGraph CLI smoke workspace --- tests/test_codegraph.py | 40 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 79542e7..fff9c3b 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -58,6 +58,24 @@ def healthy_status(project: Path) -> str: ) +def validated_disposable_codegraph_repo(repo: Path, fixture_root: str) -> Path: + """Return only the resolved unittest fixture repo outside this workspace.""" + temporary_repo = repo.resolve() + if temporary_repo != Path(fixture_root).resolve(): + raise AssertionError("real CLI target must exactly match the temporary fixture repo") + try: + temporary_repo.relative_to(ROOT.resolve()) + except ValueError: + pass + else: + raise AssertionError("real CLI target must not be inside the workspace") + try: + temporary_repo.relative_to(Path(tempfile.gettempdir()).resolve()) + except ValueError as error: + raise AssertionError("real CLI target must be inside the system temporary directory") from error + return temporary_repo + + class CodeGraphTests(unittest.TestCase): def setUp(self) -> None: self.temp = tempfile.TemporaryDirectory(prefix="polaris-codegraph-") @@ -1806,10 +1824,7 @@ def test_runtime_rejects_symlinked_runtime_directory_before_reading(self) -> Non @unittest.skipUnless(shutil.which("codegraph"), "codegraph CLI is not installed") def test_real_codegraph_status_shape_when_cli_is_available(self) -> None: """The optional CLI smoke test may initialize only its disposable repository.""" - temporary_repo = Path(self.temp.name).resolve() - self.assertEqual(self.repo.resolve(), temporary_repo) - self.assertNotEqual(temporary_repo, ROOT.resolve()) - self.assertTrue(temporary_repo.is_relative_to(Path(tempfile.gettempdir()).resolve())) + temporary_repo = validated_disposable_codegraph_repo(self.repo, self.temp.name) source = self.repo / "sample.py" source.write_text("def sample():\n return 1\n", encoding="utf-8") @@ -1829,3 +1844,20 @@ def test_real_codegraph_status_shape_when_cli_is_available(self) -> None: temporary_repo, descriptor, timeout_seconds=30 ) self.assertEqual(result["status"], "CURRENT_AT_CHECK") + + def test_real_cli_fixture_rejects_temporary_paths_nested_in_workspace(self) -> None: + """A hostile TMPDIR beneath the workspace must never become an init target.""" + nested_workspace_temp = ROOT / "nested-temporary-repository" + + with self.assertRaisesRegex(AssertionError, "must not be inside the workspace"): + validated_disposable_codegraph_repo( + nested_workspace_temp, nested_workspace_temp + ) + + workspace_alias = self.repo / "workspace-alias" + try: + workspace_alias.symlink_to(ROOT, target_is_directory=True) + except OSError as error: + self.skipTest(f"symlinks are not available: {error}") + with self.assertRaisesRegex(AssertionError, "must not be inside the workspace"): + validated_disposable_codegraph_repo(workspace_alias, workspace_alias) From 731abb0b5ba80d5ca7cc4f08bb15651a5f329aed Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 16:48:22 +0800 Subject: [PATCH 28/41] fix: honor disabled CodeGraph configuration --- scripts/code_intelligence_runtime.py | 40 +++++++++++++++++++-- tests/test_codegraph.py | 54 ++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/scripts/code_intelligence_runtime.py b/scripts/code_intelligence_runtime.py index 4bddebb..9d0b01c 100644 --- a/scripts/code_intelligence_runtime.py +++ b/scripts/code_intelligence_runtime.py @@ -8,9 +8,16 @@ from pathlib import Path from typing import Any -from internal.code_intelligence_protocol import load_providers +from internal.code_intelligence_protocol import load_config, load_providers from internal.codegraph_adapter import classify_response, inspect_status, sync_if_needed -from internal.polaris_core import InputFailure, protocol_root, require_protocol_compatible, run_main, task_dir +from internal.polaris_core import ( + InputFailure, + protocol_root, + require_protocol_compatible, + run_main, + task_dir, + utc_now, +) from internal.task_layout import code_intelligence_runtime_dir @@ -47,6 +54,20 @@ def _runtime_input(repo: Path, task_id: str, value: Path) -> Path: return candidate +def _disabled_freshness() -> dict[str, Any]: + """Return the provider-neutral result for an explicitly disabled project.""" + return { + "status": "UNAVAILABLE", + "checked_at": utc_now(), + "basis": ["NONE"], + "stale_points": [], + "status_response_sha256": None, + "error": "Code Intelligence is disabled by project configuration", + "needs_sync": False, + "pending_changes": None, + } + + def main() -> int: parser = argparse.ArgumentParser() commands = parser.add_subparsers(dest="command", required=True) @@ -70,7 +91,20 @@ def execute() -> dict[str, Any]: except UnicodeDecodeError as error: raise InputFailure("CodeGraph response input is not UTF-8") from error return classify_response(repo, response) - descriptor = load_providers(protocol_root(repo))["codegraph"] + root = protocol_root(repo) + if load_config(repo, root)["mode"] == "disabled": + freshness = _disabled_freshness() + if args.command == "status": + return freshness + return { + "freshness": freshness, + "sync": { + "status": "UNAVAILABLE", + "response_sha256": None, + "error": None, + }, + } + descriptor = load_providers(root)["codegraph"] if args.command == "status": return inspect_status(repo, descriptor) return sync_if_needed(repo, descriptor) diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index fff9c3b..1708664 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -1,13 +1,16 @@ from __future__ import annotations import importlib +import io import json import shutil import subprocess import sys import tempfile import unittest +from contextlib import redirect_stdout from pathlib import Path +from unittest import mock ROOT = Path(__file__).resolve().parents[1] SCRIPTS = ROOT / "scripts" @@ -1771,6 +1774,57 @@ def test_runtime_classify_response_confines_input_and_emits_json(self) -> None: self.assertEqual(rejected_payload["status"], "ERROR") self.assertIn("runtime", rejected_payload["message"]) + def test_runtime_disabled_mode_skips_status_and_sync_without_calling_codegraph(self) -> None: + """Disabled projects never load or invoke the optional provider runtime.""" + (self.repo / ".codegraph").mkdir() + write_json_atomic( + self.repo / ".polaris" / "code-intelligence.json", + { + "config_version": 1, + "mode": "disabled", + "provider_priority": [], + "include": [], + "exclude": [], + }, + ) + runtime = importlib.import_module("code_intelligence_runtime") + expected_freshness = { + "status": "UNAVAILABLE", + "basis": ["NONE"], + "stale_points": [], + "status_response_sha256": None, + "error": "Code Intelligence is disabled by project configuration", + "needs_sync": False, + "pending_changes": None, + } + for command in ("status", "sync-if-needed"): + with self.subTest(command=command): + output = io.StringIO() + with ( + mock.patch.object(sys, "argv", ["code_intelligence_runtime.py", command, "--repo", str(self.repo), "--json"]), + mock.patch.object(runtime, "load_providers", side_effect=AssertionError("descriptor must not be loaded")) as providers, + mock.patch.object(runtime, "inspect_status", side_effect=AssertionError("CodeGraph status must not run")) as status, + mock.patch.object(runtime, "sync_if_needed", side_effect=AssertionError("CodeGraph sync must not run")) as sync, + redirect_stdout(output), + ): + self.assertEqual(runtime.main(), 0) + + payload = json.loads(output.getvalue()) + freshness = payload if command == "status" else payload["freshness"] + self.assertEqual( + {key: freshness[key] for key in expected_freshness}, + expected_freshness, + ) + if command == "sync-if-needed": + self.assertEqual(payload["status"], "PASS") + self.assertEqual( + payload["sync"], + {"status": "UNAVAILABLE", "response_sha256": None, "error": None}, + ) + providers.assert_not_called() + status.assert_not_called() + sync.assert_not_called() + def test_runtime_rejects_symlinked_runtime_directory_before_reading(self) -> None: (self.repo / "README.md").write_text("test repository\n", encoding="utf-8") subprocess.run(["git", "add", "README.md"], cwd=self.repo, check=True) From dacf04c378ea3371839c0769c9d030f93731d529 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 16:59:55 +0800 Subject: [PATCH 29/41] fix: audit CodeGraph banner evidence --- README.md | 2 +- README.zh-CN.md | 18 +- .../internal/code_intelligence_protocol.py | 22 ++- tests/test_codegraph.py | 167 +++++++++++++++++- 4 files changed, 204 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ab63121..546aba1 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ English | [简体中文](README.zh-CN.md) -> Current version: `0.1.19` (in development) +> Current protocol version: `0.1.19` (in development); workflow version: `0.1.2` 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. diff --git a/README.zh-CN.md b/README.zh-CN.md index 50573a9..8c5e374 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,7 +2,7 @@ [English](README.md) | 简体中文 -> 当前版本:`0.1.18`(开发中) +> 当前协议版本:`0.1.19`(开发中);Workflow 版本:`0.1.2` Polaris 是运行在 Coding Agent 宿主上的仓库原生工程工作流。它把需求、计划、实现、独立审查、验证和任务状态保存在 Git 仓库中,并通过确定性门禁防止需求漂移、证据过期和 Agent 自行宣布完成。 @@ -77,6 +77,20 @@ CLI 还提供 `vendor`、`init-project`、`init-task`、`migrate` 和可选的 ` python tools/polaris/scripts/transition_task.py TASK-0001 --repo . ``` +## 可选 CodeGraph 上下文 + +唯一正式的 Code Intelligence Provider 是 [colbymchenry/codegraph](https://github.com/colbymchenry/codegraph)。它是可选能力,永远不是 Workflow 门禁。仓库所有者(而不是 Polaris)在目标仓库中自行安装和初始化: + +```text +codegraph install +codegraph init +polaris code-intelligence add codegraph --repo . +``` + +`codegraph init` 创建 `.codegraph/` marker。只有目标仓库已经有这个 marker 且项目策略允许时,Polaris 才会使用 CodeGraph;没有 marker 时,记录 `UNAVAILABLE` 并直接使用源码和 Git。Polaris 只会读取 `status`、查询 `explore`,以及只在声明的阶段边界至多执行一次有界 `codegraph sync`;它绝不安装、初始化、启动、配置、等待或管理 CodeGraph、watcher、daemon 或 MCP 配置。 + +CodeGraph 的 watcher 和连接时 reconciliation 是正常情况下的实时更新机制。Polaris 只记录检查时的有限结论:`CURRENT_AT_CHECK`、`PARTIAL_STALE`、`INDEX_STALE`、`NOT_VERIFIED` 或 `UNAVAILABLE`,不会宣称与 Git commit 精确一致。`PARTIAL_STALE` 会精确列出待同步文件:当前普通文件必须直接读取并记录 `READ_SOURCE`;已删除文件必须检查注册 subject 的 Git diff 并记录 `INSPECT_GIT_DIFF`。`INDEX_STALE` 或 `NOT_VERIFIED` 时,图只能作为导航线索,Agent 必须通过仓库搜索和 Git 证据记录 `SEARCH_SOURCE`。Provider 不可用、status 不可读或 sync 失败都不阻断阶段;Validation 不调用 CodeGraph,仍以源码、Git、构建、测试、静态检查和 Human Check 为准。 + ## v0.1 边界 Polaris v0.1 包含: @@ -85,7 +99,7 @@ Polaris v0.1 包含: - 仓库内 JSON Authority、Workflow 和恢复索引; - 标准库实现的 Validator、handoff、迁移和恢复脚本; - 只负责定位和分发脚本的薄 CLI; -- 可选、非阻断的外部 Code Intelligence Provider。 +- 可选、非阻断的 [colbymchenry/codegraph](https://github.com/colbymchenry/codegraph) 上下文。 v0.1 不包含: diff --git a/scripts/internal/code_intelligence_protocol.py b/scripts/internal/code_intelligence_protocol.py index 21cbdf2..dbdef72 100644 --- a/scripts/internal/code_intelligence_protocol.py +++ b/scripts/internal/code_intelligence_protocol.py @@ -529,6 +529,20 @@ def _validate_v2_record_value( or not set(provider["available_operations"]).issubset(OPERATIONS) ): raise RuleFailure("Code Intelligence record names an invalid provider capability set") + freshness = value["freshness"] + if value["status"] == "UNAVAILABLE" and ( + freshness["status"] != "UNAVAILABLE" + or "RESPONSE_BANNER" in freshness["basis"] + ): + raise RuleFailure( + "UNAVAILABLE Code Intelligence record cannot claim observed graph freshness" + ) + if "RESPONSE_BANNER" in freshness["basis"] and ( + provider is None or "explore" not in provider["available_operations"] + ): + raise RuleFailure( + "RESPONSE_BANNER freshness requires an advertised explore capability" + ) query_ids = [item["id"] for item in value["queries"]] expected_ids = [f"CIQ-{index:03d}" for index in range(1, len(query_ids) + 1)] if query_ids != expected_ids: @@ -544,6 +558,13 @@ def _validate_v2_record_value( path = resolve_repo_reference(repo, symbol["path"]) if not path.is_file(): raise RuleFailure(f"Code Intelligence symbol path is not a file: {symbol['path']}") + if "RESPONSE_BANNER" in freshness["basis"] and not any( + query["status"] == "SUCCESS" and query["response_sha256"] is not None + for query in value["queries"] + ): + raise RuleFailure( + "RESPONSE_BANNER freshness requires a successful explore query with a response hash" + ) status_check = value["status_check"] if status_check is not None: status_check_status = status_check["status"] @@ -581,7 +602,6 @@ def _validate_v2_record_value( sync["response_sha256"] is not None or sync["error"] is not None ): raise RuleFailure("unavailable Code Intelligence sync cannot contain response or error evidence") - freshness = value["freshness"] status_check_success = status_check is not None and status_check["status"] == "SUCCESS" status_check_evidence = status_check is not None and status_check["status"] in { "SUCCESS", "FAILED" diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 1708664..86acbb3 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -128,7 +128,12 @@ def test_managed_surfaces_only_name_the_official_codegraph(self) -> None: and "__pycache__" not in path.parts and path != ROOT / "schemas" / "code-intelligence-record-v1.schema.json" ] - managed_paths.extend([ROOT / "README.md", ROOT / "docs" / "USAGE.md", ROOT / "plan.md"]) + managed_paths.extend([ + ROOT / "README.md", + ROOT / "README.zh-CN.md", + ROOT / "docs" / "USAGE.md", + ROOT / "plan.md", + ]) for path in managed_paths: text = path.read_text(encoding="utf-8") for retired in retired_names: @@ -136,10 +141,15 @@ def test_managed_surfaces_only_name_the_official_codegraph(self) -> None: for path in [ ROOT / "providers" / "code-intelligence" / "codegraph.json", ROOT / "README.md", + ROOT / "README.zh-CN.md", ROOT / "docs" / "USAGE.md", ROOT / "plan.md", ]: 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.19", text, path.relative_to(ROOT).as_posix()) + self.assertIn("0.1.2", text, path.relative_to(ROOT).as_posix()) def adapter_functions(self) -> tuple[object, object]: adapter_path = SCRIPTS / "internal" / "codegraph_adapter.py" @@ -176,6 +186,26 @@ def v2_record(self) -> dict[str, object]: ).stdout.strip() return value + @staticmethod + def add_explore_response_evidence(value: dict[str, object]) -> None: + """Make a fixture auditable as one successful CodeGraph explore response.""" + value["provider"] = { + "id": "codegraph", + "descriptor_version": 2, + "transport": "mcp", + "available_operations": ["explore"], + } + value["queries"] = [{ + "id": "CIQ-001", + "operation": "explore", + "purpose": "inspect CodeGraph response freshness", + "status": "SUCCESS", + "summary": "CodeGraph response", + "symbols": [], + "response_sha256": "0" * 64, + "error": None, + }] + def initialize_task(self) -> None: init_task(self.repo, "TASK-0001", "R1") @@ -426,6 +456,8 @@ def test_partial_stale_record_requires_matching_source_fallback(self) -> None: "observed_sha256": digest, }], } + value["status"] = "USED" + self.add_explore_response_evidence(value) with self.assertRaisesRegex(RuleFailure, "matching source fallback"): validate_record_value(self.repo, "TASK-0001", value, ROOT) value["source_fallbacks"] = [{ @@ -442,6 +474,137 @@ def test_partial_stale_record_requires_matching_source_fallback(self) -> None: 2, ) + def test_response_banner_evidence_requires_a_hashed_explore_query(self) -> None: + """A stale banner is valid only when this record can audit its explore response.""" + self.initialize_task() + source = self.repo / "src/widget.py" + source.parent.mkdir() + source.write_text("value = 1\n", encoding="utf-8") + digest = file_sha256(source) + value = self.v2_record() + value.update({ + "status": "USED", + "provider": { + "id": "codegraph", "descriptor_version": 2, "transport": "mcp", + "available_operations": ["status"], + }, + "freshness": { + "status": "PARTIAL_STALE", + "checked_at": "2026-08-18T00:00:00Z", + "basis": ["RESPONSE_BANNER"], + "stale_points": [{ + "scope": "FILE", + "path": "src/widget.py", + "reason": "PENDING_SYNC", + "fallback": "READ_SOURCE", + "observed_sha256": digest, + }], + }, + "source_fallbacks": [{ + "action": "READ_SOURCE", + "path": "src/widget.py", + "observed_sha256": digest, + "base_commit": None, + "head_commit": None, + "diff_hash": None, + "purpose": "confirm pending CodeGraph content", + }], + }) + with self.assertRaisesRegex(RuleFailure, "explore capability"): + validate_record_value(self.repo, "TASK-0001", value, ROOT) + + value["provider"] = { + "id": "codegraph", "descriptor_version": 2, "transport": "mcp", + "available_operations": ["explore"], + } + with self.assertRaisesRegex(RuleFailure, "successful explore query"): + validate_record_value(self.repo, "TASK-0001", value, ROOT) + + value["queries"] = [{ + "id": "CIQ-001", "operation": "explore", "purpose": "inspect stale response", + "status": "SUCCESS", "summary": "stale banner", "symbols": [], + "response_sha256": "0" * 64, "error": None, + }] + self.assertEqual( + validate_record_value(self.repo, "TASK-0001", value, ROOT)["record_version"], + 2, + ) + + def test_unavailable_record_cannot_claim_response_banner_evidence(self) -> None: + self.initialize_task() + value = self.v2_record() + value["freshness"] = { + "status": "UNAVAILABLE", + "checked_at": "2026-08-18T00:00:00Z", + "basis": ["RESPONSE_BANNER"], + "stale_points": [], + } + with self.assertRaisesRegex(RuleFailure, "cannot claim observed graph freshness"): + validate_record_value(self.repo, "TASK-0001", value, ROOT) + + def test_auditable_response_banners_preserve_noncurrent_freshness_states(self) -> None: + self.initialize_task() + source = self.repo / "src/widget.py" + source.parent.mkdir() + source.write_text("value = 1\n", encoding="utf-8") + digest = file_sha256(source) + cases = [ + ( + "PARTIAL_STALE", + [{ + "scope": "FILE", "path": "src/widget.py", "reason": "PENDING_SYNC", + "fallback": "READ_SOURCE", "observed_sha256": digest, + }], + [{ + "action": "READ_SOURCE", "path": "src/widget.py", + "observed_sha256": digest, "base_commit": None, "head_commit": None, + "diff_hash": None, "purpose": "read stale response source", + }], + ), + ( + "INDEX_STALE", + [{ + "scope": "INDEX", "path": None, "reason": "AUTO_SYNC_DISABLED", + "fallback": "SEARCH_SOURCE", "observed_sha256": None, + }], + [{ + "action": "SEARCH_SOURCE", "path": None, "observed_sha256": None, + "base_commit": None, "head_commit": None, "diff_hash": None, + "purpose": "search frozen CodeGraph index scope", + }], + ), + ( + "NOT_VERIFIED", + [{ + "scope": "INDEX", "path": None, "reason": "STATUS_UNREADABLE", + "fallback": "SEARCH_SOURCE", "observed_sha256": None, + }], + [{ + "action": "SEARCH_SOURCE", "path": None, "observed_sha256": None, + "base_commit": None, "head_commit": None, "diff_hash": None, + "purpose": "search after unreadable CodeGraph response", + }], + ), + ] + for status, stale_points, fallbacks in cases: + with self.subTest(status=status): + value = self.v2_record() + value.update({ + "status": "USED", + "freshness": { + "status": status, + "checked_at": "2026-08-18T00:00:00Z", + "basis": ["RESPONSE_BANNER"], + "stale_points": stale_points, + }, + "source_fallbacks": fallbacks, + }) + self.add_explore_response_evidence(value) + self.assertEqual( + validate_record_value(self.repo, "TASK-0001", value, ROOT)["record_version"], + 2, + ) + def test_v2_freshness_statuses_require_consistent_stale_points(self) -> None: self.initialize_task() value = self.v2_record() @@ -505,6 +668,7 @@ def test_v2_fallback_and_sync_rules_are_auditable(self) -> None: "fallback": "READ_SOURCE", "observed_sha256": digest, }], } + self.add_explore_response_evidence(value) value["source_fallbacks"] = [{ "action": "READ_SOURCE", "path": "src/widget.py", "observed_sha256": "0" * 64, "base_commit": None, "head_commit": None, "diff_hash": None, "purpose": "read source", @@ -685,6 +849,7 @@ def test_inspect_git_diff_is_only_valid_for_missing_stale_files(self) -> None: "diff_hash": subject_diff_hash(self.repo, base, added_head), "purpose": "inspect change", }], }) + self.add_explore_response_evidence(value) with self.assertRaisesRegex(RuleFailure, "existing file"): validate_record_value(self.repo, "TASK-0001", value, ROOT) source.unlink() From f2092a2b6df9e6ab949d3cc87e416bc19b496969 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 17:04:12 +0800 Subject: [PATCH 30/41] test: preserve CodeGraph README boundaries --- README.md | 2 +- README.zh-CN.md | 2 +- tests/test_codegraph.py | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 546aba1..0e53b0e 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ codegraph init polaris code-intelligence add codegraph --repo . ``` -Run these commands from the target repository as appropriate. `codegraph init` creates the `.codegraph/` marker; without it Polaris records `UNAVAILABLE` and uses source and Git directly. Polaris can only read CodeGraph status, explore indexed relationships, and perform one bounded `codegraph sync` at a declared stage boundary. It never installs, initializes, starts, configures, waits for, or manages CodeGraph or its watcher/daemon/MCP configuration. +Run these commands from the target repository as appropriate. `codegraph init` creates the `.codegraph/` marker; without it Polaris records `UNAVAILABLE` and uses source and Git directly. Polaris can only read CodeGraph status, explore indexed relationships, and perform one bounded `codegraph sync` at a declared stage boundary. It never installs, initializes, starts, configures, reconfigures, waits for, or manages CodeGraph or its watcher/daemon/MCP configuration. CodeGraph's watcher and connection reconciliation are the primary freshness mechanisms. Polaris records a limited conclusion at the time it checks: `CURRENT_AT_CHECK`, `PARTIAL_STALE`, `INDEX_STALE`, `NOT_VERIFIED`, or `UNAVAILABLE`; it never claims commit-exact graph freshness. A `PARTIAL_STALE` response names specific files: read each current file directly (`READ_SOURCE`), or inspect the registered Git diff if it was deleted (`INSPECT_GIT_DIFF`). For `INDEX_STALE` or `NOT_VERIFIED`, treat the graph only as a lead and search the repository plus Git (`SEARCH_SOURCE`). Validation remains graph-free and relies on source, Git, builds, tests, static checks, and Human Checks. diff --git a/README.zh-CN.md b/README.zh-CN.md index 8c5e374..86e6cdf 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -87,7 +87,7 @@ codegraph init polaris code-intelligence add codegraph --repo . ``` -`codegraph init` 创建 `.codegraph/` marker。只有目标仓库已经有这个 marker 且项目策略允许时,Polaris 才会使用 CodeGraph;没有 marker 时,记录 `UNAVAILABLE` 并直接使用源码和 Git。Polaris 只会读取 `status`、查询 `explore`,以及只在声明的阶段边界至多执行一次有界 `codegraph sync`;它绝不安装、初始化、启动、配置、等待或管理 CodeGraph、watcher、daemon 或 MCP 配置。 +`codegraph init` 创建 `.codegraph/` marker。只有目标仓库已经有这个 marker 且项目策略允许时,Polaris 才会使用 CodeGraph;没有 marker 时,记录 `UNAVAILABLE` 并直接使用源码和 Git。Polaris 只会读取 `status`、查询 `explore`,以及只在声明的阶段边界至多执行一次有界 `codegraph sync`;它绝不安装、初始化、启动、配置、重新配置、等待或管理 CodeGraph、watcher、daemon 或 MCP 配置。 CodeGraph 的 watcher 和连接时 reconciliation 是正常情况下的实时更新机制。Polaris 只记录检查时的有限结论:`CURRENT_AT_CHECK`、`PARTIAL_STALE`、`INDEX_STALE`、`NOT_VERIFIED` 或 `UNAVAILABLE`,不会宣称与 Git commit 精确一致。`PARTIAL_STALE` 会精确列出待同步文件:当前普通文件必须直接读取并记录 `READ_SOURCE`;已删除文件必须检查注册 subject 的 Git diff 并记录 `INSPECT_GIT_DIFF`。`INDEX_STALE` 或 `NOT_VERIFIED` 时,图只能作为导航线索,Agent 必须通过仓库搜索和 Git 证据记录 `SEARCH_SOURCE`。Provider 不可用、status 不可读或 sync 失败都不阻断阶段;Validation 不调用 CodeGraph,仍以源码、Git、构建、测试、静态检查和 Human Check 为准。 diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 86acbb3..3e6d36c 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -151,6 +151,44 @@ def test_managed_surfaces_only_name_the_official_codegraph(self) -> None: self.assertIn("0.1.19", text, path.relative_to(ROOT).as_posix()) self.assertIn("0.1.2", text, path.relative_to(ROOT).as_posix()) + def test_readmes_keep_codegraph_operational_boundaries(self) -> None: + """User-facing authorities retain the source-fallback and ownership boundaries.""" + shared_anchors = [ + "codegraph install", + "codegraph init", + ".codegraph/", + "codegraph sync", + "READ_SOURCE", + "INSPECT_GIT_DIFF", + "SEARCH_SOURCE", + "Validation", + "daemon", + ] + localized_anchors = { + ROOT / "README.md": [ + "repository owner, not Polaris", + "one bounded", + "reconfigures", + "workflow gate", + "Validation remains graph-free", + ], + ROOT / "README.zh-CN.md": [ + "仓库所有者(而不是 Polaris)", + "至多执行一次有界", + "重新配置", + "Workflow 门禁", + "Validation 不调用 CodeGraph", + ], + } + for path, anchors in localized_anchors.items(): + with self.subTest(path=path.name): + text = path.read_text(encoding="utf-8") + for anchor in [*shared_anchors, *anchors]: + self.assertIn(anchor, text, f"{path.name}: {anchor}") + mutated = text.replace("codegraph sync", "codegraph-sync", 1) + with self.assertRaises(AssertionError): + self.assertIn("codegraph sync", mutated, path.name) + def adapter_functions(self) -> tuple[object, object]: adapter_path = SCRIPTS / "internal" / "codegraph_adapter.py" self.assertTrue( From 66f37b2f2b8422a8e8f6ad2c5c9d092b35781b4c Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 17:13:02 +0800 Subject: [PATCH 31/41] fix: gate CodeGraph response classification --- scripts/code_intelligence_runtime.py | 56 +++++++++--- tests/test_codegraph.py | 127 +++++++++++++++++++++++++++ 2 files changed, 171 insertions(+), 12 deletions(-) diff --git a/scripts/code_intelligence_runtime.py b/scripts/code_intelligence_runtime.py index 9d0b01c..4604598 100644 --- a/scripts/code_intelligence_runtime.py +++ b/scripts/code_intelligence_runtime.py @@ -8,10 +8,15 @@ from pathlib import Path from typing import Any -from internal.code_intelligence_protocol import load_config, load_providers +from internal.code_intelligence_protocol import ( + _project_marker_path, + load_config, + load_providers, +) from internal.codegraph_adapter import classify_response, inspect_status, sync_if_needed from internal.polaris_core import ( InputFailure, + RuleFailure, protocol_root, require_protocol_compatible, run_main, @@ -54,20 +59,29 @@ def _runtime_input(repo: Path, task_id: str, value: Path) -> Path: return candidate -def _disabled_freshness() -> dict[str, Any]: - """Return the provider-neutral result for an explicitly disabled project.""" +def _unavailable_freshness(error: str) -> dict[str, Any]: + """Return a provider-neutral result without using CodeGraph.""" return { "status": "UNAVAILABLE", "checked_at": utc_now(), "basis": ["NONE"], "stale_points": [], "status_response_sha256": None, - "error": "Code Intelligence is disabled by project configuration", + "error": error, "needs_sync": False, "pending_changes": None, } +def _initialized_marker(repo: Path) -> bool: + """Check the fixed official CodeGraph marker before loading its descriptor.""" + try: + marker = _project_marker_path(repo, ".codegraph") + except RuleFailure: + return False + return marker.is_dir() and not marker.is_symlink() + + def main() -> int: parser = argparse.ArgumentParser() commands = parser.add_subparsers(dest="command", required=True) @@ -84,18 +98,29 @@ def main() -> int: def execute() -> dict[str, Any]: require_protocol_compatible(repo) - if args.command == "classify-response": - input_path = _runtime_input(repo, args.task_id, args.input) - try: - response = input_path.read_text(encoding="utf-8") - except UnicodeDecodeError as error: - raise InputFailure("CodeGraph response input is not UTF-8") from error - return classify_response(repo, response) root = protocol_root(repo) if load_config(repo, root)["mode"] == "disabled": - freshness = _disabled_freshness() + freshness = _unavailable_freshness( + "Code Intelligence is disabled by project configuration" + ) if args.command == "status": return freshness + if args.command == "classify-response": + return freshness + return { + "freshness": freshness, + "sync": { + "status": "UNAVAILABLE", + "response_sha256": None, + "error": None, + }, + } + if not _initialized_marker(repo): + freshness = _unavailable_freshness( + "CodeGraph project marker is unavailable" + ) + if args.command in {"status", "classify-response"}: + return freshness return { "freshness": freshness, "sync": { @@ -104,6 +129,13 @@ def execute() -> dict[str, Any]: "error": None, }, } + if args.command == "classify-response": + input_path = _runtime_input(repo, args.task_id, args.input) + try: + response = input_path.read_text(encoding="utf-8") + except UnicodeDecodeError as error: + raise InputFailure("CodeGraph response input is not UTF-8") from error + return classify_response(repo, response) descriptor = load_providers(root)["codegraph"] if args.command == "status": return inspect_status(repo, descriptor) diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 3e6d36c..adc5c3f 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -1916,6 +1916,7 @@ def test_malformed_recognized_banner_downgrades_status(self) -> None: self.assertEqual(result["status"], "NOT_VERIFIED") def test_runtime_classify_response_confines_input_and_emits_json(self) -> None: + (self.repo / ".codegraph").mkdir() (self.repo / "README.md").write_text("test repository\n", encoding="utf-8") subprocess.run(["git", "add", "README.md"], cwd=self.repo, check=True) subprocess.run( @@ -2028,7 +2029,133 @@ def test_runtime_disabled_mode_skips_status_and_sync_without_calling_codegraph(s status.assert_not_called() sync.assert_not_called() + def test_runtime_classify_disabled_skips_input_descriptor_and_classifier(self) -> None: + """A disabled project does not inspect a raw CodeGraph response.""" + (self.repo / ".codegraph").mkdir() + write_json_atomic( + self.repo / ".polaris" / "code-intelligence.json", + { + "config_version": 1, + "mode": "disabled", + "provider_priority": [], + "include": [], + "exclude": [], + }, + ) + runtime = importlib.import_module("code_intelligence_runtime") + output = io.StringIO() + with ( + mock.patch.object( + sys, + "argv", + [ + "code_intelligence_runtime.py", + "classify-response", + "TASK-0001", + "--input", + "must-not-be-read.txt", + "--repo", + str(self.repo), + "--json", + ], + ), + mock.patch.object( + runtime, + "_runtime_input", + side_effect=AssertionError("response input must not be read"), + ) as input_path, + mock.patch.object( + runtime, + "load_providers", + side_effect=AssertionError("descriptor must not be loaded"), + ) as providers, + mock.patch.object( + runtime, + "classify_response", + side_effect=AssertionError("response must not be classified"), + ) as classifier, + redirect_stdout(output), + ): + self.assertEqual(runtime.main(), 0) + + payload = json.loads(output.getvalue()) + self.assertEqual(payload["status"], "UNAVAILABLE") + self.assertEqual(payload["basis"], ["NONE"]) + self.assertEqual(payload["stale_points"], []) + self.assertEqual(payload["status_response_sha256"], None) + self.assertEqual( + payload["error"], "Code Intelligence is disabled by project configuration" + ) + self.assertFalse(payload["needs_sync"]) + self.assertIsNone(payload["pending_changes"]) + input_path.assert_not_called() + providers.assert_not_called() + classifier.assert_not_called() + + def test_runtime_classify_without_safe_marker_skips_input_descriptor_and_classifier(self) -> None: + """An absent or symlinked marker never authorizes response parsing.""" + runtime = importlib.import_module("code_intelligence_runtime") + marker = self.repo / ".codegraph" + target = self.repo / "marker-target" + target.mkdir() + + for unsafe_marker in (False, True): + with self.subTest(unsafe_marker=unsafe_marker): + if marker.exists() or marker.is_symlink(): + marker.unlink() + if unsafe_marker: + marker.symlink_to(target, target_is_directory=True) + output = io.StringIO() + with ( + mock.patch.object( + sys, + "argv", + [ + "code_intelligence_runtime.py", + "classify-response", + "TASK-0001", + "--input", + "must-not-be-read.txt", + "--repo", + str(self.repo), + "--json", + ], + ), + mock.patch.object( + runtime, + "_runtime_input", + side_effect=AssertionError("response input must not be read"), + ) as input_path, + mock.patch.object( + runtime, + "load_providers", + side_effect=AssertionError("descriptor must not be loaded"), + ) as providers, + mock.patch.object( + runtime, + "classify_response", + side_effect=AssertionError("response must not be classified"), + ) as classifier, + redirect_stdout(output), + ): + self.assertEqual(runtime.main(), 0) + + payload = json.loads(output.getvalue()) + self.assertEqual(payload["status"], "UNAVAILABLE") + self.assertEqual(payload["basis"], ["NONE"]) + self.assertEqual(payload["stale_points"], []) + self.assertEqual(payload["status_response_sha256"], None) + self.assertEqual( + payload["error"], "CodeGraph project marker is unavailable" + ) + self.assertFalse(payload["needs_sync"]) + self.assertIsNone(payload["pending_changes"]) + input_path.assert_not_called() + providers.assert_not_called() + classifier.assert_not_called() + def test_runtime_rejects_symlinked_runtime_directory_before_reading(self) -> None: + (self.repo / ".codegraph").mkdir() (self.repo / "README.md").write_text("test repository\n", encoding="utf-8") subprocess.run(["git", "add", "README.md"], cwd=self.repo, check=True) subprocess.run( From 290721cb737702fe4d6cffd605064bd72efcafe1 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 17:20:40 +0800 Subject: [PATCH 32/41] fix: normalize Windows CodeGraph banner paths --- scripts/internal/codegraph_adapter.py | 19 ++++++++- tests/test_codegraph.py | 59 +++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/scripts/internal/codegraph_adapter.py b/scripts/internal/codegraph_adapter.py index 87d2c81..f908ee5 100644 --- a/scripts/internal/codegraph_adapter.py +++ b/scripts/internal/codegraph_adapter.py @@ -145,8 +145,23 @@ def _response_not_verified(checked_at: str, error: BaseException | str) -> dict[ ) +def _normalize_banner_path(raw_path: str) -> str: + """Translate a safe Windows-relative banner path to portable POSIX form.""" + if ( + not raw_path + or raw_path.startswith(("/", "\\")) + or re.match(r"^[A-Za-z]:", raw_path) is not None + ): + raise ValueError(f"invalid CodeGraph stale path: {raw_path}") + normalized = raw_path.replace("\\", "/") + if any(part in {"", ".", ".."} for part in normalized.split("/")): + raise ValueError(f"invalid CodeGraph stale path: {raw_path}") + return normalized + + def _response_file_point(repo: Path, raw_path: str) -> dict[str, Any]: - target = resolve_repo_reference(repo, raw_path) + path = _normalize_banner_path(raw_path) + target = resolve_repo_reference(repo, path) if target.exists(): if target.is_symlink() or not target.is_file(): raise ValueError(f"CodeGraph stale path is not a regular file: {raw_path}") @@ -157,7 +172,7 @@ def _response_file_point(repo: Path, raw_path: str) -> dict[str, Any]: observed_sha256 = None return { "scope": "FILE", - "path": raw_path, + "path": path, "reason": "PENDING_SYNC", "fallback": fallback, "observed_sha256": observed_sha256, diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index adc5c3f..53da0be 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -1814,6 +1814,65 @@ def test_response_banner_missing_file_requires_git_diff(self) -> None: self.assertEqual(result["stale_points"][0]["fallback"], "INSPECT_GIT_DIFF") self.assertIsNone(result["stale_points"][0]["observed_sha256"]) + def test_response_banner_normalizes_safe_windows_relative_paths(self) -> None: + source = self.repo / "src/nested/widget.py" + source.parent.mkdir(parents=True) + source.write_text("def widget():\n return 1\n", encoding="utf-8") + prefix = """⚠️ Some files referenced below were edited since the last index sync — +their codegraph entries may be stale: +""" + suffix = "For accurate content of those specific files, Read them directly.\n" + cases = [ + ("src\\nested\\widget.py", "READ_SOURCE", file_sha256(source)), + ("src\\nested/deleted.py", "INSPECT_GIT_DIFF", None), + ] + + for listed_path, fallback, digest in cases: + with self.subTest(listed_path=listed_path): + result = self.classify_response( + f"{prefix} - {listed_path} (edited 800ms ago, pending sync)\n{suffix}" + ) + + self.assertEqual(result["classification"], "PARTIAL_STALE") + point = result["stale_points"][0] + self.assertEqual( + point["path"], + "src/nested/widget.py" + if fallback == "READ_SOURCE" + else "src/nested/deleted.py", + ) + self.assertEqual(point["fallback"], fallback) + self.assertEqual(point["observed_sha256"], digest) + + def test_response_banner_rejects_unsafe_windows_style_paths(self) -> None: + prefix = """⚠️ Some files referenced below were edited since the last index sync — +their codegraph entries may be stale: +""" + suffix = "For accurate content of those specific files, Read them directly.\n" + unsafe_paths = ( + "C:\\src\\widget.py", + "C:/src/widget.py", + "\\\\server\\share\\widget.py", + "\\\\?\\C:\\src\\widget.py", + "\\\\.\\PhysicalDrive0", + "\\src\\widget.py", + "/src/widget.py", + "src\\\\widget.py", + "src\\.\\widget.py", + "src\\..\\outside.py", + ) + + for listed_path in unsafe_paths: + with self.subTest(listed_path=listed_path): + result = self.classify_response( + f"{prefix} - {listed_path} (edited 800ms ago, pending sync)\n{suffix}" + ) + + self.assertEqual(result["classification"], "NOT_VERIFIED") + self.assertEqual( + result["stale_points"][0]["reason"], "STATUS_UNREADABLE" + ) + def test_arbitrary_warning_is_not_a_codegraph_banner(self) -> None: result = self.classify_response("⚠️ maybe stale: src/widget.py\n") From 37737fec771dafbac74a7a1b2c045d9651964481 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 17:34:51 +0800 Subject: [PATCH 33/41] fix: bind CodeGraph freshness evidence --- schemas/code-intelligence-record.schema.json | 33 ++- .../internal/code_intelligence_protocol.py | 51 ++++- scripts/internal/polaris_core.py | 5 + skills/adversarial-review/SKILL.md | 2 +- skills/architecture-planning/SKILL.md | 2 +- skills/code-intelligence/SKILL.md | 4 +- skills/documentation-sync/SKILL.md | 2 +- skills/implementation/SKILL.md | 2 +- templates/AGENTS.md | 2 +- .../code-intelligence-record.json | 1 + .../task/code-intelligence/r001/planning.json | 1 + tests/test_codegraph.py | 189 ++++++++++++++++-- tests/test_core.py | 4 + 13 files changed, 271 insertions(+), 27 deletions(-) diff --git a/schemas/code-intelligence-record.schema.json b/schemas/code-intelligence-record.schema.json index 3e5ecb8..14ca0f6 100644 --- a/schemas/code-intelligence-record.schema.json +++ b/schemas/code-intelligence-record.schema.json @@ -300,6 +300,7 @@ "status", "checked_at", "basis", + "response_sha256", "stale_points" ], "additionalProperties": false, @@ -318,6 +319,13 @@ "type": "string", "minLength": 1 }, + "response_sha256": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{64}$" + }, "basis": { "type": "array", "minItems": 1, @@ -405,7 +413,8 @@ "base_commit", "head_commit", "diff_hash", - "purpose" + "purpose", + "result_paths" ], "additionalProperties": false, "properties": { @@ -453,6 +462,28 @@ }, "purpose": { "type": "string" + }, + "result_paths": { + "type": "array", + "maxItems": 100, + "items": { + "type": "object", + "required": [ + "path", + "observed_sha256" + ], + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "observed_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } + } } } } diff --git a/scripts/internal/code_intelligence_protocol.py b/scripts/internal/code_intelligence_protocol.py index dbdef72..5ae7ec6 100644 --- a/scripts/internal/code_intelligence_protocol.py +++ b/scripts/internal/code_intelligence_protocol.py @@ -39,6 +39,7 @@ LEGACY_REFRESH_ACKNOWLEDGED = "refresh_" + "acknowledged" LEGACY_SPOT_CHECKED = "spot_" + "checked" LEGACY_NOT_VERIFIED = "not_" + "verified" +MAX_SEARCH_SOURCE_RESULTS = 100 def _validate_pattern(value: str) -> None: @@ -407,8 +408,11 @@ def _validate_source_fallbacks( action = fallback["action"] path = fallback["path"] digest = fallback["observed_sha256"] + result_paths = fallback["result_paths"] if not fallback["purpose"]: raise RuleFailure("source fallback requires a non-empty purpose") + if action != "SEARCH_SOURCE" and result_paths: + raise RuleFailure("non-SEARCH_SOURCE fallback must use empty result_paths") if action == "READ_SOURCE": if not isinstance(path, str) or digest is None: raise RuleFailure("READ_SOURCE fallback requires a current SHA") @@ -433,6 +437,25 @@ def _validate_source_fallbacks( raise RuleFailure("SEARCH_SOURCE fallback cannot name a path or SHA") if any(item is not None for item in (fallback["base_commit"], fallback["head_commit"], fallback["diff_hash"])): raise RuleFailure("SEARCH_SOURCE fallback cannot claim a Git diff target") + if len(result_paths) > MAX_SEARCH_SOURCE_RESULTS: + raise RuleFailure( + f"SEARCH_SOURCE fallback may record at most {MAX_SEARCH_SOURCE_RESULTS} result paths" + ) + seen_paths: set[str] = set() + for result in result_paths: + result_path = result["path"] + if result_path in seen_paths: + raise RuleFailure("duplicate SEARCH_SOURCE result path") + seen_paths.add(result_path) + resolved = resolve_repo_reference(repo, result_path) + if not resolved.is_file(): + raise RuleFailure( + f"SEARCH_SOURCE result is not a current regular file: {result_path}" + ) + if file_sha256(resolved) != result["observed_sha256"]: + raise RuleFailure( + f"SEARCH_SOURCE result hash is stale: {result_path}" + ) def _validate_v2_freshness( @@ -530,6 +553,8 @@ def _validate_v2_record_value( ): raise RuleFailure("Code Intelligence record names an invalid provider capability set") freshness = value["freshness"] + if freshness["status"] == "UNAVAILABLE" and freshness["response_sha256"] is not None: + raise RuleFailure("UNAVAILABLE freshness response hash must be null") if value["status"] == "UNAVAILABLE" and ( freshness["status"] != "UNAVAILABLE" or "RESPONSE_BANNER" in freshness["basis"] @@ -558,13 +583,25 @@ def _validate_v2_record_value( path = resolve_repo_reference(repo, symbol["path"]) if not path.is_file(): raise RuleFailure(f"Code Intelligence symbol path is not a file: {symbol['path']}") - if "RESPONSE_BANNER" in freshness["basis"] and not any( - query["status"] == "SUCCESS" and query["response_sha256"] is not None - for query in value["queries"] - ): - raise RuleFailure( - "RESPONSE_BANNER freshness requires a successful explore query with a response hash" - ) + if "RESPONSE_BANNER" in freshness["basis"]: + response_sha256 = freshness["response_sha256"] + successful_explore_hashes = { + query["response_sha256"] + for query in value["queries"] + if query["status"] == "SUCCESS" and query["response_sha256"] is not None + } + if not successful_explore_hashes: + raise RuleFailure( + "RESPONSE_BANNER freshness requires a successful explore query with a response hash" + ) + if response_sha256 is None: + raise RuleFailure("RESPONSE_BANNER freshness response hash is required") + if response_sha256 not in successful_explore_hashes: + raise RuleFailure( + "RESPONSE_BANNER freshness response hash must match a successful explore query" + ) + elif freshness["response_sha256"] is not None: + raise RuleFailure("freshness response hash requires RESPONSE_BANNER basis") status_check = value["status_check"] if status_check is not None: status_check_status = status_check["status"] diff --git a/scripts/internal/polaris_core.py b/scripts/internal/polaris_core.py index f8b61d7..905c7f5 100644 --- a/scripts/internal/polaris_core.py +++ b/scripts/internal/polaris_core.py @@ -22,6 +22,7 @@ "const", "enum", "items", + "maxItems", "minItems", "minLength", "minimum", @@ -338,6 +339,10 @@ def validate_schema(value: Any, schema: dict[str, Any], location: str = "$") -> errors.append( f"{location}: item count is below minItems {schema['minItems']}" ) + if "maxItems" in schema and len(value) > schema["maxItems"]: + errors.append( + f"{location}: item count exceeds maxItems {schema['maxItems']}" + ) if schema.get("uniqueItems") is True: duplicate = next( ( diff --git a/skills/adversarial-review/SKILL.md b/skills/adversarial-review/SKILL.md index e0cb778..86de770 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`. -CodeGraph fallback contract: never run `codegraph init` or manage the Provider. Save and classify each response. For `PARTIAL_STALE`, if a named path is a current confined regular file, directly read it and record `READ_SOURCE` with its current SHA-256; if a safe path is missing/deleted, inspect the registered subject Git diff and record `INSPECT_GIT_DIFF` with null observed SHA-256 and bound base/head/diff evidence; for unsafe paths, record `NOT_VERIFIED` and use source search. For `INDEX_STALE` or `NOT_VERIFIED`, use source search and Git evidence, then stop graph calls for this stage. Graph evidence cannot determine the Review verdict. +CodeGraph fallback contract: never run `codegraph init` or manage the Provider. Save and classify each response; when `RESPONSE_BANNER` is present, persist its successful explore response hash as `freshness.response_sha256`. For `PARTIAL_STALE`, if a named path is a current confined regular file, directly read it and record `READ_SOURCE` with its current SHA-256; if a safe path is missing/deleted, inspect the registered subject Git diff and record `INSPECT_GIT_DIFF` with null observed SHA-256 and bound base/head/diff evidence; for unsafe paths, record `NOT_VERIFIED` and use source search. For `INDEX_STALE` or `NOT_VERIFIED`, use source search and Git evidence, then stop graph calls for this stage. Each `SEARCH_SOURCE` fallback records `result_paths`: zero or at most 100 unique POSIX paths, each a current confined regular file with its current SHA-256; non-`SEARCH_SOURCE` fallbacks use empty `result_paths`. Graph evidence cannot determine the Review verdict. diff --git a/skills/architecture-planning/SKILL.md b/skills/architecture-planning/SKILL.md index 9413815..b0a3bd8 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. -CodeGraph fallback contract: never run `codegraph init` or manage the Provider. Save and classify each response. For `PARTIAL_STALE`, if a named path is a current confined regular file, directly read it and record `READ_SOURCE` with its current SHA-256; if a safe path is missing/deleted, inspect the registered subject Git diff and record `INSPECT_GIT_DIFF` with null observed SHA-256 and bound base/head/diff evidence; for unsafe paths, record `NOT_VERIFIED` and use source search. For `INDEX_STALE` or `NOT_VERIFIED`, use source search and Git evidence, then stop graph calls for this stage. Graph evidence cannot expand scope or act as a gate. +CodeGraph fallback contract: never run `codegraph init` or manage the Provider. Save and classify each response; when `RESPONSE_BANNER` is present, persist its successful explore response hash as `freshness.response_sha256`. For `PARTIAL_STALE`, if a named path is a current confined regular file, directly read it and record `READ_SOURCE` with its current SHA-256; if a safe path is missing/deleted, inspect the registered subject Git diff and record `INSPECT_GIT_DIFF` with null observed SHA-256 and bound base/head/diff evidence; for unsafe paths, record `NOT_VERIFIED` and use source search. For `INDEX_STALE` or `NOT_VERIFIED`, use source search and Git evidence, then stop graph calls for this stage. Each `SEARCH_SOURCE` fallback records `result_paths`: zero or at most 100 unique POSIX paths, each a current confined regular file with its current SHA-256; non-`SEARCH_SOURCE` fallbacks use empty `result_paths`. Graph evidence cannot expand scope or act as a gate. diff --git a/skills/code-intelligence/SKILL.md b/skills/code-intelligence/SKILL.md index 099c6b9..d24375e 100644 --- a/skills/code-intelligence/SKILL.md +++ b/skills/code-intelligence/SKILL.md @@ -10,9 +10,9 @@ Treat Code Intelligence as read-only, best-effort evidence. Source, Git, builds, 1. Load `.polaris/code-intelligence.json` and project rules. If the policy disables Code Intelligence, record `UNAVAILABLE` and use the stage's source path. Use CodeGraph only when the repository root has an existing `.codegraph/` directory. When it is absent, record `UNAVAILABLE`, stop CodeGraph calls for this project for the session, and tell the user they may choose to initialize it; never run `codegraph init`. 2. At the calling stage's declared boundary, run `code_intelligence_runtime.py status` or `sync-if-needed`. The latter may run one bounded `codegraph sync` only when status reports pending changes; it never loops, waits for a watcher, or treats a successful command as a gate. 3. For an allowed frozen-scope relationship query, use only `codegraph_explore` when MCP exposes it. If MCP is unavailable and the executable is available, use `codegraph explore` as the non-MCP fallback. Do not select retired narrow operations. Bound the query to the Work Item, Working Set, registered subject, or a confirmed dependency; graph output cannot expand frozen scope, authorize change, satisfy acceptance, or determine a Review verdict. -4. Save each raw explore response only below the task's ignored `runtime/code-intelligence/` directory, then run `code_intelligence_runtime.py classify-response` for it. Final records contain the response hash and finite summary, never the response itself. +4. Save each raw explore response only below the task's ignored `runtime/code-intelligence/` directory, then run `code_intelligence_runtime.py classify-response` for it. When `RESPONSE_BANNER` is a freshness basis, persist that successful explore response hash as `freshness.response_sha256`; final records contain the response hash and finite summary, never the response itself. 5. On `PARTIAL_STALE`, process every named path by its current safe state. If it is a current confined regular file, directly read it and record `READ_SOURCE` with its current SHA-256. If a safe path is missing/deleted, inspect the registered subject Git diff and record `INSPECT_GIT_DIFF` with null observed SHA-256 and bound base/head/diff evidence. For unsafe paths, record `NOT_VERIFIED` and use source search. The remaining graph response may still be navigation evidence, but never a conclusion about a stale path. -6. On `INDEX_STALE` or `NOT_VERIFIED`, use repository source search and Git evidence, record the `SEARCH_SOURCE` fallback, and stop repeated graph calls for that stage. On malformed, missing, or unavailable Provider output, continue the same source fallback without blocking the stage. +6. On `INDEX_STALE` or `NOT_VERIFIED`, use repository source search and Git evidence, record the `SEARCH_SOURCE` fallback, and stop repeated graph calls for that stage. Every `SEARCH_SOURCE` fallback records `result_paths`: zero or at most 100 unique POSIX paths, each a current confined regular file with its current SHA-256; non-`SEARCH_SOURCE` fallbacks use empty `result_paths`. On malformed, missing, or unavailable Provider output, continue the same source fallback without blocking the stage. 7. Never initialize, install, start, authenticate, or reconfigure CodeGraph. Do not manage its watcher, daemon, lock, or host MCP settings. 8. Finalize an immutable v2 Code Intelligence record with the stage's actual freshness, stale points, and source fallbacks. Code Intelligence is never a workflow gate. diff --git a/skills/documentation-sync/SKILL.md b/skills/documentation-sync/SKILL.md index cd0cb4d..e22a2ea 100644 --- a/skills/documentation-sync/SKILL.md +++ b/skills/documentation-sync/SKILL.md @@ -20,4 +20,4 @@ Do not run `SYNC_DOCS` or emit a Polaris checkpoint marker. The main `{{skill:en Do not edit Review, Validation, Result, event, or state artifacts directly. -CodeGraph fallback contract: never run `codegraph init` or manage the Provider. Save and classify each response. For `PARTIAL_STALE`, if a named path is a current confined regular file, directly read it and record `READ_SOURCE` with its current SHA-256; if a safe path is missing/deleted, inspect the registered subject Git diff and record `INSPECT_GIT_DIFF` with null observed SHA-256 and bound base/head/diff evidence; for unsafe paths, record `NOT_VERIFIED` and use source search. For `INDEX_STALE` or `NOT_VERIFIED`, use source search and Git evidence, then stop graph calls for this stage. Graph evidence never gates documentation checks or state changes. +CodeGraph fallback contract: never run `codegraph init` or manage the Provider. Save and classify each response; when `RESPONSE_BANNER` is present, persist its successful explore response hash as `freshness.response_sha256`. For `PARTIAL_STALE`, if a named path is a current confined regular file, directly read it and record `READ_SOURCE` with its current SHA-256; if a safe path is missing/deleted, inspect the registered subject Git diff and record `INSPECT_GIT_DIFF` with null observed SHA-256 and bound base/head/diff evidence; for unsafe paths, record `NOT_VERIFIED` and use source search. For `INDEX_STALE` or `NOT_VERIFIED`, use source search and Git evidence, then stop graph calls for this stage. Each `SEARCH_SOURCE` fallback records `result_paths`: zero or at most 100 unique POSIX paths, each a current confined regular file with its current SHA-256; non-`SEARCH_SOURCE` fallbacks use empty `result_paths`. Graph evidence never gates documentation checks or state changes. diff --git a/skills/implementation/SKILL.md b/skills/implementation/SKILL.md index e3b96b6..ac7bec1 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 `FINISH_IMPLEMENTATION`, Documentation Sync, Review, Validation, or any completion transition. Do not emit a Polaris checkpoint marker; the main `{{skill:engineering-task}}` validates the artifact, advances the graph, and continues this task for `{{skill:documentation-sync}}`. -CodeGraph fallback contract: never run `codegraph init` or manage the Provider. Save and classify each response. For `PARTIAL_STALE`, if a named path is a current confined regular file, directly read it and record `READ_SOURCE` with its current SHA-256; if a safe path is missing/deleted, inspect the registered subject Git diff and record `INSPECT_GIT_DIFF` with null observed SHA-256 and bound base/head/diff evidence; for unsafe paths, record `NOT_VERIFIED` and use source search. For `INDEX_STALE` or `NOT_VERIFIED`, use source search and Git evidence, then stop graph calls for this stage. Graph evidence never gates implementation. +CodeGraph fallback contract: never run `codegraph init` or manage the Provider. Save and classify each response; when `RESPONSE_BANNER` is present, persist its successful explore response hash as `freshness.response_sha256`. For `PARTIAL_STALE`, if a named path is a current confined regular file, directly read it and record `READ_SOURCE` with its current SHA-256; if a safe path is missing/deleted, inspect the registered subject Git diff and record `INSPECT_GIT_DIFF` with null observed SHA-256 and bound base/head/diff evidence; for unsafe paths, record `NOT_VERIFIED` and use source search. For `INDEX_STALE` or `NOT_VERIFIED`, use source search and Git evidence, then stop graph calls for this stage. Each `SEARCH_SOURCE` fallback records `result_paths`: zero or at most 100 unique POSIX paths, each a current confined regular file with its current SHA-256; non-`SEARCH_SOURCE` fallbacks use empty `result_paths`. Graph evidence never gates implementation. diff --git a/templates/AGENTS.md b/templates/AGENTS.md index d182e5b..a4d44b8 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -17,6 +17,6 @@ - Use CodeGraph only when the repository root already contains `.codegraph/`. When it is absent, stop CodeGraph calls for this session and use repository source and Git; a user may choose to initialize CodeGraph, but agents must never run `codegraph init`. - Prefer MCP `codegraph_explore`; when MCP is unavailable, use `codegraph explore` as the CLI fallback. A bounded `codegraph sync` may run only through the Polaris stage boundary procedure and never gates a task. -- Save and classify every graph response in task runtime. For `PARTIAL_STALE`, if a named path is a current confined regular file, directly read it and record `READ_SOURCE` with its current SHA-256; if a safe path is missing/deleted, inspect the registered subject Git diff and record `INSPECT_GIT_DIFF` with null observed SHA-256 and bound base/head/diff evidence; for unsafe paths, record `NOT_VERIFIED` and use source search. For `INDEX_STALE` or `NOT_VERIFIED`, treat graph output only as a lead, use source search and Git evidence, and stop repeated graph calls for that stage. +- Save and classify every graph response in task runtime; when `RESPONSE_BANNER` is present, persist its successful explore response hash as `freshness.response_sha256`. For `PARTIAL_STALE`, if a named path is a current confined regular file, directly read it and record `READ_SOURCE` with its current SHA-256; if a safe path is missing/deleted, inspect the registered subject Git diff and record `INSPECT_GIT_DIFF` with null observed SHA-256 and bound base/head/diff evidence; for unsafe paths, record `NOT_VERIFIED` and use source search. For `INDEX_STALE` or `NOT_VERIFIED`, treat graph output only as a lead, use source search and Git evidence, and stop repeated graph calls for that stage. Each `SEARCH_SOURCE` fallback records `result_paths`: zero or at most 100 unique POSIX paths, each a current confined regular file with its current SHA-256; non-`SEARCH_SOURCE` fallbacks use empty `result_paths`. - Never install, start, authenticate, reconfigure, or manage CodeGraph, its watcher, daemon, lock, or MCP settings. CodeGraph cannot expand frozen scope or replace source, Git, builds, tests, Review, Validation, or Human gates. - Preserve any installer-managed marker block exactly as owned by that installer; Polaris does not add, edit, or remove installer marker fences. diff --git a/templates/task-sources/code-intelligence-record.json b/templates/task-sources/code-intelligence-record.json index 7452bf6..9e17567 100644 --- a/templates/task-sources/code-intelligence-record.json +++ b/templates/task-sources/code-intelligence-record.json @@ -19,6 +19,7 @@ "status": "UNAVAILABLE", "checked_at": "1970-01-01T00:00:00Z", "basis": ["NONE"], + "response_sha256": null, "stale_points": [] }, "source_fallbacks": [], diff --git a/templates/task/code-intelligence/r001/planning.json b/templates/task/code-intelligence/r001/planning.json index 7452bf6..9e17567 100644 --- a/templates/task/code-intelligence/r001/planning.json +++ b/templates/task/code-intelligence/r001/planning.json @@ -19,6 +19,7 @@ "status": "UNAVAILABLE", "checked_at": "1970-01-01T00:00:00Z", "basis": ["NONE"], + "response_sha256": null, "stale_points": [] }, "source_fallbacks": [], diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 53da0be..6398f0a 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -243,6 +243,7 @@ def add_explore_response_evidence(value: dict[str, object]) -> None: "response_sha256": "0" * 64, "error": None, }] + value["freshness"]["response_sha256"] = "0" * 64 def initialize_task(self) -> None: init_task(self.repo, "TASK-0001", "R1") @@ -486,6 +487,7 @@ def test_partial_stale_record_requires_matching_source_fallback(self) -> None: "status": "PARTIAL_STALE", "checked_at": "2026-08-18T00:00:00Z", "basis": ["RESPONSE_BANNER"], + "response_sha256": None, "stale_points": [{ "scope": "FILE", "path": "src/widget.py", @@ -506,6 +508,7 @@ def test_partial_stale_record_requires_matching_source_fallback(self) -> None: "head_commit": None, "diff_hash": None, "purpose": "confirm pending CodeGraph content", + "result_paths": [], }] self.assertEqual( validate_record_value(self.repo, "TASK-0001", value, ROOT)["record_version"], @@ -530,6 +533,7 @@ def test_response_banner_evidence_requires_a_hashed_explore_query(self) -> None: "status": "PARTIAL_STALE", "checked_at": "2026-08-18T00:00:00Z", "basis": ["RESPONSE_BANNER"], + "response_sha256": None, "stale_points": [{ "scope": "FILE", "path": "src/widget.py", @@ -546,6 +550,7 @@ def test_response_banner_evidence_requires_a_hashed_explore_query(self) -> None: "head_commit": None, "diff_hash": None, "purpose": "confirm pending CodeGraph content", + "result_paths": [], }], }) with self.assertRaisesRegex(RuleFailure, "explore capability"): @@ -563,11 +568,143 @@ def test_response_banner_evidence_requires_a_hashed_explore_query(self) -> None: "status": "SUCCESS", "summary": "stale banner", "symbols": [], "response_sha256": "0" * 64, "error": None, }] + value["freshness"]["response_sha256"] = "0" * 64 self.assertEqual( validate_record_value(self.repo, "TASK-0001", value, ROOT)["record_version"], 2, ) + def test_response_banner_freshness_digest_binds_the_classified_response(self) -> None: + """A banner conclusion must identify the exact successful explore response.""" + self.initialize_task() + source = self.repo / "src/widget.py" + source.parent.mkdir() + source.write_text("value = 1\n", encoding="utf-8") + digest = file_sha256(source) + cases = ( + ( + "PARTIAL_STALE", + [{ + "scope": "FILE", "path": "src/widget.py", "reason": "PENDING_SYNC", + "fallback": "READ_SOURCE", "observed_sha256": digest, + }], + [{ + "action": "READ_SOURCE", "path": "src/widget.py", + "observed_sha256": digest, "base_commit": None, "head_commit": None, + "diff_hash": None, "purpose": "read stale response source", "result_paths": [], + }], + ), + ( + "INDEX_STALE", + [{ + "scope": "INDEX", "path": None, "reason": "AUTO_SYNC_DISABLED", + "fallback": "SEARCH_SOURCE", "observed_sha256": None, + }], + [{ + "action": "SEARCH_SOURCE", "path": None, "observed_sha256": None, + "base_commit": None, "head_commit": None, "diff_hash": None, + "purpose": "search stale CodeGraph index scope", "result_paths": [], + }], + ), + ( + "NOT_VERIFIED", + [{ + "scope": "INDEX", "path": None, "reason": "STATUS_UNREADABLE", + "fallback": "SEARCH_SOURCE", "observed_sha256": None, + }], + [{ + "action": "SEARCH_SOURCE", "path": None, "observed_sha256": None, + "base_commit": None, "head_commit": None, "diff_hash": None, + "purpose": "search after unreadable CodeGraph response", "result_paths": [], + }], + ), + ) + for status, stale_points, fallbacks in cases: + with self.subTest(status=status): + value = self.v2_record() + value.update({ + "status": "USED", + "freshness": { + "status": status, + "checked_at": "2026-08-18T00:00:00Z", + "basis": ["RESPONSE_BANNER"], + "response_sha256": None, + "stale_points": stale_points, + }, + "source_fallbacks": fallbacks, + }) + self.add_explore_response_evidence(value) + value["freshness"]["response_sha256"] = None + with self.assertRaisesRegex(RuleFailure, "freshness response hash"): + validate_record_value(self.repo, "TASK-0001", value, ROOT) + value["freshness"]["response_sha256"] = "1" * 64 + with self.assertRaisesRegex(RuleFailure, "must match"): + validate_record_value(self.repo, "TASK-0001", value, ROOT) + value["freshness"]["response_sha256"] = "0" * 64 + self.assertEqual( + validate_record_value(self.repo, "TASK-0001", value, ROOT)["record_version"], + 2, + ) + + def test_unavailable_freshness_cannot_retain_a_classified_response_digest(self) -> None: + self.initialize_task() + value = self.v2_record() + value["freshness"]["response_sha256"] = "0" * 64 + with self.assertRaisesRegex(RuleFailure, "UNAVAILABLE freshness response hash"): + validate_record_value(self.repo, "TASK-0001", value, ROOT) + + def test_search_source_fallback_records_only_current_finite_search_results(self) -> None: + self.initialize_task() + source = self.repo / "src/widget.py" + source.parent.mkdir() + source.write_text("value = 1\n", encoding="utf-8") + entry = {"path": "src/widget.py", "observed_sha256": file_sha256(source)} + value = self.v2_record() + value["source_fallbacks"] = [{ + "action": "SEARCH_SOURCE", "path": None, "observed_sha256": None, + "base_commit": None, "head_commit": None, "diff_hash": None, + "purpose": "search affected source", "result_paths": [entry], + }] + self.assertEqual( + validate_record_value(self.repo, "TASK-0001", value, ROOT)["record_version"], 2 + ) + + invalid_cases: list[tuple[str, object, str]] = [ + ("hash", [{"path": "src/widget.py", "observed_sha256": "0" * 64}], "hash is stale"), + ("traversal", [{"path": "../widget.py", "observed_sha256": entry["observed_sha256"]}], "invalid repository reference"), + ("deleted", [{"path": "src/deleted.py", "observed_sha256": entry["observed_sha256"]}], "not a current regular file"), + ("duplicate", [entry, dict(entry)], "duplicate SEARCH_SOURCE result path"), + ("over-limit", [entry] * 101, "maxItems 100"), + ] + outside = self.repo / "outside.py" + outside.write_text("outside\n", encoding="utf-8") + link = self.repo / "src/link.py" + link.symlink_to(outside) + invalid_cases.append(( + "symlink", + [{"path": "src/link.py", "observed_sha256": file_sha256(outside)}], + "crosses a symlink", + )) + directory = self.repo / "src/directory.py" + directory.mkdir() + invalid_cases.append(( + "directory", + [{"path": "src/directory.py", "observed_sha256": entry["observed_sha256"]}], + "not a current regular file", + )) + for name, result_paths, error in invalid_cases: + with self.subTest(name=name): + value["source_fallbacks"][0]["result_paths"] = result_paths + with self.assertRaisesRegex(RuleFailure, error): + validate_record_value(self.repo, "TASK-0001", value, ROOT) + + value["source_fallbacks"][0]["result_paths"] = [entry] + value["source_fallbacks"][0]["action"] = "READ_SOURCE" + value["source_fallbacks"][0]["path"] = "src/widget.py" + value["source_fallbacks"][0]["observed_sha256"] = entry["observed_sha256"] + with self.assertRaisesRegex(RuleFailure, "non-SEARCH_SOURCE fallback"): + validate_record_value(self.repo, "TASK-0001", value, ROOT) + def test_unavailable_record_cannot_claim_response_banner_evidence(self) -> None: self.initialize_task() value = self.v2_record() @@ -575,6 +712,7 @@ def test_unavailable_record_cannot_claim_response_banner_evidence(self) -> None: "status": "UNAVAILABLE", "checked_at": "2026-08-18T00:00:00Z", "basis": ["RESPONSE_BANNER"], + "response_sha256": None, "stale_points": [], } with self.assertRaisesRegex(RuleFailure, "cannot claim observed graph freshness"): @@ -596,7 +734,7 @@ def test_auditable_response_banners_preserve_noncurrent_freshness_states(self) - [{ "action": "READ_SOURCE", "path": "src/widget.py", "observed_sha256": digest, "base_commit": None, "head_commit": None, - "diff_hash": None, "purpose": "read stale response source", + "diff_hash": None, "purpose": "read stale response source", "result_paths": [], }], ), ( @@ -608,7 +746,7 @@ def test_auditable_response_banners_preserve_noncurrent_freshness_states(self) - [{ "action": "SEARCH_SOURCE", "path": None, "observed_sha256": None, "base_commit": None, "head_commit": None, "diff_hash": None, - "purpose": "search frozen CodeGraph index scope", + "purpose": "search frozen CodeGraph index scope", "result_paths": [], }], ), ( @@ -620,7 +758,7 @@ def test_auditable_response_banners_preserve_noncurrent_freshness_states(self) - [{ "action": "SEARCH_SOURCE", "path": None, "observed_sha256": None, "base_commit": None, "head_commit": None, "diff_hash": None, - "purpose": "search after unreadable CodeGraph response", + "purpose": "search after unreadable CodeGraph response", "result_paths": [], }], ), ] @@ -633,6 +771,7 @@ def test_auditable_response_banners_preserve_noncurrent_freshness_states(self) - "status": status, "checked_at": "2026-08-18T00:00:00Z", "basis": ["RESPONSE_BANNER"], + "response_sha256": None, "stale_points": stale_points, }, "source_fallbacks": fallbacks, @@ -661,12 +800,14 @@ def test_v2_freshness_statuses_require_consistent_stale_points(self) -> None: "head_commit": None, "diff_hash": None, "purpose": "find affected source", + "result_paths": [], } value["status"] = "SKIPPED" value["freshness"] = { "status": "CURRENT_AT_CHECK", "checked_at": "2026-08-18T00:00:00Z", "basis": ["STATUS_JSON"], + "response_sha256": None, "stale_points": [index_point], } value["source_fallbacks"] = [search] @@ -701,6 +842,7 @@ def test_v2_fallback_and_sync_rules_are_auditable(self) -> None: "status": "PARTIAL_STALE", "checked_at": "2026-08-18T00:00:00Z", "basis": ["RESPONSE_BANNER"], + "response_sha256": None, "stale_points": [{ "scope": "FILE", "path": "src/widget.py", "reason": "PENDING_SYNC", "fallback": "READ_SOURCE", "observed_sha256": digest, @@ -709,7 +851,7 @@ def test_v2_fallback_and_sync_rules_are_auditable(self) -> None: self.add_explore_response_evidence(value) value["source_fallbacks"] = [{ "action": "READ_SOURCE", "path": "src/widget.py", "observed_sha256": "0" * 64, - "base_commit": None, "head_commit": None, "diff_hash": None, "purpose": "read source", + "base_commit": None, "head_commit": None, "diff_hash": None, "purpose": "read source", "result_paths": [], }] with self.assertRaisesRegex(RuleFailure, "READ_SOURCE fallback hash is stale"): validate_record_value(self.repo, "TASK-0001", value, ROOT) @@ -814,6 +956,7 @@ def test_unavailable_sync_results_project_to_v2_records(self) -> None: key: unavailable["freshness"][key] for key in ("status", "checked_at", "basis", "stale_points") } + value["freshness"]["response_sha256"] = None self.assertEqual( validate_record_value(self.repo, "TASK-0001", value, ROOT)["record_version"], 2 ) @@ -840,9 +983,10 @@ def test_unavailable_sync_results_project_to_v2_records(self) -> None: "action": "SEARCH_SOURCE", "path": None, "observed_sha256": None, "base_commit": None, "head_commit": None, "diff_hash": None, - "purpose": "recover unavailable CodeGraph evidence", + "purpose": "recover unavailable CodeGraph evidence", "result_paths": [], }], }) + value["freshness"]["response_sha256"] = None self.assertEqual( validate_record_value(self.repo, "TASK-0001", value, ROOT)["record_version"], 2 ) @@ -876,6 +1020,7 @@ def test_inspect_git_diff_is_only_valid_for_missing_stale_files(self) -> None: "status": "PARTIAL_STALE", "checked_at": "2026-08-18T00:00:00Z", "basis": ["RESPONSE_BANNER"], + "response_sha256": None, "stale_points": [{ "scope": "FILE", "path": "src/widget.py", "reason": "PENDING_SYNC", "fallback": "INSPECT_GIT_DIFF", "observed_sha256": None, @@ -884,7 +1029,7 @@ def test_inspect_git_diff_is_only_valid_for_missing_stale_files(self) -> None: "source_fallbacks": [{ "action": "INSPECT_GIT_DIFF", "path": "src/widget.py", "observed_sha256": None, "base_commit": base, "head_commit": added_head, - "diff_hash": subject_diff_hash(self.repo, base, added_head), "purpose": "inspect change", + "diff_hash": subject_diff_hash(self.repo, base, added_head), "purpose": "inspect change", "result_paths": [], }], }) self.add_explore_response_evidence(value) @@ -931,6 +1076,7 @@ def test_sync_evidence_requires_advertised_sync_capability(self) -> None: "status": "CURRENT_AT_CHECK", "checked_at": "2026-08-18T00:00:00Z", "basis": ["SYNC_ACKNOWLEDGED"], + "response_sha256": None, "stale_points": [], }, }) @@ -953,6 +1099,7 @@ def test_current_freshness_requires_a_real_non_none_evidence_path(self) -> None: "status": "CURRENT_AT_CHECK", "checked_at": "2026-08-18T00:00:00Z", "basis": ["NONE"], + "response_sha256": None, "stale_points": [], }, }) @@ -971,6 +1118,7 @@ def test_current_freshness_requires_hashed_status_check_evidence(self) -> None: "status": "CURRENT_AT_CHECK", "checked_at": "2026-08-18T00:00:00Z", "basis": ["STATUS_JSON"], + "response_sha256": None, "stale_points": [], }, }) @@ -1004,6 +1152,7 @@ def test_sync_currentness_requires_successful_post_sync_status_check(self) -> No "status": "CURRENT_AT_CHECK", "checked_at": "2026-08-18T00:00:00Z", "basis": ["SYNC_ACKNOWLEDGED"], + "response_sha256": None, "stale_points": [], }, }) @@ -1051,13 +1200,14 @@ def test_failed_status_check_records_adapter_not_verified_results(self) -> None: }, "freshness": { "status": result["status"], "checked_at": result["checked_at"], - "basis": result["basis"], "stale_points": result["stale_points"], + "basis": result["basis"], "response_sha256": None, + "stale_points": result["stale_points"], }, "source_fallbacks": [{ "action": "SEARCH_SOURCE", "path": None, "observed_sha256": None, "base_commit": None, "head_commit": None, "diff_hash": None, - "purpose": "recover unreadable CodeGraph status", + "purpose": "recover unreadable CodeGraph status", "result_paths": [], }], }) self.assertEqual( @@ -1095,13 +1245,14 @@ def project( "sync": sync, "freshness": { "status": freshness["status"], "checked_at": freshness["checked_at"], - "basis": freshness["basis"], "stale_points": freshness["stale_points"], + "basis": freshness["basis"], "response_sha256": None, + "stale_points": freshness["stale_points"], }, "source_fallbacks": ([{ "action": "SEARCH_SOURCE", "path": None, "observed_sha256": None, "base_commit": None, "head_commit": None, "diff_hash": None, - "purpose": "recover stale CodeGraph evidence", + "purpose": "recover stale CodeGraph evidence", "result_paths": [], }] if freshness["stale_points"] else []), }) return value @@ -1216,7 +1367,7 @@ def test_noncurrent_post_sync_check_downgrades_sync_evidence(self) -> None: fallback = { "action": "SEARCH_SOURCE", "path": None, "observed_sha256": None, "base_commit": None, "head_commit": None, "diff_hash": None, - "purpose": "recover stale CodeGraph index", + "purpose": "recover stale CodeGraph index", "result_paths": [], } for status, reason in (("INDEX_STALE", "INDEX_FAILED"),): with self.subTest(status=status): @@ -1237,6 +1388,7 @@ def test_noncurrent_post_sync_check_downgrades_sync_evidence(self) -> None: "freshness": { "status": status, "checked_at": "2026-08-18T00:00:00Z", "basis": ["STATUS_JSON", "SYNC_ACKNOWLEDGED"], + "response_sha256": None, "stale_points": [{ "scope": "INDEX", "path": None, "reason": reason, "fallback": "SEARCH_SOURCE", "observed_sha256": None, @@ -1276,7 +1428,7 @@ def test_noncurrent_post_sync_check_downgrades_sync_evidence(self) -> None: }, "freshness": { "status": "INDEX_STALE", "checked_at": "2026-08-18T00:00:00Z", - "basis": ["STATUS_JSON"], "stale_points": [{ + "basis": ["STATUS_JSON"], "response_sha256": None, "stale_points": [{ "scope": "INDEX", "path": None, "reason": "INDEX_FAILED", "fallback": "SEARCH_SOURCE", "observed_sha256": None, }], @@ -1324,6 +1476,15 @@ def test_all_agent_surfaces_share_codegraph_fallback_rules(self) -> None: "NOT_VERIFIED", "source search", ) + audit_binding_fragments = ( + "freshness.response_sha256", + "successful explore response", + "result_paths", + "at most 100", + "POSIX", + "current confined regular file", + "empty `result_paths`", + ) retired_operations = ( "symbol" + "_search", "call" + "_graph", @@ -1351,6 +1512,8 @@ def test_all_agent_surfaces_share_codegraph_fallback_rules(self) -> None: self.assertIn(fragment, rendered, f"{adapter['host_id']}:{skill_name}") for fragment in partial_stale_branches: self.assertIn(fragment, rendered, f"{adapter['host_id']}:{skill_name}") + for fragment in audit_binding_fragments: + self.assertIn(fragment, rendered, f"{adapter['host_id']}:{skill_name}") self.assertIn("v2", rendered, f"{adapter['host_id']}:{skill_name}") self.assertNotIn("directly read every listed stale file", rendered) for retired in retired_operations: @@ -1366,6 +1529,8 @@ def test_all_agent_surfaces_share_codegraph_fallback_rules(self) -> None: self.assertIn("installer-managed marker block", agents) for fragment in partial_stale_branches: self.assertIn(fragment, agents) + for fragment in audit_binding_fragments: + self.assertIn(fragment, agents) self.assertNotIn("directly read every listed stale file", agents) def test_provider_requires_marker_and_accepts_mcp_or_cli(self) -> None: diff --git a/tests/test_core.py b/tests/test_core.py index b69d64a..8e874e1 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -829,6 +829,10 @@ def test_schema_validator_enforces_every_declared_constraint_keyword(self) -> No validate_schema("极", {"type": "string", "minLength": 1}), [] ) self.assertTrue(validate_schema([], {"type": "array", "minItems": 1})) + self.assertTrue(validate_schema([1, 2], {"type": "array", "maxItems": 1})) + self.assertEqual( + validate_schema([1], {"type": "array", "maxItems": 1}), [] + ) self.assertTrue( validate_schema( ["AC-01", "AC-01"], From a5511b236d4db81e8e6d6fe17e796f9f61744bd0 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 17:46:22 +0800 Subject: [PATCH 34/41] fix: align merged freshness evidence --- scripts/internal/codegraph_adapter.py | 12 +- tests/test_codegraph.py | 151 ++++++++++++++++++++++++++ 2 files changed, 159 insertions(+), 4 deletions(-) diff --git a/scripts/internal/codegraph_adapter.py b/scripts/internal/codegraph_adapter.py index f908ee5..b7b0030 100644 --- a/scripts/internal/codegraph_adapter.py +++ b/scripts/internal/codegraph_adapter.py @@ -259,9 +259,11 @@ def merge_freshness( 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 = classification != "NONE" or ( - status not in {"NOT_VERIFIED", "UNAVAILABLE"} - and "STATUS_JSON" in status_basis + include_response_basis = merged_status != "UNAVAILABLE" and ( + classification != "NONE" or ( + status not in {"NOT_VERIFIED", "UNAVAILABLE"} + and "STATUS_JSON" in status_basis + ) ) basis = _unique_items( [*status_basis, *(response_basis if include_response_basis else [])] @@ -275,7 +277,9 @@ def merge_freshness( "stale_points": _unique_items( [*status_result.get("stale_points", []), *response_result.get("stale_points", [])] ), - "response_sha256": response_result.get("response_sha256"), + "response_sha256": ( + response_result.get("response_sha256") if include_response_basis else None + ), "error": status_result.get("error") or response_result.get("error"), } diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 6398f0a..55ffd85 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -2105,6 +2105,157 @@ def test_none_response_does_not_upgrade_unverified_status(self) -> None: self.assertEqual(result["status"], "NOT_VERIFIED") self.assertEqual(result["basis"], ["STATUS_JSON"]) + def test_none_response_suppression_projects_to_v2_unverified_and_unavailable_records(self) -> None: + """A discarded neutral response must not leave unverifiable hash evidence.""" + self.initialize_task() + merger = getattr(self.adapter_module(), "merge_freshness", None) + self.assertTrue(callable(merger), "CodeGraph freshness merger must exist") + response = self.classify_response("normal response\n") + + unverified = merger({ + "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, + }], + "error": "status unreadable", + }, response) + self.assertEqual(unverified["basis"], ["STATUS_JSON"]) + self.assertIsNone(unverified["response_sha256"]) + value = self.v2_record() + value.update({ + "status": "FAILED", + "provider": { + "id": "codegraph", "descriptor_version": 2, "transport": "mcp", + "available_operations": ["status"], + }, + "status_check": { + "status": "FAILED", "phase": "STAGE_ENTRY", + "response_sha256": None, "error": "status unreadable", + }, + "freshness": { + "status": unverified["status"], "checked_at": unverified["checked_at"], + "basis": unverified["basis"], + "response_sha256": unverified["response_sha256"], + "stale_points": unverified["stale_points"], + }, + "source_fallbacks": [{ + "action": "SEARCH_SOURCE", "path": None, "observed_sha256": None, + "base_commit": None, "head_commit": None, "diff_hash": None, + "purpose": "recover unreadable CodeGraph status", "result_paths": [], + }], + }) + self.assertEqual( + validate_record_value(self.repo, "TASK-0001", value, ROOT)["record_version"], 2 + ) + + unavailable = merger({ + "status": "UNAVAILABLE", + "checked_at": "2026-08-18T00:00:00Z", + "basis": ["NONE"], + "stale_points": [], + "error": "CodeGraph project marker is unavailable", + }, response) + self.assertEqual(unavailable["basis"], ["NONE"]) + self.assertIsNone(unavailable["response_sha256"]) + value = self.v2_record() + value["freshness"] = { + "status": unavailable["status"], "checked_at": unavailable["checked_at"], + "basis": unavailable["basis"], + "response_sha256": unavailable["response_sha256"], + "stale_points": unavailable["stale_points"], + } + self.assertEqual( + validate_record_value(self.repo, "TASK-0001", value, ROOT)["record_version"], 2 + ) + + def test_retained_response_evidence_projects_to_v2_with_matching_explore_hash(self) -> None: + """Retained neutral and banner evidence binds the recorded explore response.""" + self.initialize_task() + merger = getattr(self.adapter_module(), "merge_freshness", None) + self.assertTrue(callable(merger), "CodeGraph freshness merger must exist") + status = { + "status": "CURRENT_AT_CHECK", + "checked_at": "2026-08-18T00:00:00Z", + "basis": ["STATUS_JSON"], + "stale_points": [], + "status_response_sha256": "1" * 64, + "error": None, + } + source = self.repo / "src/widget.py" + source.parent.mkdir() + source.write_text("def widget():\n return 1\n", encoding="utf-8") + responses = [ + ("normal response\n", []), + ( + "⚠️ Some files referenced below were edited since the last index sync —\n" + "their codegraph entries may be stale:\n" + " - src/widget.py (edited 800ms ago, pending sync)\n" + "For accurate content of those specific files, Read them directly.\n", + [{ + "action": "READ_SOURCE", "path": "src/widget.py", + "observed_sha256": file_sha256(source), "base_commit": None, + "head_commit": None, "diff_hash": None, + "purpose": "read CodeGraph stale file from source", + "result_paths": [], + }], + ), + ( + "⚠️ CodeGraph auto-sync is DISABLED — the index is frozen.\n", + [{ + "action": "SEARCH_SOURCE", "path": None, "observed_sha256": None, + "base_commit": None, "head_commit": None, "diff_hash": None, + "purpose": "recover stale CodeGraph index", "result_paths": [], + }], + ), + ( + "⚠️ Some files referenced below were edited since the last index sync —\n" + "their codegraph entries may be stale:\n", + [{ + "action": "SEARCH_SOURCE", "path": None, "observed_sha256": None, + "base_commit": None, "head_commit": None, "diff_hash": None, + "purpose": "recover unreadable CodeGraph response", "result_paths": [], + }], + ), + ] + + for response_text, source_fallbacks in responses: + with self.subTest(response=response_text[:24]): + response = self.classify_response(response_text) + merged = merger(status, response) + self.assertIn("RESPONSE_BANNER", merged["basis"]) + self.assertEqual(merged["response_sha256"], response["response_sha256"]) + value = self.v2_record() + value.update({ + "status": "USED", + "provider": { + "id": "codegraph", "descriptor_version": 2, "transport": "mcp", + "available_operations": ["explore", "status"], + }, + "queries": [{ + "id": "CIQ-001", "operation": "explore", + "purpose": "inspect CodeGraph response freshness", "status": "SUCCESS", + "summary": "CodeGraph response", "symbols": [], + "response_sha256": merged["response_sha256"], "error": None, + }], + "status_check": { + "status": "SUCCESS", "phase": "STAGE_ENTRY", + "response_sha256": status["status_response_sha256"], "error": None, + }, + "freshness": { + "status": merged["status"], "checked_at": merged["checked_at"], + "basis": merged["basis"], + "response_sha256": merged["response_sha256"], + "stale_points": merged["stale_points"], + }, + "source_fallbacks": source_fallbacks, + }) + self.assertEqual( + validate_record_value(self.repo, "TASK-0001", value, ROOT)["record_version"], 2 + ) + def test_none_response_records_banner_check_after_successful_stale_status(self) -> None: merger = getattr(self.adapter_module(), "merge_freshness", None) self.assertTrue(callable(merger), "CodeGraph freshness merger must exist") From acac17ae8590997c614317e88e01676a973f5762 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 17:53:21 +0800 Subject: [PATCH 35/41] fix: preserve unavailable CodeGraph freshness --- scripts/internal/codegraph_adapter.py | 12 +++++++ tests/test_codegraph.py | 49 +++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/scripts/internal/codegraph_adapter.py b/scripts/internal/codegraph_adapter.py index b7b0030..5e586b0 100644 --- a/scripts/internal/codegraph_adapter.py +++ b/scripts/internal/codegraph_adapter.py @@ -255,6 +255,18 @@ def merge_freshness( }: raise ValueError("unrecognized CodeGraph freshness result") + # An unavailable provider cannot yield auditable CodeGraph response evidence. + # Keep this result directly projectable to the v2 provider-neutral shape even + # if a caller has already classified a response before learning availability. + if status == "UNAVAILABLE": + return { + **status_result, + "status": "UNAVAILABLE", + "basis": ["NONE"], + "stale_points": [], + "response_sha256": None, + } + response_status = status if classification == "NONE" else classification merged_status = max((status, response_status), key=FRESHNESS_ORDER.__getitem__) status_basis = status_result.get("basis", []) diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 55ffd85..9c79112 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -2171,6 +2171,55 @@ def test_none_response_suppression_projects_to_v2_unverified_and_unavailable_rec validate_record_value(self.repo, "TASK-0001", value, ROOT)["record_version"], 2 ) + def test_unavailable_merge_discards_every_response_conclusion(self) -> None: + """A provider-neutral unavailable result cannot retain graph response evidence.""" + self.initialize_task() + merger = getattr(self.adapter_module(), "merge_freshness", None) + self.assertTrue(callable(merger), "CodeGraph freshness merger must exist") + unavailable = { + "status": "UNAVAILABLE", + "checked_at": "2026-08-18T00:00:00Z", + "basis": ["NONE"], + "stale_points": [], + "status_response_sha256": None, + "error": "CodeGraph project marker is unavailable", + "needs_sync": False, + "pending_changes": None, + } + responses = ( + "normal response\n", + "⚠️ Some files referenced below were edited since the last index sync —\n" + "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", + "⚠️ CodeGraph auto-sync is DISABLED — the index is frozen.\n", + "⚠️ Some files referenced below were edited since the last index sync —\n" + "their codegraph entries may be stale:\n", + ) + + for response_text in responses: + with self.subTest(response=response_text[:24]): + response = self.classify_response(response_text) + merged = merger(unavailable, response) + self.assertEqual(merged["status"], "UNAVAILABLE") + self.assertEqual(merged["basis"], ["NONE"]) + self.assertEqual(merged["stale_points"], []) + self.assertIsNone(merged["response_sha256"]) + value = self.v2_record() + value["freshness"] = { + "status": merged["status"], + "checked_at": merged["checked_at"], + "basis": merged["basis"], + "response_sha256": merged["response_sha256"], + "stale_points": merged["stale_points"], + } + self.assertEqual( + validate_record_value( + self.repo, "TASK-0001", value, ROOT + )["record_version"], + 2, + ) + def test_retained_response_evidence_projects_to_v2_with_matching_explore_hash(self) -> None: """Retained neutral and banner evidence binds the recorded explore response.""" self.initialize_task() From f2e740bc647e1a716b16a7100c4ba4002b1e7598 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 20:38:41 +0800 Subject: [PATCH 36/41] docs: design CodeGraph freshness wrapper --- ...-codegraph-cli-freshness-wrapper-design.md | 309 ++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-18-codegraph-cli-freshness-wrapper-design.md diff --git a/docs/superpowers/specs/2026-08-18-codegraph-cli-freshness-wrapper-design.md b/docs/superpowers/specs/2026-08-18-codegraph-cli-freshness-wrapper-design.md new file mode 100644 index 0000000..8fe08c7 --- /dev/null +++ b/docs/superpowers/specs/2026-08-18-codegraph-cli-freshness-wrapper-design.md @@ -0,0 +1,309 @@ +# CodeGraph CLI Freshness Wrapper Design + +## Status + +- Date: 2026-08-18 +- Status: proposed for implementation +- Scope: Polaris Code Intelligence protocol and stage behavior +- Provider: `colbymchenry/codegraph` + +## Problem + +Polaris currently lets Planning, Implementation, and Review choose either a +standalone CodeGraph `status` check or `sync-if-needed`, then query +`codegraph_explore` directly. A structurally healthy status with non-zero +`pendingChanges` is represented internally as `CURRENT_AT_CHECK` plus +`needs_sync: true`. The compact v2 record does not retain `needs_sync` or the +pending counts, so a status-only path can discard the known stale signal and +still validate as `CURRENT_AT_CHECK`. + +Freshness checks and queries also use different transports: status and sync +run through the CLI in the repository working directory, while explore +prefers the host MCP connection. Polaris does not bind those operations to the +same project or deliver one mandatory freshness warning together with the +graph response. + +The required behavior is deliberately fail-safe: + +- Polaris may give an Agent stale graph output. +- Polaris must tell the Agent when the output is stale or cannot be verified. +- Known stale or unverifiable output must never be presented as current. +- Stale output remains useful only as a navigation lead; current source and + Git remain authoritative. + +## Guarantee Boundary + +The wrapper guarantees that every graph response delivered by a Polaris stage +has an adjacent Polaris freshness envelope, and that every freshness signal +Polaris observes is classified conservatively. It does not prove that +CodeGraph's parser or relationship inference is semantically correct, and it +does not claim permanent or commit-exact freshness after delivery. + +The core invariant is: + +> Absence of freshness proof is not freshness. Any known stale signal becomes +> `STALE`; any unavailable or unreadable proof becomes `UNKNOWN`; only a fully +> healthy bounded query window may become `CURRENT`. + +`UNKNOWN` has the same Agent usage restrictions as `STALE`. + +## Selected Approach + +Add a Polaris-owned CLI freshness wrapper and make it the only CodeGraph query +entry point used by Polaris stages. The wrapper uses CodeGraph CLI commands for +status, optional one-shot sync, and explore, all with the same explicit +repository working directory. It writes the raw response only to the ignored +task runtime and emits a freshness envelope before any graph content. + +Direct `codegraph_explore` MCP calls remain available outside Polaris, but +Polaris stage Skills must not use them. If the wrapper cannot run, the stage +falls back to source and Git rather than calling the Provider directly. + +This approach is preferred over instruction-only changes because the warning +must be mechanically adjacent to the graph response. It is preferred over a +new MCP proxy because the existing CLI exposes equivalent `status`, `sync`, +and `explore` operations without adding a daemon or transport subsystem. + +## CLI Surface + +Create `scripts/code_intelligence_query.py` with one bounded operation: + +```text +python3 scripts/code_intelligence_query.py TASK-0001 \ + --repo . \ + --stage PLANNING \ + --query-id CIQ-001 \ + --purpose "discover frozen-task relationships" \ + --sync-if-needed \ + --query "symbols and paths relevant to the frozen task" +``` + +Required inputs: + +- task ID, used to confine runtime evidence; +- repository path; +- Polaris stage and finite query purpose; +- the next sequential `CIQ-*` ID for this stage record; +- one non-empty query string. + +`--sync-if-needed` permits the existing one-shot sync behavior. Omitting it is +read-only and does not wait for CodeGraph auto-sync. Neither mode sleeps, +polls, retries, initializes CodeGraph, or manages its daemon or watcher. + +The command exits successfully when graph output is available, even when its +delivery state is `STALE` or `UNKNOWN`. Provider execution failure exits +successfully only after emitting an `UNKNOWN` envelope with no graph response +and a required source fallback. Invalid Polaris inputs remain command errors. + +## Query Flow + +1. Validate protocol compatibility, project configuration, `.codegraph/`, task + identity, stage, purpose, and runtime confinement. +2. Run a CLI pre-query status check in `cwd=repo`. +3. If `--sync-if-needed` is set and the pre-query status has pending changes, + run at most one bounded sync and at most one post-sync status check. +4. If the effective pre-query state permits a query, run one bounded + `codegraph explore` in the same `cwd=repo`. Known pending changes and + index-stale states permit a navigation-only query; unavailable status, + project mismatch, or an unreadable pre-query status do not. +5. Save its exact UTF-8 response below + `runtime/code-intelligence/`; never write raw output to Git artifacts. +6. Classify the response banner. +7. Run one CLI post-query status check. +8. Merge pre-query, sync, response, and post-query observations using the most + conservative state. +9. Emit the envelope first, followed by raw graph output only when available. +10. Include the compact evidence bundle path in the envelope for the immutable + stage record. The explicit query ID determines its unique runtime filename; + the wrapper rejects an existing destination rather than overwriting it. + +There is no query retry. A stale response is delivered once with restrictions; +an unknown response is either delivered as navigation-only evidence or +discarded when its project or response integrity cannot be established. + +## Delivery States + +### `CURRENT` + +Allowed only when all of the following hold: + +- pre-query or successful post-sync status is structurally healthy; +- its `projectPath` resolves to the requested repository; +- pending added, modified, and removed counts are all zero; +- there is no worktree mismatch, partial/indexing/failed index, pending + reference, or reindex recommendation; +- the explore response contains no stale or disabled-auto-sync banner; +- the post-query status is also structurally healthy with zero pending counts. + +The envelope uses `usage: NON_AUTHORITATIVE_CONTEXT`. Source, Git, builds, +tests, Review, and Validation remain authoritative. + +### `STALE` + +Used when Polaris observes any known stale signal, including: + +- non-zero pending source changes before or after the query; +- a per-file stale response banner; +- auto-sync disabled; +- worktree mismatch; +- partial, indexing, or failed index state; +- pending references or reindex recommendation; +- a failed sync or an unhealthy post-sync status. + +The envelope uses `usage: NAVIGATION_ONLY` and supplies exact required +fallback actions. A pending count without safe file names makes the entire +query `INDEX_STALE`; Polaris must not infer that unlisted relationships are +current. + +### `UNKNOWN` + +Used when freshness cannot be established, including missing capabilities, +timeouts, malformed status JSON, malformed recognized banners, unsafe paths, +project mismatch, or an unclassifiable Provider response. The envelope uses +`usage: NAVIGATION_ONLY` and requires repository source search and Git +evidence. It must never be promoted to `CURRENT` by a banner-free response. + +Response classification is fail-safe. An exact supported banner produces its +documented stale state. Any other warning-like response containing a warning +marker, stale wording, or pending-sync wording produces `UNKNOWN`, including a +supported banner wrapped in prose, quotation, leading whitespace, or a BOM. +Only a response with no supported or suspicious freshness signal is neutral. + +`UNAVAILABLE` remains the no-query state for disabled Code Intelligence, +missing `.codegraph/`, or missing CLI capability. + +## Envelope + +Every successful wrapper invocation begins stdout with a finite block: + +```text +[POLARIS_CODEGRAPH_FRESHNESS] +state: STALE +record_status: INDEX_STALE +reason: PENDING_CHANGES +checked_at: 2026-08-18T12:00:00Z +pending_added: 0 +pending_modified: 1 +pending_removed: 0 +usage: NAVIGATION_ONLY +required_fallback: SEARCH_SOURCE +evidence_bundle: runtime/code-intelligence/CIQ-001.json +[/POLARIS_CODEGRAPH_FRESHNESS] +``` + +The raw CodeGraph response, when retained, follows this block. The wrapper +must never print graph output before the envelope. Human-readable diagnostic +text is captured into the finite envelope error field. The wrapper flushes the +complete stdout envelope before writing graph bytes or any stderr diagnostic, +so a combined host transcript cannot expose raw graph content first. + +## Evidence and Record Protocol + +Introduce a new Code Intelligence record version rather than weakening or +rewriting immutable v2 evidence. Preserve v1 and v2 records as readable +historical artifacts; new stage records use v3. + +The v3 query evidence contains: + +- Provider ID and descriptor version; +- repository identity and stage target; +- query purpose and response SHA-256; +- pre-query status observation; +- optional sync observation and post-sync status; +- post-query status observation; +- pending added, modified, and removed counts for each successful status; +- response-banner classification and stale points; +- delivery state and usage restriction; +- exact source/Git fallback evidence. + +`CURRENT` requires successful pre-query/effective status and post-query status +evidence with zero pending changes. `STALE` requires at least one explicit +stale reason. `UNKNOWN` requires an explicit verification failure. The +validator rejects missing observations, contradictory state, project +mismatch, response-hash mismatch, or a `CURRENT` claim with any non-zero +pending count. + +Migration inventories immutable v2 records without rewriting them. The +Polaris protocol version increments; the workflow graph version does not +change because no workflow state or transition changes. + +## Stage Behavior + +- Planning, Implementation, and Review call only the wrapper for graph + queries. They may request one-shot sync but cannot call raw MCP explore. +- Implementation may query after edits only through a fresh wrapper + invocation; it does not reuse the entry envelope. +- Documentation Sync uses the same wrapper/status machinery for its final + bounded sync evidence when supported source changed. +- Validation remains graph-free. +- On `STALE` or `UNKNOWN`, stage conclusions concerning returned files or + relationships require the envelope's source/Git fallback before use. + +Vendored `AGENTS.md`, host overlays, and canonical Skills must share this +contract. Because these are behavior-shaping Skill changes, they require the +repository's `writing-skills` workflow and adversarial before/after evaluation. + +## Failure Handling + +- Missing `.codegraph/` or disabled policy: emit `UNAVAILABLE`, no query. +- Missing CLI: emit `UNAVAILABLE`, no MCP bypass. +- Status failure before query: emit `UNKNOWN`; source fallback; no query by + default. +- Explore failure: emit `UNKNOWN`; record the finite error; no graph output. +- Post-query status failure: graph may be delivered only as `UNKNOWN` and + navigation-only. +- Pending changes after a successful explore: deliver as `STALE`, never + `CURRENT`. +- Unsafe or cross-project response paths: discard the graph response and use + source search. +- Sync failure: do not retry; continue with a stale or unknown envelope. + +## Testing and Evaluation + +Deterministic unit and integration tests must cover: + +1. pending pre-query status cannot produce `CURRENT`; +2. pending post-query status downgrades an otherwise clean response; +3. zero-pending pre/post status plus a clean response produces `CURRENT`; +4. failed or malformed status produces `UNKNOWN` and never calls raw MCP; +5. stale and disabled-auto-sync banners produce `STALE`; +6. prefixed, malformed, or changed banner shapes fail conservatively; +7. CLI status and explore always share the requested `cwd`; +8. project mismatch discards graph output; +9. envelope always precedes graph bytes; +10. v3 validator rejects `CURRENT` with pending counts or missing post-query + evidence; +11. historical v1/v2 records remain byte-identical and readable; +12. Planning, Implementation, Documentation Sync, Review, vendored agents, + and host renderings prohibit raw CodeGraph MCP use; +13. Validation remains graph-free; +14. the full Polaris suite passes without requiring CodeGraph; +15. an optional real-CLI smoke test uses only a disposable temporary repo. + +Skill evaluation must include pressure cases where an Agent is asked to skip +the wrapper, trust a clean-looking graph despite pending changes, reuse an old +Implementation envelope, or treat `UNKNOWN` as current. The post-change Agent +must refuse each shortcut and perform the required fallback. + +## Non-Goals + +- Proving that CodeGraph's parser or inferred relationships are semantically + correct. +- Making graph freshness a workflow or acceptance gate. +- Waiting for automatic sync to finish. +- Installing, initializing, configuring, or managing CodeGraph. +- Building a second watcher, daemon, index, or MCP proxy. +- Guaranteeing that a response remains current after it has been delivered. + +## Acceptance Criteria + +1. No Polaris stage can receive CodeGraph graph output without a preceding + freshness envelope from the wrapper. +2. Any observed pending change, stale banner, unhealthy index, project + mismatch, or failed verification prevents a `CURRENT` delivery. +3. `UNKNOWN` is mechanically restricted exactly like `STALE`. +4. Pending counts and pre/post-query evidence remain auditable in immutable v3 + records. +5. Stale graph output remains available as navigation-only evidence with + mandatory current-source or Git fallback. +6. Existing historical records and workflow transitions remain valid. From 4c66a0b6b6e5bb5d2c660c6d30d1555cae8853c9 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 20:44:14 +0800 Subject: [PATCH 37/41] docs: revise freshness wrapper as Polaris MCP proxy --- ...-18-codegraph-polaris-mcp-proxy-design.md} | 146 ++++++++++++------ 1 file changed, 96 insertions(+), 50 deletions(-) rename docs/superpowers/specs/{2026-08-18-codegraph-cli-freshness-wrapper-design.md => 2026-08-18-codegraph-polaris-mcp-proxy-design.md} (65%) diff --git a/docs/superpowers/specs/2026-08-18-codegraph-cli-freshness-wrapper-design.md b/docs/superpowers/specs/2026-08-18-codegraph-polaris-mcp-proxy-design.md similarity index 65% rename from docs/superpowers/specs/2026-08-18-codegraph-cli-freshness-wrapper-design.md rename to docs/superpowers/specs/2026-08-18-codegraph-polaris-mcp-proxy-design.md index 8fe08c7..30a3cff 100644 --- a/docs/superpowers/specs/2026-08-18-codegraph-cli-freshness-wrapper-design.md +++ b/docs/superpowers/specs/2026-08-18-codegraph-polaris-mcp-proxy-design.md @@ -1,4 +1,4 @@ -# CodeGraph CLI Freshness Wrapper Design +# Polaris-only CodeGraph MCP Freshness Proxy Design ## Status @@ -33,12 +33,18 @@ The required behavior is deliberately fail-safe: ## Guarantee Boundary -The wrapper guarantees that every graph response delivered by a Polaris stage -has an adjacent Polaris freshness envelope, and that every freshness signal -Polaris observes is classified conservatively. It does not prove that +The proxy guarantees that every proxy graph response delivered by a Polaris +stage has an adjacent Polaris freshness envelope, and that every freshness +signal Polaris observes is classified conservatively. It does not prove that CodeGraph's parser or relationship inference is semantically correct, and it does not claim permanent or commit-exact freshness after delivery. +The original `codegraph_explore` MCP tool and unrestricted shell access remain +available. Consequently, Polaris cannot prevent an Agent from bypassing the +proxy. It can require the proxy in canonical stage instructions and +mechanically reject Code Intelligence evidence that lacks proxy provenance, +but it cannot prove that an Agent never observed an out-of-band raw response. + The core invariant is: > Absence of freshness proof is not freshness. Any known stale signal becomes @@ -49,58 +55,72 @@ The core invariant is: ## Selected Approach -Add a Polaris-owned CLI freshness wrapper and make it the only CodeGraph query -entry point used by Polaris stages. The wrapper uses CodeGraph CLI commands for -status, optional one-shot sync, and explore, all with the same explicit -repository working directory. It writes the raw response only to the ignored -task runtime and emits a freshness envelope before any graph content. +Add a project-scoped Polaris MCP server exposing one +`polaris_codegraph_explore` tool. Polaris stage Skills use this proxy for +freshness-aware graph queries. The existing `codegraph_explore` MCP tool stays +installed and callable for non-Polaris work, and Polaris does not remove, +wrap, deny, or restrict shell commands. + +The proxy uses CodeGraph CLI commands internally for status, optional +one-shot sync, and explore, all with the same explicit repository working +directory. This avoids an MCP-to-MCP dependency while preserving output +equivalence with `codegraph_explore`. It writes the raw response only to the +ignored task runtime and returns a structured freshness envelope together +with any graph content. -Direct `codegraph_explore` MCP calls remain available outside Polaris, but -Polaris stage Skills must not use them. If the wrapper cannot run, the stage -falls back to source and Git rather than calling the Provider directly. +If the proxy cannot run, the Polaris stage falls back to source and Git. It +must not silently substitute the raw Provider tool for Polaris evidence. +Agents remain free to use the original tool or shell outside that evidence +path, but any such output is unverified navigation context and cannot produce +a `CURRENT` Polaris record. -This approach is preferred over instruction-only changes because the warning -must be mechanically adjacent to the graph response. It is preferred over a -new MCP proxy because the existing CLI exposes equivalent `status`, `sync`, -and `explore` operations without adding a daemon or transport subsystem. +This approach is preferred over instruction-only changes because freshness +must be mechanically adjacent to the graph response. It preserves the raw +Provider and shell surfaces as requested while giving Polaris records one +auditable, fail-safe path. -## CLI Surface +## MCP Surface -Create `scripts/code_intelligence_query.py` with one bounded operation: +Create `scripts/code_intelligence_mcp.py`, a standard-library stdio MCP server +launched from the vendored Polaris project runtime. It exposes one bounded +tool: ```text -python3 scripts/code_intelligence_query.py TASK-0001 \ - --repo . \ - --stage PLANNING \ - --query-id CIQ-001 \ - --purpose "discover frozen-task relationships" \ - --sync-if-needed \ - --query "symbols and paths relevant to the frozen task" +polaris_codegraph_explore({ + "task_id": "TASK-0001", + "stage": "PLANNING", + "query_id": "CIQ-001", + "purpose": "discover frozen-task relationships", + "query": "symbols and paths relevant to the frozen task", + "sync_if_needed": true +}) ``` Required inputs: - task ID, used to confine runtime evidence; -- repository path; - Polaris stage and finite query purpose; - the next sequential `CIQ-*` ID for this stage record; - one non-empty query string. -`--sync-if-needed` permits the existing one-shot sync behavior. Omitting it is +The repository is fixed by the project-scoped server launch configuration and +is intentionally not a tool argument. + +`sync_if_needed: true` permits the existing one-shot sync behavior. `false` is read-only and does not wait for CodeGraph auto-sync. Neither mode sleeps, polls, retries, initializes CodeGraph, or manages its daemon or watcher. -The command exits successfully when graph output is available, even when its -delivery state is `STALE` or `UNKNOWN`. Provider execution failure exits -successfully only after emitting an `UNKNOWN` envelope with no graph response -and a required source fallback. Invalid Polaris inputs remain command errors. +The MCP result is successful when graph output is available, even when its +delivery state is `STALE` or `UNKNOWN`. Provider execution failure returns an +`UNKNOWN` result with no graph response and a required source fallback. +Invalid Polaris inputs return an MCP tool error. ## Query Flow 1. Validate protocol compatibility, project configuration, `.codegraph/`, task identity, stage, purpose, and runtime confinement. 2. Run a CLI pre-query status check in `cwd=repo`. -3. If `--sync-if-needed` is set and the pre-query status has pending changes, +3. If `sync_if_needed` is true and the pre-query status has pending changes, run at most one bounded sync and at most one post-sync status check. 4. If the effective pre-query state permits a query, run one bounded `codegraph explore` in the same `cwd=repo`. Known pending changes and @@ -115,7 +135,7 @@ and a required source fallback. Invalid Polaris inputs remain command errors. 9. Emit the envelope first, followed by raw graph output only when available. 10. Include the compact evidence bundle path in the envelope for the immutable stage record. The explicit query ID determines its unique runtime filename; - the wrapper rejects an existing destination rather than overwriting it. + the proxy rejects an existing destination rather than overwriting it. There is no query retry. A stale response is delivered once with restrictions; an unknown response is either delivered as navigation-only evidence or @@ -172,9 +192,26 @@ Only a response with no supported or suspicious freshness signal is neutral. `UNAVAILABLE` remains the no-query state for disabled Code Intelligence, missing `.codegraph/`, or missing CLI capability. +## Proxy Activation + +The proxy is project-scoped and Polaris-owned. Host adapters render a local +MCP registration that launches the vendored server with one fixed repository +root. They do not add a global server, change the user's raw CodeGraph MCP +registration, remove any CodeGraph tool, or alter shell permissions. + +The server process receives the repository root at launch and does not accept +an arbitrary project path from the tool call. It rejects a missing, moved, or +symlinked project root. Removing or disabling the project-local Polaris MCP +registration disables the proxy without affecting CodeGraph itself. + +The adapter contract must represent the project-scoped MCP registration for +each supported host rather than embedding host-specific configuration writes +in the Code Intelligence adapter. Vendoring and project validation verify that +the registration launches only the repository's vendored Polaris runtime. + ## Envelope -Every successful wrapper invocation begins stdout with a finite block: +Every successful proxy tool result begins with a finite text content block: ```text [POLARIS_CODEGRAPH_FRESHNESS] @@ -191,11 +228,10 @@ evidence_bundle: runtime/code-intelligence/CIQ-001.json [/POLARIS_CODEGRAPH_FRESHNESS] ``` -The raw CodeGraph response, when retained, follows this block. The wrapper -must never print graph output before the envelope. Human-readable diagnostic -text is captured into the finite envelope error field. The wrapper flushes the -complete stdout envelope before writing graph bytes or any stderr diagnostic, -so a combined host transcript cannot expose raw graph content first. +The raw CodeGraph response, when retained, is a later content block in the same +MCP tool result. The proxy must never return graph content before the envelope. +Human-readable diagnostics are captured in the finite envelope error field; +subprocess stdout and stderr are never forwarded ahead of the envelope. ## Evidence and Record Protocol @@ -229,11 +265,13 @@ change because no workflow state or transition changes. ## Stage Behavior -- Planning, Implementation, and Review call only the wrapper for graph - queries. They may request one-shot sync but cannot call raw MCP explore. -- Implementation may query after edits only through a fresh wrapper - invocation; it does not reuse the entry envelope. -- Documentation Sync uses the same wrapper/status machinery for its final +- Planning, Implementation, and Review use the proxy for freshness-aware + Polaris graph evidence. The raw `codegraph_explore` remains callable, but + its output is out-of-band, always unverified for Polaris, and cannot back a + `CURRENT` stage record. +- Implementation may query after edits only through a fresh proxy invocation + for Polaris evidence; it does not reuse the entry envelope. +- Documentation Sync uses the same proxy/status machinery for its final bounded sync evidence when supported source changed. - Validation remains graph-free. - On `STALE` or `UNKNOWN`, stage conclusions concerning returned files or @@ -265,7 +303,8 @@ Deterministic unit and integration tests must cover: 1. pending pre-query status cannot produce `CURRENT`; 2. pending post-query status downgrades an otherwise clean response; 3. zero-pending pre/post status plus a clean response produces `CURRENT`; -4. failed or malformed status produces `UNKNOWN` and never calls raw MCP; +4. failed or malformed status produces `UNKNOWN` and does not run Provider + explore inside the proxy; 5. stale and disabled-auto-sync banners produce `STALE`; 6. prefixed, malformed, or changed banner shapes fail conservatively; 7. CLI status and explore always share the requested `cwd`; @@ -275,13 +314,14 @@ Deterministic unit and integration tests must cover: evidence; 11. historical v1/v2 records remain byte-identical and readable; 12. Planning, Implementation, Documentation Sync, Review, vendored agents, - and host renderings prohibit raw CodeGraph MCP use; + and host renderings require proxy provenance for Polaris evidence while + preserving the raw CodeGraph MCP tool and unrestricted shell access; 13. Validation remains graph-free; 14. the full Polaris suite passes without requiring CodeGraph; 15. an optional real-CLI smoke test uses only a disposable temporary repo. Skill evaluation must include pressure cases where an Agent is asked to skip -the wrapper, trust a clean-looking graph despite pending changes, reuse an old +the proxy, trust a clean-looking graph despite pending changes, reuse an old Implementation envelope, or treat `UNKNOWN` as current. The post-change Agent must refuse each shortcut and perform the required fallback. @@ -292,13 +332,17 @@ must refuse each shortcut and perform the required fallback. - Making graph freshness a workflow or acceptance gate. - Waiting for automatic sync to finish. - Installing, initializing, configuring, or managing CodeGraph. -- Building a second watcher, daemon, index, or MCP proxy. +- Building a second watcher, daemon, or index. +- Removing, disabling, or restricting the original CodeGraph MCP tool. +- Restricting shell access or rejecting ordinary shell use outside Polaris + Code Intelligence evidence. - Guaranteeing that a response remains current after it has been delivered. ## Acceptance Criteria -1. No Polaris stage can receive CodeGraph graph output without a preceding - freshness envelope from the wrapper. +1. No CodeGraph output can be accepted as Polaris Code Intelligence evidence + without a proxy evidence bundle whose MCP result placed the freshness + envelope before the graph content. 2. Any observed pending change, stale banner, unhealthy index, project mismatch, or failed verification prevents a `CURRENT` delivery. 3. `UNKNOWN` is mechanically restricted exactly like `STALE`. @@ -307,3 +351,5 @@ must refuse each shortcut and perform the required fallback. 5. Stale graph output remains available as navigation-only evidence with mandatory current-source or Git fallback. 6. Existing historical records and workflow transitions remain valid. +7. The original `codegraph_explore` tool and unrestricted shell access remain + available and unchanged. From f68ffb61288e4dfdb98813da26118476b82743b3 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 20:48:30 +0800 Subject: [PATCH 38/41] docs: translate CodeGraph MCP proxy design --- ...odegraph-polaris-mcp-proxy-design.zh-CN.md | 316 ++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-18-codegraph-polaris-mcp-proxy-design.zh-CN.md diff --git a/docs/superpowers/specs/2026-08-18-codegraph-polaris-mcp-proxy-design.zh-CN.md b/docs/superpowers/specs/2026-08-18-codegraph-polaris-mcp-proxy-design.zh-CN.md new file mode 100644 index 0000000..543a9f8 --- /dev/null +++ b/docs/superpowers/specs/2026-08-18-codegraph-polaris-mcp-proxy-design.zh-CN.md @@ -0,0 +1,316 @@ +# Polaris 专用 CodeGraph MCP 新鲜度代理设计 + +## 状态 + +- 日期:2026-08-18 +- 状态:提议实施 +- 范围:Polaris Code Intelligence 协议与阶段行为 +- Provider:`colbymchenry/codegraph` + +## 问题 + +目前,Polaris 允许 Planning、Implementation 和 Review 在独立的 +CodeGraph `status` 检查与 `sync-if-needed` 之间任选一种,然后直接查询 +`codegraph_explore`。当 status 的结构健康但 `pendingChanges` 非零时, +内部结果会表示为 `CURRENT_AT_CHECK` 加 `needs_sync: true`。精简的 v2 +record 不保留 `needs_sync` 或 pending 计数,因此,只执行 status 的路径可能 +丢弃已知的陈旧信号,同时仍以 `CURRENT_AT_CHECK` 通过校验。 + +新鲜度检查和查询还使用不同的传输方式:status 和 sync 通过 CLI 在仓库工作 +目录中运行,而 explore 优先使用宿主 MCP 连接。Polaris 没有将这些操作绑定到 +同一个项目,也没有把强制的新鲜度警告与 graph 响应一起交付。 + +所需行为刻意采用 fail-safe 原则: + +- Polaris 可以向 Agent 提供陈旧的 graph 输出。 +- 当输出已经陈旧或无法验证时,Polaris 必须告知 Agent。 +- 已知陈旧或无法验证的输出绝不能被呈现为最新输出。 +- 陈旧输出只能继续作为导航线索;当前源码和 Git 仍是权威来源。 + +## 保证边界 + +代理保证:Polaris 阶段通过代理交付的每一份 graph 响应,都带有紧邻的 Polaris +新鲜度 envelope;Polaris 观察到的每个新鲜度信号都会被保守分类。代理不证明 +CodeGraph 的解析器或关系推断在语义上正确,也不声称交付后的结果永久新鲜或与 +commit 精确一致。 + +原始 `codegraph_explore` MCP 工具和不受限制的 shell 访问继续保留。因此, +Polaris 无法阻止 Agent 绕过代理。Polaris 可以在 canonical 阶段指令中要求使用 +代理,并机械拒绝缺少代理来源的 Code Intelligence 证据,但不能证明 Agent 从未 +看到过通过其他通道取得的原始响应。 + +核心不变量是: + +> 缺少新鲜度证明不等于新鲜。任何已知的陈旧信号都归类为 `STALE`;任何不可用 +> 或不可读的证明都归类为 `UNKNOWN`;只有完全健康且有界的查询窗口才能归类为 +> `CURRENT`。 + +`UNKNOWN` 与 `STALE` 具有相同的 Agent 使用限制。 + +## 选定方案 + +新增一个项目级 Polaris MCP server,并暴露唯一工具 +`polaris_codegraph_explore`。Polaris 阶段 Skill 使用这个代理执行具备新鲜度感知 +能力的 graph 查询。现有 `codegraph_explore` MCP 工具继续保持安装状态,并可供 +非 Polaris 工作调用;Polaris 不移除、不包装、不拒绝也不限制 shell 命令。 + +代理内部使用 CodeGraph CLI 执行 status、可选的一次性 sync 和 explore,且这些 +命令都使用同一个显式仓库工作目录。这样既避免 MCP 调用 MCP 的依赖,又能保持 +与 `codegraph_explore` 等价的输出。代理只把原始响应写入已忽略的任务 runtime, +并把结构化新鲜度 envelope 与 graph 内容一起返回。 + +如果代理无法运行,Polaris 阶段会回退到源码和 Git。它不得静默改用原始 +Provider 工具来生成 Polaris 证据。Agent 仍可在该证据路径之外自由使用原始工具 +或 shell,但此类输出属于未验证的导航上下文,不能产生 `CURRENT` Polaris +record。 + +相比只修改指令,本方案更合适,因为新鲜度必须与 graph 响应机械地相邻。本方案 +也按照要求保留原始 Provider 和 shell 接口,同时为 Polaris record 提供一条可 +审计、fail-safe 的路径。 + +## MCP 接口 + +创建 `scripts/code_intelligence_mcp.py`,它是一个仅依赖标准库的 stdio MCP +server,由 vendored Polaris 项目 runtime 启动。它暴露一个有界工具: + +```text +polaris_codegraph_explore({ + "task_id": "TASK-0001", + "stage": "PLANNING", + "query_id": "CIQ-001", + "purpose": "discover frozen-task relationships", + "query": "symbols and paths relevant to the frozen task", + "sync_if_needed": true +}) +``` + +必需输入: + +- task ID,用于约束 runtime 证据范围; +- Polaris 阶段和有限的查询目的; +- 当前阶段 record 中下一个连续的 `CIQ-*` ID; +- 一个非空查询字符串。 + +仓库由项目级 server 启动配置固定,刻意不作为工具参数传入。 + +`sync_if_needed: true` 允许现有的一次性 sync 行为。`false` 表示只读,并且不 +等待 CodeGraph 自动同步。两种模式都不会 sleep、轮询、重试、初始化 CodeGraph, +也不会管理其 daemon 或 watcher。 + +只要 graph 输出可用,即使交付状态是 `STALE` 或 `UNKNOWN`,MCP 结果仍视为成功。 +Provider 执行失败时,返回一个不含 graph 响应、但要求源码回退的 `UNKNOWN` +结果。非法 Polaris 输入返回 MCP 工具错误。 + +## 查询流程 + +1. 校验协议兼容性、项目配置、`.codegraph/`、任务身份、阶段、目的和 runtime + confinement。 +2. 在 `cwd=repo` 中通过 CLI 执行查询前 status 检查。 +3. 如果 `sync_if_needed` 为 true 且查询前 status 存在 pending changes,最多执行 + 一次有界 sync,并且最多执行一次 sync 后 status 检查。 +4. 如果有效的查询前状态允许查询,则在同一个 `cwd=repo` 中执行一次有界的 + `codegraph explore`。已知 pending changes 和 index-stale 状态允许执行仅用于 + 导航的查询;status 不可用、项目不匹配或查询前 status 不可读时不允许查询。 +5. 把精确的 UTF-8 响应保存到 `runtime/code-intelligence/` 下;绝不把原始输出 + 写入 Git artifact。 +6. 对响应 banner 进行分类。 +7. 通过 CLI 执行一次查询后 status 检查。 +8. 使用最保守的状态合并查询前、sync、响应和查询后观察结果。 +9. 先发出 envelope;只有 graph 输出可用时,才在其后发出原始 graph 输出。 +10. 在 envelope 中包含供不可变阶段 record 使用的精简证据 bundle 路径。显式 + query ID 决定其唯一 runtime 文件名;如果目标已经存在,代理必须拒绝,而不 + 是覆盖。 + +查询不会重试。陈旧响应只交付一次,并附带限制;未知响应可以作为仅用于导航的 +证据交付,也可以在无法确认其项目或响应完整性时被丢弃。 + +## 交付状态 + +### `CURRENT` + +只有同时满足以下全部条件时才允许使用: + +- 查询前 status 或成功 sync 后的 status 在结构上健康; +- 其 `projectPath` 解析后等于请求的仓库; +- pending added、modified 和 removed 计数全部为零; +- 不存在 worktree mismatch、partial/indexing/failed index、pending reference 或 + reindex recommendation; +- explore 响应不包含 stale banner 或 auto-sync-disabled banner; +- 查询后 status 同样在结构上健康,并且 pending 计数为零。 + +envelope 使用 `usage: NON_AUTHORITATIVE_CONTEXT`。源码、Git、构建、测试、Review +和 Validation 仍是权威来源。 + +### `STALE` + +当 Polaris 观察到任何已知陈旧信号时使用,包括: + +- 查询前或查询后的 pending source changes 非零; +- 逐文件 stale response banner; +- auto-sync 被禁用; +- worktree mismatch; +- partial、indexing 或 failed index 状态; +- pending references 或 reindex recommendation; +- sync 失败,或 sync 后 status 不健康。 + +envelope 使用 `usage: NAVIGATION_ONLY`,并给出确切的必要回退动作。如果只有 +pending 计数而没有安全的文件名,则整个查询都归类为 `INDEX_STALE`;Polaris +不得推断未列出的关系仍是最新的。 + +### `UNKNOWN` + +当无法确定新鲜度时使用,包括能力缺失、超时、畸形 status JSON、畸形的已识别 +banner、不安全路径、项目不匹配或无法分类的 Provider 响应。envelope 使用 +`usage: NAVIGATION_ONLY`,并要求通过仓库源码搜索和 Git 证据回退。无 banner 的 +响应绝不能把它提升为 `CURRENT`。 + +响应分类采用 fail-safe 原则。精确匹配且受支持的 banner 产生其规定的 stale +状态。其他任何包含警告标记、stale 字样或 pending-sync 字样的疑似警告响应都产生 +`UNKNOWN`;其中包括被说明文字、引用、前导空格或 BOM 包装的受支持 banner。 +只有完全不含受支持或可疑新鲜度信号的响应才是中性的。 + +当 Code Intelligence 被禁用、缺少 `.codegraph/` 或缺少 CLI 能力时, +`UNAVAILABLE` 仍表示“不查询”状态。 + +## 代理激活 + +代理是项目级且归 Polaris 所有。宿主 adapter 会渲染一份本地 MCP 注册配置, +以一个固定仓库根目录启动 vendored server。它们不会添加全局 server,不会修改 +用户原有的 CodeGraph MCP 注册,不会移除任何 CodeGraph 工具,也不会改变 shell +权限。 + +server 进程在启动时接收仓库根目录,工具调用本身不接受任意项目路径。仓库根目录 +缺失、已移动或为 symlink 时,server 必须拒绝。移除或禁用项目本地的 Polaris +MCP 注册,只会禁用代理,不影响 CodeGraph 本身。 + +adapter 契约必须为每个受支持宿主表示项目级 MCP 注册,而不能把宿主专属配置写入 +Code Intelligence adapter。Vendoring 和项目校验会验证该注册只启动仓库中的 +vendored Polaris runtime。 + +## Envelope + +每次成功的代理工具结果都以一个有限的文本 content block 开头: + +```text +[POLARIS_CODEGRAPH_FRESHNESS] +state: STALE +record_status: INDEX_STALE +reason: PENDING_CHANGES +checked_at: 2026-08-18T12:00:00Z +pending_added: 0 +pending_modified: 1 +pending_removed: 0 +usage: NAVIGATION_ONLY +required_fallback: SEARCH_SOURCE +evidence_bundle: runtime/code-intelligence/CIQ-001.json +[/POLARIS_CODEGRAPH_FRESHNESS] +``` + +如果保留原始 CodeGraph 响应,它会作为同一 MCP 工具结果中位置更后的 content +block。代理绝不能在 envelope 之前返回 graph 内容。人类可读诊断信息会被写入有限 +的 envelope error 字段;子进程 stdout 和 stderr 绝不能在 envelope 之前转发。 + +## 证据与 Record 协议 + +引入新的 Code Intelligence record 版本,而不是削弱或改写不可变的 v2 证据。 +保留 v1 和 v2 record 作为可读取的历史 artifact;新的阶段 record 使用 v3。 + +v3 查询证据包含: + +- Provider ID 和 descriptor 版本; +- 仓库身份和阶段目标; +- 查询目的和响应 SHA-256; +- 查询前 status 观察结果; +- 可选的 sync 观察结果与 sync 后 status; +- 查询后 status 观察结果; +- 每次成功 status 的 pending added、modified 和 removed 计数; +- response-banner 分类和 stale points; +- 交付状态和使用限制; +- 精确的源码/Git 回退证据。 + +`CURRENT` 要求查询前或有效 status 和查询后 status 都成功,并且 pending changes +为零。`STALE` 至少需要一个明确的 stale reason。`UNKNOWN` 需要明确的验证失败。 +validator 会拒绝观察结果缺失、状态自相矛盾、项目不匹配、响应 hash 不匹配,或在 +任何 pending 计数非零时声明 `CURRENT`。 + +迁移过程会盘点不可变 v2 record,但不改写它们。Polaris 协议版本递增;workflow +graph 版本不变,因为 workflow 状态和 transition 都没有变化。 + +## 阶段行为 + +- Planning、Implementation 和 Review 使用代理生成具备新鲜度感知的 Polaris + graph 证据。原始 `codegraph_explore` 仍可调用,但它的输出属于其他通道,对 + Polaris 而言始终是未验证的,不能支撑 `CURRENT` 阶段 record。 +- Implementation 在编辑后若要为 Polaris 生成证据,只能发起一次新的代理调用; + 不能复用阶段入口的 envelope。 +- Documentation Sync 在受支持源码发生变化时,使用相同的代理/status 机制生成 + 最终有界 sync 证据。 +- Validation 仍然不使用 graph。 +- 当状态为 `STALE` 或 `UNKNOWN` 时,任何涉及返回文件或关系的阶段结论,都必须先 + 完成 envelope 要求的源码/Git 回退。 + +Vendored `AGENTS.md`、宿主 overlay 和 canonical Skill 必须共享此契约。由于这些 +改动会塑造 Skill 行为,因此必须遵循仓库的 `writing-skills` 工作流,并进行对抗性 +前后评估。 + +## 失败处理 + +- 缺少 `.codegraph/` 或策略被禁用:发出 `UNAVAILABLE`,不执行查询。 +- 缺少 CLI:发出 `UNAVAILABLE`,不通过 MCP 绕过。 +- 查询前 status 失败:发出 `UNKNOWN`;回退源码;默认不查询。 +- Explore 失败:发出 `UNKNOWN`;记录有限错误;不返回 graph 输出。 +- 查询后 status 失败:graph 只能以 `UNKNOWN` 且仅供导航的形式交付。 +- Explore 成功后出现 pending changes:以 `STALE` 交付,绝不能为 `CURRENT`。 +- 不安全或跨项目响应路径:丢弃 graph 响应并使用源码搜索。 +- Sync 失败:不重试;继续使用 stale 或 unknown envelope。 + +## 测试与评估 + +确定性单元测试和集成测试必须覆盖: + +1. 查询前 status 存在 pending 时不能产生 `CURRENT`; +2. 查询后 status 存在 pending 时,会降级原本干净的响应; +3. 查询前后 status 都是零 pending,且响应干净时,产生 `CURRENT`; +4. status 失败或格式错误时产生 `UNKNOWN`,并且代理内部不执行 Provider explore; +5. stale banner 和 disabled-auto-sync banner 产生 `STALE`; +6. 有前缀、格式错误或发生变化的 banner 采用保守失败; +7. CLI status 和 explore 始终使用请求的同一个 `cwd`; +8. 项目不匹配时丢弃 graph 输出; +9. envelope 始终位于 graph bytes 之前; +10. v3 validator 拒绝 pending 计数非零或缺少查询后证据的 `CURRENT`; +11. 历史 v1/v2 record 保持字节不变且仍可读取; +12. Planning、Implementation、Documentation Sync、Review、vendored agent 和 + 宿主渲染要求 Polaris 证据具有代理来源,同时保留原始 CodeGraph MCP 工具和 + 不受限制的 shell 访问; +13. Validation 仍然不使用 graph; +14. 完整 Polaris 测试套件不依赖 CodeGraph 即可通过; +15. 可选的真实 CLI smoke test 只使用一次性临时仓库。 + +Skill 评估必须包含以下压力场景:要求 Agent 跳过代理;在存在 pending changes 时 +仍信任看似干净的 graph;复用旧 Implementation envelope;或者把 `UNKNOWN` 当成 +current。变更后的 Agent 必须拒绝每种捷径并执行要求的回退。 + +## 非目标 + +- 证明 CodeGraph 的解析器或推断关系在语义上正确。 +- 把 graph freshness 变成 workflow 或 acceptance gate。 +- 等待自动同步完成。 +- 安装、初始化、配置或管理 CodeGraph。 +- 构建第二套 watcher、daemon 或 index。 +- 移除、禁用或限制原始 CodeGraph MCP 工具。 +- 限制 shell 访问,或拒绝 Polaris Code Intelligence 证据之外的普通 shell 使用。 +- 保证响应在交付后继续保持最新。 + +## 验收条件 + +1. 任何 CodeGraph 输出若缺少代理 evidence bundle,或其 MCP 结果没有把 freshness + envelope 放在 graph 内容之前,都不能被接受为 Polaris Code Intelligence + 证据。 +2. 任何已观察到的 pending change、stale banner、不健康索引、项目不匹配或验证 + 失败,都会阻止 `CURRENT` 交付。 +3. `UNKNOWN` 在机制上受到与 `STALE` 完全相同的限制。 +4. Pending 计数和查询前后证据在不可变 v3 record 中保持可审计。 +5. 陈旧 graph 输出仍可以作为仅供导航的证据使用,但必须完成当前源码或 Git + 回退。 +6. 现有历史 record 和 workflow transition 继续有效。 +7. 原始 `codegraph_explore` 工具和不受限制的 shell 访问保持可用且不变。 From 9849b1facaffd8a0e981c828b390cc0fb9df1249 Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 20:58:26 +0800 Subject: [PATCH 39/41] docs: design simplified Polaris workflow --- ...26-08-18-workflow-simplification-design.md | 242 ++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-18-workflow-simplification-design.md diff --git a/docs/superpowers/specs/2026-08-18-workflow-simplification-design.md b/docs/superpowers/specs/2026-08-18-workflow-simplification-design.md new file mode 100644 index 0000000..4f72b14 --- /dev/null +++ b/docs/superpowers/specs/2026-08-18-workflow-simplification-design.md @@ -0,0 +1,242 @@ +# Polaris Workflow Simplification Design + +## Status + +Approved direction: implement the workflow simplifications identified in the repository audit. + +Target protocol version: `0.1.20` +Target workflow version: `0.1.3` + +## Problem + +The current `0.1.2` workflow preserves strong governance boundaries, but several persisted states and gates do not introduce new authority, evidence, or a human decision. The largest problem is that ignored local telemetry in `runtime/progress.json` is a hard prerequisite for the durable `FINISH_IMPLEMENTATION` transition even though the protocol says runtime state does not participate in phase gates or Fresh Clone recovery. + +The happy path also contains mechanically adjacent transitions that can be combined: + +- `START_IMPLEMENTATION` followed by the `DISPATCH_IMPLEMENTATION` self-transition; +- `FINISH_IMPLEMENTATION` followed by resuming the same Implementer for `SYNC_DOCS`; +- `ACCEPT_REVIEW` followed by `START_VALIDATION`, with the same Reviews checked twice; +- `PASS_VALIDATION` followed by `CLOSE` for R0/R1, where no final Human approval exists. + +Code Intelligence is optional and non-blocking, but every stage currently writes an unavailable or skipped record even when the Provider is not used. This creates durable noise without strengthening a gate. + +Finally, the product authority says closure requires a full task validation pass, while the implemented closure gate currently checks only Result and the optional R2 final approval. + +## Goals + +1. Remove persisted states and transitions that have no distinct governance boundary. +2. Make ignored live progress optional telemetry rather than durable authority. +3. Keep Work Item, Plan decisions, Implementation handoff, Implementation, Knowledge Delta, Review handoff, Review, Validation, Result, event ledger, and state projection as durable artifacts. +4. Preserve independent Review and acceptance-driven Validation as separate stages. +5. Preserve an explicit `VERIFIED` waiting state only for R2 final Human approval. +6. Make closure validate the complete candidate task projection before committing the transition. +7. Provide an explicit, recoverable migration from workflow `0.1.2` to `0.1.3`. +8. Keep the runtime dependency-free beyond the Python standard library. + +## Non-goals + +- Removing independent Implementer or Reviewer isolation. +- Removing Work Item confirmation, Plan decisions, Knowledge Delta, Review, or Validation. +- Introducing a daemon, scheduler, task DAG, database, or custom Agent Runtime. +- Automatically pushing, merging, publishing, or running remote CI. +- Rewriting or deleting historical artifacts or events. + +## Options Considered + +### Option A: Change only Skills and documentation + +This would reduce conversational ceremony but leave the persisted workflow and gates unchanged. Existing frozen workflow projects would still require the old transitions. It would also leave the ignored-progress hard gate in place. + +Rejected because it does not solve the mechanical redundancy. + +### Option B: Keep all states but automatically chain transitions + +The controller could immediately run `DISPATCH_IMPLEMENTATION`, `START_VALIDATION`, and `CLOSE` after their predecessors. This reduces user-visible pauses but retains duplicate events, repeated validation, intermediate checkpoint commits, and recovery states with no independent meaning. + +Rejected because it hides rather than removes the complexity. + +### Option C: Version and simplify the workflow + +Introduce workflow `0.1.3`, remove redundant states and events, loosen the telemetry dependency, and explicitly migrate existing tasks. + +Selected because it aligns persisted control flow with actual governance boundaries while preserving auditability. + +## Target Workflow + +The normal persisted path becomes: + +```text +DRAFT → QUALIFIED → PLANNED → IMPLEMENTING + → REVIEWING → VALIDATING → CLOSED +``` + +R2 uses an additional final approval state: + +```text +VALIDATING → VERIFIED → CLOSED +``` + +The states `IMPLEMENTED`, `DOCS_SYNCED`, and `REVIEWED` are removed from workflow `0.1.3`. + +The following governance loops remain: + +```text +REVIEWING -- REJECT_REVIEW --> IMPLEMENTING +VALIDATING -- FAIL_IMPLEMENTATION --> IMPLEMENTING +VALIDATING -- FAIL_PLAN --> PLANNED +non-terminal -- NEW_REVISION --> QUALIFIED +non-terminal -- BLOCK --> BLOCKED +BLOCKED -- RESOLVE_BLOCK --> blocked_from +non-terminal -- CANCEL --> CANCELLED +``` + +## Transition Design + +### Start Implementation + +`START_IMPLEMENTATION` moves `PLANNED → IMPLEMENTING` and requires the Implementation handoff in the same transition. Its gate combines: + +- R2 pre-approval validation; +- handoff identity, revision, attempt, Plan, Working Set, and package validation. + +`DISPATCH_IMPLEMENTATION` is removed. Worker dispatch remains a host action performed after the handoff is registered; it is not a persisted workflow state. + +### Finish Implementation and Start Review + +The Implementer completes code, tests, required project documentation, final checks, the Implementation artifact, and Knowledge Delta before returning. Both artifacts bind the same final subject commit and diff hash. + +The main controller then builds the immutable Review handoff and runs `START_REVIEW` directly from `IMPLEMENTING`. The transition registers: + +- `implementation`; +- `knowledge_delta`; +- `review_handoff`; +- the final subject base/head commits. + +Its combined gate checks the Implementation handoff binding, Implementation artifact, Knowledge Delta, documentation impact, final subject, and Review handoff. It then moves `IMPLEMENTING → REVIEWING`. + +There is no separate Implementation checkpoint commit before documentation. The final subject checkpoint already includes code, tests, build configuration, and project documentation. + +### Accept Review and Start Validation + +`ACCEPT_REVIEW` validates all required Review artifacts once and moves `REVIEWING → VALIDATING`. `START_VALIDATION` is removed. Validation remains a separate stage and produces a new immutable Validation artifact. + +### Pass Validation and Close + +Two explicit pass events avoid conditional destinations hidden in code: + +- `PASS_AND_CLOSE`: valid only for R0/R1, registers Validation and Result, validates the complete candidate CLOSED projection, and moves `VALIDATING → CLOSED`. +- `PASS_VALIDATION`: valid only for R2, registers Validation, validates all acceptance criteria, and moves `VALIDATING → VERIFIED`. + +R2 then records final Human approval and Result before `CLOSE` moves `VERIFIED → CLOSED`. Both closing paths execute the same complete candidate-task validator before appending the event. + +## Candidate Projection Validation + +Task validation will be refactored so the same rules can validate either: + +- the projection currently stored in `state.json`; or +- a candidate projection prepared by `transition_task.py` before an event is appended. + +The public `validate_task.py` command continues to validate the stored state and event reconstruction. Closing gates call the shared candidate validator with the proposed CLOSED state and registered artifacts. No transition may append a CLOSED event and validate afterward. + +This eliminates the current discrepancy between `plan.md` and `closure_ready` without duplicating a second set of closure rules. + +## Live Implementation Progress + +`runtime/progress.json` remains available for hosts that can expose live progress, but it is explicitly best-effort and optional: + +- it remains Git ignored; +- its absence never blocks `START_REVIEW`, recovery, or closure; +- R0 does not require initialization or step events; +- R1/R2 may use ordered steps for status reporting, but the final Implementation artifact is authoritative; +- if a valid progress snapshot exists, the controller may compare it with the Implementation summary and report discrepancies as a warning, not a transition failure; +- Implementation `step_results` remain required durable summaries and are written directly into the Implementation artifact. + +The progress updater continues to reject corrupt or conflicting updates when it is used. Its local state machine is not part of the project workflow graph. + +## Code Intelligence Records + +Code Intelligence remains optional, provider-neutral at artifact boundaries, and non-blocking. + +- Stage artifacts may omit the Code Intelligence reference when no query or freshness-relevant operation was performed. +- Missing marker, disabled policy, or a Provider known to be unavailable in the current session does not require a new durable stage record. +- A durable record is written only when a stage performed a Provider status, sync, or explore operation whose result is useful audit evidence. +- Source and Git fallbacks remain mandatory whenever Provider evidence is stale or insufficient. +- Validation continues to exclude Code Intelligence as acceptance evidence. + +Historical v1 and v2 records remain immutable and readable. + +## Migration from Workflow 0.1.2 + +Protocol `0.1.20` adds an explicit migration strategy capable of replacing the frozen workflow and mapping task projections. The migration remains adjacent, append-only, resumable, and lock-protected. + +State mapping: + +| Old state | New state | +|---|---| +| `DRAFT` | `DRAFT` | +| `QUALIFIED` | `QUALIFIED` | +| `PLANNED` | `PLANNED` | +| `IMPLEMENTING` with registered handoff | `IMPLEMENTING` | +| `IMPLEMENTING` without registered handoff | `PLANNED` | +| `IMPLEMENTED` | `IMPLEMENTING` | +| `DOCS_SYNCED` | `IMPLEMENTING` | +| `REVIEWING` | `REVIEWING` | +| `REVIEWED` | `VALIDATING` | +| `VALIDATING` | `VALIDATING` | +| `VERIFIED` | `VERIFIED` | +| `BLOCKED` | `BLOCKED`, with `blocked_from` mapped by the same rules | +| `CLOSED` | `CLOSED` | +| `CANCELLED` | `CANCELLED` | + +Artifacts are preserved. Mapping `DOCS_SYNCED → IMPLEMENTING` lets the new `START_REVIEW` gate reuse the existing Implementation and Knowledge Delta and generate only the missing Review handoff. Mapping `IMPLEMENTED → IMPLEMENTING` lets the same Implementer finish documentation without relying on the ignored progress file. + +Each migrated task receives one `MIGRATE_POLARIS` event containing old/new protocol version, old/new workflow version, and old/new state. The migration record stores before/after event sequence and mapped status. Reruns reuse an already appended matching event and reject inconsistent partial state. + +## Authority and Artifact Compatibility + +- Historical `events.jsonl` entries may name removed states; they remain valid historical events. +- The rebuilt current projection uses the final migration event and workflow `0.1.3`. +- Existing immutable artifacts are never rewritten merely to adopt the new workflow. +- New Implementation and Knowledge Delta artifacts bind one final subject. +- `state.json` continues to store only current artifact pointers. +- Result remains a durable closure summary, but R0/R1 controllers generate it before `PASS_AND_CLOSE` rather than through a separate VERIFIED checkpoint. + +## Skills and User-Facing Contract + +The stable nine-field Polaris status block remains unchanged. Removed checkpoint markers are no longer emitted on new workflow tasks: + +- `IMPLEMENTATION_FINISHED` and `DOCS_SYNCED` collapse into `REVIEW_HANDOFF_READY` after the final subject is ready; +- `REVIEW_ACCEPTED` reports state `VALIDATING` and immediately identifies Validation as the next action; +- R0/R1 `VALIDATION_PASS` is followed by the same successful transition result at `CLOSED`, so the controller emits only `TASK_CLOSED`; +- R2 still emits `VALIDATION_PASS` at `VERIFIED`, requesting final approval. + +Recovery recommendations and documentation are updated to describe the new states and legal next actions. + +## Testing Strategy + +Tests are written before implementation changes and must cover: + +1. `START_IMPLEMENTATION` atomically registers and validates its handoff. +2. `DISPATCH_IMPLEMENTATION` is absent and rejected. +3. Missing `runtime/progress.json` does not block the final implementation-to-review transition. +4. `START_REVIEW` requires matching Implementation, Knowledge Delta, documentation check, final subject, and Review handoff. +5. Implementation and Knowledge Delta bind the same final subject. +6. `ACCEPT_REVIEW` moves directly to `VALIDATING`; `START_VALIDATION` is absent. +7. R0/R1 `PASS_AND_CLOSE` requires Validation, Result, and a complete candidate-task validation pass. +8. R2 cannot use `PASS_AND_CLOSE`, reaches `VERIFIED` through `PASS_VALIDATION`, and still requires final approval to close. +9. Code Intelligence references may be omitted when unused, while present records remain fully validated. +10. Every old workflow state migrates deterministically, including `BLOCKED.blocked_from` and the pre-handoff IMPLEMENTING edge case. +11. Interrupted migration resumes without duplicate events. +12. Documentation, templates, schemas, host-rendered Skills, and the full R1/R2 flow match workflow `0.1.3`. + +The full repository test suite, compile check, template materialization check, and clean-worktree inspection are required before completion. + +## Success Criteria + +- New R1 happy paths require one start-implementation transition, one start-review transition, one review-accept transition, and one pass-and-close transition after planning. +- No ignored runtime file is required by a durable gate. +- Review and Validation remain independently evidenced. +- R2 retains both Human approval gates. +- Existing `0.1.2` projects have an explicit adjacent migration path. +- `validate_task.py` and closing transitions share one legality implementation. +- All tests pass using only Python standard-library runtime code. From c9a33d1e8d7bb776a806b5ec1f1c321049f384ff Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Tue, 18 Aug 2026 23:58:32 +0800 Subject: [PATCH 40/41] test: configure git identity in CodeGraph fixtures --- tests/test_codegraph.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 9c79112..5474a1a 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -84,6 +84,16 @@ def setUp(self) -> None: self.temp = tempfile.TemporaryDirectory(prefix="polaris-codegraph-") self.repo = Path(self.temp.name) subprocess.run(["git", "init", "-q"], cwd=self.repo, check=True) + subprocess.run( + ["git", "config", "user.email", "polaris@test.local"], + cwd=self.repo, + check=True, + ) + subprocess.run( + ["git", "config", "user.name", "Polaris Test"], + cwd=self.repo, + check=True, + ) init_project(self.repo, "codegraph-test") subprocess.run(["git", "add", "."], cwd=self.repo, check=True) subprocess.run( From 4472aeb8610beef0fb628bb873c28c359906301d Mon Sep 17 00:00:00 2001 From: GraphZLL Date: Wed, 19 Aug 2026 00:06:47 +0800 Subject: [PATCH 41/41] test: make CodeGraph path assertion portable --- tests/test_codegraph.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index 5474a1a..ed94f6e 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -384,7 +384,10 @@ def test_migration_retires_v1_records_without_rewriting_them(self) -> None: current = self.v2_record() current.update({"stage": "IMPLEMENTATION", "artifact_attempt": 1}) result = record(self.repo, "TASK-0001", current, ROOT) - self.assertTrue(result["path"].endswith("code-intelligence/r001/implementation-001.json")) + self.assertEqual( + Path(result["path"]).parts[-3:], + ("code-intelligence", "r001", "implementation-001.json"), + ) def test_migration_rejects_noncanonical_v2_record_paths(self) -> None: """Migration scans only the canonical Code Intelligence record layout."""