From 510b46636ac6936ed220e694518fb86d3ecfaa46 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 23 Sep 2026 17:51:04 +0000 Subject: [PATCH] feat(gov): add codegraph hooks and read-only cleanup/MATRIX audits Wire CodeGraph into agentctl init/create/sync and expose cleanup report plus docs drift so agents can inventory stale worktrees and MATRIX regressions without auto-deleting or auto-promoting done. --- .cursor/mcp.json | 5 + .../agentctl-integrate-closeout/SKILL.md | 9 + .cursor/skills/agentctl-lease-write/SKILL.md | 1 + .../skills/agentctl-session-start/SKILL.md | 15 +- .../skills/agentctl-worktree-setup/SKILL.md | 6 +- .../ops/2026-09-21-agent-worktree-workflow.md | 23 +- .doc/ops/2026-09-21-policy-registry.md | 6 + .doc/ops/CURRENT.md | 1 + .../2026-09-24-codegraph-governance.md | 28 ++ .doc/ops/templates/session-close-checklist.md | 3 + ...-09-24-codegraph-governance-test-report.md | 76 ++++++ .gitignore | 3 + AGENTS.md | 3 +- scripts/agent_worktree.py | 66 ++++- scripts/agentctl.py | 240 +++++++++++++++++- scripts/agentctl_w4.py | 12 + scripts/tests/test_codegraph_governance.py | 155 +++++++++++ 17 files changed, 638 insertions(+), 14 deletions(-) create mode 100644 .doc/ops/short-term/2026-09-24-codegraph-governance.md create mode 100644 .doc/test-reports/2026-09-24-codegraph-governance-test-report.md create mode 100644 scripts/tests/test_codegraph_governance.py diff --git a/.cursor/mcp.json b/.cursor/mcp.json index 2e619ff1..ce1f0359 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -7,6 +7,11 @@ "jev": { "command": "node", "args": ["${workspaceFolder}/scripts/mcp/jev-mcp.mjs"] + }, + "codegraph": { + "type": "stdio", + "command": "codegraph", + "args": ["serve", "--mcp", "--path", "${workspaceFolder}"] } } } diff --git a/.cursor/skills/agentctl-integrate-closeout/SKILL.md b/.cursor/skills/agentctl-integrate-closeout/SKILL.md index a482f248..6b59080c 100644 --- a/.cursor/skills/agentctl-integrate-closeout/SKILL.md +++ b/.cursor/skills/agentctl-integrate-closeout/SKILL.md @@ -51,3 +51,12 @@ CURRENT 行 → `done`(`docs apply`);`task close --status done`。 ## 清理前 确认 tip 为 local_dev 祖先、证据已持久化、无 tracked/untracked 遗留、无进程占用。唯一日志/对话归档进 `.doc/ops/evidence/` 后再 `worktree remove`。 + +只读盘点(不自动删除、不自动写 MATRIX): + +```bash +python3 ../../scripts/agentctl.py cleanup report # 主目录或任意同仓 cwd +python3 ../../scripts/agentctl.py docs drift +``` + +对 `verdict=ready` 的 worktree 由独立维护者执行 `agent-worktree.sh remove … --delete-branch`;拒绝则保留 cleanup_pending。daemon 未装载时用 `queue schedule --drain`(主目录 + 分支 dev)。 diff --git a/.cursor/skills/agentctl-lease-write/SKILL.md b/.cursor/skills/agentctl-lease-write/SKILL.md index c0d4726f..bfae61fd 100644 --- a/.cursor/skills/agentctl-lease-write/SKILL.md +++ b/.cursor/skills/agentctl-lease-write/SKILL.md @@ -33,6 +33,7 @@ python3 scripts/agentctl.py docs apply --task "$ID" --request /tmp/req.json ``` FEATURE-MATRIX 升 `done` 需要 `docs:approve` + 可核验 evidence(`git cat-file -e`),否则 `EVIDENCE_REQUIRED`。 +收口或合入含 MATRIX 的变更后跑 `python3 scripts/agentctl.py docs drift`:硬告警仅 `done→非 done`;不因任务结束自动升 done。 ## 常见拒绝 diff --git a/.cursor/skills/agentctl-session-start/SKILL.md b/.cursor/skills/agentctl-session-start/SKILL.md index 4c5792b0..be29bc9a 100644 --- a/.cursor/skills/agentctl-session-start/SKILL.md +++ b/.cursor/skills/agentctl-session-start/SKILL.md @@ -19,9 +19,18 @@ python3 scripts/agentctl.py daemon status 或 MCP 工具 `status`。 -3. 若任务已存在:在注册 worktree 内工作,确认 `tasks.state=active`。 -4. 若新任务:跟 [agentctl-worktree-setup](../agentctl-worktree-setup/SKILL.md)。 -5. 任何仓库写 → [agentctl-lease-write](../agentctl-lease-write/SKILL.md);合入 → [agentctl-integrate-closeout](../agentctl-integrate-closeout/SKILL.md)。 +3. 刷新 CodeGraph 索引(fail-open;CLI 缺失可跳过): + +```bash +codegraph sync -q +# 或:python3 scripts/agentctl.py hooks codegraph-sync +``` + +跨文件定位优先用 CodeGraph MCP/`explore`;**grep 仍是完整性唯一权威**(假阴性时以 grep 为准)。 + +4. 若任务已存在:在注册 worktree 内工作,确认 `tasks.state=active`。 +5. 若新任务:跟 [agentctl-worktree-setup](../agentctl-worktree-setup/SKILL.md)。 +6. 任何仓库写 → [agentctl-lease-write](../agentctl-lease-write/SKILL.md);合入 → [agentctl-integrate-closeout](../agentctl-integrate-closeout/SKILL.md)。 ## 禁止 diff --git a/.cursor/skills/agentctl-worktree-setup/SKILL.md b/.cursor/skills/agentctl-worktree-setup/SKILL.md index 287ce775..c33c8c94 100644 --- a/.cursor/skills/agentctl-worktree-setup/SKILL.md +++ b/.cursor/skills/agentctl-worktree-setup/SKILL.md @@ -14,13 +14,16 @@ cd /path/to/Agent-System # 主目录(dev) D=$(git rev-parse HEAD) ID= WT=.worktrees/$ID -git worktree add -b feature/$ID "$WT" "$D" +# 推荐入口:自动 best-effort codegraph init(后台;CLI 缺失不阻断) +./scripts/agent-worktree.sh create feature/$ID +# 等价:git worktree add -b feature/$ID "$WT" "$D" cd "$WT" python3 ../../scripts/agentctl.py task register \ --id "$ID" --owner \ --scope scripts --scope .doc \ --capability docs:publish # 需要整文/行级 admin 时再加 --capability docs:admin;证据升 done 加 docs:approve +codegraph sync -q # 若建场已 init,此处只需 sync;缺失 CLI 可跳过 ``` ## CURRENT 认领(docs apply) @@ -46,3 +49,4 @@ python3 ../../scripts/agentctl.py docs apply --task "$ID" --request /tmp/claim.j - 从确切 `dev` SHA 创建,不从他人 feature 分支启动 - 重叠 path/mutex 只允许一个写者 +- CodeGraph 索引是加速器:有假阴性;穷举调用方仍用 grep diff --git a/.doc/ops/2026-09-21-agent-worktree-workflow.md b/.doc/ops/2026-09-21-agent-worktree-workflow.md index fa03f553..e348985c 100644 --- a/.doc/ops/2026-09-21-agent-worktree-workflow.md +++ b/.doc/ops/2026-09-21-agent-worktree-workflow.md @@ -121,13 +121,34 @@ validate 要求干净树、记录精确 HEAD 与命令摘要;必要的实际 任务状态、集成、部署、远端同步和 cleanup 分别记录,见[收口清单](./templates/session-close-checklist.md)。命令结束不等于任务完成;task close 会拒绝仍有运行或待恢复 operation 的任务。 +### 8.1 只读盘点(先盘点,再人工处置) + +```bash +# 批量 worktree 清理就绪度;verdict=ready 才可考虑 remove。从不自动删除。 +./scripts/agentctl cleanup report + +# FEATURE-MATRIX 状态漂移:硬告警仅 done→非 done(ISSUE-20260923 工具化); +# open_capabilities 为人工判断候选。从不自动升 done / 写 MATRIX。 +./scripts/agentctl docs drift +``` + +daemon 前台循环可按 `--audit-every N`(默认 30)周期跑上述盘点;**不在 daemon 内调用 `docs apply`**(主目录 cwd 必 `WORKTREE_MISMATCH`)。常驻 systemd/launchd 装载需独立授权,本规范默认不装载。 + +### 8.2 关闭与删除 + ```bash ./scripts/agentctl task close --id task-id --status done ./scripts/agent-worktree.sh remove feature/task-id --delete-branch ``` -清理由目录外的维护者执行,核对实际 Git 映射、dev 祖先、所有证据、tracked/untracked/ignored 文件、未完成 Git 操作、registry、生命周期锁和使用目录的进程。只用普通 remove/-d,拒绝时保留 cleanup_pending 和复查责任,不换成强制删除。 +清理由目录外的维护者执行,核对实际 Git 映射、dev 祖先、所有证据、tracked/untracked/ignored 文件、未完成 Git 操作、registry、生命周期锁和使用目录的进程。只用普通 remove/-d,拒绝时保留 cleanup_pending 和复查责任,不换成强制删除。盘点只读;删除仍人工;拒绝即保留 cleanup_pending。 **本机限制**:无法读取某些同 UID 进程 cwd 时,工具不能证明其未使用目录,故保留 cleanup_pending;明确识别的 OS 用户 launchd(macOS)/systemd(Linux)管理进程有诊断性例外。进程 cwd 可见性走双路径:Darwin 用 `lsof`,Linux 用 `/proc`;测试中的删除正例可替换对应枚举以模拟可见主机,其余 Git/锁/文件条件为真实执行;可见占用、未知不可读、未合入、脏文件与活跃登记的拒绝另有真实/针对性测试。不要把这项限制转成跳过检查的通用开关。 保护任务、旧 detached 工作区和非本次产物不自动清理。用户未要求 push 时仅本地提交/合入;用户已要求时再按授权同步并核对远端引用。 + +### 8.3 CodeGraph(治理加速器) + +- `agentctl init` 与 `agent-worktree.sh create` 会 best-effort 初始化/同步 `.codegraph/`(CLI 缺失则跳过,不阻断)。 +- `hooks install` 安装 `post-merge` / `post-checkout` → `hooks codegraph-sync`(fail-open,永不阻断 integrate)。 +- 会话启动执行 `codegraph sync -q`。索引是 tree-sitter **近似**:有假阴性;**grep 是完整性唯一权威**;重命名与穷举调用方必须 grep 确认。 diff --git a/.doc/ops/2026-09-21-policy-registry.md b/.doc/ops/2026-09-21-policy-registry.md index c237c5a3..5553e290 100644 --- a/.doc/ops/2026-09-21-policy-registry.md +++ b/.doc/ops/2026-09-21-policy-registry.md @@ -39,3 +39,9 @@ CURRENT 是带时间的活跃协调快照;Git/机器状态用于核验实际 修订 ID:`issue-learning-2026-09-23-r1`;owner:Codex;state:`active`(已通过 `7bbd7a407f857515005c0324d90339017ea5f3e8` 合入 dev);执行能力:`manual`。用户要求同时管理出现的问题、修复记录和思路,并及时总结为开发注意事项,授权与验证见 [任务](short-term/2026-09-23-issue-learning.md)。 [闭环规范](2026-09-23-issue-learning-workflow.md) 为本流程唯一来源;AGENTS 增加按任务触发的读取/更新入口,模板承载证据,问题台账和经验索引提供检索。生效提交为包含本修订的首个受控 dev 集成提交,以任务收据与 Git 父链核实;未合入不能声称全仓生效。此修订不新增产品功能、自动门禁或运行权限。 + +## CodeGraph 与只读盘点局部修订 + +修订 ID:`codegraph-governance-2026-09-24-r1`;owner:`Cursor`;state:`proposed`(待本任务受控合入 local_dev 后升 `active`);工具能力:盘点命令与 hooks sync 为 `enforced`(脚本已落地);常驻 daemon 装载仍为 `manual`(本机默认不装载)。用户授权:将 CodeGraph 纳入治理启动/建场/sync,并对 worktree 清理与 FEATURE-MATRIX 做只读盘点提醒(不自动删除、不自动升 done)。 + +唯一来源增补:[AGENTS §1/§4](../../AGENTS.md)、[worktree 操作规范 §8](./2026-09-21-agent-worktree-workflow.md)、[收口清单](./templates/session-close-checklist.md)。命令入口:`agentctl cleanup report`、`agentctl docs drift`、`agentctl hooks codegraph-sync`、建场 `agent-worktree.sh create` 的 best-effort `codegraph init`。不改产品 SWBOOT-10 stub,不装载 systemd coordinator(需另授)。 diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 771896de..eae51bd3 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -18,6 +18,7 @@ | owner | task | branch | worktree | mutex | status | |---|---|---|---|---|---| +| Cursor | codegraph-governance-r2-2026-09-24 | feature/codegraph-governance-2026-09-24 | .worktrees/codegraph-governance-2026-09-24 | agent-workflow-governance+codegraph | in_progress | | Cursor | jev-mcp-docs-done-2026-09-24 | feature/jev-mcp-docs-done-2026-09-24 | .worktrees/jev-mcp-docs-done-2026-09-24 | jev-mcp+docs | done | | Cursor | jev-mcp-integration-2026-09-24 | feature/jev-mcp-integration-2026-09-24 | .worktrees/jev-mcp-integration-2026-09-24 | jev-mcp+docs | done | | Cursor | integrate-intent-plan-u9u12-land-2026-09-24 | feature/integrate-intent-plan-u9u12-land-2026-09-24 | .worktrees/integrate-intent-plan-u9u12-land-2026-09-24 | git:dev | done | diff --git a/.doc/ops/short-term/2026-09-24-codegraph-governance.md b/.doc/ops/short-term/2026-09-24-codegraph-governance.md new file mode 100644 index 00000000..01f2d7eb --- /dev/null +++ b/.doc/ops/short-term/2026-09-24-codegraph-governance.md @@ -0,0 +1,28 @@ +--- +status: in_progress +task_id: codegraph-governance-2026-09-24 +owner: Cursor +mutex: agent-workflow-governance+codegraph +base_dev_sha: 0ff698db6f3e49df7808a30ae328f3b81f9dd4ba +write_scope: scripts, .doc, .cursor, .gitignore, AGENTS.md +review_state: in_progress +integration_state: not_merged +deployment: not_required +cleanup: pending +remote_sync: not_requested +policy_revision: codegraph-governance-2026-09-24-r1 +--- + +# CodeGraph 纳入治理 + worktree/MATRIX 只读盘点 + +## 目标 + +1. 治理层接入 CodeGraph:MCP、gitignore、建场 init、hooks sync、会话 sync;soundness 边界(grep 权威)。 +2. 只读盘点:`cleanup report` / `docs drift`;daemon `--audit-every`;不自动删除、不自动升 done。 +3. 不碰产品 SWBOOT-10;不装载本机 systemd coordinator。 + +## 验收 + +- unittest(scripts/tests)+ 针对性正负例 +- 真实命令:cleanup report / docs drift / hooks codegraph-sync +- 测试报告:`.doc/test-reports/2026-09-24-codegraph-governance-test-report.md`(工程规范变更,PI/全量 Go 标 NOT REQUIRED) diff --git a/.doc/ops/templates/session-close-checklist.md b/.doc/ops/templates/session-close-checklist.md index 194ea709..b60e875f 100644 --- a/.doc/ops/templates/session-close-checklist.md +++ b/.doc/ops/templates/session-close-checklist.md @@ -25,8 +25,11 @@ ## 清理与归档 - [ ] 按 [操作规范](../2026-09-21-agent-worktree-workflow.md) 核对 dev 祖先、验收、文件/唯一 ignored 证据、进程、保护项;普通 remove/-d 拒绝则保留,不强删。 +- [ ] 已跑 `agentctl cleanup report`;对 `verdict=ready` 项由维护者执行 remove;blocked 保留 cleanup_pending。 +- [ ] 已跑 `agentctl docs drift`;处理硬告警(`done→非 done`);`open_capabilities` 按声明范围人工决定是否 `docs apply`(升 done 仍需 `docs:approve` + 已提交证据)。 - [ ] cleanup 未完成时有 cleanup_owner、保留原因、证据位置、可观测复查触发;owner 退出后由独立集成/维护执行者复查。 - [ ] 可再生缓存与唯一证据分开处理;唯一日志/截图已持久保存,不能仅留下即将删除的路径。 - [ ] 只有 done/cancelled 任务归档;移动时修复相对链接并保留历史。未完成任务保留原状态,不为归档改 done。 - [ ] 仅模块声明变化时更新 [FEATURE-MATRIX](../../status/FEATURE-MATRIX.md),仅 blocker 变化时更新 [gap-matrix](../../remediation/gap-matrix.md);CURRENT 焦点最多 3 条。 - [ ] 实现与相应证据形成可解释、可验证、可回退的逻辑提交;无三大主干直接开发提交。 +- [ ] 本会话触及的仓库若已装 CodeGraph:收口前 `codegraph sync -q`(缺失 CLI 可跳过)。 diff --git a/.doc/test-reports/2026-09-24-codegraph-governance-test-report.md b/.doc/test-reports/2026-09-24-codegraph-governance-test-report.md new file mode 100644 index 00000000..baefa3e6 --- /dev/null +++ b/.doc/test-reports/2026-09-24-codegraph-governance-test-report.md @@ -0,0 +1,76 @@ +# CodeGraph 治理与只读盘点 测试报告 + +## 一、 测试背景 (Background) + +Agent 任务 worktree 完成后不自动清理、FEATURE-MATRIX 状态不及时更新,根因是治理面只有人工 `remove` / `docs apply`,无只读盘点触发器。本轮将 CodeGraph 纳入治理启动/建场/sync,并落地 `cleanup report` / `docs drift`(只提醒、不自动删除、不自动升 done)。 + +## 二、 测试目标 (Test Objectives) + +- CodeGraph:MCP 注册、gitignore、建场 best-effort init、hooks fail-open sync、会话 sync;soundness 边界声明。 +- 只读盘点:`agentctl cleanup report`、`agentctl docs drift`(`done→非 done` 硬告警);daemon `--audit-every`。 +- 回归:scripts/tests 全量通过;新增针对性用例。 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS:Linux;Python 3.14;本机已装 `codegraph` CLI。 +- 层级:治理脚本单元测试 + 真实 CLI 取样。 +- **产品 PI 全链路 / 全量 Go 回归:NOT REQUIRED**(依据 PROJECT.md §8 与 worktree 操作规范:纯工程规范/治理工具变更)。 + +## 四、 预期结果 (Expected Results) + +- `hooks codegraph-sync` 在无 CLI 时 exit 0 且 `not_installed`。 +- `hooks install` 含 `post-merge` / `post-checkout`。 +- `docs drift` 能检出 `done→planned` 回退。 +- `cleanup report` 不删除任何 worktree。 +- 建场在无 codegraph / 未 ignore 时不阻断。 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 针对性单元测试 + +**Input**: `python3 -B -m unittest scripts.tests.test_codegraph_governance -v` + +**Actual**: 5/5 ok(cleanup_report_never_deletes、codegraph_sync_fail_open_without_binary、create_skips_codegraph_when_missing、docs_drift_detects_done_regression、hooks_install_includes_codegraph_sync_hooks) + +**Verdict**: PASS + +### 5.2 全量 scripts/tests + +**Input**: `python3 -B -m unittest discover -s scripts/tests -p 'test_*.py'` + +**Actual**: `Ran 139 tests in 68.951s` → `OK` + +**Verdict**: PASS + +### 5.3 真实 CLI:cleanup report + +**Input**: `python3 scripts/agentctl.py cleanup report`(任务 worktree) + +**Actual**(摘要): `summary: {total: 36, ready: 26, blocked: 10}`;不删除目录。 + +**Verdict**: PASS + +### 5.4 真实 CLI:docs drift + +**Input**: `python3 scripts/agentctl.py docs drift` + +**Actual**: `status=ok`, `baseline=first_snapshot`, `record_count=29`, `regressions=[]`, `open_capabilities=7` + +**Verdict**: PASS(首次快照无回退告警属预期) + +### 5.5 真实 CLI:hooks codegraph-sync + +**Input**: `python3 scripts/agentctl.py hooks codegraph-sync` + +**Actual**: `{"status":"ok","hook":"codegraph-sync","codegraph":{"status":"ok","exit_code":0}}` + +**Verdict**: PASS + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +- 治理脚本:`139` tests OK(见上)。 +- `go test ./...`:**NOT REQUIRED**(本任务无产品 Go 变更)。 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +达到合入 local_dev 标准。后续:独立授权后再装载本机 systemd coordinator;合入后在主目录执行 `hooks install` 以启用 post-merge/checkout sync;T7 按 `cleanup report` 的 ready 清单人工 remove。 diff --git a/.gitignore b/.gitignore index 85d58d3d..b8ccde8b 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,9 @@ tmp/ temp/ .tmp/ +# CodeGraph local index (DB/WAL stay machine-local; see .codegraph/.gitignore) +.codegraph/ + # IDE / Editor Files .idea/ .vscode/ diff --git a/AGENTS.md b/AGENTS.md index 12225a88..073b172f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ - 写入成果前:读 [文档地图](./.doc/README.md)、相关任务/方案/handoff;创建、集成、部署、清理前读 [worktree 操作规范](./.doc/ops/2026-09-21-agent-worktree-workflow.md)。 - 设计、编码、排障或运维前:按模块、症状和机制检索 [问题台账](./.doc/audit/ISSUES.md) 与 [开发经验](./.doc/ops/lessons/INDEX.md),核对命中条目的适用条件,在任务中记录采纳的检查或无命中。问题发现、修复验证、复发和交接时按 [经验闭环](./.doc/ops/2026-09-23-issue-learning-workflow.md) 更新问题记录、修复经过和经验;未验证结论保留为候选。 - 编码前:读相关 FEATURE-MATRIX 行、[模块开发标准](./.doc/system-design/2026-09-20-module-development-standards.md)及其接口、事件、表写者约束;依 [验证契约](./.doc/ops/2026-09-21-validation-contract.md) 选择测试。 +- 跨文件定位 / 子系统摸底:优先用本仓 CodeGraph(`codegraph sync -q` 后走 MCP/`codegraph explore`);**grep 仍是完整性唯一权威**(tree-sitter 近似有假阴性;重命名/穷举调用方必须 grep 确认)。缺失 CLI 时跳过,不阻断会话。 - 发布/审计:读 [发布门禁](./.doc/remediation/gap-matrix.md) 和本次范围对应证据;完整发布判断须复核全部发布门禁。 - 仅使用 Herdr 编排/启动/派发时:读 [角色与备援 SSOT](./.doc/ops/2026-09-13-herdr-agent-roster.md)。会话结束/换人:用 [收口清单](./.doc/ops/templates/session-close-checklist.md)。 @@ -59,7 +60,7 @@ 任务状态仅为 `planned | in_progress | blocked | done | cancelled`。会话结束不等于 done:只有授权范围内验收满足才完成;方案研究的验收是可审产物,未获实施授权无需部署。换人记录剩余工作和接管条件,只有终态任务归档。分别报告 `review_state`、`integration_state`、`deployment`、`cleanup`、`remote_sync`;业务已合入但部署失败不得报交付完成。 -清理前逐项确认任务提交为 dev 祖先、所需验收通过、证据已持久化、无 tracked/untracked/唯一 ignored 遗留、无 Agent/进程使用、路径分支匹配且非保护项。普通 `git worktree remove` / `git branch -d` 拒绝时记录原因,不升级 `--force` / `-D`。`cleanup=pending` 是操作状态,不是第六种任务状态;保留 `cleanup_owner`、原因、证据及下次复查触发。由独立集成/维护执行者在 owner 退出后复查;可再生缓存登记后处理,唯一日志/截图先归档,只有路径不算保存证据。不按年龄批量删除 worktree。 +清理前逐项确认任务提交为 dev 祖先、所需验收通过、证据已持久化、无 tracked/untracked/唯一 ignored 遗留、无 Agent/进程使用、路径分支匹配且非保护项。普通 `git worktree remove` / `git branch -d` 拒绝时记录原因,不升级 `--force` / `-D`。`cleanup=pending` 是操作状态,不是第六种任务状态;保留 `cleanup_owner`、原因、证据及下次复查触发。由独立集成/维护执行者在 owner 退出后复查;可再生缓存登记后处理,唯一日志/截图先归档,只有路径不算保存证据。不按年龄批量删除 worktree。收口前跑只读盘点:`agentctl cleanup report`(可清理清单)与 `agentctl docs drift`(MATRIX `done→非 done` 回退告警);盘点只提醒,删除与 MATRIX 写入仍人工执行。 晋升路径 `dev → test`(rc tag)`→ main`(release tag),需相应授权。用户未要求不 push;用户明确要求 push/同步远端则自主完成。收口始终区分本地提交、dev 合入、实际运行版本、远端同步和清理结果。 diff --git a/scripts/agent_worktree.py b/scripts/agent_worktree.py index 09dc5caa..b64319f4 100644 --- a/scripts/agent_worktree.py +++ b/scripts/agent_worktree.py @@ -13,15 +13,60 @@ import json import os from pathlib import Path import re +import shutil import stat import subprocess import sys +# Never leave __pycache__ that dirties an otherwise clean worktree. +sys.dont_write_bytecode = True +os.environ.setdefault('PYTHONDONTWRITEBYTECODE', '1') + class Refused(RuntimeError): pass +def maybe_codegraph_init(path, *, timeout=180): + """Best-effort CodeGraph index for a new worktree. Never blocks create. + + Linked worktrees get their own .codegraph/ when gitignored. Missing CLI or + unignored path → skip with a notice. Failures are warnings only. + """ + binary = shutil.which('codegraph') + if not binary: + print(json.dumps({'codegraph': 'not_installed'}), file=sys.stderr) + return {'status': 'not_installed'} + target = Path(path).resolve() + ignored = subprocess.run( + ['git', '-C', str(target), 'check-ignore', '-q', '--', '.codegraph'], + capture_output=True, check=False).returncode == 0 + if not ignored: + print(json.dumps({ + 'codegraph': 'skipped', + 'reason': '.codegraph not gitignored', + }), file=sys.stderr) + return {'status': 'skipped', 'reason': 'not_ignored'} + try: + # Detach so create returns immediately; index can take tens of seconds. + proc = subprocess.Popen( + [binary, 'init', '-y', str(target)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + print(json.dumps({ + 'codegraph': 'init_started', + 'pid': proc.pid, + 'path': str(target), + 'timeout_hint_seconds': timeout, + }), file=sys.stderr) + return {'status': 'init_started', 'pid': proc.pid} + except OSError as exc: + print(json.dumps({'codegraph': 'init_failed', 'error': str(exc)}), file=sys.stderr) + return {'status': 'init_failed', 'error': str(exc)} + + def command(args, cwd, check=True): result = subprocess.run(args, cwd=cwd, text=True, capture_output=True) if check and result.returncode: @@ -118,7 +163,13 @@ def create(repo, main, branch): base = git(main, "rev-parse", "--verify", "refs/heads/dev^{commit}").stdout.strip() path.parent.mkdir(exist_ok=True) git(main, "worktree", "add", "-b", branch, str(path), base) - print(json.dumps({"branch": branch, "worktree": str(path), "base_dev_sha": base})) + codegraph = maybe_codegraph_init(path) + print(json.dumps({ + "branch": branch, + "worktree": str(path), + "base_dev_sha": base, + "codegraph": codegraph.get("status"), + })) def _linux_same_user_cwds(): @@ -386,8 +437,8 @@ def inspect_worktree(repo, main, entry): return report -def cleanup_report(repo, main): - """Surface cleanup readiness without deleting anything.""" +def build_cleanup_report(repo, main): + """Return cleanup readiness payload without deleting anything.""" try: control(repo, "status") registry = "initialized" @@ -412,12 +463,17 @@ def cleanup_report(repo, main): continue items.append(inspect_worktree(repo, main, entry)) ready = sum(1 for item in items if item.get("verdict") == "ready") - print(json.dumps({ + return { "status": "ok", "registry": registry, "worktrees": items, "summary": {"total": len(items), "ready": ready, "blocked": len(items) - ready}, - }, ensure_ascii=False, sort_keys=True)) + } + + +def cleanup_report(repo, main): + """Surface cleanup readiness without deleting anything.""" + print(json.dumps(build_cleanup_report(repo, main), ensure_ascii=False, sort_keys=True)) def main(): parser = argparse.ArgumentParser(description=__doc__) diff --git a/scripts/agentctl.py b/scripts/agentctl.py index ee77e696..edc6efd2 100755 --- a/scripts/agentctl.py +++ b/scripts/agentctl.py @@ -10,6 +10,7 @@ import os from dataclasses import dataclass from pathlib import Path import re +import shutil import signal import sqlite3 import subprocess @@ -21,6 +22,9 @@ import uuid # Pure W4 helpers (deploy/projection/PI checks) live beside this file for tests. sys.path.insert(0, str(Path(__file__).resolve().parent)) +# Governance tools must not leave __pycache__ that dirties clean Git trees. +sys.dont_write_bytecode = True +os.environ.setdefault('PYTHONDONTWRITEBYTECODE', '1') import agentctl_w4 as w4 # noqa: E402 VERSION = 1 @@ -668,7 +672,9 @@ def initialize(repo): with repo.db(True) as db: db.execute('SELECT count(*) FROM runs').fetchone() db.execute('SELECT count(*) FROM queue_items').fetchone() - emit({'status': 'initialized', 'lock_root': str(repo.root), **identity}) + codegraph = codegraph_ensure_fail_open(repo.main) + emit({'status': 'initialized', 'lock_root': str(repo.root), + 'codegraph': codegraph, **identity}) return old = legacy / 'dev-integration.lock' if old.is_symlink(): @@ -727,7 +733,9 @@ def initialize(repo): identity = {'version': VERSION, 'repo_id': str(uuid.uuid4()), 'epoch': str(uuid.uuid4()), 'main': str(repo.main), 'common': str(repo.common)} atomic_write(repo.root / 'identity.json', json.dumps(identity)) - emit({'status': 'initialized', 'lock_root': str(repo.root), **identity}) + codegraph = codegraph_ensure_fail_open(repo.main) + emit({'status': 'initialized', 'lock_root': str(repo.root), + 'codegraph': codegraph, **identity}) def ensure_schema(db): @@ -743,6 +751,85 @@ def ensure_schema(db): w4.ensure_schema(db) +def codegraph_is_ignored(path): + """True when git will not track .codegraph under path (safe to init).""" + target = Path(path).resolve() + try: + completed = subprocess.run( + ['git', '-C', str(target), 'check-ignore', '-q', '--', '.codegraph'], + capture_output=True, check=False) + return completed.returncode == 0 + except OSError: + return False + + +def codegraph_binary(): + return shutil.which('codegraph') + + +def codegraph_sync_fail_open(path=None, *, timeout=60): + """Run `codegraph sync -q`. Always returns; never raises into git hooks.""" + binary = codegraph_binary() + target = str(Path(path).resolve()) if path else None + if not binary: + return {'status': 'not_installed', 'exit_code': 0} + cmd = [binary, 'sync', '-q'] + if target: + cmd.append(target) + try: + completed = subprocess.run( + cmd, capture_output=True, text=True, timeout=timeout, check=False) + return { + 'status': 'ok' if completed.returncode == 0 else 'sync_failed', + 'exit_code': completed.returncode, + 'stderr': (completed.stderr or '')[:500], + } + except (OSError, subprocess.TimeoutExpired) as exc: + return {'status': 'sync_error', 'exit_code': 0, 'error': str(exc)} + + +def codegraph_ensure_fail_open(path, *, timeout=300): + """Init if missing, else sync. Fail-open for agentctl init / session start. + + Skips creating a new index when `.codegraph/` is not gitignored, so init + cannot dirty a clean source tree (test fixtures / partial checkouts). + """ + binary = codegraph_binary() + target = Path(path).resolve() + if not binary: + return {'status': 'not_installed'} + db = target / '.codegraph' / 'codegraph.db' + if db.exists(): + result = codegraph_sync_fail_open(target, timeout=min(timeout, 60)) + result['action'] = 'sync' + return result + if not codegraph_is_ignored(target): + return {'status': 'skipped', 'action': 'init', + 'reason': '.codegraph not gitignored; refusing to dirty worktree'} + try: + completed = subprocess.run( + [binary, 'init', '-y', str(target)], + capture_output=True, text=True, timeout=timeout, check=False) + return { + 'status': 'ok' if completed.returncode == 0 else 'init_failed', + 'action': 'init', + 'exit_code': completed.returncode, + 'stderr': (completed.stderr or '')[:500], + } + except (OSError, subprocess.TimeoutExpired) as exc: + return {'status': 'init_error', 'action': 'init', 'error': str(exc)} + + +def record_audit(repo, kind, payload): + """Append a read-only audit ledger entry. Never mutates shared docs.""" + with repo.db(True) as db: + ensure_schema(db) + db.execute( + 'INSERT INTO audit_ledger(kind, created, payload) VALUES(?,?,?)', + (kind, time.time(), json.dumps(payload, ensure_ascii=False, sort_keys=True))) + return payload + + def task_row(repo, task_id): with repo.db() as db: row = db.execute('SELECT * FROM tasks WHERE id=?', (task_id,)).fetchone() @@ -1537,7 +1624,15 @@ def install_hooks(repo): deny('EXISTING_HOOKS', 'Custom hooksPath detected; integrate hooks explicitly instead of overriding it') hooks = repo.common / 'hooks' hooks.mkdir(exist_ok=True) - commands = {'pre-commit':'hooks check', 'pre-merge-commit':'hooks check --merge', 'reference-transaction':'hooks ref "$1"'} + # post-merge / post-checkout only sync codegraph (fail-open); they must never + # write tracked files or deny integrate's ff-only merge. + commands = { + 'pre-commit': 'hooks check', + 'pre-merge-commit': 'hooks check --merge', + 'reference-transaction': 'hooks ref "$1"', + 'post-merge': 'hooks codegraph-sync', + 'post-checkout': 'hooks codegraph-sync', + } for name in commands: p = hooks / name if p.exists() and '# agentctl-managed-v1' not in p.read_text(): @@ -1551,6 +1646,13 @@ def install_hooks(repo): emit({'status':'installed','hooks':list(commands)}) +def hooks_codegraph_sync(repo): + """Git post-merge / post-checkout entry: sync index, always exit 0.""" + result = codegraph_sync_fail_open(repo.current) + emit({'status': 'ok', 'hook': 'codegraph-sync', 'codegraph': result}) + return 0 + + def validate(repo, args): task = task_row(repo, args.task) if repo.current != Path(task['worktree']): @@ -2059,6 +2161,115 @@ def cleanup_status(repo, args): return 0 +def cleanup_report(repo, args=None): + """Batch cleanup readiness for all .worktrees entries. Never deletes.""" + # Lazy import keeps agentctl usable when agent_worktree is mid-edit in tests. + import agent_worktree as aw # noqa: E402 + payload = aw.build_cleanup_report(repo.main, repo.main) + # Enrich with registry owners when available. + with repo.db() as db: + try: + ensure_schema(db) + except Exception: + pass + for item in payload.get('worktrees', []): + path = item.get('path') + if not path: + continue + rows = [dict(r) for r in db.execute( + 'SELECT id,owner,state FROM tasks WHERE worktree=?', (path,)).fetchall()] + item['tasks'] = rows + active = [r for r in rows if r.get('state') == 'active'] + item['cleanup_owner'] = (active[0]['owner'] if active + else (rows[0]['owner'] if rows else None)) + record_audit(repo, 'cleanup_report', payload) + emit(payload) + return 0 + + +def docs_drift(repo, args=None): + """Compare FEATURE-MATRIX status columns against last snapshot. + + Hard alerts only for done → non-done regressions (ISSUE-20260923 toolization). + Open (non-terminal) rows are listed as soft candidates for human judgment. + Read-only: never writes CURRENT/MATRIX. + """ + matrix_path = repo.main / '.doc/status/FEATURE-MATRIX.md' + if not matrix_path.is_file(): + deny('INVALID_DOCUMENT', 'FEATURE-MATRIX.md missing on main worktree') + # Prefer committed tip so dirty local MATRIX edits do not pollute drift. + tip = git(repo.main, 'show', 'refs/heads/dev:.doc/status/FEATURE-MATRIX.md', check=False) + content = tip.stdout if tip.returncode == 0 else matrix_path.read_text() + current = matrix_states(content) + previous = {} + with repo.db(True) as db: + ensure_schema(db) + for row in db.execute('SELECT record_id, status FROM matrix_state_snapshot'): + previous[row['record_id']] = row['status'] + regressions = [] + for record_id, prev in previous.items(): + now = current.get(record_id) + if prev == 'done' and now is not None and now != 'done': + regressions.append({ + 'record_id': record_id, + 'previous': prev, + 'current': now, + 'severity': 'hard', + 'message': 'MATRIX status regressed from done', + }) + elif prev == 'done' and record_id not in current: + regressions.append({ + 'record_id': record_id, + 'previous': prev, + 'current': None, + 'severity': 'hard', + 'message': 'MATRIX done row disappeared', + }) + now_ts = time.time() + db.execute('DELETE FROM matrix_state_snapshot') + for record_id, status in current.items(): + db.execute( + 'INSERT INTO matrix_state_snapshot(record_id, status, updated) VALUES(?,?,?)', + (record_id, status, now_ts)) + open_rows = sorted( + [{'record_id': rid, 'status': st} for rid, st in current.items() + if st not in ('done', 'removed')], + key=lambda item: item['record_id']) + payload = { + 'status': 'ok', + 'file': '.doc/status/FEATURE-MATRIX.md', + 'record_count': len(current), + 'previous_count': len(previous), + 'regressions': regressions, + 'open_capabilities': open_rows, + 'baseline': 'first_snapshot' if not previous else 'compared', + 'note': ('Hard alerts are done→non-done only; open_capabilities are ' + 'candidates for human MATRIX updates via docs apply.'), + } + record_audit(repo, 'docs_drift', payload) + emit(payload) + return 0 + + +def run_audit_cycle(repo): + """Daemon-side read-only audit: cleanup report + docs drift. Never docs_apply.""" + results = {} + try: + cleanup_report(repo) + results['cleanup_report'] = 'ok' + except Exception as exc: # noqa: BLE001 — daemon must continue + results['cleanup_report'] = {'status': 'error', 'error': str(exc)} + try: + docs_drift(repo) + results['docs_drift'] = 'ok' + except Exception as exc: # noqa: BLE001 + results['docs_drift'] = {'status': 'error', 'error': str(exc)} + with repo.db(True) as db: + ensure_schema(db) + event(db, 'daemon', {'event': 'audit_cycle', **results}) + return results + + def _claim_fingerprint(task_id, resources, mode='X'): return digest(json.dumps({'task': task_id, 'resources': sorted(resources), 'mode': mode}, sort_keys=True)) @@ -2340,6 +2551,9 @@ def daemon_run(repo, args): max_cycles = int(max_cycles) if max_cycles < 1: deny('INVALID_REQUEST', 'daemon max-cycles must be >= 1') + audit_every = int(getattr(args, 'audit_every', 30) or 0) + if audit_every < 0: + deny('INVALID_REQUEST', 'daemon audit-every must be >= 0') stop = {'flag': False} def _request_stop(signum, frame): @@ -2355,6 +2569,7 @@ def daemon_run(repo, args): 'pid': os.getpid(), 'interval': interval, 'max_cycles': max_cycles, + 'audit_every': audit_every, }) while not stop['flag']: cycles += 1 @@ -2374,6 +2589,12 @@ def daemon_run(repo, args): event(db, 'daemon', detail) emit({'status': 'denied', 'code': exc.code, 'message': exc.message, **exc.details}, sys.stderr) + # Read-only audit: never docs_apply (main cwd would WORKTREE_MISMATCH). + if audit_every and cycles % audit_every == 0: + try: + run_audit_cycle(repo) + except Exception as exc: # noqa: BLE001 + emit({'status': 'audit_cycle_error', 'error': str(exc)}, sys.stderr) if max_cycles is not None and cycles >= max_cycles: break if stop['flag']: @@ -2448,6 +2669,7 @@ def parser(): proj.add_argument('--record');proj.add_argument('--replacement') proj.add_argument('--apply',action='store_true',help='Apply projected change via docs apply') dproj=d.add_parser('projections');dproj.add_argument('--file') + d.add_parser('drift', help='Read-only MATRIX status regression audit') q=sub.add_parser('queue').add_subparsers(dest='queue_action',required=True) qe=q.add_parser('enqueue');qe.add_argument('--task',required=True);qe.add_argument('--expected-dev',required=True) qe.add_argument('--head');qe.add_argument('--priority',type=int,default=0);qe.add_argument('--request-id') @@ -2456,12 +2678,14 @@ def parser(): qs=q.add_parser('schedule');qs.add_argument('--drain',action='store_true') cu=sub.add_parser('cleanup').add_subparsers(dest='cleanup_action',required=True) cs=cu.add_parser('status');cs.add_argument('--path');cs.add_argument('--branch') + cu.add_parser('report', help='Batch cleanup readiness for .worktrees (never deletes)') rec=sub.add_parser('recover');rec.add_argument('--run',required=True);rec.add_argument('--reason',required=True) op=sub.add_parser('operation');op.add_argument('run') for action in ('freeze','unfreeze'): fr=sub.add_parser(action);fr.add_argument('--task',required=True);fr.add_argument('--resource',required=True);fr.add_argument('--reason',required=True) hooks=sub.add_parser('hooks').add_subparsers(dest='hook_action',required=True) hooks.add_parser('install');hc=hooks.add_parser('check');hc.add_argument('--merge',action='store_true');hr=hooks.add_parser('ref');hr.add_argument('state') + hooks.add_parser('codegraph-sync', help='Fail-open codegraph sync for post-merge/checkout') v=sub.add_parser('validate');v.add_argument('--task',required=True);v.add_argument('command',nargs=argparse.REMAINDER) i=sub.add_parser('integrate');i.add_argument('--task',required=True);i.add_argument('--expected-dev',required=True);i.add_argument('--request-id') dep=sub.add_parser('deploy').add_subparsers(dest='deploy_action',required=True) @@ -2490,6 +2714,8 @@ def parser(): drun.add_argument('--interval',type=float,default=1.0,help='Seconds between schedule cycles') drun.add_argument('--max-cycles',type=int,dest='max_cycles', help='Stop after N cycles (tests / bounded runs)') + drun.add_argument('--audit-every',type=int,default=30,dest='audit_every', + help='Every N cycles run cleanup report + docs drift (0=off)') dm.add_parser('status') return p @@ -2507,6 +2733,8 @@ def main(): pass # snapshot is read-only CAS helper elif args.action == 'docs' and getattr(args, 'doc_action', None) == 'projections': pass # projection ledger readout is read-only + elif args.action == 'docs' and getattr(args, 'doc_action', None) == 'drift': + require_policy(repo) # drift needs schema; never writes shared docs elif args.action == 'queue' and getattr(args, 'queue_action', None) == 'list': pass elif args.action == 'claim' and getattr(args, 'claim_action', None) in ('status', 'list'): @@ -2517,6 +2745,8 @@ def main(): pass # read-only probe; never creates manager.lock or heartbeat elif args.action == 'cleanup': require_policy(repo) + elif args.action == 'hooks' and getattr(args, 'hook_action', None) == 'codegraph-sync': + pass # fail-open sync; must not require policy or deny merges else: require_policy(repo) if args.action=='init':initialize(repo) @@ -2580,6 +2810,7 @@ def main(): if args.doc_action=='snapshot':docs_snapshot(repo,args) elif args.doc_action=='projections':return docs_projections(repo,args) elif args.doc_action=='project':return docs_project(repo,args) + elif args.doc_action=='drift':return docs_drift(repo,args) else:return docs_apply(repo,args) elif args.action=='queue': if args.queue_action=='enqueue':return queue_enqueue(repo,args) @@ -2587,6 +2818,8 @@ def main(): if args.queue_action=='cancel':return queue_cancel(repo,args) return queue_schedule(repo,args) elif args.action=='cleanup': + if args.cleanup_action=='report': + return cleanup_report(repo,args) if not args.path and not args.branch: deny('INVALID_REQUEST', 'cleanup status requires --path or --branch') if args.path and args.branch: @@ -2613,6 +2846,7 @@ def main(): elif args.action=='hooks': if args.hook_action=='install':install_hooks(repo) elif args.hook_action=='check':hook_check(repo,args.merge) + elif args.hook_action=='codegraph-sync':return hooks_codegraph_sync(repo) else:hook_ref(repo,args.state) elif args.action=='validate':return validate(repo,args) elif args.action=='integrate':return integrate(repo,args) diff --git a/scripts/agentctl_w4.py b/scripts/agentctl_w4.py index 404c70f6..4650f94f 100644 --- a/scripts/agentctl_w4.py +++ b/scripts/agentctl_w4.py @@ -59,6 +59,18 @@ CREATE TABLE IF NOT EXISTS projections( updated REAL NOT NULL, PRIMARY KEY(file, record_id) ); +CREATE TABLE IF NOT EXISTS audit_ledger( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + created REAL NOT NULL, + payload TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS audit_ledger_kind ON audit_ledger(kind, created); +CREATE TABLE IF NOT EXISTS matrix_state_snapshot( + record_id TEXT PRIMARY KEY, + status TEXT NOT NULL, + updated REAL NOT NULL +); ''' JOB_KINDS = frozenset({ diff --git a/scripts/tests/test_codegraph_governance.py b/scripts/tests/test_codegraph_governance.py new file mode 100644 index 00000000..0af57a72 --- /dev/null +++ b/scripts/tests/test_codegraph_governance.py @@ -0,0 +1,155 @@ +"""CodeGraph governance + read-only audit commands.""" +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +SOURCE = Path(__file__).resolve().parents[1] + + +class CodegraphGovernanceTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) / 'repo' + self.root.mkdir() + (self.root / 'scripts').mkdir() + for name in ('agent-worktree.sh', 'agent_worktree.py', 'agentctl.py', + 'agentctl', 'agentctl_w4.py'): + shutil.copy2(SOURCE / name, self.root / 'scripts' / name) + (self.root / 'configs').mkdir(parents=True, exist_ok=True) + shutil.copy2(SOURCE.parent / 'configs' / 'agent-governance.json', + self.root / 'configs' / 'agent-governance.json') + (self.root / '.gitignore').write_text('.worktrees/\n.locks/\n.codegraph/\n') + (self.root / 'source.txt').write_text('base\n') + doc = self.root / '.doc/status' + doc.mkdir(parents=True) + ops = self.root / '.doc/ops' + ops.mkdir(parents=True) + (ops / 'CURRENT.md').write_text( + '# 当前焦点\n\n## 焦点\n\n0. x\n\n## 并行认领\n\n' + '| owner | task | branch | worktree | mutex | status |\n' + '|---|---|---|---|---|---|\n' + '| Cursor | seed | feature/seed | .worktrees/seed | m | done |\n\n' + '## 清理接管与遗留现场\n\n| 路径/事项 | 分类与保留原因 | cleanup_owner | 复查触发 |\n' + '|---|---|---|---|\n| — | none | — | — |\n\n' + '## 集成槽位\n| holder | phase | since | scope | queue |\n' + '|--------|-------|-------|-------|-------|\n' + '| - | idle | 2026-09-21 | - | - |\n\n' + '## 受控约束\n\n- none\n\n## 活跃入口\n\n- x\n' + ) + (doc / 'FEATURE-MATRIX.md').write_text( + '# 矩阵\n\n| record_id | 模块 | 声明范围 | 状态 | 发布关系 | 证据 | 剩余工作 / 关闭条件 | mutex |\n' + '|------|----------|------|----------|------|---------------------|-------|-------|\n' + '| alpha-mod | Alpha | scope | done | non_blocker | e | closed | a |\n' + '| beta-mod | Beta | scope | partial | non_blocker | e | open | b |\n' + ) + self.env = {**os.environ, 'PYTHONDONTWRITEBYTECODE': '1'} + for name in ('AGENTCTL_RUN_ID', 'AGENTCTL_TASK_ID'): + self.env.pop(name, None) + self.git('init', '-b', 'dev') + self.git('config', 'user.email', 'test@example.invalid') + self.git('config', 'user.name', 'Test') + self.git('add', '.') + self.git('commit', '-qm', 'fixture') + + def run_cmd(self, args, cwd=None, env=None, timeout=30): + return subprocess.run(args, cwd=cwd or self.root, env=env or self.env, + text=True, capture_output=True, timeout=timeout) + + def git(self, *args, cwd=None): + result = self.run_cmd(['git', *args], cwd=cwd) + self.assertEqual(result.returncode, 0, result.stderr) + return result + + def ctl(self, *args, cwd=None, check=True): + result = self.run_cmd( + [sys.executable, str(self.root / 'scripts/agentctl.py'), *args], cwd=cwd) + if check: + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + return result + + def test_codegraph_sync_fail_open_without_binary(self): + self.ctl('init') + env = {**self.env, 'PATH': '/usr/bin:/bin'} + result = self.run_cmd( + [sys.executable, str(self.root / 'scripts/agentctl.py'), + 'hooks', 'codegraph-sync'], + env=env) + self.assertEqual(result.returncode, 0, result.stderr) + payload = json.loads(result.stdout) + self.assertEqual(payload['status'], 'ok') + self.assertEqual(payload['hook'], 'codegraph-sync') + self.assertEqual(payload['codegraph']['status'], 'not_installed') + self.assertEqual(payload['codegraph'].get('exit_code', 0), 0) + + def test_hooks_install_includes_codegraph_sync_hooks(self): + self.ctl('init') + self.ctl('hooks', 'install') + for name in ('post-merge', 'post-checkout'): + path = self.root / '.git/hooks' / name + self.assertTrue(path.exists(), name) + text = path.read_text() + self.assertIn('agentctl-managed-v1', text) + self.assertIn('hooks codegraph-sync', text) + + def test_docs_drift_detects_done_regression(self): + self.ctl('init') + first = self.ctl('docs', 'drift') + payload = json.loads(first.stdout) + self.assertEqual(payload['status'], 'ok') + self.assertEqual(payload['baseline'], 'first_snapshot') + self.assertEqual(payload['regressions'], []) + # Regress alpha-mod done → planned on disk + commit so tip reflects it. + matrix = self.root / '.doc/status/FEATURE-MATRIX.md' + text = matrix.read_text().replace( + '| alpha-mod | Alpha | scope | done |', + '| alpha-mod | Alpha | scope | planned |') + matrix.write_text(text) + self.git('add', '.doc/status/FEATURE-MATRIX.md') + self.git('commit', '-qm', 'regress alpha') + second = self.ctl('docs', 'drift') + payload = json.loads(second.stdout) + self.assertTrue(any( + r['record_id'] == 'alpha-mod' and r['severity'] == 'hard' + for r in payload['regressions']), payload) + open_ids = {row['record_id'] for row in payload['open_capabilities']} + self.assertIn('alpha-mod', open_ids) + self.assertIn('beta-mod', open_ids) + + def test_cleanup_report_never_deletes(self): + self.ctl('init') + helper = self.root / 'scripts/agent-worktree.sh' + created = self.run_cmd(['bash', str(helper), 'create', 'feature/task-a']) + self.assertEqual(created.returncode, 0, created.stderr) + wt = Path(json.loads(created.stdout)['worktree']) + self.assertTrue(wt.is_dir()) + report = self.ctl('cleanup', 'report') + payload = json.loads(report.stdout) + self.assertEqual(payload['status'], 'ok') + self.assertGreaterEqual(payload['summary']['total'], 1) + self.assertTrue(wt.is_dir(), 'cleanup report must not remove worktrees') + + def test_create_skips_codegraph_when_missing(self): + self.ctl('init') + helper = self.root / 'scripts/agent-worktree.sh' + env = {**self.env, 'PATH': '/nonexistent'} + # Keep bash/git on a minimal path: prepend system essentials. + env['PATH'] = '/usr/bin:/bin' + # Hide only codegraph by putting a stub dir without it first... PATH without + # codegraph is enough if which looks at PATH. Ensure no codegraph on PATH. + result = self.run_cmd( + ['bash', str(helper), 'create', 'feature/task-b'], env=env, timeout=60) + self.assertEqual(result.returncode, 0, result.stderr) + out = json.loads(result.stdout) + self.assertEqual(out.get('codegraph'), 'not_installed') + + +if __name__ == '__main__': + unittest.main()