Merge authoritative main into the task branch

Why: include the current integration baseline before verification.

Evidence: fetched origin/main=682a4e8263fe007cb102fc9deaf71bda514ee387.

Impact: integrate upstream changes without rewriting task history.

Refs: task-sync
This commit is contained in:
2026-10-17 05:39:39 +00:00
26 changed files with 4029 additions and 90 deletions
+82 -2
View File
@@ -5,7 +5,7 @@ import ast
import re
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from pathlib import Path, PurePosixPath
from typing import Any
SCHEMA_V1 = "agent-execution/v1"
@@ -47,7 +47,17 @@ SECRET_VALUE_RE = re.compile(
r"(?i)^(bearer\s+\S+|sk-[A-Za-z0-9_\-]{8,}|[A-Fa-f0-9]{32,})$"
)
ROOT_KEYS = frozenset({"schema", "execution", "roles", "profiles", "custom_adapters"})
ROOT_KEYS = frozenset(
{"schema", "execution", "roles", "profiles", "custom_adapters", "integration"}
)
INTEGRATION_KEYS = frozenset({"exempt_paths", "transport", "admission", "cleanup"})
ENTRYPOINT_KEYS = frozenset({"command"})
# Mirrors the host-managed prefixes enforced by scripts/agent_exec.py
# (_HOST_MANAGED_PREFIXES, the enforcement source). A declared
# integration.exempt_paths must equal this set so it cannot widen host authority.
HOST_EXEMPT_PATHS = frozenset(
{".task/**", "docs/superpowers/specs/**", "docs/superpowers/plans/**"}
)
EXECUTION_KEYS = frozenset(
{
"fresh_process_per_unit",
@@ -116,6 +126,14 @@ class CustomAdapter:
output_format: str
@dataclass(frozen=True)
class IntegrationConfig:
exempt_paths: tuple[str, ...]
transport_command: str
admission_command: str
cleanup_command: str
@dataclass(frozen=True)
class ExecutionConfig:
schema: str
@@ -123,6 +141,7 @@ class ExecutionConfig:
roles: Mapping[str, tuple[str, ...]]
profiles: Mapping[str, Profile]
custom_adapters: Mapping[str, CustomAdapter]
integration: IntegrationConfig | None = None
def load_config(repo: Path) -> ExecutionConfig:
@@ -351,12 +370,17 @@ def _build_config(raw: dict[str, Any]) -> ExecutionConfig:
_validate_normal_role_adapters(profiles, roles)
_validate_normal_role_exactness(profiles, roles)
integration = None
if "integration" in raw:
integration = _build_integration(raw["integration"])
return ExecutionConfig(
schema=schema,
execution=execution,
roles=roles,
profiles=profiles,
custom_adapters=custom_adapters,
integration=integration,
)
@@ -634,6 +658,62 @@ def _build_custom_adapters(raw: Any) -> dict[str, CustomAdapter]:
return out
def _build_integration(raw: Any) -> IntegrationConfig:
if not isinstance(raw, dict):
raise ConfigError("integration must be a mapping")
unknown = set(raw) - INTEGRATION_KEYS
if unknown:
raise ConfigError(f"unknown keys in integration: {sorted(unknown)}")
missing = INTEGRATION_KEYS - set(raw)
if missing:
raise ConfigError(f"missing keys in integration: {sorted(missing)}")
exempt_paths = _exempt_paths_tuple(raw["exempt_paths"])
if set(exempt_paths) != HOST_EXEMPT_PATHS:
raise ConfigError(
"integration.exempt_paths must exactly match the host-managed set "
f"{sorted(HOST_EXEMPT_PATHS)}; got {list(exempt_paths)}"
)
return IntegrationConfig(
exempt_paths=exempt_paths,
transport_command=_entrypoint_command(raw["transport"], "integration.transport"),
admission_command=_entrypoint_command(raw["admission"], "integration.admission"),
cleanup_command=_entrypoint_command(raw["cleanup"], "integration.cleanup"),
)
def _exempt_paths_tuple(value: Any) -> tuple[str, ...]:
if not isinstance(value, list) or not value:
raise ConfigError("integration.exempt_paths must be a non-empty list")
items: list[str] = []
for item in value:
if not isinstance(item, str) or not item:
raise ConfigError("integration.exempt_paths entries must be non-empty strings")
if item.startswith("/") or ".." in PurePosixPath(item).parts:
raise ConfigError(
f"integration.exempt_paths must be relative without '..': {item!r}"
)
if item in items:
raise ConfigError(f"duplicate integration.exempt_paths entry: {item!r}")
items.append(item)
return tuple(items)
def _entrypoint_command(raw: Any, label: str) -> str:
if not isinstance(raw, dict):
raise ConfigError(f"{label} must be a mapping")
unknown = set(raw) - ENTRYPOINT_KEYS
if unknown:
raise ConfigError(f"unknown keys in {label}: {sorted(unknown)}")
missing = ENTRYPOINT_KEYS - set(raw)
if missing:
raise ConfigError(f"missing keys in {label}: {sorted(missing)}")
command = raw["command"]
if not isinstance(command, str) or not command.strip():
raise ConfigError(f"{label}.command must be a non-empty string")
_reject_shell_or_secret_value(f"{label}.command", command)
return command
def _argv_tuple(value: Any, label: str) -> tuple[str, ...]:
if not isinstance(value, list):
raise ConfigError(f"{label} must be a list")
+4 -3
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)
+46 -5
View File
@@ -20,6 +20,11 @@ SCHEMA = 1
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")
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 +116,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 +159,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 +197,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):
@@ -350,6 +389,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 +406,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))
+267 -50
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# 任务启动门禁:precheck / start / resume / sync / close / finish(配套 Makefile 同名目标)。
# 任务启动门禁:precheck / triage / start / resume / sync / close / finish(配套 Makefile 同名目标)。
# 规范依据:collaboration.md 共同基线与同步节点、治理清理;delivery.md §1/§5。
set -euo pipefail
@@ -59,7 +59,7 @@ cmd_precheck() {
card_pending "${pc##*/}" && continue
# card_merge 兼容 merge 提交与 squash 落地(squash 走 Gitea API 兜底,无 token 时只认 merge 提交);
# 其内部以完整消费输出取首行,避免 grep -q 早退令 git log 吃 SIGPIPE(141 under pipefail)。
if card_merge "$pb" >/dev/null; then
if card_merged_into "$pc" "$pb" >/dev/null; then
open_cards="${open_cards} ${pc##*/}"
fi
done
@@ -72,6 +72,9 @@ cmd_precheck() {
esac
done
[ -n "$done_open" ] && echo "[cards] 已完成但未归档:$(echo $done_open)"
local initiatives
initiatives=$(active_initiatives | cut -d'|' -f1)
echo "[initiatives] local cards (not authoritative): $([ -n "$initiatives" ] && echo $initiatives || echo none)"
now=$(date +%s)
while read -r ref ts; do
[ -n "$ref" ] || continue
@@ -142,10 +145,36 @@ cmd_start() {
need_repo
local name="${1:-}"; [ -n "$name" ] || die "usage: task.sh start <feature|fix|hotfix|release>/<slug>"
echo "$name" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || die "branch name must match (feature|fix|hotfix|release)/<slug>"
local kind="${TASK_BRANCH_KIND:-standalone}" initiative="${TASK_INITIATIVE:-}"
case "$kind" in standalone|initiative|child) ;; *) die "TASK_BRANCH_KIND must be standalone, initiative or child" ;; esac
if [ "$kind" = child ]; then
[ -n "$initiative" ] || die "child task requires INITIATIVE=<parent initiative branch>"
echo "$initiative" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || die "invalid parent initiative branch"
case "${name#*/}" in "${initiative#*/}"-?*) ;; *) die "child slug must start with parent initiative slug plus '-'" ;; esac
fi
[ -n "${TRIAGE_RECEIPT:-}" ] || die "TRIAGE_RECEIPT required: make task-triage REQ='<需求>' TRIAGE_RECEIPT=<path> and decide first (no branch created)"
fetch_target
[ -n "${PRECHECK_RECEIPT:-}" ] || die "PRECHECK_RECEIPT required: capture RECEIPT=/tmp/task-precheck.json CMD='make precheck' make evidence first"
PRECHECK_RECEIPT=$(realpath -- "$PRECHECK_RECEIPT")
VALIDATE_RECEIPT="$PRECHECK_RECEIPT" VALIDATE_RECEIPT_PURPOSE=start bash "${SCRIPT_DIR}/evidence.sh"
# 分流核验必须先于任何 worktree/分支创建:收据与当前远端候选不一致则无副作用拒绝。
[ -f "$TRIAGE_RECEIPT" ] || die "triage receipt missing: ${TRIAGE_RECEIPT} (no branch created)"
TRIAGE_RECEIPT=$(realpath -- "$TRIAGE_RECEIPT")
local -a triage_args=(verify --receipt "$TRIAGE_RECEIPT" --kind "$kind")
if [ "$kind" = child ]; then triage_args+=(--initiative "$initiative"); fi
local triage_out triage_line triage_head triage_verdict triage_init triage_rsha triage_digest triage_note
if ! triage_out=$(python3 "${SCRIPT_DIR}/task_triage.py" "${triage_args[@]}"); then
printf '%s\n' "$triage_out" >&2
die "triage receipt rejected; no worktree or branch was created"
fi
triage_line=$(printf '%s\n' "$triage_out" | sed -n 's/^\[triage-verified\] //p')
triage_head=$(triage_field selected_head_sha "$(printf '%s\n' "$triage_out" | sed -n 's/^\[triage-head\] //p')")
triage_verdict=$(triage_field verdict "$triage_line")
triage_init=$(triage_field selected_initiative "$triage_line")
triage_rsha=$(triage_field requirement_sha256 "$triage_line")
triage_digest=$(triage_field receipt_sha256 "$triage_line")
[ -n "$triage_verdict" ] && [ -n "$triage_rsha" ] && [ -n "$triage_digest" ] || die "triage verification output unparsable; no branch created"
triage_note="- 需求分流:verdict=${triage_verdict};selected_initiative=${triage_init:-无};requirement_sha256=${triage_rsha};triage_receipt=${TRIAGE_RECEIPT};receipt_sha256=${triage_digest}"
check_worktree before-sync
# 已合入的同名分支不得重建(2026-10-05 实测:合入后再 task-start 同名会从新 main 长出
# 第二段历史,空卡覆盖已合入任务卡,两轮证据差点丢失)。复核/追加沿用原分支或换新 slug。
@@ -159,10 +188,31 @@ cmd_start() {
[ "$(git status --porcelain | { grep -v '^.. \.zcode/' || true; } | wc -l)" -eq 0 ] || die "dirty tree — resolve first (precheck verdict BUSY)"
[ "$(git submodule status --recursive 2>/dev/null | { grep -cE '^\+|^U' || true; })" -eq 0 ] || die "submodule drift/conflict — resolve first (precheck verdict BUSY)"
git merge-base --is-ancestor "origin/${TARGET_BRANCH}" HEAD || die "local ${TARGET_BRANCH} behind origin — pull --ff-only first"
local rmain slug wt mainroot start_ref parent_head="" parent_note=""
local rmain slug wt mainroot start_ref parent_head="" parent_source="" parent_note=""
rmain=$(git rev-parse "origin/${TARGET_BRANCH}")
start_ref=$rmain
if [ -n "${TASK_PARENT_BRANCH:-}${TASK_PARENT_PR:-}" ]; then
slug=$(echo "$name" | tr '/' '-')
local branch_meta
case "$kind" in
initiative)
branch_meta="- 分支类型:initiative;initiative_id:${slug};root_target:${TARGET_BRANCH};immediate_target:${TARGET_BRANCH};accepting_requirements:true" ;;
child)
if [ "$(work_mode)" = delivery ]; then
# 交付模式:父 initiative 只认 origin 上刚取回的头,且必须等于分流收据核验的头(未准入的本地提交不算)。
[ -n "$triage_head" ] || die "triage receipt carries no selected initiative head; re-run make task-triage (no worktree or branch was created)"
parent_head=$(remote_parent_head "$initiative") || die "remote parent initiative ${initiative} unavailable; no worktree or branch was created"
[ "$parent_head" = "$triage_head" ] || die "remote ${initiative} is ${parent_head} but triage verified ${triage_head}; re-run make task-triage (no worktree or branch was created)"
parent_source=origin
else
parent_head=$(git rev-parse --verify "refs/heads/${initiative}^{commit}" 2>/dev/null) || die "parent initiative branch ${initiative} missing locally"
parent_source="local(非正式准入)"
fi
start_ref=$parent_head
branch_meta="- 分支类型:child;initiative_id:$(echo "$initiative" | tr '/' '-');root_target:${TARGET_BRANCH};immediate_target:${initiative};accepting_requirements:false;parent_branch:${initiative};parent_head_sha:${parent_head};parent_pr:${TASK_PARENT_PR:-无};parent_source:${parent_source}" ;;
*)
branch_meta="- 分支类型:standalone;root_target:${TARGET_BRANCH};immediate_target:${TARGET_BRANCH};accepting_requirements:false" ;;
esac
if [ "$kind" != child ] && [ -n "${TASK_PARENT_BRANCH:-}${TASK_PARENT_PR:-}" ]; then
[ -n "${TASK_PARENT_BRANCH:-}" ] && [ -n "${TASK_PARENT_PR:-}" ] || die "stacked task requires TASK_PARENT_BRANCH and TASK_PARENT_PR"
echo "$TASK_PARENT_BRANCH" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || die "invalid parent task branch"
echo "$TASK_PARENT_PR" | grep -qE '^https?://[^[:space:]]+/pulls/[0-9]+$' || die "TASK_PARENT_PR must be a parent PR URL"
@@ -172,7 +222,6 @@ cmd_start() {
start_ref=$parent_head
parent_note="- 堆叠父任务:${TASK_PARENT_BRANCH};父 PR:${TASK_PARENT_PR};parent_head_sha=${parent_head};父合入后 task-sync 并将 PR base 改回 ${TARGET_BRANCH}"
fi
slug=$(echo "$name" | tr '/' '-')
# 落点须为主检出的平级目录:$PWD 推算在子目录(或任务 worktree 内)运行时会算错,
# 以 Git 登记表定位主检出,兼容 linked worktree 与独立 git-dir。
mainroot=$(main_worktree_root)
@@ -198,6 +247,8 @@ cmd_start() {
- 权威仓:$(git -C "$wt" remote get-url origin 2>/dev/null || echo '(local)');目标分支:${TARGET_BRANCH};R0(initial_base_sha)= ${rmain}
- 工作区:${wt}(worktree);分支:${name}
${branch_meta}
${triage_note}
${parent_note}
- 授权与交付终点:<本次允许的动作与终点;未授权事项写明>
- 交付模式:goal-first(目标、范围和交付终点一次确认;范围内连续推进)
@@ -322,27 +373,57 @@ cmd_resume() {
local receipt
receipt=$(realpath -- "$PRECHECK_RECEIPT")
VALIDATE_RECEIPT="$receipt" VALIDATE_RECEIPT_PURPOSE="resume-${mode}" bash "${SCRIPT_DIR}/evidence.sh"
# 立即目标:子任务为其父 initiative(delivery 下取远端头),其余为根目标。
local branch card immediate="${TARGET_BRANCH}" observed recorded
branch=$(git rev-parse --abbrev-ref HEAD)
card=$(branch_card "$branch")
[ -n "$card" ] && immediate=$(card_immediate_target "$card")
if [ "$immediate" != "${TARGET_BRANCH}" ]; then
observed=$(resolve_parent_ref "$immediate") || die "immediate target ${immediate} unavailable; receipt not imported"
recorded=$(card_field "$card" parent_head_sha)
if [ -n "$recorded" ] && ! git merge-base --is-ancestor "$recorded" "$observed"; then
die "immediate target ${immediate} no longer contains recorded parent head ${recorded}; receipt not imported"
fi
else
observed=$(git rev-parse "origin/${TARGET_BRANCH}")
fi
IMPORT_RECEIPT="$receipt" bash "${SCRIPT_DIR}/evidence.sh"
echo "[resumed] work_mode=${mode} source_head=$(git rev-parse HEAD) observed_target=$(git rev-parse "origin/${TARGET_BRANCH}")"
echo "[resumed] work_mode=${mode} source_head=$(git rev-parse HEAD) observed_target=${observed} immediate_target=${immediate}"
}
cmd_sync() {
need_repo
fetch_target
check_worktree before-sync
local base head card branch slug pc changed=0 ancestry_status
base=$(git rev-parse "origin/${TARGET_BRANCH}")
local base head card branch changed=0 ancestry_status immediate="${TARGET_BRANCH}"
branch=$(git rev-parse --abbrev-ref HEAD)
card=$(branch_card "$branch")
[ -n "$card" ] && immediate=$(card_immediate_target "$card")
if [ "$immediate" != "${TARGET_BRANCH}" ]; then
base=$(resolve_parent_ref "$immediate")
else
base=$(git rev-parse "origin/${TARGET_BRANCH}")
fi
if git merge-base --is-ancestor "$base" HEAD; then
:
else
ancestry_status=$?
[ "$ancestry_status" -eq 1 ] || die "cannot verify target ancestry"
git merge --no-edit "$base" \
-m "Merge authoritative ${TARGET_BRANCH} into the task branch" \
-m "Why: include the current integration baseline before verification." \
-m "Evidence: fetched origin/${TARGET_BRANCH}=${base}." \
-m "Impact: integrate upstream changes without rewriting task history." \
-m "Refs: task-sync"
if [ "$immediate" != "${TARGET_BRANCH}" ]; then
git merge --no-edit "$base" \
-m "Merge parent initiative ${immediate} into the task branch" \
-m "Why: include the current parent initiative baseline before verification." \
-m "Evidence: resolved ${immediate}=${base}." \
-m "Impact: integrate parent initiative changes without rewriting task history." \
-m "Refs: task-sync"
else
git merge --no-edit "$base" \
-m "Merge authoritative ${TARGET_BRANCH} into the task branch" \
-m "Why: include the current integration baseline before verification." \
-m "Evidence: fetched origin/${TARGET_BRANCH}=${base}." \
-m "Impact: integrate upstream changes without rewriting task history." \
-m "Refs: task-sync"
fi
changed=1
fi
git merge-base --is-ancestor "$base" HEAD || die "sync incomplete"
@@ -350,19 +431,6 @@ cmd_sync() {
check_worktree materialize
check_worktree after-sync
head=$(git rev-parse HEAD)
branch=$(git rev-parse --abbrev-ref HEAD)
slug=$(echo "$branch" | tr '/' '-')
card=".task/${slug}.md"
if [ ! -f "$card" ]; then
card=""
for pc in .task/*.md; do
[ -f "$pc" ] || continue
if [ "$(card_of_branch "$pc")" = "$branch" ]; then
card=$pc
break
fi
done
fi
if [ -n "$card" ] && [ -f "$card" ]; then
if [ "$changed" -eq 1 ]; then
printf '| 同步(记录前对象) | %s | %s | %s |\n' "$base" "$head" "$(date -u +%FT%TZ)" >> "$card"
@@ -378,7 +446,7 @@ cmd_sync() {
echo "[warn] 找不到分支 ${branch} 的任务卡,同步记录未写入" >&2
fi
head=$(git rev-parse HEAD)
echo "[synced] sync_base=${base} tested_head=${head} checked_at=$(date -u +%FT%TZ) changed=${changed}"
echo "[synced] sync_base=${base} tested_head=${head} checked_at=$(date -u +%FT%TZ) changed=${changed} sync_target=${immediate}"
echo "[next] 跑正式验收(make test 等),证据写入任务卡"
}
@@ -391,19 +459,140 @@ cmd_sync() {
card_of_branch() { sed -n 's/^- 工作区:.*分支:\([A-Za-z0-9._/-]\+\).*/\1/p' "$1" | head -1; }
card_status() { sed -n 's/^.*;状态:\([^;]*\).*/\1/p' "$1" | head -1; }
# 两级分支元数据行:'- 分支类型:<kind>;initiative_id:…;root_target:…;immediate_target:…;accepting_requirements:…'。
# 旧卡没有该行:按 standalone、root/immediate 均为目标分支、不接收新需求处理。
card_field() { sed -n "s/^- 分支类型:.*;$2:\([^;]*\).*/\1/p" "$1" 2>/dev/null | head -1; }
card_branch_kind() { local v; v=$(sed -n 's/^- 分支类型:\([^;]*\).*/\1/p' "$1" 2>/dev/null | head -1); printf '%s\n' "${v:-standalone}"; }
card_initiative_id() { card_field "$1" initiative_id; }
card_root_target() { local v; v=$(card_field "$1" root_target); printf '%s\n' "${v:-${TARGET_BRANCH}}"; }
card_immediate_target() { local v; v=$(card_field "$1" immediate_target); printf '%s\n' "${v:-${TARGET_BRANCH}}"; }
card_accepting_requirements() { local v; v=$(card_field "$1" accepting_requirements); printf '%s\n' "${v:-false}"; }
branch_card() { # $1=branch → 当前检出中该分支的任务卡路径(无则空)
local pc=".task/$(echo "$1" | tr '/' '-').md"
[ -f "$pc" ] && { printf '%s\n' "$pc"; return 0; }
for pc in .task/*.md; do
[ -f "$pc" ] || continue
[ "$(card_of_branch "$pc")" = "$1" ] && { printf '%s\n' "$pc"; return 0; }
done
return 0
}
active_initiatives() { # → 每行 "branch|initiative_id|card":根目标为集成分支、未完成、仍接收需求且未合入/待闭合
local pc pb
for pc in .task/*.md; do
[ -f "$pc" ] || continue
[ "$(card_branch_kind "$pc")" = initiative ] || continue
[ "$(card_root_target "$pc")" = "${TARGET_BRANCH}" ] || continue
[ "$(card_accepting_requirements "$pc")" = true ] || continue
case "$(card_status "$pc")" in 已完成*) continue ;; esac
pb=$(card_of_branch "$pc"); [ -n "$pb" ] || continue
card_pending "${pc##*/}" && continue
card_merged_into "$pc" "$pb" >/dev/null && continue
printf '%s|%s|%s\n' "$pb" "$(card_initiative_id "$pc")" "$pc"
done
}
remote_parent_head() { # $1=父 initiative 分支 → 打印刚从 origin 取回的头(不看本地);不可查询返回 6,远端缺失返回 1
git check-ref-format "refs/heads/$1" >/dev/null || die "invalid parent branch $1"
local rc=0
git ls-remote --exit-code --heads origin "refs/heads/$1" >/dev/null 2>&1 || rc=$?
if [ "$rc" -eq 2 ]; then echo "ERROR: parent branch $1 not found on origin" >&2; return 1; fi
if [ "$rc" -ne 0 ]; then echo "[verdict] REMOTE_UNVERIFIED — cannot query origin/$1; preserve local work and retry" >&2; return 6; fi
if ! git fetch -q origin "refs/heads/$1:refs/remotes/origin/$1"; then
echo "[verdict] REMOTE_UNVERIFIED — cannot refresh origin/$1; preserve local work and retry" >&2
return 6
fi
git rev-parse --verify "refs/remotes/origin/$1^{commit}"
}
resolve_parent_ref() { # $1=父 initiative 分支 → 打印用于同步/核验的提交(delivery 仅远端;local 取远端与本地包含对方的一侧)
git check-ref-format "refs/heads/$1" >/dev/null || die "invalid parent branch $1"
if [ "$(work_mode)" = delivery ]; then
# delivery 只认 origin 上刚取回的父分支头:本地领先/分叉意味着未准入的提交,不得被同步或合入。
local remote_head local_head
remote_head=$(remote_parent_head "$1") || return $?
local_head=$(git rev-parse -q --verify "refs/heads/$1^{commit}" 2>/dev/null || true)
if [ -n "$local_head" ] && ! git merge-base --is-ancestor "$local_head" "$remote_head"; then
die "local $1 has commits origin lacks; push or reconcile them first (delivery mode uses origin only)"
fi
printf '%s\n' "$remote_head"
return 0
fi
local remote="" local_ref="" rc=0
git ls-remote --exit-code --heads origin "refs/heads/$1" >/dev/null 2>&1 || rc=$?
if [ "$rc" -eq 0 ]; then
if ! git fetch -q origin "refs/heads/$1:refs/remotes/origin/$1"; then
echo "[verdict] REMOTE_UNVERIFIED — cannot refresh origin/$1; preserve local work and retry" >&2
return 6
fi
remote=$(git rev-parse --verify "refs/remotes/origin/$1^{commit}")
elif [ "$rc" -ne 2 ]; then
echo "[verdict] REMOTE_UNVERIFIED — cannot query origin/$1; preserve local work and retry" >&2
return 6
fi
local_ref=$(git rev-parse -q --verify "refs/heads/$1^{commit}" 2>/dev/null || true)
if [ -n "$remote" ] && [ -n "$local_ref" ]; then
if git merge-base --is-ancestor "$remote" "$local_ref"; then printf '%s\n' "$local_ref"
elif git merge-base --is-ancestor "$local_ref" "$remote"; then printf '%s\n' "$remote"
else die "parent branch $1 diverged between local and origin; reconcile it first"
fi
elif [ -n "$remote" ]; then printf '%s\n' "$remote"
elif [ -n "$local_ref" ]; then printf '%s\n' "$local_ref"
else die "parent branch $1 not found locally or on origin"
fi
}
# 需求分流以 origin 为准(scripts/task_triage.py):远端 open PR + 远端分支卡片;本地 .task/ 不参与发现。
# 计数不自动选择 JOIN;候选存在时必须经 triage-decide 记录显式决策。
cmd_triage() {
need_repo
[ -n "${TRIAGE_RECEIPT:-}" ] || die "TRIAGE_RECEIPT required: make task-triage REQ='<需求>' TRIAGE_RECEIPT=/tmp/<name>.json"
local req="${*:-}"
[ -n "$req" ] || die "requirement text required: make task-triage REQ='<需求>' TRIAGE_RECEIPT=<path>"
python3 "${SCRIPT_DIR}/task_triage.py" discover --receipt "$TRIAGE_RECEIPT" --requirement "$req"
}
cmd_triage_decide() {
need_repo
[ -n "${TRIAGE_RECEIPT:-}" ] || die "TRIAGE_RECEIPT required: make task-triage-decide TRIAGE_RECEIPT=<path> VERDICT=JOIN|NEW REASON='<理由>'"
local -a args=(decide --receipt "$TRIAGE_RECEIPT" --verdict "${VERDICT:-}" --reason "${REASON:-}")
if [ -n "${INITIATIVE:-}" ]; then args+=(--initiative "$INITIATIVE"); fi
python3 "${SCRIPT_DIR}/task_triage.py" "${args[@]}"
}
# triage_field KEY "key=value key2=value2" → value(值不含空格)
triage_field() { printf '%s\n' "$2" | tr ' ' '\n' | sed -n "s/^$1=//p"; }
card_merge() { # $1=branch → "sha|subject|pr"(该分支合入 origin/<target> 的记录;兼容 merge 提交与 squash 落地)
local hit pr
hit=$(git log --merges --format='%H|%s' "origin/${TARGET_BRANCH}" 2>/dev/null \
| grep -F " from $1 into ${TARGET_BRANCH}" | sed -n '1p') || hit=""
hit=$(git log --merges --format='%H|%s' "origin/$2" 2>/dev/null \
| grep -F " from $1 into $3" | sed -n '1p') || hit=""
if [ -n "$hit" ]; then
pr=$(printf '%s' "$hit" | sed -n 's/.*(#\([0-9]\+\)) from.*/\1/p')
printf '%s|%s\n' "$hit" "${pr:-unknown}"
return 0
fi
card_merge_squash "$1"
card_merge_squash "$1" "$2"
}
# 卡片闭合判定:子任务优先核直接目标(父 initiative 仍在远端);父已合入并删除则回落到根目标历史。
# 打印 "目标分支|merge_sha|subject|pr";未合入或不可核则失败。参数:$1=card $2=branch
card_merged_into() {
local card="$1" branch="$2" imm root rc=0 hit
imm=$(card_immediate_target "$card"); root=$(card_root_target "$card")
if [ "$imm" != "$root" ]; then
git ls-remote --exit-code --heads origin "refs/heads/${imm}" >/dev/null 2>&1 || rc=$?
case "$rc" in
0)
git fetch -q origin "refs/heads/${imm}:refs/remotes/origin/${imm}" 2>/dev/null || return 1
hit=$(card_merge "$branch" "$imm" "$imm") || return 1
printf '%s|%s\n' "$imm" "$hit"
return 0 ;;
2) ;; # 父 initiative 已不在远端:子任务合入记录仍在根目标历史中
*) return 1 ;;
esac
fi
hit=$(card_merge "$branch" "$root" "$imm") || return 1
printf '%s|%s\n' "$root" "$hit"
}
card_merge_squash() { # $1=branch → squash/rebase 落地没有 merge 提交,经 Gitea API 按 head 分支找 merged PR。
card_merge_squash() { # $1=branch $2=ref → squash/rebase 落地没有 merge 提交,经 Gitea API 按 head 分支找 merged PR。
# 返回 "merge_commit_sha|PR标题|PR号";sha 必须可核为 origin/<target> 祖先,否则不认(不可核即不闭合)。
# 仅查最近 5 页(250 条):card_merge 的 merge 提交路径仍是主路径,此兜底面向新近任务卡。
local token="${GITEA_TOKEN:-}" web_base url repo owner api payload pg found sha
@@ -444,7 +633,7 @@ for p in entries if isinstance(entries, list) else []:
done
[ -n "$found" ] || return 1
sha="${found%%|*}"
git merge-base --is-ancestor "$sha" "origin/${TARGET_BRANCH}" 2>/dev/null || return 1
git merge-base --is-ancestor "$sha" "origin/$2" 2>/dev/null || return 1
printf '%s\n' "$found"
}
card_pending() { # $1=卡片文件名——闭合是否已在某 origin/feature/card-close-* 分支待合入
@@ -454,10 +643,10 @@ card_pending() { # $1=卡片文件名——闭合是否已在某 origin/feature/
done
return 1
}
card_backfill() { # $1=card $2=pr_num $3=merge_sha $4=merge_subject $5=web_base(原地改写)
python3 - "$1" "$2" "$3" "$4" "$5" <<'PY'
card_backfill() { # $1=card $2=pr_num $3=merge_sha $4=merge_subject $5=web_base $6=contained_in_target(原地改写)
python3 - "$1" "$2" "$3" "$4" "$5" "$6" <<'PY'
import re, sys
path, pr, sha, subject, base = sys.argv[1:6]
path, pr, sha, subject, base, target = sys.argv[1:7]
s = open(path, encoding='utf-8').read()
s = re.sub(r'状态:[^;\n]*', '状态:已完成(回填)', s, count=1)
m = re.search(r'任务主记录:([^;\n]*)', s)
@@ -468,7 +657,7 @@ s = s.rstrip('\n') + f'''
## 闭合记录(card-autoclose 回填)
- 依据:merge {sha}(PR #{pr})已包含于 origin/main,分支成果已落盘;本段为合入后回填。
- 依据:merge {sha}(PR #{pr})已包含于 origin/{target},分支成果已落盘;本段为合入后回填。
- 合并主题:{subject}
- 归档:随闭合移入 .task/archive/;任务执行期的过程证据见原任务记录与 PR。
'''
@@ -487,8 +676,13 @@ cmd_close() {
local url owner repo
url=$(git remote get-url origin); url="${url%.git}"
repo=$(basename "$url"); owner=$(basename "$(dirname "$url")")
local branch c b st m cards=""
local branch c b st m cards="" own pr_base="${TARGET_BRANCH}"
branch=$(git rev-parse --abbrev-ref HEAD)
if [ "$fold" = 1 ]; then
# 任务分支内折叠:PR 的 base 必须是本分支的立即目标(子任务为父 initiative,而非 main)。
own=$(branch_card "$branch")
if [ -n "$own" ]; then pr_base=$(card_immediate_target "$own"); fi
fi
for c in .task/*.md; do
[ -f "$c" ] || continue
b=$(card_of_branch "$c"); [ -n "$b" ] || continue
@@ -499,7 +693,7 @@ cmd_close() {
echo "[close] ${c##*/} 已有待合入的闭合分支,跳过(等其 PR 合入)"
continue
fi
m=$(card_merge "$b") || m=""
m=$(card_merged_into "$c" "$b") || m=""
if [ -z "$m" ]; then
[ "$done_unarchived" = 1 ] || continue
m="ARCHIVE"
@@ -518,9 +712,10 @@ $c|$b|$m"
elif [ "$fold" != "1" ]; then
die "close 在任务分支内须加 --fold(并入当前任务 PR);或到主检出运行(将建 feature/card-close-* 分支+PR)"
fi
local n=0 sha subject pr closed_list=""
local n=0 sha subject pr tgt closed_list=""
while IFS='|' read -r c b m; do
[ -n "$c" ] || continue
tgt="${m%%|*}"; m="${m#*|}"
if [ "$m" = "ARCHIVE" ]; then
mkdir -p .task/archive
git mv "$c" ".task/archive/${c##*/}"
@@ -530,10 +725,10 @@ $c|$b|$m"
fi
sha="${m%%|*}"; rest="${m#*|}"; pr="${rest##*|}"; subject="${rest%|*}"
case "$pr" in "$rest"|''|*[!0-9]*) pr=unknown ;; esac
card_backfill "$c" "$pr" "$sha" "$subject" "${web_base}/${owner}/${repo}"
card_backfill "$c" "$pr" "$sha" "$subject" "${web_base}/${owner}/${repo}" "$tgt"
mkdir -p .task/archive
git mv "$c" ".task/archive/${c##*/}"
closed_list="$closed_list ${c##*/}(PR#${pr})"
closed_list="$closed_list ${c##*/}(PR#${pr} into ${tgt})"
n=$((n+1))
done <<EOF
$cards
@@ -541,8 +736,8 @@ EOF
local msg; msg=$(mktemp)
{ echo "Close ${n} merged task card(s) and archive them"
echo
echo "Why: branches merged into ${TARGET_BRANCH} had left their task cards open; backfill status and archive to prevent bookkeeping debt."
echo "Evidence: $(echo $closed_list) — merges verified via git log --merges plus Gitea API squash lookup against origin/${TARGET_BRANCH}."
echo "Why: branches merged into their immediate or root target had left their task cards open; backfill status and archive to prevent bookkeeping debt."
echo "Evidence: $(echo $closed_list) — merges verified via git log --merges plus Gitea API squash lookup against each card's origin target."
echo "Impact: .task/*.md moved to .task/archive/ with status backfilled; no code or submodule change."
echo "Refs: task.sh close (card-autoclose-r1)"
} > "$msg"
@@ -560,7 +755,7 @@ EOF
for attempt in 1 2 3 4; do
resp=$(curl -sS -m 25 -X POST -H "Authorization: token ${GITEA_TOKEN}" -H 'Content-Type: application/json' \
"$api" \
-d "{\"head\":\"${headbr}\",\"base\":\"${TARGET_BRANCH}\",\"title\":\"Close merged task cards (${headbr})\",\"body\":\"Auto-generated by task.sh close; see commit message for evidence.\"}" 2>&1) || resp=""
-d "{\"head\":\"${headbr}\",\"base\":\"${pr_base}\",\"title\":\"Close merged task cards (${headbr})\",\"body\":\"Auto-generated by task.sh close; see commit message for evidence.\"}" 2>&1) || resp=""
pr_url=$(printf '%s' "$resp" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("html_url","") if isinstance(d,dict) else "")' 2>/dev/null || true)
[ -n "$pr_url" ] && break
echo "[close] 建 PR 第 ${attempt} 次未成功${resp:+($(printf '%s' "$resp" | head -c 120))},退避重试" >&2
@@ -594,6 +789,13 @@ cmd_finish() {
head=$(git rev-parse HEAD)
mainrepo=$(main_worktree_root)
fetch_target
local card immediate="${TARGET_BRANCH}" merged_into
card=$(branch_card "$branch")
[ -n "$card" ] && immediate=$(card_immediate_target "$card")
merged_into="origin/${TARGET_BRANCH}"
if [ "$immediate" != "${TARGET_BRANCH}" ] && [ "$abandon" != "--abandon" ]; then
merged_into=$(resolve_parent_ref "$immediate")
fi
if [ "$abandon" = "--abandon" ]; then
local reason="${2:-}"; [ -n "$reason" ] || die "--abandon requires a reason"
# An explicit abandon removes only the clean checkout; commits stay reachable.
@@ -602,15 +804,19 @@ cmd_finish() {
elif [ -n "$abandon" ]; then
die "usage: task.sh finish [--abandon <reason>]"
else
git merge-base --is-ancestor "$head" "origin/${TARGET_BRANCH}" || die "branch has not merged into origin/${TARGET_BRANCH}; preserve it or explicitly abandon the checkout"
git merge-base --is-ancestor "$head" "$merged_into" || die "branch has not merged into ${immediate}; preserve it or explicitly abandon the checkout"
remote_status=$(git ls-remote --heads origin "refs/heads/${branch}") || die "cannot verify remote task branch; preserve workspace"
if [ -n "$remote_status" ]; then
# Refresh its object, then use that exact observation as a deletion lease.
git fetch -q origin "refs/heads/${branch}" || die "cannot fetch remote task branch; preserve workspace"
remote_head=$(git rev-parse --verify FETCH_HEAD)
git merge-base --is-ancestor "$remote_head" "origin/${TARGET_BRANCH}" || die "remote task branch has unmerged work; preserve workspace and remote branch"
git merge-base --is-ancestor "$remote_head" "$merged_into" || die "remote task branch has unmerged work; preserve workspace and remote branch"
fi
if [ "$immediate" != "${TARGET_BRANCH}" ]; then
echo "[verified] ${branch} is included in parent initiative ${immediate}"
else
echo "[verified] ${branch} is included in origin/${TARGET_BRANCH}"
fi
echo "[verified] ${branch} is included in origin/${TARGET_BRANCH}"
fi
# Re-check after fetch. --force only compensates Git's gitlink limitation after all
# registered-root, owner, lock, recursive dirt and ignored-file checks have passed.
@@ -623,9 +829,18 @@ cmd_finish() {
die "Git refused worktree removal; workspace and branch retained. Owner must inspect the refusal; no rm fallback or additional force level is permitted."
fi
cd "$mainrepo"
local deleted=1
if [ "$abandon" = "--abandon" ]; then
:
elif [ "$immediate" != "${TARGET_BRANCH}" ]; then
# git branch -d compares with the main checkout HEAD; the child is merged into its parent instead.
git update-ref -d "refs/heads/${branch}" "$head" && deleted=0
else
git branch -d "$branch" && deleted=0
fi
if [ "$abandon" = "--abandon" ]; then
echo "[recycled] worktree removed; local branch=${branch} retained; remote=NOT_RUN"
elif git branch -d "$branch"; then
elif [ "$deleted" -eq 0 ]; then
if [ -z "$remote_head" ]; then
echo "[recycled] worktree and local branch=${branch} removed; remote=ALREADY_ABSENT"
elif git push -q "--force-with-lease=refs/heads/${branch}:${remote_head}" origin ":refs/heads/${branch}"; then
@@ -644,10 +859,12 @@ cmd_finish() {
case "${1:-}" in
precheck) shift; cmd_precheck "$@" ;;
triage) shift; cmd_triage "$@" ;;
triage-decide) shift; cmd_triage_decide "$@" ;;
start) shift; cmd_start "$@" ;;
resume) shift; cmd_resume "$@" ;;
sync) shift; cmd_sync "$@" ;;
close) shift; cmd_close "$@" ;;
finish) shift; cmd_finish "$@" ;;
*) echo "usage: task.sh <precheck|start|resume|sync|close|finish> [args]"; exit 2 ;;
*) echo "usage: task.sh <precheck|triage|triage-decide|start|resume|sync|close|finish> [args]"; exit 2 ;;
esac
+291
View File
@@ -0,0 +1,291 @@
#!/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/ 顶层取卡片,核对 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, http_get_json,
open_pulls, read_token, repo_slug, target_branch)
EXIT_FAILED = 1
EXIT_USAGE = 2
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 find_card(branch):
"""只看检出树的 .task/ 顶层(archive 不参与);返回 (路径, 文本),无卡返回 (None, None)。"""
found = []
for path in sorted(Path(".task").glob("*.md")):
text = path.read_text(encoding="utf-8", errors="replace")
if card_branch(text) == branch:
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, 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())
+505
View File
@@ -0,0 +1,505 @@
#!/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"
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):
super().__init__(message)
self.code = code
self.message = message
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 read_token():
token = os.environ.get("GITEA_TOKEN", "").strip()
if token:
return token
try:
with open(os.path.expanduser(SECRETS_FILE), encoding="utf-8") as fh:
for raw in fh:
line = raw.strip()
if line.startswith("- "):
line = line[2:].strip()
if line.startswith("ACCESS_TOKEN="):
return line.split("=", 1)[1].strip() or None
except (OSError, UnicodeDecodeError):
return None
return None
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):
request = urllib.request.Request(
url, headers={"Accept": "application/json", "Authorization": f"token {token}"})
try:
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}")
except (urllib.error.URLError, http.client.HTTPException, OSError) as exc:
raise Stop(EXIT_REMOTE, f"Gitea API unreachable ({type(exc).__name__})")
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())