Merge authoritative main into the task branch

Why: include the current integration baseline before verification.

Evidence: fetched origin/main=d9ac28f89159e17781e2a8ef3094965130f29d2f.

Impact: integrate upstream changes without rewriting task history.

Refs: task-sync
This commit is contained in:
2026-10-19 01:39:07 +00:00
165 changed files with 32570 additions and 644 deletions
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# 共享 Development-Manager 执行器薄入口:核对 deps/development-manager 与 index 钉住的提交一致且无修改,
# 构建(或复用缓存的)dm-server 二进制后执行 `dm-server agent-exec`。结果仅为本地完整性证据(SELF)。
set -euo pipefail
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
root=$(cd -- "$script_dir/.." && pwd)
sub_path="deps/development-manager"
dm="$root/$sub_path"
die() {
echo "agent-exec: $*" >&2
exit 2
}
# 在 DM checkout 内执行 git;清除 pre-commit 可能继承的仓库环境变量,避免读取超级仓的索引或对象库。
dm_git() {
env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE git -C "$dm" "$@"
}
index_entry=$(git -C "$root" ls-files -s -- "$sub_path") || index_entry=""
if [ -z "$index_entry" ]; then
die "index 中不存在 $sub_path 的 gitlink,拒绝执行"
fi
read -r mode pin _ <<<"$index_entry"
if [ "$mode" != "160000" ]; then
die "$sub_path 在 index 中不是 gitlink(mode=$mode),拒绝执行"
fi
if ! [[ "$pin" =~ ^[0-9a-f]{40}$ ]]; then
die "$sub_path 的 index 钉住值无效:$pin"
fi
remedy="请运行 git submodule update --init deps/development-manager"
if [ ! -e "$dm/.git" ]; then
die "$sub_path 未检出;$remedy"
fi
top=$(dm_git rev-parse --show-toplevel 2>/dev/null) || top=""
real_dm=$(cd -- "$dm" && pwd -P)
if [ -z "$top" ] || [ "$top" != "$real_dm" ]; then
die "$sub_path 不是独立的 checkout;$remedy"
fi
head=$(dm_git rev-parse HEAD 2>/dev/null) || head=""
if [ "$head" != "$pin" ]; then
die "$sub_path 的 HEAD(${head:-未知})与 index 钉住的 $pin 不一致;$remedy"
fi
dirty=$(dm_git status --porcelain --ignore-submodules=all) || die "无法读取 $sub_path 的工作区状态,拒绝执行"
if [ -n "$dirty" ]; then
die "$sub_path 存在未提交的修改,拒绝执行"
fi
cache_dir="${AGENT_EXEC_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/agent-system-dev/agent-exec}"
bin="$cache_dir/dm-server-$pin"
if [ ! -x "$bin" ]; then
command -v go >/dev/null 2>&1 || die "未找到 go,无法构建 dm-server"
mkdir -p "$cache_dir"
tmp=$(mktemp "$cache_dir/.dm-server-$pin.XXXXXX")
trap 'rm -f "$tmp"' EXIT
if ! (cd "$dm" && env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE GOWORK=off go build -trimpath -o "$tmp" ./cmd/dm-server); then
die "构建 dm-server 失败"
fi
chmod 0755 "$tmp"
mv -f "$tmp" "$bin"
trap - EXIT
fi
exec "$bin" agent-exec "$@"
+76 -4
View File
@@ -64,10 +64,11 @@ def check_snapshot(snapshot):
def check_receipt(value):
if not isinstance(value, dict) or type(value.get("schema")) is not int or value["schema"] != evidence.SCHEMA:
raise ValueError("unsupported or malformed receipt")
expected = {"schema", "source", "timestamp", "command", "command_sha256", "cwd", "common_dir",
"before", "after", "after_error", "exit_code", "output", "output_sha256", "receipt_sha256"}
if set(value) not in (expected, expected | {"capture_note"}):
if not evidence.receipt_keys_ok(value):
raise ValueError("invalid receipt fields")
for key in evidence.TARGET_FIELDS:
if key in value and not evidence.valid_branch(value[key]):
raise ValueError("invalid receipt target: " + key)
for key in ("source", "timestamp", "command", "cwd", "common_dir", "output"):
if not isinstance(value[key], str):
raise ValueError("invalid receipt field: " + key)
@@ -96,6 +97,72 @@ def check_receipt(value):
return before_stale or after_stale
SUMMARY_FIELDS = {"summary_schema", "source", "timestamp", "command", "exit_code", "cwd",
"before_head", "after_head", "branch", "target_branch", "target_head",
"before_tracked_diff_sha256", "after_tracked_diff_sha256", "output_tail",
"output_sha256", "receipt_sha256", "receipt_path"}
def optional_match(pattern, value):
return value is None or (isinstance(value, str) and pattern.fullmatch(value) is not None)
def check_summary_fields(value):
if type(value.get("summary_schema")) is not int or value["summary_schema"] != evidence.SUMMARY_SCHEMA:
raise ValueError("unsupported summary schema")
keys = set(value)
targets = keys & set(evidence.TARGET_FIELDS)
if targets not in (set(), set(evidence.TARGET_FIELDS)) or keys - targets != SUMMARY_FIELDS:
raise ValueError("invalid summary fields")
for key in targets:
if not evidence.valid_branch(value[key]):
raise ValueError("invalid summary target: " + key)
for key in ("source", "timestamp", "command", "cwd", "branch", "target_branch", "receipt_path"):
if not isinstance(value[key], str):
raise ValueError("invalid summary field: " + key)
if value["source"] != "SELF" or type(value["exit_code"]) is not int or not 0 <= value["exit_code"] <= 255:
raise ValueError("invalid summary source or exit code")
try:
datetime.fromisoformat(value["timestamp"].replace("Z", "+00:00"))
except ValueError:
raise ValueError("invalid summary timestamp") from None
if not isinstance(value["before_head"], str) or not evidence.GIT_SHA.fullmatch(value["before_head"]):
raise ValueError("invalid summary before_head")
for key in ("after_head", "target_head"):
if not optional_match(evidence.GIT_SHA, value[key]):
raise ValueError("invalid summary field: " + key)
for key in ("before_tracked_diff_sha256", "output_sha256", "receipt_sha256"):
if not isinstance(value[key], str) or not evidence.SHA256.fullmatch(value[key]):
raise ValueError("invalid summary digest: " + key)
if not optional_match(evidence.SHA256, value["after_tracked_diff_sha256"]) or (
(value["after_head"] is None) != (value["after_tracked_diff_sha256"] is None)):
raise ValueError("invalid summary digest: after_tracked_diff_sha256")
if not isinstance(value["output_tail"], list) or not all(isinstance(line, str) for line in value["output_tail"]):
raise ValueError("invalid summary output tail")
path = Path(value["receipt_path"])
if not Path(value["cwd"]).is_absolute() or not path.is_absolute() or (
path.name != value["receipt_sha256"] + ".json"):
raise ValueError("invalid summary paths")
def check_summary(label, value):
"""Verify a card summary against its full receipt when it is stored on this machine."""
check_summary_fields(value)
path = Path(value["receipt_path"])
if not path.is_file():
warn(label, f"unverified: full receipt not available locally ({path})")
try:
heads = [value["before_head"]] + ([value["after_head"]] if value["after_head"] else [])
return any([check_head(head) for head in heads])
except (ValueError, OSError):
return False
full = json.loads(path.read_text(encoding="utf-8"))
stale = check_receipt(full)
if evidence.build_summary(full, value["receipt_path"], len(value["output_tail"])) != value:
raise ValueError("summary does not match full receipt")
return stale
def records(content):
"""Yield fenced JSON and timestamped legacy rows without interpreting text."""
block = None
@@ -124,7 +191,12 @@ def main():
return
for label, kind, record in records(content):
try:
stale = check_receipt(json.loads(record)) if kind == "json" else check_head(record)
if kind == "json":
value = json.loads(record)
stale = (check_summary(label, value) if isinstance(value, dict) and "summary_schema" in value
else check_receipt(value))
else:
stale = check_head(record)
except (ValueError, TypeError, KeyError, OSError, evidence.EvidenceError) as exc:
warn(label, "unverified: " + str(exc))
continue
+5 -4
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
# 组合指针一致:容器仓在 Dev 钉住的提交上,deps/ 里的每个 gitlink
# 必须等于 Dev 顶层对应指针。development-guidelines 只告警。
# 必须等于 Dev 顶层对应指针。development-guidelines / development-manager
# 是治理规范与工具,版本由各项目固定采纳自行决定,不是运行时依赖,只告警。
set -euo pipefail
root=$(cd "$(dirname "$0")/.." && pwd)
cd "$root"
@@ -48,10 +49,10 @@ for container in "${containers[@]}"; do
while read -r mode _typ sha name; do
[ "$mode" = "160000" ] || continue
base=${name#deps/}
if [ "$base" = "development-guidelines" ]; then
dev=$(pin_of deps/development-guidelines)
if [ "$base" = "development-guidelines" ] || [ "$base" = "development-manager" ]; then
dev=$(pin_of "deps/$base")
if [ "$sha" != "$dev" ]; then
echo "warn: $container deps/development-guidelines is ${sha:0:12}, Dev is ${dev:0:12}" >&2
echo "warn: $container deps/$base is ${sha:0:12}, Dev is ${dev:0:12}" >&2
fi
continue
fi
+1 -1
View File
@@ -97,7 +97,7 @@ def materialize(repo):
for child, expected in submodules(repo):
check_children(repo, "before-update")
result = subprocess.run(["git", "-C", str(repo), "submodule", "update", "--init",
"--checkout", "--", str(child.relative_to(repo))])
"--jobs", "8", "--checkout", "--", str(child.relative_to(repo))])
if result.returncode:
raise RuntimeError(f"component materialization failed: {child}; preserve and reconcile before retrying")
materialize(child)
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""CI 等待:以单条阻塞命令代替手写 `sleep N; curl` 轮询。
用法:ci_wait.py [--sha SHA] [--interval SECONDS] [--timeout SECONDS] [--contexts NAME[,NAME...]]
--sha 缺省为 `git rev-parse HEAD`,须解析为完整 40 位 SHA;--interval 缺省 15(最小 5);--timeout 缺省 1200。
轮询 Gitea 合并状态 GET {api}/repos/{owner}/{repo}/commits/{sha}/status:
- 任一状态为 failure/error:逐条打印 `[ci-wait] FAIL <context> <target_url>`,立即退出 1;
- 成功须同时满足:至少一条状态、全部 success(skipped/warning 视同 success,不算失败)、每个 --contexts 名称是某个 context 的子串,
且连续两次轮询观察到同一全绿集合(防止迟注册的 job 被提前判绿);
- 超时:打印最后观察到的状态,退出 2;
- 远端/API/token 问题:`[verdict] REMOTE_UNVERIFIED`,退出 6(瞬时错误在超时内重试,
截止前从未成功轮询才以 6 放弃)。
状态变化时才打印一行,结束时打印 `[verdict] CI_SUCCESS|CI_FAILED|CI_TIMEOUT sha=<sha>`。
退出码:0 CI_SUCCESS;1 CI_FAILED;2 CI_TIMEOUT 或用法错误;6 REMOTE_UNVERIFIED。
仅使用标准库,复用 task_triage 的 Gitea 客户端与 token 读取。token 不打印、不写入任何产物。
"""
import argparse
import re
import subprocess
import sys
import time
import urllib.parse
from task_triage import EXIT_REMOTE, Stop, api_base, http_get_json, read_token, repo_slug
EXIT_FAILED = 1
EXIT_TIMEOUT = 2
EXIT_USAGE = 2
SHA_RE = re.compile(r"^[0-9a-f]{40}$")
FAILING = ("failure", "error")
# skipped:分级门禁下 base 非 main 的 PR 跳过全量作业;与 warning 一样是非失败终态。
DONE_OK = ("success", "skipped", "warning")
FATAL_HTTP = (400, 401, 403, 404, 410)
def git(*args):
proc = subprocess.run(["git", *args], capture_output=True, text=True, encoding="utf-8", errors="replace")
return proc.stdout.strip() if proc.returncode == 0 else None
def resolve_sha(sha):
value = (sha or "HEAD").strip()
if not SHA_RE.match(value.lower()):
value = git("rev-parse", "--verify", "--quiet", f"{value}^{{commit}}") or ""
value = value.lower()
if not SHA_RE.match(value):
raise Stop(EXIT_USAGE, f"--sha {sha or 'HEAD'} does not resolve to a full 40-hex SHA")
return value
def interval_arg(text):
value = float(text)
if value < 5:
raise argparse.ArgumentTypeError("interval must be at least 5 seconds")
return value
def timeout_arg(text):
value = float(text)
if value <= 0:
raise argparse.ArgumentTypeError("timeout must be positive")
return value
def observe(data):
"""[(context, state, target_url)],按 context 排序;忽略非 dict 项。"""
items = data.get("statuses") if isinstance(data, dict) else None
rows = []
for item in items if isinstance(items, list) else []:
if not isinstance(item, dict):
continue
state = str(item.get("status") or item.get("state") or "").strip().lower() or "unknown"
rows.append((str(item.get("context") or "(no context)"), state, str(item.get("target_url") or "")))
return sorted(rows)
def describe(rows):
return " ".join(f"{ctx}={state}" for ctx, state, _ in rows) if rows else "(no statuses yet)"
def green_key(rows, contexts):
"""满足全绿(success/skipped/warning)与 --contexts 时返回可比较的集合键,否则 None。"""
if not rows or any(state not in DONE_OK for _, state, _ in rows):
return None
if any(not any(name in ctx for ctx, _, _ in rows) for name in contexts):
return None
return tuple((ctx, state) for ctx, state, _ in rows)
def wait(sha, interval, timeout, contexts, sleep, clock):
token = read_token()
if not token:
raise Stop(EXIT_REMOTE, "no Gitea token (GITEA_TOKEN or ~/.secrets/gitea ACCESS_TOKEN=)")
origin = git("remote", "get-url", "origin")
if not origin:
raise Stop(EXIT_REMOTE, "cannot read origin remote URL")
owner, name = (urllib.parse.quote(part, safe="") for part in repo_slug(origin).split("/"))
url = f"{api_base()}/repos/{owner}/{name}/commits/{sha}/status"
deadline = clock() + timeout
rows, shown, previous_green, polled, last_error = [], None, None, False, None
while True:
try:
data = http_get_json(url, token)
except Stop as stop:
if stop.status in FATAL_HTTP:
raise
last_error = stop.message
else:
polled = True
rows = observe(data)
line = describe(rows)
if line != shown:
print(f"[ci-wait] sha={sha[:12]} {line}", flush=True)
shown = line
failing = [row for row in rows if row[1] in FAILING]
if failing:
for ctx, _, target in failing:
print(f"[ci-wait] FAIL {ctx} {target or '(no target_url)'}")
print(f"[verdict] CI_FAILED sha={sha}")
return EXIT_FAILED
key = green_key(rows, contexts)
if key is not None and key == previous_green:
print(f"[verdict] CI_SUCCESS sha={sha}")
return 0
previous_green = key
remaining = deadline - clock()
if remaining <= 0:
break
sleep(min(interval, remaining))
if not polled:
raise Stop(EXIT_REMOTE, last_error or "no successful status poll before the deadline")
print(f"[ci-wait] timeout after {timeout:g}s; last observed: {describe(rows)}")
missing = [n for n in contexts if not any(n in ctx for ctx, _, _ in rows)]
if missing:
print(f"[ci-wait] missing contexts: {', '.join(missing)}")
print(f"[verdict] CI_TIMEOUT sha={sha}")
return EXIT_TIMEOUT
def main(argv=None, sleep=time.sleep, clock=time.monotonic):
parser = argparse.ArgumentParser(prog="ci_wait.py", description="阻塞等待指定 SHA 的 Gitea CI 结论")
parser.add_argument("--sha")
parser.add_argument("--interval", type=interval_arg, default=15.0)
parser.add_argument("--timeout", type=timeout_arg, default=1200.0)
parser.add_argument("--contexts", default="")
args = parser.parse_args(argv)
contexts = [name.strip() for name in args.contexts.split(",") if name.strip()]
try:
sha = resolve_sha(args.sha)
return wait(sha, args.interval, args.timeout, contexts, sleep, clock)
except Stop as stop:
print(f"[ci-wait] ERROR {stop.message}")
if stop.code == EXIT_REMOTE:
print("[verdict] REMOTE_UNVERIFIED")
return stop.code
if __name__ == "__main__":
sys.exit(main())
Executable
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# 共享 dm-server 薄入口:读取 deps/development-manager 的 gitlink 钉住提交,
# 按该提交构建并缓存 dm-server,再 exec 其子命令(agent-exec、task、governance)。执行语义与退出码以该二进制为准。
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
dm="$root/deps/development-manager"
die() { echo "dm: $*" >&2; exit 2; }
# git 钩子会导出仓库本地变量(GIT_DIR、GIT_INDEX_FILE 等),它们会让 `git -C "$dm"` 解析到外层仓库。
# 所有针对 DM 检出的 git 与 go 调用都在清除这些变量后执行;读取外层 gitlink 与最终 exec 仍保留继承环境。
dm_local_env="GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_PREFIX GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_GRAFT_FILE GIT_NAMESPACE GIT_CONFIG GIT_CONFIG_PARAMETERS GIT_CONFIG_COUNT GIT_SHALLOW_FILE GIT_IMPLICIT_WORK_TREE $(git rev-parse --local-env-vars 2>/dev/null || true)"
dm_clean_env() { for v in $dm_local_env; do unset "$v"; done; }
dmgit() ( dm_clean_env; git -C "$dm" "$@" )
# 1. 钉住版本只来自索引中的 gitlink(mode 160000),不读取工作区文件。
entry=$(git -C "$root" ls-files -s -- deps/development-manager 2>/dev/null || true)
[ -n "$entry" ] || die "索引中缺少 deps/development-manager 的 gitlink,无法确定钉住的 DM 版本"
read -r mode pin _stage _path <<<"$entry"
[ "$mode" = 160000 ] || die "deps/development-manager 不是 gitlink(mode=$mode)"
[[ "$pin" =~ ^[0-9a-f]{40}$ ]] || die "gitlink 提交格式无效:$pin"
# 2. 检出必须存在且确为 DM 独立仓库(避免 git -C 误解析到外层仓库),HEAD 必须等于钉住提交。
[ -e "$dm/.git" ] || die "deps/development-manager 未检出;请按钉住提交 $pin 检出该 gitlink"
top=$(dmgit rev-parse --show-toplevel 2>/dev/null || true)
[ -n "$top" ] && [ "$top" = "$(cd "$dm" && pwd -P)" ] \
|| die "deps/development-manager 不是独立检出;请按钉住提交 $pin 检出该 gitlink"
head=$(dmgit rev-parse HEAD 2>/dev/null || true)
[ "$head" = "$pin" ] \
|| die "deps/development-manager HEAD(${head:-未知})与钉住提交 $pin 不一致;请按钉住提交检出"
# 3. 检出工作树必须干净(忽略嵌套 submodule 状态)。
dirty=$(dmgit status --porcelain --ignore-submodules=all) \
|| die "无法读取 deps/development-manager 的工作树状态"
[ -z "$dirty" ] || die "deps/development-manager 存在未提交改动,拒绝运行(请先清理该检出)"
# 4. 二进制按钉住提交缓存;缺失时在缓存目录内临时构建,完成后原子改名,避免留下半成品。
cache="${DM_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/development-manager/dm-server}"
bin="$cache/dm-server-$pin"
if [ ! -x "$bin" ]; then
command -v go >/dev/null 2>&1 || die "缺少 go,无法构建 dm-server($pin)"
mkdir -p "$cache"
cache=$(cd "$cache" && pwd -P)
bin="$cache/dm-server-$pin"
work=$(mktemp -d "$cache/build-$pin.XXXXXX")
if ! (dm_clean_env; cd "$dm" && GOWORK=off go build -trimpath -o "$work/dm-server" ./cmd/dm-server); then
rm -rf "$work"
die "构建 dm-server 失败($pin)"
fi
mv -f "$work/dm-server" "$bin"
rm -rf "$work"
fi
exec "$bin" "$@"
+294
View File
@@ -0,0 +1,294 @@
#!/usr/bin/env bash
# e2e-duties.sh —— 文档职责三视图真实浏览器验收(2026-10-16 S9 验收发现 2/4 复测结论的固化)。
#
# 动线(与 scripts/e2e.sh 同族;种子改用 2026-10-13 后的凭据契约):
# 1. 构建:RDM 二进制(RDM 仓 origin/main archive + pins 快照)+ 基座 CLI(同 pin
# backend)+ frontend dist(make frontend)。
# 2. 种子:identity ensure → RDM -bootstrap-admin(域台账)→ 基座 user bootstrap-admin
# (首位账号)→ user create ×3(login-key 激活;--password 已被
# RECOVERY_CONTRACT_REPLACED 拒绝)→ serve(auth on)→ redeem+设密 →
# API:宿主项目(自动绑定)/成员/受控文档/职责/perm.set duty.assign/整文送审。
# 3. 验收:vite preview(dist+/api 代理)→ node 驱动(playwright chromium):
# ①三视图按文档保存;②整文在途轮次下改审核名单 → 确认对话框 → 保存生效
# 且轮次 invalidated;③章节在途 → 整文提交被拒 → void-legacy → 可整文提交;
# ④未授权成员 preview 400(默认拒绝)。
# 4. 收尾:停 serve/preview,工件(截图/results/日志)留 $OUT。
#
# 用法:bash scripts/e2e-duties.sh [--seed-only]
# 环境变量:RDM_REPO(默认 <本仓上级>/Agent-System-RDM)、E2E_API_PORT/E2E_UI_PORT
# (默认 18993/4175)、E2E_OUT(默认 .tmp/e2e-duties/<ts>)。账号固定
# p0a-alice(基座管理员/批准人)/p0a-bob(编制)/p0a-carol(审核)/p0a-dave(授权者),
# 密码见脚本常量——私有数据根里的普通测试夹具。
# 依赖:go、node/npm、curl、python3;首次运行 npx playwright install chromium。
set -euo pipefail
DEV_ROOT=$(git -C "$(dirname "$0")/.." rev-parse --show-toplevel)
cd "$DEV_ROOT"
SEED_ONLY=0
[ "${1:-}" = "--seed-only" ] && SEED_ONLY=1
RDM_REPO=${RDM_REPO:-"$DEV_ROOT/../Agent-System-RDM"}
API_PORT=${E2E_API_PORT:-18993}
UI_PORT=${E2E_UI_PORT:-4175}
API_BASE="http://127.0.0.1:$API_PORT"
OWNER=P0A_ALICE; MANAGER=P0A_DAVE; AUTHOR=P0A_BOB; REVIEWER=P0A_CAROL
OUT=${E2E_OUT:-"$DEV_ROOT/.tmp/e2e-duties/$(date +%Y%m%d-%H%M%S)"}
case "$OUT" in /*) ;; *) OUT="$PWD/$OUT" ;; esac
mkdir -p "$OUT"
log() { printf '\033[36m[e2e-duties]\033[0m %s\n' "$*"; }
die() { printf '\033[31m[e2e-duties][FAIL]\033[0m %s\n' "$*" >&2; exit 1; }
command -v go >/dev/null || die "go not found"
command -v node >/dev/null || die "node not found"
command -v curl >/dev/null || die "curl not found"
[ "$(git -C "$RDM_REPO" rev-parse --is-inside-work-tree 2>/dev/null)" = true ] || die "RDM repo not at $RDM_REPO"
for p in "$API_PORT" "$UI_PORT"; do
timeout 2 bash -c "echo > /dev/tcp/127.0.0.1/$p" 2>/dev/null && die "port $p in use"
done
# ── 1. 构建(与 e2e.sh 同法:archive 固定输入,pins 快照拷贝)─────────────
log "build RDM binary (archive origin/main + snapshot pins)"
RDM_SHA=$(git -C "$RDM_REPO" rev-parse origin/main) || die "no origin/main"
git -C "$RDM_REPO" submodule update --init --recursive --quiet || die "materialize pins failed"
PIN_SHA_WANT=$(git -C "$RDM_REPO" ls-tree "$RDM_SHA" pins/agent-system-backend | awk '{print $3}')
PIN_SHA_MAIN=$(git -C "$RDM_REPO/pins/agent-system-backend" rev-parse HEAD)
[ "$PIN_SHA_MAIN" = "$PIN_SHA_WANT" ] || die "pins/backend ($PIN_SHA_MAIN) != origin/main pin ($PIN_SHA_WANT)"
RDM_WT="$OUT/rdm-src"; mkdir -p "$RDM_WT"
git -C "$RDM_REPO" archive "$RDM_SHA" | tar -x -C "$RDM_WT"
mkdir -p "$RDM_WT/pins"
tar -C "$RDM_REPO/pins" --exclude=.git -cf - agent-system-backend | tar -C "$RDM_WT/pins" -xf -
(cd "$RDM_WT" && go build -o "$OUT/agent-system-rdm" .) || die "rdm build failed"
log "build base CLI (same pinned backend as the RDM binary)"
(cd "$RDM_WT/pins/agent-system-backend" && go build -o "$OUT/agent-system" ./cmd/agent-system) || die "cli build failed"
log "build frontend dist (make frontend)"
make -C "$DEV_ROOT" frontend >/dev/null || die "frontend build failed"
# ── 2. 种子 ─────────────────────────────────────────────
DATA="$OUT/data"; mkdir -p "$DATA"
RDM="$OUT/agent-system-rdm -data $DATA"
CLI="$OUT/agent-system"
log "serve at $API_BASE (auth on;首启创建基座存储)"
start_serve() {
"$OUT/agent-system-rdm" -data "$DATA" -addr "127.0.0.1:$API_PORT" -auth on \
>"$OUT/serve.log" 2>&1 &
API_PID=$!
for i in $(seq 1 40); do
[ "$(curl -s -o /dev/null -w '%{http_code}' "$API_BASE/api/me" || echo 000)" != 000 ] && return 0
sleep 0.25
[ "$i" = 40 ] && { tail -5 "$OUT/serve.log"; die "api not ready"; }
done
}
stop_serve() { [ -n "${API_PID:-}" ] && { kill "$API_PID" 2>/dev/null || true; wait "$API_PID" 2>/dev/null || true; API_PID=""; }; }
cleanup() {
stop_serve
if [ -n "${UI_PID:-}" ] && ps -p "$UI_PID" -o args= 2>/dev/null | grep -Fq -- "vite preview --port $UI_PORT"; then
kill "$UI_PID" 2>/dev/null || true
fi
}
trap cleanup EXIT
start_serve
log "identity ensure + RDM ledger bootstrap ($OWNER)"
ID_OK=0
for i in $(seq 1 15); do
if $CLI identity ensure --data "$DATA" --instance-id "e2e-duties" >/dev/null 2>&1; then ID_OK=1; break; fi
sleep 1 # 服务端受控迁移(含 identity 表)可能晚于 HTTP 就绪
done
[ "$ID_OK" = 1 ] || { tail -5 "$OUT/serve.log"; die "identity ensure failed"; }
$RDM -bootstrap-admin "$OWNER" >/dev/null || die "rdm ledger bootstrap failed"
log "base first account + members (login-key 契约)"
$CLI user bootstrap-admin --data "$DATA" --owner "$OWNER" --out-file "$OUT/$OWNER.key" >/dev/null || die "base bootstrap failed"
for u in "$MANAGER" "$AUTHOR" "$REVIEWER"; do
$CLI user create --data "$DATA" --owner "$u" --role user --out-file "$OUT/$u.key" >/dev/null || die "user create $u failed"
done
activate() { # activate <user> <password>:redeem login-key → 设密 → 登录
# 注意:jar 不得与 u 同一条 local 声明——local 的各赋值在同一命令行先展开,
# $u 会取到外层残留值(曾致四账号会话写入同一个 jar)。
local u=$1 pw=$2 k
local jar="$OUT/$u.jar"
k=$(cat "$OUT/$u.key")
curl -sf -D "$OUT/$u.redeem.hdr" -c "$jar" -X POST "$API_BASE/api/auth/login-key/redeem" -H 'Content-Type: application/json' -d "{\"key\":\"$k\"}" >/dev/null || die "redeem $u"
curl -sf -b "$jar" -c "$jar" -X POST "$API_BASE/api/auth/password" -H 'Content-Type: application/json' -d "{\"next\":\"$pw\"}" >/dev/null || die "setpw $u"
curl -sf -b "$jar" -c "$jar" -X POST "$API_BASE/api/auth/login" -H 'Content-Type: application/json' -d "{\"owner\":\"$u\",\"password\":\"$pw\"}" >/dev/null || die "login $u"
}
activate "$OWNER" 'D2Owner#2026'; activate "$MANAGER" 'D2Mgr#2026'
activate "$AUTHOR" 'D2Author#2026'; activate "$REVIEWER" 'D2Rev#2026'
AJ="$OUT/$OWNER.jar"
cmd() { # cmd <jar> <id> <params> → payload;rejected 即死
curl -sf -b "$1" -X POST "$API_BASE/api/commands/$2" -H 'Content-Type: application/json' -d "{\"params\":$3}" \
| python3 -c 'import json,sys; d=json.load(sys.stdin); r=(d or {}).get("receipt",{}); sys.exit(3) if r.get("state")=="rejected" else None; print(json.dumps(d.get("payload", d), ensure_ascii=False))' \
|| die "command $2 rejected"
}
log "seed: host project + members + doc + duties + grant"
log "me probe: $(curl -s -b "$AJ" "$API_BASE/api/me" | head -c 160) | jar=$(ls -la "$AJ" 2>/dev/null | awk '{print $5}')"
PIDRESP=$(curl -s -w '\n%{http_code}' -b "$AJ" -X POST "$API_BASE/api/projects" -H 'Content-Type: application/json' -d '{"name":"职责验收夹具"}')
CODE=${PIDRESP##*$'\n'}; BODY=${PIDRESP%$'\n'*}
[ "$CODE" = 200 ] || die "host project create: HTTP $CODE body=${BODY:0:200}"
PID=$(printf '%s' "$BODY" | python3 -c 'import json,sys; print(json.load(sys.stdin)["settings"]["rdm.projectId"])')
for m in "$MANAGER" "$AUTHOR" "$REVIEWER"; do
cmd "$AJ" rdm.project.member.add "{\"projectId\":\"$PID\",\"member\":\"$m\",\"role\":\"member\"}" >/dev/null
done
DOC=$(cmd "$AJ" rdm.docs.instantiate "{\"projectId\":\"$PID\",\"docType\":\"rd_proposal\",\"templateId\":\"dhf.2750fbaf1f67d10c\",\"version\":\"1.0.0\",\"title\":\"职责验收建议书\"}" \
| python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("docId") or d.get("id") or "")')
[ -n "$DOC" ] || { DOC=$(cmd "$AJ" rdm.duty.catalog "{\"projectId\":\"$PID\"}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["docs"][0]["docId"])'); }
cmd "$AJ" rdm.review.assign.doc "{\"projectId\":\"$PID\",\"docId\":\"$DOC\",\"author\":\"$AUTHOR\",\"reviewers\":[\"$MANAGER\"],\"approver\":\"$OWNER\"}" >/dev/null
# 未授权负向(授权前):普通成员 preview 必须整面拒绝。
NEG=$(curl -s -b "$OUT/$REVIEWER.jar" -X POST "$API_BASE/api/commands/rdm.duty.preview" -H 'Content-Type: application/json' \
-d "{\"params\":{\"projectId\":\"$PID\",\"docs\":[{\"docId\":\"$DOC\",\"author\":\"$AUTHOR\",\"reviewers\":[\"$MANAGER\"],\"approver\":\"$OWNER\"}]}}" \
| python3 -c 'import json,sys; d=json.load(sys.stdin); r=(d or {}).get("receipt",{}); print(r.get("state",""), r.get("reason",""))')
case "$NEG" in rejected*duty.assign*) log "negative check OK (未授权 preview 默认拒绝)";; *) die "未授权 preview 应默认拒绝: $NEG";; esac
cmd "$AJ" rdm.perm.set "{\"projectId\":\"$PID\",\"member\":\"$MANAGER\",\"action\":\"duty.assign\",\"allow\":true}" >/dev/null
# 授权后正向(P0-A 回归):授权者 preview 必须 200。
curl -sf -b "$OUT/$MANAGER.jar" -X POST "$API_BASE/api/commands/rdm.duty.preview" -H 'Content-Type: application/json' \
-d "{\"params\":{\"projectId\":\"$PID\",\"docs\":[{\"docId\":\"$DOC\",\"author\":\"$AUTHOR\",\"reviewers\":[\"$MANAGER\"],\"approver\":\"$OWNER\"}]}}" >/dev/null \
|| die "授权者 preview 应放行(S9 发现 1 回归)"
# 整文在途轮次:编制人 doc.submit(身份只认认证主体 → 用 AUTHOR 会话)。
cmd "$OUT/$AUTHOR.jar" rdm.review.doc.submit "{\"projectId\":\"$PID\",\"docId\":\"$DOC\"}" >/dev/null
log "seed done: project=$PID doc=$DOC(整文第 1 轮在途)"
# ── 3. 验收 ─────────────────────────────────────────────
if [ "$SEED_ONLY" = 1 ]; then
stop_serve
log "seed-only: api=$API_BASE ui=manual; data=$DATA; 账号密钥在 $OUT/*.key"
exit 0
fi
log "start vite preview at http://127.0.0.1:$UI_PORT"
(
cd "$DEV_ROOT/modules/frontend"
export PLAYWRIGHT_API_TARGET="$API_BASE"
exec ./node_modules/.bin/vite preview --port "$UI_PORT" --strictPort
) >"$OUT/preview.log" 2>&1 &
UI_PID=$!
for i in $(seq 1 40); do
curl -sf -o /dev/null "http://127.0.0.1:$UI_PORT/" && break
sleep 0.25
[ "$i" = 40 ] && { tail -5 "$OUT/preview.log"; die "preview not ready"; }
done
(cd "$DEV_ROOT/modules/frontend" && npx playwright install chromium >/dev/null 2>&1 || true)
cat > "$OUT/driver.cjs" <<'DRIVER'
// 文档职责真实浏览器驱动:三视图保存 + 整文在途确认对话框 + 轮次失效断言。
const { chromium } = require('playwright');
const fs = require('fs');
const BASE = process.env.DRIVER_BASE_URL, API = process.env.DRIVER_API_URL;
const OUT = process.env.DRIVER_OUT, HOST = process.env.DRIVER_PROJECT, PID = process.env.DRIVER_PID, DOC = process.env.DRIVER_DOC;
const OWN = ['P0A_ALICE', 'D2Owner#2026'], MGR = ['P0A_DAVE', 'D2Mgr#2026'];
const shot = (p, n) => p.screenshot({ path: `${OUT}/${n}.png`, fullPage: true }).catch(() => {});
const results = [];
const record = (id, ok, detail) => { results.push({ id, ok, detail }); console.log(`${ok ? 'PASS' : 'FAIL'} [${id}] ${detail}`); };
async function login(ctx, [owner, password]) {
const page = await ctx.newPage();
await page.goto(BASE + '/');
await page.getByPlaceholder('由管理员分配').fill(owner);
await page.getByPlaceholder('初始密码或常用密码').fill(password);
await page.getByRole('button', { name: '登录', exact: true }).click();
await page.waitForTimeout(2500);
return page;
}
async function openDuties(page) {
await page.getByText(HOST).first().click(); await page.waitForTimeout(1500);
await page.getByRole('button', { name: '项目设置', exact: true }).click(); await page.waitForTimeout(1000);
await page.getByRole('tab', { name: '文档职责', exact: true }).click(); await page.waitForTimeout(1200);
}
async function saveDuties(page, tag) {
await page.getByRole('button', { name: '保存职责' }).click();
await page.waitForTimeout(1800);
let confirms = 0, dialogText = '';
for (let i = 0; i < 3; i++) {
const btn = page.getByRole('button', { name: /^(确认保存|确认并保存|确认替换|确认|确定|仍要保存|继续保存)$/ }).first();
if (await btn.isVisible().catch(() => false)) {
await shot(page, `${tag}-dialog-${i}`);
const body = await page.locator('body').innerText().catch(() => '');
dialogText = body.split('\n').filter(l => /在途|失效|替换|确认/.test(l)).slice(0, 5).join(' | ').slice(0, 260);
console.log(`[dialog:${tag}] ${dialogText}`);
await btn.click(); confirms++;
await page.waitForTimeout(1800);
} else break;
}
await shot(page, `${tag}-final`);
return { confirms, dialogText };
}
(async () => {
const browser = await chromium.launch();
const ownerCtx = await browser.newContext(), mgrCtx = await browser.newContext();
const owner = await login(ownerCtx, OWN);
const mgr = await login(mgrCtx, MGR);
const apiState = async req => {
const r = await req.post(`${API}/api/commands/rdm.review.doc.status`, { data: { params: { projectId: PID, docId: DOC } } });
return (await r.json().catch(() => ({}))).payload || {};
};
// 场景②(核心):整文在途轮次(种子 doc.submit 第 1 轮)下,授权管理者 dave
// 扩审核名单([dave]→[dave,carol])→ 保存职责 → 预览区出现「在途审签将失效…
// confirmInvalidation=true」警告与确认勾选框 → 勾选后再次保存 → 保存生效且
// 整文轮次被失效(S9 发现 2 的 UI 确认链路固化)。
await openDuties(mgr);
await mgr.getByRole('tab', { name: '按文档', exact: true }).click(); await mgr.waitForTimeout(1200);
await mgr.getByLabel('选择文档').selectOption(DOC); await mgr.waitForTimeout(1200);
await mgr.getByLabel(`P0A_CAROL 在 ${process.env.DRIVER_DOC_TITLE} 的职责`).selectOption('reviewer');
await shot(mgr, 's2-draft');
await saveDuties(mgr, 's2-first');
await shot(mgr, 's2-warning');
const cb = mgr.locator('input[type="checkbox"]').last();
const cbVisible = await cb.isVisible().catch(() => false);
if (cbVisible) { await cb.check(); await mgr.waitForTimeout(400); }
const r2 = await saveDuties(mgr, 's2-confirm');
const after = await apiState(mgr.request);
const resp2 = await mgr.request.post(`${API}/api/commands/rdm.duty.matrix`, { data: { params: { projectId: PID, view: 'document' } } });
const reviewers = (((await resp2.json()).payload || {}).cells || []).filter(c => c.docId === DOC && c.duty === 'reviewer').map(c => c.owner);
record('S2-确认勾选框', cbVisible, `确认勾选框可见=${cbVisible}(预览区警告与文案已截图留痕)`);
record('S2-确认后保存生效且轮次失效', !!after.submission && after.submission.state === 'invalidated' && reviewers.length === 2,
`after.submission.state=${after.submission ? after.submission.state : 'null'} reviewers=${JSON.stringify(reviewers)} confirms=${r2.confirms}`);
// 场景①(幂等回归):同一目标经 API 再保存 → applied=0 unchanged=1,版本不变。
const resp1 = await mgr.request.post(`${API}/api/commands/rdm.review.assign.doc.batch`, { data: { params: { projectId: PID, docs: [{ docId: DOC, author: process.env.DRIVER_AUTHOR, reviewers: [process.env.DRIVER_MANAGER, process.env.DRIVER_REVIEWER], approver: process.env.DRIVER_APPROVER }] } } });
const b1 = (await resp1.json().catch(() => ({}))).payload || {};
record('S1-幂等计数', b1.applied === 0 && b1.unchanged === 1, `applied=${b1.applied} unchanged=${b1.unchanged}`);
await mgrCtx.close();
await ownerCtx.close();
await browser.close();
fs.writeFileSync(`${OUT}/results.json`, JSON.stringify(results, null, 2));
const bad = results.filter(r => !r.ok);
console.log(`\n==== driver: ${results.length - bad.length}/${results.length} PASS ====`);
if (bad.length) { bad.forEach(b => console.log('FAIL', b.id, b.detail)); process.exit(1); }
})().catch(e => { console.error('FATAL', e.message); process.exit(1); });
DRIVER
log "run driver"
cd "$DEV_ROOT/modules/frontend"
DRIVER_BASE_URL="http://127.0.0.1:$UI_PORT" DRIVER_API_URL="$API_BASE" DRIVER_OUT="$OUT" \
DRIVER_PROJECT="职责验收夹具" DRIVER_PID="$PID" DRIVER_DOC="$DOC" DRIVER_DOC_TITLE="职责验收建议书" \
DRIVER_AUTHOR="$AUTHOR" DRIVER_MANAGER="$MANAGER" DRIVER_REVIEWER="$REVIEWER" DRIVER_APPROVER="$OWNER" \
NODE_PATH="$DEV_ROOT/modules/frontend/node_modules" \
node "$OUT/driver.cjs" 2>&1 | tee "$OUT/driver.log"
[ "${PIPESTATUS[0]}" = 0 ] || die "driver failed (log: $OUT/driver.log)"
# ── 4. API 断言:整文在途处置与章节迁移语义 ────────────────────
AJ="$OUT/$OWNER.jar"; BJ="$OUT/$AUTHOR.jar"
subState=$(curl -s -b "$AJ" -X POST "$API_BASE/api/commands/rdm.review.doc.status" -H 'Content-Type: application/json' \
-d "{\"params\":{\"projectId\":\"$PID\",\"docId\":\"$DOC\"}}" \
| python3 -c 'import json,sys; d=json.load(sys.stdin); s=(d.get("payload") or {}).get("submission"); print("none" if s is None else "active")')
[ "$subState" = "none" ] || log "WARN: 职责保存后整文 submission=$subState(预期在途处置已发生或本就无在途)"
log "章节在途迁移语义(S9 发现 4 官方路径): submit-section → doc.submit 拒 → void-legacy → doc.submit 过"
SEC=$(curl -s -b "$AJ" -X POST "$API_BASE/api/commands/rdm.duty.get" -H 'Content-Type: application/json' \
-d "{\"params\":{\"projectId\":\"$PID\",\"docId\":\"$DOC\"}}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["payload"]["sections"][0]["sectionId"])')
curl -sf -b "$BJ" -X POST "$API_BASE/api/commands/rdm.review.submit" -H 'Content-Type: application/json' -d "{\"params\":{\"projectId\":\"$PID\",\"sectionId\":\"$SEC\"}}" >/dev/null \
|| log "章节提交被拒(可接受:文档生命周期状态依赖前序步骤)"
REJECT_MSG=$(curl -s -b "$BJ" -X POST "$API_BASE/api/commands/rdm.review.doc.submit" -H 'Content-Type: application/json' \
-d "{\"params\":{\"projectId\":\"$PID\",\"docId\":\"$DOC\"}}" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("receipt",{}).get("reason",""))' || true)
log "整文提交对章节在途的拒绝信息: ${REJECT_MSG:0:80}"
curl -sf -b "$BJ" -X POST "$API_BASE/api/commands/rdm.review.doc.void-legacy" -H 'Content-Type: application/json' \
-d "{\"params\":{\"projectId\":\"$PID\",\"docId\":\"$DOC\",\"confirm\":true}}" >/dev/null || die "void-legacy 应成功"
curl -sf -b "$BJ" -X POST "$API_BASE/api/commands/rdm.review.doc.submit" -H 'Content-Type: application/json' \
-d "{\"params\":{\"projectId\":\"$PID\",\"docId\":\"$DOC\"}}" >/dev/null || die "void-legacy 后整文提交应通过"
log "PASS 章节在途迁移语义(void-legacy → 整文提交)"
log "PASS 全部场景;工件在 $OUT"
Executable → Regular
+62 -21
View File
@@ -5,8 +5,9 @@
# 1. 构建:RDM 二进制(RDM 仓 origin/main 临时 worktree,pins 物化)
# + 基座 CLI(本 worktree modules/backend,建号用)
# + frontend dist(本 worktree modules/frontend)
# 2. 种子:RDM -bootstrap-admin(域管理员)→ 基座 user create(登录账号)
# → serve(auth on)→ API rdm.project.create×2 + rdm.ingest(主项目)
# 2. 种子:RDM -bootstrap-admin(域管理员)→ serve 首启建基座存储 → identity ensure →
# 基座 user bootstrap-admin(login-key 激活契约;--password 已被拒绝)→
# redeem+设密 → API rdm.project.create×2 + rdm.ingest(主项目)
# 3. 验收:vite preview(dist+/api 代理)→ npx playwright test
# 4. 收尾:停 serve/preview,保存认证/失败工件到 $E2E_OUT
#
@@ -87,7 +88,7 @@ trap cleanup EXIT
(cd "$RDM_WT" && go build -o "$OUT/agent-system-rdm" .) || die "rdm build failed"
log "rdm binary: $OUT/agent-system-rdm (RDM@${RDM_SHA:0:12}, pins@${PIN_SHA_WANT:0:12})"
log "build base CLI (user create) from the same pinned backend as the RDM binary"
log "build base CLI (account bootstrap/activation) from the same pinned backend as the RDM binary"
# 版本一致性:建号 CLI 必须与被测 RDM 二进制共用同一基座 pin——用 Dev 顶层
# backend(main 最新)建号会把库迁到更高 schema,RDM pins(较旧)打不开。
(cd "$RDM_WT/pins/agent-system-backend" && go build -o "$OUT/agent-system" ./cmd/agent-system) \
@@ -104,11 +105,10 @@ RDM="$OUT/agent-system-rdm -data $DATA"
log "bootstrap: RDM domain admin = $OWNER"
$RDM -bootstrap-admin "$OWNER" >/dev/null
log "bootstrap: base account $OWNER (admin)"
"$OUT/agent-system" user create --data "$DATA" --owner "$OWNER" --name "E2E 审计员" \
--password "$PASSWORD" --admin >/dev/null
log "serve RDM at $API_BASE (auth on)"
# ── 凭据契约(2026-10-13 起):--password 建号被 RECOVERY_CONTRACT_REPLACED 拒绝,
# 改走 login-key 激活:serve 首启创建基座存储 → identity ensure → 基座首位账号
# bootstrap-admin(激活 Key 交付)→ API redeem → 设密(PASSWORD2 为登录密码)。
log "serve RDM at $API_BASE (auth on;首启创建基座存储)"
start_serve() {
"$OUT/agent-system-rdm" -data "$DATA" -addr "127.0.0.1:$API_PORT" -auth on \
>"$OUT/serve.log" 2>&1 &
@@ -136,16 +136,6 @@ login() {
done
die "login failed"
}
# CLI 建号留下 must_change_password。会话在改密前不能调用业务命令。
# 强制设密会作废当前会话,所以设密后用新密码再登录。
finish_password_gate() {
curl -sf -b "$JAR" -c "$JAR" -X POST "$API_BASE/api/auth/password" \
-H 'Content-Type: application/json' \
-d "{\"next\":\"$PASSWORD2\"}" >/dev/null || die "password setup failed"
PASSWORD=$PASSWORD2
login
}
cmd() { # cmd <id> <params-json> → payload(失败即死,种子步骤不容忍部分成功)
local id=$1 params=${2:-{\}}
curl -sf -b "$JAR" -X POST "$API_BASE/api/commands/$id" \
@@ -154,11 +144,27 @@ cmd() { # cmd <id> <params-json> → payload(失败即死,种子步骤不容
|| die "command $id rejected"
}
# 阶段①:建项目与需求台账(API;需 serve)。
# 阶段①:激活账号并建项目与需求台账(API;需 serve)。
start_serve
JAR="$OUT/cookies.txt"
log "seed: identity ensure + base first account (login-key 契约)"
ID_OK=0
for i in $(seq 1 15); do
if "$OUT/agent-system" identity ensure --data "$DATA" --instance-id "e2e-audit" >/dev/null 2>&1; then ID_OK=1; break; fi
sleep 1 # 服务端受控迁移(含 identity 表)可能晚于 HTTP 就绪
done
[ "$ID_OK" = 1 ] || { tail -5 "$OUT/serve.log"; die "identity ensure failed"; }
"$OUT/agent-system" user bootstrap-admin --data "$DATA" --owner "$OWNER" \
--name "E2E 审计员" --out-file "$OUT/$OWNER.bootstrap.key" >/dev/null \
|| die "base bootstrap-admin failed"
K=$(cat "$OUT/$OWNER.bootstrap.key")
curl -sf -c "$JAR" -X POST "$API_BASE/api/auth/login-key/redeem" \
-H 'Content-Type: application/json' -d "{\"key\":\"$K\"}" >/dev/null || die "login-key redeem failed"
# 激活会话仅能设密:设为 PASSWORD2(后续登录与规格消费一致)。
curl -sf -b "$JAR" -c "$JAR" -X POST "$API_BASE/api/auth/password" \
-H 'Content-Type: application/json' -d "{\"next\":\"$PASSWORD2\"}" >/dev/null || die "password setup failed"
PASSWORD=$PASSWORD2
login
finish_password_gate
log "seed: create projects"
PID_MAIN=$(cmd rdm.project.create "{\"name\":\"$PROJ_MAIN\"}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["projectId"])')
PID_ISO=$(cmd rdm.project.create "{\"name\":\"$PROJ_ISO\"}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["projectId"])')
@@ -171,6 +177,10 @@ cmd rdm.perm.cap '{"action":"audit.read","allow":true}' >/dev/null
cmd rdm.perm.set "{\"projectId\":\"$PID_MAIN\",\"member\":\"$OWNER\",\"action\":\"audit.read\",\"allow\":true}" >/dev/null
cmd rdm.perm.set "{\"projectId\":\"$PID_ISO\",\"member\":\"$OWNER\",\"action\":\"audit.read\",\"allow\":true}" >/dev/null
log "seed: grant explicit doc.read for the cockpit fixture owner"
cmd rdm.perm.set "{\"projectId\":\"$PID_MAIN\",\"member\":\"$OWNER\",\"action\":\"doc.read\",\"allow\":true}" >/dev/null
cmd rdm.perm.set "{\"projectId\":\"$PID_ISO\",\"member\":\"$OWNER\",\"action\":\"doc.read\",\"allow\":true}" >/dev/null
log "seed: import requirement ledger into $PID_MAIN"
cmd rdm.reqs.import "{\"projectId\":\"$PID_MAIN\",\"items\":$(
python3 - <<'PY'
@@ -193,6 +203,11 @@ log "seed: load doc-tree structure into $PID_MAIN"
"$OUT/agent-system-rdm" -data "$DATA" \
-structure "$RDM_WT/docs/structure/rdm-self.yaml" -project "$PID_MAIN" \
>"$OUT/structure.log" 2>&1 || { tail -5 "$OUT/structure.log"; die "structure load failed"; }
log "seed: load cockpit doc-tree into $PID_MAIN"
"$OUT/agent-system-rdm" -data "$DATA" \
-structure "$DEV_ROOT/docs/structure/cockpit-e2e.yaml" -project "$PID_MAIN" \
>"$OUT/structure-cockpit.log" 2>&1 || { tail -5 "$OUT/structure-cockpit.log"; die "cockpit structure load failed"; }
grep -o '装载(.*)' "$OUT/structure.log" | head -1 || true
# 阶段③:重启服务,切片入库挂载到已装载骨架(API)。
@@ -213,6 +228,31 @@ log "seed: ingest corpus into $PID_MAIN"
cmd rdm.ingest "{\"projectId\":\"$PID_MAIN\",\"source\":\"01-用户需求与系统需求.md\",\"stage\":\"BD\",\"text\":$(python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' <<<"$SEED_MD")}" >/dev/null
log "seed done"
# ── 驾驶舱 I4 种子:章节探针提案 + 非成员账号(正文经结构 YAML 装载) ──
cmd rdm.perm.set "{\"projectId\":\"$PID_MAIN\",\"member\":\"$OWNER\",\"action\":\"doc.write\",\"allow\":true}" >/dev/null
log "seed: cockpit pending proposal probe (non-danger; scenario 4)"
COCKPIT_OV=$(cmd rdm.cockpit.overview "{\"projectId\":\"$PID_MAIN\"}")
DOC_ID=$(printf '%s' "$COCKPIT_OV" | python3 -c 'import json,sys; d=json.load(sys.stdin); print([i["ref"]["id"] for i in d["documents"] if i["title"]=="验证主体报告"][0])')
SEC_JSON=$(cmd rdm.cockpit.tree "{\"projectId\":\"$PID_MAIN\",\"parent\":\"$DOC_ID\"}")
SEC_DESIGN=$(printf '%s' "$SEC_JSON" | python3 -c 'import json,sys; d=json.load(sys.stdin); print([i["ref"]["id"] for i in d["items"] if i["title"]=="设计说明"][0])')
# 场景 4 驱动用:留一个未采纳提案(浏览器内经 API 采纳后观察投影一致)。
cmd rdm.draft.propose "{\"projectId\":\"$PID_MAIN\",\"sectionId\":\"$SEC_DESIGN\",\"content\":\"一致性探针内容。\"}" >/dev/null
# 场景 2 驱动:验证主体报告设文档级三权(职责判据与正文版本作者无关;
# 结构装载作者为 structure,owner 经文档职责获得正文可读)。
log "seed: duty accounts + doc-level duties (scenario 2)"
DOC_MAIN=$(printf '%s' "$COCKPIT_OV" | python3 -c 'import json,sys; d=json.load(sys.stdin); print([i["ref"]["id"] for i in d["documents"] if i["title"]=="验证主体报告"][0])')
"$OUT/agent-system" user create --data "$DATA" --owner e2e-rev --out-file "$OUT/e2e-rev.bootstrap.key" >/dev/null || die "rev create failed"
"$OUT/agent-system" user create --data "$DATA" --owner e2e-app --out-file "$OUT/e2e-app.bootstrap.key" >/dev/null || die "app create failed"
cmd rdm.project.member.add "{\"projectId\":\"$PID_MAIN\",\"member\":\"e2e-rev\",\"role\":\"member\"}" >/dev/null
cmd rdm.project.member.add "{\"projectId\":\"$PID_MAIN\",\"member\":\"e2e-app\",\"role\":\"member\"}" >/dev/null
cmd rdm.review.assign.doc "{\"projectId\":\"$PID_MAIN\",\"docId\":\"$DOC_MAIN\",\"author\":\"$OWNER\",\"reviewers\":[\"e2e-rev\"],\"approver\":\"e2e-app\"}" >/dev/null
log "seed: second account (non-member negative case)"
"$OUT/agent-system" user create --data "$DATA" --owner e2e-reader --out-file "$OUT/e2e-reader.bootstrap.key" >/dev/null || die "reader create failed"
K2=$(cat "$OUT/e2e-reader.bootstrap.key")
JAR2="$OUT/reader.jar"
curl -sf -c "$JAR2" -X POST "$API_BASE/api/auth/login-key/redeem" -H 'Content-Type: application/json' -d "{\"key\":\"$K2\",\"owner\":\"e2e-reader\"}" >/dev/null || die "reader redeem failed"
curl -sf -b "$JAR2" -c "$JAR2" -X POST "$API_BASE/api/auth/password" -H 'Content-Type: application/json' -d "{\"next\":\"E2eReader#2026\"}" >/dev/null || die "reader password setup failed"
# ── 3. 验收 ─────────────────────────────────────────────
if [ "$SEED_ONLY" = 1 ]; then
log "seed-only mode: api=$API_BASE ui=next;cookie jar=$JAR;数据=$DATA"
@@ -245,7 +285,8 @@ export PLAYWRIGHT_BASE_URL="http://127.0.0.1:$UI_PORT" E2E_OWNER="$OWNER" \
E2E_PROJECT_MAIN="$PROJ_MAIN" E2E_PROJECT_ISOLATED="$PROJ_ISO"
set +e
# 本脚本的种子只覆盖审计工作区。u7 现场验收用例依赖另一套入口,不在这里跑。
npx playwright test e2e/rdm-audit.playwright.ts --reporter=list >"$OUT/playwright.log" 2>&1
# admin-create 场景复用本种子的基座管理员账号(bootstrap-admin)。
npx playwright test e2e/rdm-audit.playwright.ts e2e/rdm-cockpit.playwright.ts e2e/admin-user-create.playwright.ts --reporter=list >"$OUT/playwright.log" 2>&1
PW_EXIT=$?
set -e
cat "$OUT/playwright.log"
+178 -22
View File
@@ -14,12 +14,22 @@ from pathlib import Path
import re
import subprocess
import sys
import tempfile
SCHEMA = 1
SUMMARY_SCHEMA = 1
DEFAULT_TAIL = 15
SECRET_NAME = re.compile(r"token|password|passwd|secret|api[_-]?key|credential|private[_-]?key", re.I)
SHA256 = re.compile(r"[0-9a-f]{64}\Z")
GIT_SHA = re.compile(r"(?:[0-9a-f]{40}|[0-9a-f]{64})\Z")
BRANCH = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/-]{0,199}\Z")
# 可选的分支目标字段:两者须同时出现或同时缺省(旧收据没有)。
TARGET_FIELDS = ("root_target", "immediate_target")
PRE_MERGE_CLOSED = "已完成(合入前闭合)"
TASK_STATE = re.compile(r"^\[task-state\] registered=(.+) initial_base=([0-9a-f]+)$", re.M)
BASE_FIELDS = {"schema", "source", "timestamp", "command", "command_sha256", "cwd", "common_dir",
"before", "after", "after_error", "exit_code", "output", "output_sha256", "receipt_sha256"}
class EvidenceError(Exception):
@@ -111,9 +121,41 @@ def snapshot():
}
def valid_branch(name):
return (isinstance(name, str) and BRANCH.match(name) is not None and ".." not in name
and "//" not in name and not name.endswith(("/", ".", ".lock")))
def receipt_keys_ok(keys):
keys = set(keys)
targets = keys & set(TARGET_FIELDS)
if targets not in (set(), set(TARGET_FIELDS)):
return False
rest = keys - targets
return rest in (BASE_FIELDS, BASE_FIELDS | {"capture_note"})
def task_targets():
"""Root and immediate targets named by the current branch card; no metadata means main/main."""
default = os.environ.get("TASK_TARGET_BRANCH", "main")
try:
card = find_card()
except EvidenceError:
return default, default
match = re.search(r"^- 分支类型:[^\n]*?;root_target:([^;\n]+);immediate_target:([^;\n]+)",
card.read_text(encoding="utf-8"), re.M)
if not match:
return default, default
root, immediate = match.group(1).strip(), match.group(2).strip()
if not (valid_branch(root) and valid_branch(immediate)):
raise EvidenceError("Task card target metadata is invalid")
return root, immediate
def create_receipt(command, output, exit_code, before, after, *, timestamp=None,
cwd=None, git_common_dir=None, after_error=None, capture_note=None):
cwd=None, git_common_dir=None, after_error=None, capture_note=None, targets=None):
"""Package an actual captured run; callers must not reconstruct missing output."""
root_target, immediate_target = targets or task_targets()
receipt = sanitize_tree({
"schema": SCHEMA, "source": "SELF",
"timestamp": timestamp or datetime.datetime.now(datetime.timezone.utc).isoformat(),
@@ -122,6 +164,7 @@ def create_receipt(command, output, exit_code, before, after, *, timestamp=None,
"common_dir": git_common_dir or common_dir(),
"before": before, "after": after, "after_error": after_error,
"exit_code": exit_code, "output": output,
"root_target": root_target, "immediate_target": immediate_target,
})
if capture_note is not None:
receipt["capture_note"] = sanitize(capture_note)
@@ -159,10 +202,11 @@ def read_receipt(filename):
value = json.loads(Path(filename).read_text())
if not isinstance(value, dict) or type(value.get("schema")) is not int or value["schema"] != SCHEMA:
raise EvidenceError("Unsupported receipt schema")
expected = {"schema", "source", "timestamp", "command", "command_sha256", "cwd", "common_dir",
"before", "after", "after_error", "exit_code", "output", "output_sha256", "receipt_sha256"}
if set(value) not in (expected, expected | {"capture_note"}):
if not receipt_keys_ok(value):
raise EvidenceError("Invalid receipt fields")
for key in TARGET_FIELDS:
if key in value and not valid_branch(value[key]):
raise EvidenceError("Invalid receipt target: " + key)
for key in ("source", "timestamp", "command", "command_sha256", "cwd", "common_dir",
"output", "output_sha256", "receipt_sha256"):
if not isinstance(value.get(key), str):
@@ -197,7 +241,7 @@ def read_receipt(filename):
return value
def task_context(current=None):
def task_context(current=None, require_target=True):
"""Verify an existing task checkout; this is not an approval identity."""
current = snapshot() if current is None else current
root = Path(git_text("rev-parse", "--show-toplevel")).resolve()
@@ -216,7 +260,7 @@ def task_context(current=None):
if (root / ".task").is_symlink():
raise EvidenceError("Task registration must belong to the current checkout")
card = find_card()
if card.is_symlink() or card.resolve().parent != root / ".task":
if card.is_symlink() or card.resolve().parent not in (root / ".task", root / ".task" / "archive"):
raise EvidenceError("Task registration must belong to the current checkout")
content = card.read_text()
locations = re.findall(r"^- 工作区:(.+?)\s*[((]worktree[))];分支:([^\s]+)\s*$", content, re.M)
@@ -226,10 +270,11 @@ def task_context(current=None):
if len(bases) != 1 or bases[0][0] != current["target_branch"] or not GIT_SHA.fullmatch(bases[0][1]):
raise EvidenceError("Task card has no valid target and initial baseline")
r0 = bases[0][1]
if not current["target_head"]:
if not current["target_head"] and require_target:
raise EvidenceError("Task target is unverified; fetch it first")
for tip in (current["head"], current["target_head"]):
git("merge-base", "--is-ancestor", r0, tip)
if tip:
git("merge-base", "--is-ancestor", r0, tip)
check = subprocess.run([sys.executable, str(Path(__file__).with_name("check-worktree.py")), "before-sync"],
capture_output=True, text=True)
if check.returncode:
@@ -252,12 +297,13 @@ def validate_precheck(receipt, purpose="start"):
raise EvidenceError("Precheck object capture is incomplete")
if receipt["before"]["head"] != current["head"] or after["head"] != current["head"]:
raise EvidenceError("Precheck source HEAD has changed")
if after["target_branch"] != current["target_branch"] or not after["target_head"]:
if after["target_branch"] != current["target_branch"] or (
not after["target_head"] and purpose != "resume-local"):
raise EvidenceError("Precheck authoritative target has changed")
if purpose != "resume-local" and after["target_head"] != current["target_head"]:
raise EvidenceError("Precheck authoritative target has changed")
if purpose.startswith("resume-"):
context = task_context(current)
context = task_context(current, require_target=purpose != "resume-local")
try:
Path(receipt["cwd"]).resolve().relative_to(context["root"])
except ValueError:
@@ -265,12 +311,38 @@ def validate_precheck(receipt, purpose="start"):
if any(obj["branch"] != current["branch"] or obj["target_branch"] != current["target_branch"]
for obj in (receipt["before"], after)):
raise EvidenceError("Precheck belongs to a different task branch")
git("merge-base", "--is-ancestor", context["r0"], after["target_head"])
if after["target_head"]:
git("merge-base", "--is-ancestor", context["r0"], after["target_head"])
if purpose == "resume-local":
try:
git("merge-base", "--is-ancestor", after["target_head"], current["target_head"])
except EvidenceError:
raise EvidenceError("Precheck target history has rewound or diverged") from None
if after["target_head"] and current["target_head"]:
try:
git("merge-base", "--is-ancestor", after["target_head"], current["target_head"])
except EvidenceError:
raise EvidenceError("Precheck target history has rewound or diverged") from None
validate_local_scope(receipt, current, context)
def require_active_task(root):
"""Closed, archived or abandoned cards cannot authorize local continuation."""
card = find_card()
status = re.search(r";状态:\s*([^;\s][^;\n]*)", card.read_text(encoding="utf-8"))
if card.resolve().parent != root / ".task" or not status or status.group(1).startswith("已"):
raise EvidenceError("Local continuation requires an active registered task")
def validate_local_scope(receipt, current, context):
"""LOCAL_READY continues one active registered task; it remains SELF local-work evidence only."""
require_active_task(context["root"])
states = TASK_STATE.findall(receipt["output"])
if len(states) != 1 or Path(states[0][0]) != context["root"] or states[0][1] != context["r0"]:
raise EvidenceError("Precheck belongs to a different task identity or initial baseline")
for obj in (receipt["before"], receipt["after"]):
if obj["status"] or obj["untracked"] or obj["tracked_diff_sha256"] != digest(b""):
raise EvidenceError("Precheck workspace was not clean")
if obj["submodules"] != current["submodules"]:
raise EvidenceError("Precheck component state has changed")
if tuple(receipt.get(key) for key in TARGET_FIELDS) != task_targets():
raise EvidenceError("Precheck authorization scope differs from the registered task")
def find_card():
@@ -279,14 +351,92 @@ def find_card():
card = root / ".task" / (branch.replace("/", "-") + ".md")
if card.is_file():
return card
owner = re.compile(r"^- 工作区:.*分支:" + re.escape(branch) + r"(?:\s|$)", re.M)
for candidate in (root / ".task").glob("*.md"):
if re.search(r"^- 工作区:.*分支:" + re.escape(branch) + r"(?:\s|$)", candidate.read_text(), re.M):
if owner.search(candidate.read_text()):
return candidate
# A card fold-closed before merge (task.sh close --self) lives in the archive until its PR lands.
for candidate in sorted((root / ".task" / "archive").glob("*.md")):
text = candidate.read_text()
status = re.search(r";状态:([^;\n]*)", text)
if owner.search(text) and status and status.group(1).startswith(PRE_MERGE_CLOSED):
return candidate
raise EvidenceError("No task card exists for the current branch")
def evidence_dir():
configured = os.environ.get("AGENT_EVIDENCE_DIR")
if configured:
base = Path(configured)
else:
base = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "agent-system-dev" / "evidence"
base.mkdir(mode=0o700, parents=True, exist_ok=True)
return base.resolve()
def store_receipt(receipt):
"""Write the full receipt once under its digest; identical existing content is accepted."""
directory = evidence_dir()
path = directory / (receipt["receipt_sha256"] + ".json")
encoded = json.dumps(receipt, ensure_ascii=False, indent=2) + "\n"
if path.exists() or path.is_symlink():
try:
same = not path.is_symlink() and json.loads(path.read_text(encoding="utf-8")) == receipt
except (OSError, ValueError):
same = False
if not same:
raise EvidenceError("Full receipt path holds different content: " + str(path))
return path
fd, temporary = tempfile.mkstemp(prefix=".receipt-", dir=directory)
try:
with os.fdopen(fd, "w", encoding="utf-8") as stream:
os.fchmod(stream.fileno(), 0o600)
stream.write(encoded)
stream.flush()
os.fsync(stream.fileno())
try:
os.link(temporary, path)
except FileExistsError:
return store_receipt(receipt)
finally:
os.unlink(temporary)
return path
def tail_lines():
value = os.environ.get("TAIL", "")
return int(value) if value.isdigit() else DEFAULT_TAIL
def build_summary(receipt, receipt_path, tail=None):
"""Card-sized view of a full receipt; every field is derived from the sanitized receipt."""
tail = tail_lines() if tail is None else tail
before, after = receipt["before"], receipt["after"]
summary = {
"summary_schema": SUMMARY_SCHEMA, "source": "SELF",
"timestamp": receipt["timestamp"], "command": receipt["command"],
"exit_code": receipt["exit_code"], "cwd": receipt["cwd"],
"before_head": before["head"], "after_head": after["head"] if after else None,
"branch": before["branch"], "target_branch": before["target_branch"],
"target_head": before["target_head"],
}
for key in TARGET_FIELDS:
if key in receipt:
summary[key] = receipt[key]
summary.update({
"before_tracked_diff_sha256": before["tracked_diff_sha256"],
"after_tracked_diff_sha256": after["tracked_diff_sha256"] if after else None,
"output_tail": receipt["output"].splitlines()[-tail:] if tail > 0 else [],
"output_sha256": receipt["output_sha256"],
"receipt_sha256": receipt["receipt_sha256"],
"receipt_path": str(receipt_path),
})
return summary
def append_card(card, receipt):
encoded = json.dumps(receipt, ensure_ascii=False, indent=2)
path = store_receipt(receipt)
encoded = json.dumps(build_summary(receipt, path), ensure_ascii=False, indent=2)
block = "\n## 证据(Evidence)\n\n" + (
"<!-- Local receipt: SELF. Digests detect corruption; they do not authenticate a reviewer or CI. -->\n"
+ "```json\n" + encoded + "\n```\n")
@@ -322,8 +472,10 @@ def main():
try:
if os.environ.get("SRC", "SELF") not in ("", "SELF"):
raise EvidenceError("Local evidence is SELF; GATE/REPLAY require external execution identity")
if sys.argv[1:] == ["--local-context"]:
context = task_context()
if sys.argv[1:] in (["--local-context"], ["--local-context", "--active"]):
context = task_context(require_target=False)
if sys.argv[2:]:
require_active_task(context["root"])
print("[task-state] registered=" + str(context["root"]) + " initial_base=" + context["r0"])
return 0
if sys.argv[1:]:
@@ -336,8 +488,10 @@ def main():
if imported or validated:
receipt = read_receipt(imported or validated)
if validated:
validate_precheck(receipt, os.environ.get("VALIDATE_RECEIPT_PURPOSE", "start"))
report(receipt, "precheck validated")
purpose = os.environ.get("VALIDATE_RECEIPT_PURPOSE", "start")
validate_precheck(receipt, purpose)
report(receipt, "local continuation validated (local work only; not delivery evidence)"
if purpose == "resume-local" else "precheck validated")
else:
card = find_card()
append_card(card, receipt)
@@ -350,6 +504,7 @@ def main():
raise EvidenceError("Capture requires an absolute, unused receipt path")
card = find_card() if path is None else None
before = snapshot()
targets = task_targets()
source_cwd, source_common = str(Path.cwd().resolve()), common_dir()
timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat()
child_env = os.environ.copy()
@@ -366,7 +521,8 @@ def main():
after_error = str(error)
receipt = create_receipt(command, result.stdout.decode("utf-8", "replace"), child_exit,
before, after, timestamp=timestamp, cwd=source_cwd,
git_common_dir=source_common, after_error=after_error)
git_common_dir=source_common, after_error=after_error,
targets=targets)
if path is not None:
write_receipt(path, receipt)
report(receipt, "receipt=" + str(path))
+79
View File
@@ -3,7 +3,86 @@
# 规则依据:AGENTS.md「证据与验证门禁」(外部事实零信任);verification.md「声明与证据来源」。
# 用法:make facts-verify [ARGS=--update] (--update:把 PASS 项 last_verified 刷为当天并写回)
# 表内验证命令约定不含竖线;含 "\|" 转义时按字面还原后执行。
# 登记表引用的探针子命令(单独运行时在 stderr 说明判定,不输出凭据):
# api-token URL URL 须逐字等于登记端点 https://git-home.u1680832.nyat.app:30319/api/v1/user,
# 校验通过后才进程内读 ~/.secrets/gitea HOME 段 ACCESS_TOKEN 并 GET(不跟随重定向);
# 0=接受 3=缺失 4=格式不符 5=请求失败/被拒/重定向 6=非登记端点
# tls-handshake-fails HOST PORT 仅当 TCP 已连通且 TLS 层握手报错时 0;
# 3=DNS 失败 4=超时 5=连接失败 6=握手成功(端点仍可用)
set -uo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
case "${1:-}" in
api-token)
[ $# -eq 2 ] || { echo "usage: facts-verify.sh api-token URL" >&2; exit 2; }
exec python3 - "$SCRIPT_DIR" "$2" <<'PY'
import sys, urllib.request
sys.path.insert(0, sys.argv[1])
import task_triage
ENDPOINT = "https://git-home.u1680832.nyat.app:30319/api/v1/user"
if sys.argv[2] != ENDPOINT:
print("api-token: URL 不是 policy/facts.md F2 登记端点,拒绝发送凭据", file=sys.stderr)
sys.exit(6)
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
urllib.request.install_opener(urllib.request.build_opener(NoRedirect))
url = ENDPOINT
token = task_triage.secrets_file_token()
if not token:
print("api-token: ~/.secrets/gitea HOME 段 ACCESS_TOKEN 缺失", file=sys.stderr)
sys.exit(3)
if not task_triage.TOKEN_RE.fullmatch(token):
print("api-token: ~/.secrets/gitea HOME 段 ACCESS_TOKEN 格式不符(应为单个无空白值)", file=sys.stderr)
sys.exit(4)
try:
task_triage.http_get_json(url, token)
except task_triage.Stop as stop:
print("api-token: " + stop.message, file=sys.stderr)
sys.exit(5)
print("api-token: accepted by " + url, file=sys.stderr)
PY
;;
tls-handshake-fails)
[ $# -eq 3 ] || { echo "usage: facts-verify.sh tls-handshake-fails HOST PORT" >&2; exit 2; }
exec python3 - "$2" "$3" <<'PY'
import socket, ssl, sys
host, port = sys.argv[1], int(sys.argv[2])
def verdict(code, text):
print(f"tls-handshake-fails {host}:{port}: {text}", file=sys.stderr)
sys.exit(code)
try:
sock = socket.create_connection((host, port), timeout=8)
except socket.gaierror:
verdict(3, "DNS resolution failed (not a TLS observation)")
except TimeoutError:
verdict(4, "TCP connect timed out (not a TLS observation)")
except OSError as exc:
verdict(5, f"TCP connect failed ({type(exc).__name__}; not a TLS observation)")
try:
with sock, ssl.create_default_context().wrap_socket(sock, server_hostname=host) as tls:
version = tls.version()
except ssl.SSLError as exc:
verdict(0, f"TLS handshake failed ({type(exc).__name__}: {getattr(exc, 'reason', None) or 'unknown'})")
except TimeoutError:
verdict(4, "TLS handshake timed out (not a TLS-layer error)")
except OSError as exc:
verdict(5, f"connection failed during handshake ({type(exc).__name__}; not a TLS-layer error)")
verdict(6, f"TLS handshake succeeded ({version}); endpoint still answers")
PY
;;
esac
F="${FACTS:-policy/facts.md}"
[ -f "$F" ] || { echo "ERROR: 事实登记表 $F 不存在" >&2; exit 1; }
python3 - "$F" "${1:-}" <<'PY'
+4 -3
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
# 本地钩子(post-merge):pull/merge 落地后提示 owner 按授权闭合任务卡;ff-only 也会触发。
# precheck [cards] 报告欠账;此钩子只提示,不执行闭合或其他写入。
# 本地钩子(post-merge):pull/merge 落地后提示任务卡闭合方式;ff-only 也会触发。
# 新任务在合入前用 make task-close SELF=1 闭合本卡;precheck [cards] 报告的遗留卡才需主检出闭合。
# 此钩子只提示,不执行闭合或其他写入。
# 触发条件收紧到"主检出 + 集成分支 main"——任务 worktree 里 task-sync 的 merge 不触发,
# 防止把卡片闭合提示混进任务分支现场。
common=$(git rev-parse --git-common-dir) && common=$(cd "$common" 2>/dev/null && pwd) || exit 0
@@ -8,5 +9,5 @@ mainrepo=$(dirname "$common")
[ "$mainrepo" = "$PWD" ] || exit 0
[ "$(git rev-parse --abbrev-ref HEAD)" = "main" ] || exit 0
[ -f "$mainrepo/scripts/task.sh" ] || exit 0
echo "[next] owner 按已有授权在主检出运行 make task-close 闭合已合入任务卡;未授权时先取得授权。"
echo "[next] 新任务在合入前于任务 worktree 运行 make task-close SELF=1 闭合本卡;主检出的 make task-close 仅用于 precheck [cards] 报告的遗留卡,owner 按已有授权运行,未授权时先取得授权。"
exit 0
+5 -2
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# 本地硬闸(pre-commit):集成分支直提拒绝 + 分支命名。权威准入在 PR(baseline.md:26)。
# 本地检查(pre-commit):集成分支直提拒绝 + 分支命名 + 本地完整性核对。权威准入在 PR(baseline.md:26)。
branch=$(git rev-parse --abbrev-ref HEAD)
case "$branch" in
main|master)
@@ -10,4 +10,7 @@ echo "$branch" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || {
echo "pre-commit: 拒绝——分支名须为 feature|fix|hotfix|release/<slug>,当前:${branch}" >&2
exit 1
}
exit 0
root=$(git rev-parse --show-toplevel)
# admit 只是本地完整性/篡改证据(SELF):同一 OS 用户可读取本地 HMAC 密钥并绕过本钩子,
# 它不是 GATE,也不是可信身份。缺失或无效的本地记录仍拒绝;交付信任来自精确 HEAD 的 CI 或独立 REPLAY。
"$root/scripts/agent-exec" admit --repo "$root"
+846 -54
View File
File diff suppressed because it is too large Load Diff
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
# 通用任务入口:转调 scripts/dm 的 task 子命令(triage / start / pr-check)。
set -euo pipefail
exec "$(dirname "$0")/dm" task "$@"
+623 -88
View File
File diff suppressed because it is too large Load Diff
+304
View File
@@ -0,0 +1,304 @@
#!/usr/bin/env python3
"""PR base/head 门禁(两级分支模型:child → initiative → main)。
子命令:
local [--pr N] [--final]
核验当前检出的任务分支及其 PR(事实来源是 Gitea 与 origin):PR 处于开放状态、base 为卡片期望目标、
PR head 与本地 HEAD 一致、origin 分支头与本地 HEAD 一致、期望目标分支存在;child 的父 initiative
须仍有开放 PR 指向其 root_target。--final(仅 initiative):accepting_requirements=false,且
`## 子任务登记` 中「必需=是」的子任务为「已合入」(PR 已合并入本 initiative)或「已延期」。
ci
仅 pull_request 事件:按 GITHUB_HEAD_REF 在检出的 .task/ 顶层取卡片(顶层无卡时取 .task/archive/
中状态为「已完成(合入前闭合)」的卡),核对 GITHUB_BASE_REF。
不联网、不需要 token;非 pull_request 事件跳过(退出 0)。
期望目标:child → immediate_target;其余(initiative / standalone / 无卡)→ root_target(无卡即 main)。
退出码:0 通过;1 门禁拒绝(PR_CHECK_FAILED);2 用法;6 远端无法核验(REMOTE_UNVERIFIED,fail closed)。
仅使用标准库,复用 task_triage 的 Gitea 客户端与 token 读取。token 不打印、不写入任何产物。
"""
import argparse
import os
import re
import subprocess
import sys
import urllib.parse
from pathlib import Path
from task_triage import (EXIT_REMOTE, Stop, api_base, as_dict, card_branch, card_status, http_get_json,
open_pulls, read_token, repo_slug, target_branch)
EXIT_FAILED = 1
EXIT_USAGE = 2
PRE_MERGE_CLOSED = "已完成(合入前闭合)"
CHILD_COLUMNS = ("子任务分支", "必需", "状态", "PR")
DONE_STATES = ("已合入", "已延期")
def sh(*args):
return subprocess.run(["git", *args], capture_output=True, text=True,
encoding="utf-8", errors="replace")
def standalone(target):
return {"kind": "standalone", "initiative_id": "", "root_target": target,
"immediate_target": target, "accepting": "false"}
def card_meta(text, target):
"""两级分支元数据行;无该行即旧卡,按 standalone 处理(与 task.sh 的 card_branch_kind 一致)。"""
match = re.search(r"^- 分支类型:([^\n]*)$", text, re.M)
if not match:
return standalone(target)
line = match.group(1)
def field(name, default):
found = re.search(";" + name + r":([^;]*)", line)
return found.group(1).strip() if found else default
return {"kind": line.split(";", 1)[0].strip(), "initiative_id": field("initiative_id", ""),
"root_target": field("root_target", target),
"immediate_target": field("immediate_target", target),
"accepting": field("accepting_requirements", "false")}
def expected_base(meta):
return meta["immediate_target"] if meta["kind"] == "child" else meta["root_target"]
def matching_card(directory, branch, archived):
"""directory 下声明该分支的唯一卡片;archived=True 时只认状态以「已完成(合入前闭合)」开头的卡。"""
found = []
for path in sorted(Path(directory).glob("*.md")):
text = path.read_text(encoding="utf-8", errors="replace")
if card_branch(text) != branch:
continue
if archived and not card_status(text).startswith(PRE_MERGE_CLOSED):
continue
found.append((path, text))
if len(found) > 1:
raise Stop(EXIT_FAILED, f"ambiguous task cards for {branch}: {', '.join(str(p) for p, _ in found)}")
return found[0] if found else None
def find_card(branch):
"""先看检出树的 .task/ 顶层;顶层无卡时再看 .task/archive/ 中合入前闭合(close --self)的卡,
其余归档卡不参与。返回 (路径, 文本),无卡返回 (None, None)。"""
return (matching_card(".task", branch, False)
or matching_card(".task/archive", branch, True)
or (None, None))
def branch_meta(branch, target):
_, text = find_card(branch)
return (standalone(target) if text is None else card_meta(text, target)), text
def current_branch():
proc = sh("symbolic-ref", "--quiet", "--short", "HEAD")
if proc.returncode != 0 or not proc.stdout.strip():
raise Stop(EXIT_USAGE, "detached HEAD: check out the task branch first")
return proc.stdout.strip()
def local_head():
proc = sh("rev-parse", "--verify", "-q", "HEAD^{commit}")
if proc.returncode != 0:
raise Stop(EXIT_USAGE, "cannot resolve local HEAD")
return proc.stdout.strip()
def remote_head(branch):
proc = sh("ls-remote", "origin", f"refs/heads/{branch}")
if proc.returncode != 0:
raise Stop(EXIT_REMOTE, "git ls-remote origin failed")
for line in proc.stdout.splitlines():
sha, _, ref = line.partition("\t")
if ref == f"refs/heads/{branch}":
return sha
return None
def remote_context():
origin = sh("remote", "get-url", "origin")
if origin.returncode != 0:
raise Stop(EXIT_REMOTE, "origin remote is not configured")
slug = repo_slug(origin.stdout.strip())
token = read_token()
if not token:
raise Stop(EXIT_REMOTE, "no Gitea token: set GITEA_TOKEN or ~/.secrets/gitea ACCESS_TOKEN")
return api_base(), slug, token
def pull_request(api, slug, token, number):
owner, name = (urllib.parse.quote(part, safe="") for part in slug.split("/"))
pr = http_get_json(f"{api}/repos/{owner}/{name}/pulls/{number}", token)
if not isinstance(pr, dict):
raise Stop(EXIT_REMOTE, f"Gitea PR #{number} response is malformed")
return pr
def base_ref(pr):
return as_dict(pr.get("base")).get("ref")
def head_ref(pr):
return as_dict(pr.get("head")).get("ref")
def head_sha(pr):
return as_dict(pr.get("head")).get("sha")
def parent_pr_open(api, slug, token, parent, root):
return any(isinstance(pr, dict) and pr.get("state") == "open"
and head_ref(pr) == parent and base_ref(pr) == root
for pr in open_pulls(api, slug, token))
def pr_number(cell):
for pattern in (r"pulls/(\d+)", r"#(\d+)", r"^(\d+)$"):
found = re.search(pattern, cell.strip())
if found:
return int(found.group(1))
return None
def child_registry(text):
"""解析 `## 子任务登记` 表(列:子任务分支 | 必需 | 状态 | PR);缺失即拒绝(fail closed)。"""
lines = text.splitlines()
starts = [i for i, line in enumerate(lines) if line.strip() == "## 子任务登记"]
if not starts:
raise Stop(EXIT_FAILED, "initiative card has no ## 子任务登记 section")
rows, columns = [], None
for line in lines[starts[0] + 1:]:
if line.startswith("## "):
break
if not line.strip().startswith("|"):
continue
cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
if columns is None:
if not all(name in cells for name in CHILD_COLUMNS):
raise Stop(EXIT_FAILED, "子任务登记 table must have columns 子任务分支 | 必需 | 状态 | PR")
columns = {name: cells.index(name) for name in CHILD_COLUMNS}
continue
if all(re.fullmatch(r":?-{3,}:?", cell) for cell in cells):
continue
if len(cells) <= max(columns.values()):
raise Stop(EXIT_FAILED, f"malformed 子任务登记 row: {line.strip()}")
rows.append({name: cells[index] for name, index in columns.items()})
if columns is None:
raise Stop(EXIT_FAILED, "子任务登记 section has no table")
return rows
def final_problems(api, slug, token, branch, meta, text):
problems = []
if meta["accepting"] != "false":
problems.append(f"initiative {branch} still accepts requirements "
f"(accepting_requirements={meta['accepting']}); set false before --final")
for row in child_registry(text):
name, required, status, pr_cell = row["子任务分支"], row["必需"], row["状态"], row["PR"]
if required == "否":
continue
if required != "是":
problems.append(f"child {name} has invalid 必需 value {required!r}")
elif status == "已合入":
number = pr_number(pr_cell)
if number is None:
problems.append(f"child {name} is 已合入 but PR cell {pr_cell!r} names no PR")
continue
pr = pull_request(api, slug, token, number)
if pr.get("merged") is not True or base_ref(pr) != branch:
problems.append(f"child {name} PR #{number} is not merged into {branch}")
elif status not in DONE_STATES:
problems.append(f"required child {name} is {status!r}; must be 已合入 or 已延期")
return problems
def verdict(problems, kind, base, head):
if problems:
for problem in problems:
print(f"[pr-check] FAIL {problem}")
print("[verdict] PR_CHECK_FAILED")
return EXIT_FAILED
print(f"[verdict] PR_CHECK_OK base={base} head={head} kind={kind}")
return 0
def cmd_local(args):
if args.pr is not None and args.pr < 1:
raise Stop(EXIT_USAGE, "--pr must be a positive PR number")
branch = current_branch()
head = local_head()
meta, text = branch_meta(branch, target_branch())
kind, expected = meta["kind"], expected_base(meta)
api, slug, token = remote_context()
if args.pr is not None:
pr = pull_request(api, slug, token, args.pr)
else:
pulls = [pr for pr in open_pulls(api, slug, token) if isinstance(pr, dict) and head_ref(pr) == branch]
if not pulls:
raise Stop(EXIT_FAILED, f"no open PR has head {branch}")
if len(pulls) > 1:
raise Stop(EXIT_FAILED, f"{len(pulls)} open PRs have head {branch}; pass --pr N")
pr = pulls[0]
number = pr.get("number")
problems = []
if pr.get("merged") is True:
problems.append(f"PR #{number} is already merged")
elif pr.get("state") != "open":
problems.append(f"PR #{number} is not open (state={pr.get('state')})")
if base_ref(pr) != expected:
problems.append(f"PR #{number} base is {base_ref(pr)!r}; expected {expected!r} for {kind} branch {branch}")
if head_sha(pr) != head:
problems.append(f"PR #{number} head {head_sha(pr) or '(none)'} != local HEAD {head}")
remote = remote_head(branch)
if remote is None:
problems.append(f"origin has no branch {branch}")
elif remote != head:
problems.append(f"origin/{branch} is {remote}, local HEAD is {head}")
if remote_head(expected) is None:
problems.append(f"base branch {expected} is absent on origin")
if kind == "child" and not parent_pr_open(api, slug, token, expected, meta["root_target"]):
problems.append(f"parent initiative {expected} has no open PR into {meta['root_target']}")
if args.final and kind == "initiative":
problems.extend(final_problems(api, slug, token, branch, meta, text))
return verdict(problems, kind, expected, head)
def cmd_ci(_args):
event = os.environ.get("GITHUB_EVENT_NAME", "")
if event != "pull_request":
print(f"[pr-check] skip: event {event or '(none)'} is not pull_request")
return 0
base = os.environ.get("GITHUB_BASE_REF", "").strip()
head = os.environ.get("GITHUB_HEAD_REF", "").strip()
if not base or not head:
raise Stop(EXIT_FAILED, "pull_request event lacks GITHUB_BASE_REF or GITHUB_HEAD_REF")
meta, _ = branch_meta(head, target_branch())
expected = expected_base(meta)
if base != expected:
raise Stop(EXIT_FAILED, f"PR base {base!r} differs from expected {expected!r} "
f"for {meta['kind']} branch {head}")
return verdict([], meta["kind"], base, head)
def main(argv=None):
parser = argparse.ArgumentParser(prog="task_pr_check.py", description="PR base/head 门禁(两级分支模型)")
sub = parser.add_subparsers(dest="cmd", required=True)
local = sub.add_parser("local")
local.add_argument("--pr", type=int)
local.add_argument("--final", action="store_true")
sub.add_parser("ci")
args = parser.parse_args(argv)
try:
return cmd_local(args) if args.cmd == "local" else cmd_ci(args)
except Stop as stop:
print(f"[pr-check] FAIL {stop.message}")
print("[verdict] REMOTE_UNVERIFIED" if stop.code == EXIT_REMOTE else "[verdict] PR_CHECK_FAILED")
return stop.code
if __name__ == "__main__":
sys.exit(main())
+535
View File
@@ -0,0 +1,535 @@
#!/usr/bin/env python3
"""远端需求分流收据(task.sh triage / triage-decide / start 共用的唯一分流依据)。
事实来源是 origin:`git ls-remote` 取分支头,Gitea open PR 取 base/head 关系,候选分支提交中的
任务卡取 initiative 元数据。本地 `.task/` 不参与发现。
子命令:
discover --receipt PATH --requirement TEXT
查询远端并写收据;无候选 -> NEW;有候选 -> DECISION_REQUIRED(退出 5,不自动选择 JOIN)。
decide --receipt PATH --verdict JOIN|NEW [--initiative BRANCH] --reason TEXT
复查远端;候选集合或任一候选的 HEAD/卡片/PR 字段变化则拒绝(退出 7,收据不改)。
verify --receipt PATH --kind standalone|initiative|child [--initiative BRANCH] [--max-age-seconds N]
复查远端并核对收据;供 task.sh start 在创建任何 worktree/分支之前调用(拒绝退出 8)。
退出码:0 成功;1 本地错误;2 用法;5 DECISION_REQUIRED;6 REMOTE_UNVERIFIED;7 决策被拒;8 核验被拒。
仅使用标准库。token 只读自环境变量 GITEA_TOKEN 或 ~/.secrets/gitea 的 ACCESS_TOKEN=,不打印、不写入收据。
"""
import argparse
import datetime as dt
import hashlib
import http.client
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
SCHEMA = "agent-system/task-triage/v1"
DEFAULT_API = "https://git-home.u1680832.nyat.app:30319/api/v1"
DEFAULT_TARGET = "main"
DEFAULT_MAX_AGE = 14400
PAGE_LIMIT = 50
MAX_PAGES = 100
SECRETS_FILE = "~/.secrets/gitea"
SECTION_RE = re.compile(r"^([A-Za-z][A-Za-z0-9_-]*):$")
TOKEN_RE = re.compile(r"[A-Za-z0-9._~+/=-]+")
CARD_RE = re.compile(r"^\.task/[^/]+\.md$")
CANDIDATE_KEYS = ("branch", "head_sha", "card_path", "card_sha256", "pr_number", "pr_state", "pr_base", "pr_head_sha")
RECEIPT_KEYS = ("repo", "root_target", "root_target_sha", "requirement", "discovered_at",
"candidates", "discovery_verdict", "final_verdict", "decision")
EXIT_LOCAL = 1
EXIT_USAGE = 2
EXIT_DECISION_REQUIRED = 5
EXIT_REMOTE = 6
EXIT_DECIDE = 7
EXIT_VERIFY = 8
class Stop(Exception):
"""受控失败:退出码 + 不含 token 的说明。"""
def __init__(self, code, message, status=None):
super().__init__(message)
self.code = code
self.message = message
self.status = status
def utc_now():
return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def parse_time(text):
value = dt.datetime.fromisoformat(text)
if value.tzinfo is None:
value = value.replace(tzinfo=dt.timezone.utc)
return value
def sha256_bytes(data):
return hashlib.sha256(data).hexdigest()
def as_dict(value):
return value if isinstance(value, dict) else {}
def target_branch():
return os.environ.get("TASK_TARGET_BRANCH", "").strip() or DEFAULT_TARGET
def git(*args):
return subprocess.run(["git", *args], capture_output=True)
def redact_url(url):
"""去掉 URL 中的 userinfo(https://user:pw@host 与 user@host:path 两种形式)。"""
if "://" in url:
parts = urllib.parse.urlsplit(url)
host = parts.netloc.rsplit("@", 1)[-1]
return urllib.parse.urlunsplit((parts.scheme, host, parts.path, parts.query, parts.fragment))
scp = re.match(r"^[^/@]+@([^/]+:.*)$", url)
return scp.group(1) if scp else url
def repo_slug(origin_url):
slug = os.environ.get("GITEA_REPO", "").strip()
if not slug:
if "://" in origin_url:
path = urllib.parse.urlsplit(origin_url).path
else:
path = origin_url.split(":", 1)[1] if re.match(r"^[^/]+:", origin_url) else origin_url
segments = [s for s in path.split("/") if s]
if len(segments) < 2:
raise Stop(EXIT_REMOTE, "cannot derive owner/repo from origin; set GITEA_REPO")
name = segments[-1][:-4] if segments[-1].endswith(".git") else segments[-1]
slug = f"{segments[-2]}/{name}"
parts = slug.split("/")
if len(parts) != 2 or not all(parts) or any(p in (".", "..") for p in parts):
raise Stop(EXIT_REMOTE, "invalid owner/repo slug")
return slug
def api_base():
return (os.environ.get("GITEA_API_URL", "").strip() or DEFAULT_API).rstrip("/")
def secrets_file_token():
"""HOME/IMC 分段时只取 HOME 段的 ACCESS_TOKEN(同 policy/facts.md F2);无分段时取第一条。"""
try:
with open(os.path.expanduser(SECRETS_FILE), encoding="utf-8") as fh:
lines = fh.read().splitlines()
except (OSError, UnicodeDecodeError):
return None
first = home = None
sectioned = False
section = None
for raw in lines:
line = raw.strip()
header = SECTION_RE.match(line)
if header:
sectioned = True
section = header.group(1)
continue
if line.startswith("- "):
line = line[2:].strip()
if not line.startswith("ACCESS_TOKEN="):
continue
value = line.split("=", 1)[1].strip()
if first is None:
first = value
if section == "HOME" and home is None:
home = value
return (home if sectioned else first) or None
def read_token():
token = os.environ.get("GITEA_TOKEN", "").strip() or secrets_file_token()
if not token:
return None
if not TOKEN_RE.fullmatch(token):
raise Stop(EXIT_REMOTE, "Gitea token is malformed (expected one value without whitespace); "
"check GITEA_TOKEN or ~/.secrets/gitea")
return token
def remote_heads():
proc = git("ls-remote", "--heads", "origin")
if proc.returncode != 0:
raise Stop(EXIT_REMOTE, "git ls-remote --heads origin failed")
heads = {}
for line in proc.stdout.decode("utf-8", "replace").splitlines():
sha, _, ref = line.partition("\t")
if ref.startswith("refs/heads/"):
heads[ref[len("refs/heads/"):]] = sha
return heads
def http_get_json(url, token):
try:
request = urllib.request.Request(
url, headers={"Accept": "application/json", "Authorization": f"token {token}"})
with urllib.request.urlopen(request, timeout=20) as response:
body = response.read()
except urllib.error.HTTPError as exc:
raise Stop(EXIT_REMOTE, f"Gitea API returned HTTP {exc.code}", status=exc.code) from None
except (urllib.error.URLError, http.client.HTTPException, OSError) as exc:
raise Stop(EXIT_REMOTE, f"Gitea API unreachable ({type(exc).__name__})") from None
except Stop:
raise
except Exception as exc:
# 异常文本可能含 Authorization 头(如 http.client 的 Invalid header value),只报类型名。
raise Stop(EXIT_REMOTE, f"Gitea API request failed ({type(exc).__name__})") from None
try:
return json.loads(body.decode("utf-8"))
except ValueError:
raise Stop(EXIT_REMOTE, "Gitea API returned invalid JSON")
def open_pulls(api, slug, token):
owner, name = (urllib.parse.quote(part, safe="") for part in slug.split("/"))
pulls = []
for page in range(1, MAX_PAGES + 1):
url = f"{api}/repos/{owner}/{name}/pulls?state=open&limit={PAGE_LIMIT}&page={page}"
batch = http_get_json(url, token)
if not isinstance(batch, list):
raise Stop(EXIT_REMOTE, "Gitea pulls response is not a list")
if not batch:
return pulls
pulls.extend(batch)
raise Stop(EXIT_REMOTE, f"open pull requests exceed {MAX_PAGES} pages")
def fetch_head(branch):
proc = git("fetch", "-q", "origin", f"refs/heads/{branch}")
if proc.returncode != 0:
raise Stop(EXIT_REMOTE, f"cannot fetch origin/{branch}")
rev = git("rev-parse", "--verify", "-q", "FETCH_HEAD^{commit}")
if rev.returncode != 0:
raise Stop(EXIT_REMOTE, f"fetched origin/{branch} has no commit")
return rev.stdout.decode("utf-8", "replace").strip()
def task_card_paths(commit):
proc = git("ls-tree", "-r", "-z", "--name-only", commit, "--", ".task")
if proc.returncode != 0:
raise Stop(EXIT_REMOTE, "cannot list task cards at candidate head")
names = proc.stdout.decode("utf-8", "replace").split("\0")
return sorted(name for name in names if CARD_RE.match(name))
def blob_bytes(commit, path):
proc = git("cat-file", "blob", f"{commit}:{path}")
if proc.returncode != 0:
raise Stop(EXIT_REMOTE, f"cannot read {path} at candidate head")
return proc.stdout
def card_branch(text):
for line in text.splitlines():
match = re.match(r"^- 工作区:.*分支:([A-Za-z0-9._/-]+)", line)
if match:
return match.group(1)
return None
def card_status(text):
match = re.search(r";状态:([^;\n]*)", text)
return match.group(1).strip() if match else ""
def branch_meta(text, target):
"""分支元数据行 '- 分支类型:…;field:value…';旧卡缺省为 standalone、不接收需求。"""
match = re.search(r"^- 分支类型:([^\n]*)$", text, re.M)
if not match:
return {"kind": "standalone", "initiative_id": "", "root_target": target, "accepting": "false"}
line = match.group(1)
def field(name, default):
found = re.search(";" + name + r":([^;]*)", line)
return found.group(1).strip() if found else default
return {
"kind": line.split(";", 1)[0].strip(),
"initiative_id": field("initiative_id", ""),
"root_target": field("root_target", target),
"accepting": field("accepting_requirements", "false"),
}
def goal_excerpt(text):
lines, inside = [], False
for line in text.splitlines():
if line.startswith("## "):
if inside:
break
inside = line.strip() == "## 目标与非目标"
continue
if inside and line.strip():
lines.append(line.strip())
if len(lines) == 5:
break
return " / ".join(lines)[:300]
def inspect_branch(target, branch, head_sha, pr):
fetched = fetch_head(branch)
if fetched != head_sha:
raise Stop(EXIT_REMOTE, f"{branch} moved during discovery; retry")
matches = []
for path in task_card_paths(head_sha):
raw = blob_bytes(head_sha, path)
text = raw.decode("utf-8", "replace")
if card_branch(text) != branch:
continue
meta = branch_meta(text, target)
if meta["kind"] != "initiative" or meta["accepting"] != "true" or meta["root_target"] != target:
continue
if card_status(text).startswith("已完成"):
continue
matches.append((path, raw, text, meta))
if not matches:
return None
if len(matches) > 1:
raise Stop(EXIT_REMOTE, f"ambiguous initiative cards on {branch}")
path, raw, text, meta = matches[0]
head = as_dict(pr.get("head"))
return {
"branch": branch,
"head_sha": head_sha,
"pr_number": pr["number"],
"pr_state": pr.get("state"),
"pr_base": as_dict(pr.get("base")).get("ref"),
"pr_head_sha": head.get("sha") or "",
"card_path": path,
"card_sha256": sha256_bytes(raw),
"initiative_id": meta["initiative_id"],
"goal_excerpt": goal_excerpt(text),
}
def candidates_from(target, api, slug, token, heads):
by_branch = {}
for pr in open_pulls(api, slug, token):
if not isinstance(pr, dict) or pr.get("state") != "open":
continue
base = as_dict(pr.get("base")).get("ref")
head = as_dict(pr.get("head")).get("ref")
if base != target or not isinstance(head, str) or head == target or head not in heads:
continue
if not isinstance(pr.get("number"), int):
raise Stop(EXIT_REMOTE, "malformed pull request number")
by_branch.setdefault(head, []).append(pr)
found = []
for branch in sorted(by_branch):
if len(by_branch[branch]) != 1:
raise Stop(EXIT_REMOTE, f"ambiguous: several open PRs target {target} from {branch}")
candidate = inspect_branch(target, branch, heads[branch], by_branch[branch][0])
if candidate:
found.append(candidate)
return found
def snapshot(target):
origin = git("remote", "get-url", "origin")
if origin.returncode != 0:
raise Stop(EXIT_REMOTE, "origin remote is not configured")
origin_url = origin.stdout.decode("utf-8", "replace").strip()
heads = remote_heads()
if target not in heads:
raise Stop(EXIT_REMOTE, f"origin has no {target} branch")
slug = repo_slug(origin_url)
api = api_base()
token = read_token()
if not token:
raise Stop(EXIT_REMOTE, "no Gitea token: set GITEA_TOKEN or ~/.secrets/gitea ACCESS_TOKEN")
return {
"repo": {"origin_url": redact_url(origin_url), "api_base": redact_url(api), "repo_slug": slug},
"root_target": target,
"root_target_sha": heads[target],
"candidates": candidates_from(target, api, slug, token, heads),
}
def fingerprint(candidates):
return sorted(tuple(c[key] for key in CANDIDATE_KEYS) for c in candidates)
def check_shape(receipt):
missing = [key for key in RECEIPT_KEYS if key not in receipt]
if missing:
raise ValueError(f"missing {missing[0]}")
if not isinstance(receipt["candidates"], list):
raise ValueError("candidates")
for cand in receipt["candidates"]:
if not isinstance(cand, dict) or any(key not in cand for key in CANDIDATE_KEYS):
raise ValueError("candidate")
if not isinstance(as_dict(receipt["requirement"]).get("sha256"), str):
raise ValueError("requirement")
parse_time(receipt["discovered_at"])
def load_receipt(path, code):
try:
with open(path, "rb") as fh:
raw = fh.read()
except OSError:
raise Stop(code, f"receipt missing or unreadable: {path}")
try:
receipt = json.loads(raw.decode("utf-8"))
except ValueError:
raise Stop(code, "receipt is not valid JSON")
if not isinstance(receipt, dict) or receipt.get("schema") != SCHEMA:
raise Stop(code, f"receipt schema is not {SCHEMA}")
try:
check_shape(receipt)
except (ValueError, TypeError):
raise Stop(code, "receipt is malformed")
return receipt, raw
def write_receipt(path, receipt):
data = json.dumps(receipt, ensure_ascii=False, indent=2) + "\n"
tmp = f"{path}.tmp-{os.getpid()}"
try:
with open(tmp, "w", encoding="utf-8") as fh:
fh.write(data)
os.replace(tmp, path)
except OSError as exc:
raise Stop(EXIT_LOCAL, f"cannot write receipt {path}: {exc.strerror}")
def describe(cand):
return (f"[candidate] branch={cand['branch']} head_sha={cand['head_sha']} pr=#{cand['pr_number']} "
f"base={cand['pr_base']} card={cand['card_path']} card_sha256={cand['card_sha256']} "
f"initiative_id={cand['initiative_id'] or '无'}")
def cmd_discover(args):
text = args.requirement.strip()
if not text:
raise Stop(EXIT_USAGE, "requirement text is required")
snap = snapshot(target_branch())
now = utc_now()
receipt = {
"schema": SCHEMA,
**snap,
"requirement": {"sha256": sha256_bytes(text.encode("utf-8")),
"summary": " ".join(text.split())[:200]},
"discovered_at": now,
}
path = os.path.realpath(args.receipt)
if not snap["candidates"]:
receipt.update(discovery_verdict="NEW", final_verdict="NEW", decision={
"verdict": "NEW", "selected_initiative": None,
"rationale": "no accepting initiative", "decided_at": now})
write_receipt(args.receipt, receipt)
print(f"[verdict] NEW — 远端没有接纳中的 initiative;收据:{path}")
return 0
receipt.update(discovery_verdict="DECISION_REQUIRED", final_verdict="DECISION_REQUIRED", decision=None)
write_receipt(args.receipt, receipt)
print(f"[triage] 远端接纳中的 initiative 候选: {len(snap['candidates'])}(以 origin 为准,本地 .task 不参与)")
for cand in snap["candidates"]:
print(describe(cand))
if cand["goal_excerpt"]:
print(f"[goal] branch={cand['branch']} {cand['goal_excerpt']}")
print(f"[verdict] DECISION_REQUIRED — 须人工决策:JOIN 其一,或 NEW 并说明不适配理由;收据:{path}")
for cand in snap["candidates"]:
print(f"[decide] make task-triage-decide TRIAGE_RECEIPT={path} VERDICT=JOIN "
f"INITIATIVE={cand['branch']} REASON='<加入理由>'")
print(f"[decide] make task-triage-decide TRIAGE_RECEIPT={path} VERDICT=NEW REASON='<不适配理由>'")
return EXIT_DECISION_REQUIRED
def cmd_decide(args):
if args.verdict not in ("JOIN", "NEW"):
raise Stop(EXIT_DECIDE, "verdict must be JOIN or NEW")
receipt, _ = load_receipt(args.receipt, EXIT_DECIDE)
if receipt["discovery_verdict"] != "DECISION_REQUIRED":
raise Stop(EXIT_DECIDE, f"no decision needed: discovery verdict is {receipt['discovery_verdict']}")
reason = args.reason.strip()
if not reason:
raise Stop(EXIT_DECIDE, "--reason is required")
initiative = args.initiative.strip()
if args.verdict == "JOIN" and not initiative:
raise Stop(EXIT_DECIDE, "JOIN requires --initiative")
if args.verdict == "NEW" and initiative:
raise Stop(EXIT_DECIDE, "NEW must not select an initiative")
current = snapshot(receipt["root_target"])
if fingerprint(current["candidates"]) != fingerprint(receipt["candidates"]):
raise Stop(EXIT_DECIDE, "candidate set changed since discovery; re-run make task-triage")
if args.verdict == "JOIN" and initiative not in {c["branch"] for c in receipt["candidates"]}:
raise Stop(EXIT_DECIDE, f"{initiative} is not a discovered candidate")
receipt["decision"] = {"verdict": args.verdict, "selected_initiative": initiative or None,
"rationale": reason, "decided_at": utc_now()}
receipt["final_verdict"] = args.verdict
write_receipt(args.receipt, receipt)
print(f"[decided] verdict={args.verdict} selected_initiative={initiative or '无'} "
f"receipt={os.path.realpath(args.receipt)}")
return 0
def cmd_verify(args):
receipt, raw = load_receipt(args.receipt, EXIT_VERIFY)
final = receipt["final_verdict"]
decision = as_dict(receipt["decision"])
if final not in ("JOIN", "NEW") or decision.get("verdict") != final:
raise Stop(EXIT_VERIFY, "receipt has no final JOIN/NEW decision; run make task-triage-decide")
age = (dt.datetime.now(dt.timezone.utc) - parse_time(receipt["discovered_at"])).total_seconds()
if age > args.max_age_seconds:
raise Stop(EXIT_VERIFY, f"receipt is stale ({int(age)}s old, max {args.max_age_seconds}s); re-run make task-triage")
if age < -300:
raise Stop(EXIT_VERIFY, "receipt discovered_at is in the future")
if receipt["root_target"] != target_branch():
raise Stop(EXIT_VERIFY, "receipt root target does not match TASK_TARGET_BRANCH")
selected = decision.get("selected_initiative")
if args.kind == "child":
if final != "JOIN" or not args.initiative or args.initiative != selected:
raise Stop(EXIT_VERIFY, f"child requires a JOIN receipt selecting {args.initiative or '(none)'}")
elif final != "NEW":
raise Stop(EXIT_VERIFY, f"{args.kind} requires a NEW receipt, got {final}")
current = snapshot(receipt["root_target"])
if fingerprint(current["candidates"]) != fingerprint(receipt["candidates"]):
raise Stop(EXIT_VERIFY, "remote initiative candidates changed since triage; re-run make task-triage")
head = next((c["head_sha"] for c in receipt["candidates"] if c["branch"] == selected), None)
print(f"[triage-verified] verdict={final} selected_initiative={selected or '无'} "
f"requirement_sha256={receipt['requirement']['sha256']} receipt_sha256={sha256_bytes(raw)}")
print(f"[triage-head] selected_head_sha={head or '无'}")
return 0
def main(argv=None):
parser = argparse.ArgumentParser(prog="task_triage.py", description="远端需求分流收据")
sub = parser.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("discover")
p.add_argument("--receipt", required=True)
p.add_argument("--requirement", required=True)
p = sub.add_parser("decide")
p.add_argument("--receipt", required=True)
p.add_argument("--verdict", required=True)
p.add_argument("--initiative", default="")
p.add_argument("--reason", default="")
p = sub.add_parser("verify")
p.add_argument("--receipt", required=True)
p.add_argument("--kind", required=True, choices=("standalone", "initiative", "child"))
p.add_argument("--initiative", default="")
p.add_argument("--max-age-seconds", type=int, default=DEFAULT_MAX_AGE)
args = parser.parse_args(argv)
handlers = {"discover": cmd_discover, "decide": cmd_decide, "verify": cmd_verify}
try:
return handlers[args.cmd](args)
except Stop as stop:
if stop.code == EXIT_REMOTE:
print(f"[verdict] REMOTE_UNVERIFIED — {stop.message}")
else:
print(f"[triage] refused ({stop.code}): {stop.message}", file=sys.stderr)
return stop.code
if __name__ == "__main__":
sys.exit(main())
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""按分支改动选择最小直接检查:host 实施中的 make 目标子集(完整套件仍以 CI 为准)。
用法:test_affected.py [--base REF] [--dry-run]
base:--base,否则环境变量 BASE,否则当前分支卡片的 immediate_target(task_pr_check 助手),
再否则 origin/main;裸分支名在 origin/<name> 存在时解析为 origin/<name>。
改动路径 = `git diff <merge-base>...HEAD` ∪ 未提交的已跟踪改动(相对 HEAD)。路径到目标:
scripts/** tests/** .gitea/** policy/** Makefile AGENTS.md AGENT-EXECUTION.md PROJECT.md → test-governance
modules/{core,backend,frontend,ui} → test-{core,backend,frontend,ui};deps/agent-command → test-agentcmd;
deps/agent-{intent,native,squad} → test-libs;gitlink 本身或 .gitmodules → test-pins;
modules/** 或 scripts/lint-boundaries.sh → test-boundaries;其余(.task/**、docs/**、其他 *.md)不选。
选中任一目标时先跑 test-worktree;单次 `make <targets>` 执行,退出码随 make。
退出码:make 的退出码;无目标或 --dry-run 为 0;用法/ref 错误为 2。仅使用标准库。
"""
import argparse
import os
import subprocess
import sys
DEFAULT_BASE = "origin/main"
EXIT_USAGE = 2
GITLINK_MODE = "160000"
ORDER = ("test-governance", "test-boundaries", "test-pins", "test-libs", "test-core",
"test-backend", "test-frontend", "test-ui", "test-agentcmd")
GOVERNANCE_DIRS = ("scripts/", "tests/", ".gitea/", "policy/")
GOVERNANCE_FILES = ("Makefile", "AGENTS.md", "AGENT-EXECUTION.md", "PROJECT.md")
COMPONENTS = {
"modules/core": "test-core",
"modules/backend": "test-backend",
"modules/frontend": "test-frontend",
"modules/ui": "test-ui",
"deps/agent-command": "test-agentcmd",
"deps/agent-intent": "test-libs",
"deps/agent-native": "test-libs",
"deps/agent-squad": "test-libs",
}
class Stop(Exception):
pass
def git(*args):
proc = subprocess.run(["git", *args], capture_output=True, text=True, encoding="utf-8", errors="replace")
if proc.returncode != 0:
raise Stop(f"git {' '.join(args)} failed: {proc.stderr.strip() or proc.returncode}")
return proc.stdout
def ref_exists(ref):
return subprocess.run(["git", "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"],
capture_output=True).returncode == 0
def card_target():
"""当前分支卡片的 immediate_target;无分支/无助手/无卡时返回 None。"""
try:
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import task_pr_check
meta, text = task_pr_check.branch_meta(task_pr_check.current_branch(), "main")
except Exception:
return None
return meta.get("immediate_target") if text is not None else None
def resolve_base(requested):
name = (requested or os.environ.get("BASE", "")).strip() or card_target() or DEFAULT_BASE
if not name.startswith("origin/") and ref_exists(f"refs/remotes/origin/{name}"):
name = f"origin/{name}"
if not ref_exists(name):
raise Stop(f"base {name} does not resolve to a commit")
return name
def raw_changes(*diff_args):
"""{path: is_gitlink},取自 `git diff --raw -z --no-renames`。"""
out = git("diff", "--raw", "-z", "--no-renames", *diff_args)
fields = out.split("\0")
changes = {}
for meta, path in zip(fields[0::2], fields[1::2]):
modes = meta.lstrip(":").split()[:2]
changes[path] = changes.get(path, False) or GITLINK_MODE in modes
return changes
def changed_paths(base):
merge_base = git("merge-base", base, "HEAD").strip()
changes = raw_changes(merge_base, "HEAD")
for path, gitlink in raw_changes("HEAD").items():
changes[path] = changes.get(path, False) or gitlink
return changes
def targets_for(path, gitlink):
found = set()
if path.startswith(GOVERNANCE_DIRS) or path in GOVERNANCE_FILES:
found.add("test-governance")
for prefix, target in COMPONENTS.items():
if path == prefix or path.startswith(prefix + "/"):
found.add(target)
if gitlink or path == ".gitmodules":
found.add("test-pins")
if path.startswith("modules/") or path == "scripts/lint-boundaries.sh":
found.add("test-boundaries")
return found
def select(changes):
found = set()
for path, gitlink in changes.items():
found |= targets_for(path, gitlink)
return ["test-worktree", *(t for t in ORDER if t in found)] if found else []
def main(argv=None):
parser = argparse.ArgumentParser(prog="test_affected.py", description="按分支改动运行最小直接检查")
parser.add_argument("--base")
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args(argv)
try:
os.chdir(git("rev-parse", "--show-toplevel").strip())
base = resolve_base(args.base)
changes = changed_paths(base)
except Stop as stop:
print(f"[affected] ERROR {stop}")
return EXIT_USAGE
targets = select(changes)
print(f"[affected] base={base} changed={len(changes)} targets={','.join(targets) or 'none'}", flush=True)
if not targets:
print("[affected] nothing to test")
return 0
if args.dry_run:
print(f"[affected] dry-run: make {' '.join(targets)}")
return 0
return subprocess.run(["make", *targets]).returncode
if __name__ == "__main__":
sys.exit(main())