merge: candidate codegraph-governance into local_dev
This commit is contained in:
@@ -7,6 +7,11 @@
|
||||
"jev": {
|
||||
"command": "node",
|
||||
"args": ["${workspaceFolder}/scripts/mcp/jev-mcp.mjs"]
|
||||
},
|
||||
"codegraph": {
|
||||
"type": "stdio",
|
||||
"command": "codegraph",
|
||||
"args": ["serve", "--mcp", "--path", "${workspaceFolder}"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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。
|
||||
|
||||
## 常见拒绝
|
||||
|
||||
|
||||
@@ -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)。
|
||||
|
||||
## 禁止
|
||||
|
||||
|
||||
@@ -14,13 +14,16 @@ cd /path/to/Agent-System # 主目录(dev)
|
||||
D=$(git rev-parse HEAD)
|
||||
ID=<task-id-YYYY-MM-DD>
|
||||
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 <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
|
||||
|
||||
@@ -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 确认。
|
||||
|
||||
@@ -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(需另授)。
|
||||
|
||||
@@ -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)
|
||||
@@ -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 可跳过)。
|
||||
|
||||
@@ -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。
|
||||
@@ -36,6 +36,9 @@ tmp/
|
||||
temp/
|
||||
.tmp/
|
||||
|
||||
# CodeGraph local index (DB/WAL stay machine-local; see .codegraph/.gitignore)
|
||||
.codegraph/
|
||||
|
||||
# IDE / Editor Files
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
@@ -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 合入、实际运行版本、远端同步和清理结果。
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
+237
-3
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user