merge: candidate codegraph-governance into local_dev

This commit is contained in:
weichen
2026-09-23 17:52:09 +00:00
16 changed files with 637 additions and 14 deletions
+5
View File
@@ -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。
## 常见拒绝
+12 -3
View File
@@ -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
+22 -1
View File
@@ -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 确认。
+6
View File
@@ -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。
+3
View File
@@ -36,6 +36,9 @@ tmp/
temp/
.tmp/
# CodeGraph local index (DB/WAL stay machine-local; see .codegraph/.gitignore)
.codegraph/
# IDE / Editor Files
.idea/
.vscode/
+2 -1
View File
@@ -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 合入、实际运行版本、远端同步和清理结果。
+61 -5
View File
@@ -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
View File
@@ -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)
+12
View File
@@ -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({
+155
View File
@@ -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()