diff --git a/AGENT.md b/AGENT.md --- a/AGENT.md +++ b/AGENT.md @@ -28,9 +28,10 @@ Before assigned repository work — and always when running autonomously — take a [tick](wiki/process/tick.md): run `tools/tick-brief.sh`, work the queues in -order (`decision-made` harvest, then the findings queue, then a fresh audit), -and act on exactly one violation, contradiction, question, bug, or -insecurity. The [tick ledger](wiki/process/tick-ledger.md) steers fresh +order (`decision-made` harvest, one eligible current-agent active arc, the +findings queue, then a fresh audit), and act on exactly one violation, +contradiction, question, bug, or insecurity. The +[tick ledger](wiki/process/tick-ledger.md) steers fresh audits to the stalest slice and holds surplus findings, so the corpus is covered without pretending one session can reread everything. diff --git a/tools/heartbeat.sh b/tools/heartbeat.sh --- a/tools/heartbeat.sh +++ b/tools/heartbeat.sh @@ -3,7 +3,8 @@ # Binding: wiki/process/agent-scale.md slice G. # # Usage: -# tools/heartbeat.sh start [--worktree path] [--phase text] +# tools/heartbeat.sh start [--worktree path] [--phase text] \ +# [--arc-name text --arc-outcome text --arc-done-when text] # tools/heartbeat.sh phase # tools/heartbeat.sh end [--status ok|fail] # @@ -18,7 +19,11 @@ */.git) root=${common%/.git} ;; esac fi -RUNS_DIR=${MISALIGNED_RUNS_DIR:-"$root/.agents/runs"} +RUNS_DIR=${MISALIGNED_RUNS_DIR:-.agents/runs} +case "$RUNS_DIR" in + /*) ;; + *) RUNS_DIR="$root/$RUNS_DIR" ;; +esac usage() { sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//' @@ -35,44 +40,200 @@ now() { date -u +"%Y-%m-%dT%H:%M:%SZ"; } +mutate_status() { + python3 - "$@" <<'PY' +import fcntl +import json +import os +from pathlib import Path +import sys +import tempfile + +ARC_FIELDS = ("arc_name", "arc_outcome", "arc_done_when") + + +def load(path: Path) -> dict: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"cannot read {path}: {exc}") from exc + if not isinstance(payload, dict): + raise ValueError(f"{path} does not contain a JSON object") + return payload + + +def atomic_write(path: Path, payload: dict) -> None: + fd, temporary = tempfile.mkstemp(prefix=".status.", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(payload, handle, separators=(",", ":")) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + finally: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + + +def complete_arc(payload: dict) -> tuple[str, str, str] | None: + present = [field in payload for field in ARC_FIELDS] + if not any(present): + return None + values = tuple(payload.get(field) for field in ARC_FIELDS) + if not all(present) or not all(isinstance(value, str) and value.strip() for value in values): + raise ValueError("existing arc metadata is partial or empty") + return values + + +def main() -> None: + operation, raw_path, raw_events, *args = sys.argv[1:] + path = Path(raw_path) + events = Path(raw_events) + lock_path = path.with_suffix(path.suffix + ".lock") + with lock_path.open("a+", encoding="utf-8") as lock: + fcntl.flock(lock.fileno(), fcntl.LOCK_EX) + if operation == "start": + ( + item_id, + pid, + worktree, + phase, + timestamp, + agent_id, + arc_name, + arc_outcome, + arc_done_when, + ) = args + requested_arc = (arc_name, arc_outcome, arc_done_when) if arc_name else None + if path.exists() and load(path).get("status") == "running": + payload = load(path) + if payload.get("id") != item_id: + raise ValueError(f"running record declares id {payload.get('id', '?')}, not {item_id}") + owner = str(payload.get("agent_id", "")) + if owner and agent_id != owner: + raise ValueError( + f"running heartbeat {item_id} belongs to agent {owner}; start cannot transfer ownership" + ) + existing_worktree = str(payload.get("worktree", "")) + if existing_worktree and worktree and existing_worktree != worktree: + raise ValueError( + f"running heartbeat {item_id} is linked to {existing_worktree}; start cannot retarget it" + ) + existing_arc = complete_arc(payload) + if existing_arc and requested_arc and existing_arc != requested_arc: + raise ValueError(f"running heartbeat {item_id} already belongs to arc {existing_arc[0]}") + if existing_arc is None and requested_arc: + payload.update(zip(ARC_FIELDS, requested_arc)) + payload.update( + pid=int(pid), + agent_id=owner or agent_id, + worktree=existing_worktree or worktree, + phase=phase, + updated_at=timestamp, + status="running", + ) + else: + payload = { + "id": item_id, + "pid": int(pid), + "agent_id": agent_id, + "worktree": worktree, + "phase": phase, + "started_at": timestamp, + "updated_at": timestamp, + "status": "running", + } + if requested_arc: + payload.update(zip(ARC_FIELDS, requested_arc)) + event = f"{timestamp} start phase={phase} pid={pid}\n" + elif operation == "phase": + pid, phase, timestamp, agent_id = args + payload = load(path) + owner = str(payload.get("agent_id", "")) + if owner and agent_id != owner: + raise ValueError( + f"running heartbeat {payload.get('id', path.parent.name)} belongs to agent {owner}; phase cannot transfer ownership" + ) + if payload.get("status") != "running": + raise ValueError( + f"heartbeat {payload.get('id', path.parent.name)} is {payload.get('status', '?')}; phase cannot revive it" + ) + complete_arc(payload) + payload.update(pid=int(pid), phase=phase, updated_at=timestamp) + event = f"{timestamp} phase={phase}\n" + elif operation == "end": + pid, status, timestamp, agent_id = args + payload = load(path) + owner = str(payload.get("agent_id", "")) + if owner and agent_id != owner: + raise ValueError( + f"running heartbeat {payload.get('id', path.parent.name)} belongs to agent {owner}; end cannot transfer ownership" + ) + if payload.get("status") != "running": + raise ValueError( + f"heartbeat {payload.get('id', path.parent.name)} is already {payload.get('status', '?')}" + ) + complete_arc(payload) + payload.update(pid=int(pid), updated_at=timestamp, status=status) + event = f"{timestamp} end status={status}\n" + else: + raise ValueError(f"unknown mutation {operation}") + atomic_write(path, payload) + with events.open("a", encoding="utf-8") as handle: + handle.write(event) + + +try: + main() +except (OSError, ValueError, json.JSONDecodeError) as exc: + print(f"FAIL: {exc}", file=sys.stderr) + raise SystemExit(1) +PY +} + cmd_start() { local id="${1:-}"; shift || true [ -n "$id" ] || usage local worktree="" phase="start" + local arc_name="" arc_outcome="" arc_done_when="" + local arc_options=0 while [ $# -gt 0 ]; do case "$1" in --worktree) shift; worktree="${1:-}"; shift || true ;; --phase) shift; phase="${1:-start}"; shift || true ;; + --arc-name) arc_options=$((arc_options + 1)); shift; arc_name="${1:-}"; shift || true ;; + --arc-outcome) arc_options=$((arc_options + 1)); shift; arc_outcome="${1:-}"; shift || true ;; + --arc-done-when) arc_options=$((arc_options + 1)); shift; arc_done_when="${1:-}"; shift || true ;; *) usage ;; esac done - local dir + if [ "$arc_options" -ne 0 ] && { + [ "$arc_options" -ne 3 ] || [ -z "$arc_name" ] || [ -z "$arc_outcome" ] || [ -z "$arc_done_when" ]; + }; then + echo "FAIL: arc metadata requires --arc-name, --arc-outcome, and --arc-done-when together" >&2 + exit 2 + fi + local dir timestamp dir=$(run_dir "$id") mkdir -p "$dir" - cat > "$dir/status.json" <> "$dir/events.ndjson" + timestamp=$(now) + mutate_status start "$dir/status.json" "$dir/events.ndjson" "$id" "$$" \ + "$worktree" "$phase" "$timestamp" "${AGENT_ID:-}" \ + "$arc_name" "$arc_outcome" "$arc_done_when" echo "heartbeat: started $id -> $dir" } cmd_phase() { local id="${1:-}" phase="${2:-}" [ -n "$id" ] && [ -n "$phase" ] || usage - local dir + local dir timestamp dir=$(run_dir "$id") - mkdir -p "$dir" - local started worktree - if [ -f "$dir/status.json" ]; then - started=$(grep -o '"started_at":"[^"]*"' "$dir/status.json" | head -1 | sed 's/.*"started_at":"//;s/"$//') - worktree=$(grep -o '"worktree":"[^"]*"' "$dir/status.json" | head -1 | sed 's/.*"worktree":"//;s/"$//' || true) - fi - started=${started:-$(now)} - worktree=${worktree:-} - cat > "$dir/status.json" <> "$dir/events.ndjson" + [ -f "$dir/status.json" ] || { echo "no heartbeat: $id" >&2; exit 1; } + timestamp=$(now) + mutate_status phase "$dir/status.json" "$dir/events.ndjson" "$$" "$phase" "$timestamp" "${AGENT_ID:-}" echo "heartbeat: $id -> $phase" } @@ -86,20 +247,12 @@ *) usage ;; esac done - local dir + case "$status" in ok|fail) ;; *) echo "FAIL: status must be ok or fail" >&2; exit 2 ;; esac + local dir timestamp dir=$(run_dir "$id") - mkdir -p "$dir" - local started phase worktree - started=$(now); phase=end; worktree="" - if [ -f "$dir/status.json" ]; then - started=$(grep -o '"started_at":"[^"]*"' "$dir/status.json" | head -1 | sed 's/.*"started_at":"//;s/"$//' || echo "$started") - phase=$(grep -o '"phase":"[^"]*"' "$dir/status.json" | head -1 | sed 's/.*"phase":"//;s/"$//' || echo end) - worktree=$(grep -o '"worktree":"[^"]*"' "$dir/status.json" | head -1 | sed 's/.*"worktree":"//;s/"$//' || true) - fi - cat > "$dir/status.json" <> "$dir/events.ndjson" + [ -f "$dir/status.json" ] || { echo "no heartbeat: $id" >&2; exit 1; } + timestamp=$(now) + mutate_status end "$dir/status.json" "$dir/events.ndjson" "$$" "$status" "$timestamp" "${AGENT_ID:-}" echo "heartbeat: ended $id ($status)" } diff --git a/tools/project-status.py b/tools/project-status.py --- a/tools/project-status.py +++ b/tools/project-status.py @@ -6,11 +6,16 @@ import argparse from datetime import datetime import json +import os import subprocess +import sys import time from pathlib import Path import work_orders + + +ARC_FIELDS = ("arc_name", "arc_outcome", "arc_done_when") def run(cmd: list[str], cwd: Path, timeout: int = 8) -> tuple[int, str]: @@ -125,10 +130,12 @@ for path in sorted(directory.glob("*/status.json")): try: row = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(row, dict): + raise ValueError("run record must be a JSON object") row["file"] = str(path) row["record_name"] = path.parent.name rows.append(row) - except (OSError, json.JSONDecodeError) as exc: + except (OSError, json.JSONDecodeError, ValueError) as exc: rows.append( { "file": str(path), @@ -215,6 +222,16 @@ if status not in run_statuses: errors.append(f"run {item_id} has invalid status {status or '?'}") continue + arc_values = [run_row.get(field) for field in ARC_FIELDS] + arc_present = [field in run_row for field in ARC_FIELDS] + if any(arc_present) and not ( + all(arc_present) + and all(isinstance(value, str) and value.strip() for value in arc_values) + ): + errors.append( + f"run {item_id} arc metadata must declare non-empty " + + ", ".join(ARC_FIELDS) + ) updated = parse_time(run_row.get("updated_at")) if updated is None: errors.append(f"run {item_id} has invalid updated_at") @@ -277,6 +294,41 @@ } +def active_arc_rows( + runs: list[dict], worktrees: list[dict], agent_id: str +) -> list[dict]: + """Return consistency-valid running arcs owned by this agent.""" + if not agent_id.strip(): + return [] + registered = { + str(Path(row["path"]).resolve()) + for row in worktrees + if isinstance(row, dict) and row.get("path") and not row.get("error") + } + eligible = [] + for row in runs: + item_id = row.get("id") + record_name = row.get("record_name") + arc_values = [row.get(field) for field in ARC_FIELDS] + worktree = row.get("worktree") + if ( + isinstance(item_id, str) + and item_id + and record_name == item_id + and not row.get("parse_error") + and row.get("status") == "running" + and parse_time(row.get("updated_at")) is not None + and row.get("agent_id") == agent_id + and all( + isinstance(value, str) and value.strip() for value in arc_values + ) + and isinstance(worktree, str) + and str(Path(worktree).resolve()) in registered + ): + eligible.append(row) + return eligible + + def collect(root: Path, offline: bool) -> dict: root = root.resolve() primary = primary_root(root) @@ -291,30 +343,217 @@ for rows in lanes.values(): rows.sort(key=lambda row: (row["priority"] or 9999, row["path"])) next_lane = lanes["current"][0] if lanes["current"] else None + runs_dir = Path(os.environ.get("MISALIGNED_RUNS_DIR", ".agents/runs")) + if not runs_dir.is_absolute(): + runs_dir = primary / runs_dir payload = { "root": str(root), "primary_root": str(primary), - "recommended_next": next_lane, "work_orders": lanes, - "runs": run_files(primary / ".agents/runs"), + "runs": run_files(runs_dir), "worktrees": worktree_rows(primary), "issues": issues(primary, offline), "freshness": freshness(root, specs), } payload["consistency"] = consistency(payload) + payload["active_arcs"] = active_arc_rows( + payload["runs"], payload["worktrees"], os.environ.get("AGENT_ID", "") + ) + payload["ordinary_fallback"] = next_lane + decision_made = any( + any(label.get("name") == "decision-made" for label in item.get("labels", [])) + for item in payload["issues"]["items"] + ) + recommendation_blocked = ( + not payload["issues"]["available"] + or decision_made + or bool(payload["active_arcs"]) + ) + payload["recommended_next"] = None if recommendation_blocked else next_lane return payload + + +def validate_snapshot(payload: dict) -> None: + """Reject malformed captured dashboards at the stdin trust boundary.""" + + def mapping(value: object, location: str) -> dict: + if not isinstance(value, dict): + raise ValueError(f"{location} must be an object") + return value + + def sequence(value: object, location: str) -> list: + if not isinstance(value, list): + raise ValueError(f"{location} must be an array") + return value + + def text(value: object, location: str) -> None: + if not isinstance(value, str): + raise ValueError(f"{location} must be a string") + + def nonempty_text(value: object, location: str) -> None: + text(value, location) + if not value.strip(): + raise ValueError(f"{location} must not be empty") + + def work_order(value: object, location: str) -> None: + row = mapping(value, location) + priority = row.get("priority") + if not isinstance(priority, int) or isinstance(priority, bool): + raise ValueError(f"{location}.priority must be an integer") + for field in ("task", "status", "class"): + text(row.get(field), f"{location}.{field}") + blockers = sequence(row.get("active_blockers"), f"{location}.active_blockers") + for index, blocker in enumerate(blockers): + text(blocker, f"{location}.active_blockers[{index}]") + + work_orders = mapping(payload.get("work_orders"), "work_orders") + for lane in ("current", "held", "staged"): + rows = sequence(work_orders.get(lane), f"work_orders.{lane}") + for index, row in enumerate(rows): + work_order(row, f"work_orders.{lane}[{index}]") + + for field in ("recommended_next", "ordinary_fallback"): + row = payload.get(field) + if row is not None: + work_order(row, field) + + runs = sequence(payload.get("runs"), "runs") + for index, value in enumerate(runs): + run_row = mapping(value, f"runs[{index}]") + for field in ("id", "status", "phase"): + nonempty_text(run_row.get(field), f"runs[{index}].{field}") + present = [field in run_row for field in ARC_FIELDS] + if any(present): + if not all(present): + raise ValueError( + f"runs[{index}] arc metadata must declare " + + ", ".join(ARC_FIELDS) + ) + for field in ARC_FIELDS: + nonempty_text(run_row.get(field), f"runs[{index}].{field}") + + active_arcs = sequence(payload.get("active_arcs"), "active_arcs") + for index, value in enumerate(active_arcs): + arc = mapping(value, f"active_arcs[{index}]") + for field in ("id", "phase", "worktree", *ARC_FIELDS): + nonempty_text(arc.get(field), f"active_arcs[{index}].{field}") + + worktrees = sequence(payload.get("worktrees"), "worktrees") + for index, value in enumerate(worktrees): + row = mapping(value, f"worktrees[{index}]") + if "error" in row: + text(row["error"], f"worktrees[{index}].error") + continue + for field in ("branch", "path"): + text(row.get(field), f"worktrees[{index}].{field}") + if not isinstance(row.get("dirty"), bool): + raise ValueError(f"worktrees[{index}].dirty must be a boolean") + for field in ("ahead", "behind"): + count = row.get(field) + if count is not None and (not isinstance(count, int) or isinstance(count, bool)): + raise ValueError(f"worktrees[{index}].{field} must be an integer or null") + age = row.get("last_commit_age_hours") + if age is not None and (not isinstance(age, (int, float)) or isinstance(age, bool)): + raise ValueError( + f"worktrees[{index}].last_commit_age_hours must be a number or null" + ) + touching = sequence(row.get("touching", []), f"worktrees[{index}].touching") + for item_index, item in enumerate(touching): + text(item, f"worktrees[{index}].touching[{item_index}]") + + issue_state = mapping(payload.get("issues"), "issues") + if not isinstance(issue_state.get("available"), bool): + raise ValueError("issues.available must be a boolean") + issue_items = sequence(issue_state.get("items"), "issues.items") + for index, value in enumerate(issue_items): + item = mapping(value, f"issues.items[{index}]") + number = item.get("number") + if not isinstance(number, int) or isinstance(number, bool): + raise ValueError(f"issues.items[{index}].number must be an integer") + for field in ("title", "rkey"): + text(item.get(field), f"issues.items[{index}].{field}") + labels = sequence(item.get("labels", []), f"issues.items[{index}].labels") + for label_index, value in enumerate(labels): + label = mapping(value, f"issues.items[{index}].labels[{label_index}]") + text(label.get("name"), f"issues.items[{index}].labels[{label_index}].name") + if not issue_state["available"]: + text(issue_state.get("reason"), "issues.reason") + + freshness = mapping(payload.get("freshness"), "freshness") + for field in ("work_orders", "ledgers"): + if not isinstance(freshness.get(field), bool): + raise ValueError(f"freshness.{field} must be a boolean") + + checked = mapping(payload.get("consistency"), "consistency") + if not isinstance(checked.get("ok"), bool): + raise ValueError("consistency.ok must be a boolean") + for field in ("warnings", "errors"): + entries = sequence(checked.get(field), f"consistency.{field}") + for index, entry in enumerate(entries): + text(entry, f"consistency.{field}[{index}]") def human(payload: dict) -> str: lines = ["MISALIGNED PROJECT STATUS", ""] - recommended = payload["recommended_next"] - if recommended: + active_arcs = payload.get("active_arcs", []) + decision_made = [ + item + for item in payload.get("issues", {}).get("items", []) + if any(label.get("name") == "decision-made" for label in item.get("labels", [])) + ] + if len(active_arcs) == 1: + arc = active_arcs[0] + lines.append( + f"Active arc: {arc['arc_name']} ({arc.get('id', '?')}, " + f"phase={arc.get('phase', '?')})" + ) + lines.append(f" outcome: {arc['arc_outcome']}") + lines.append(f" done when: {arc['arc_done_when']}") + elif len(active_arcs) > 1: + lines.append(f"Active arc: AMBIGUOUS ({len(active_arcs)} eligible)") + for arc in active_arcs: + lines.append( + f" {arc.get('id', '?')}: {arc['arc_name']} " + f"(phase={arc.get('phase', '?')})" + ) + else: + lines.append("Active arc: none") + recommended = payload.get("recommended_next") + fallback = payload.get("ordinary_fallback", recommended) + issue_state = payload.get("issues", {}) + if not issue_state.get("available", False): + lines.append( + "Next: decision-label state unavailable; restore the harvest read " + "before selecting work" + ) + if len(active_arcs) == 1: + lines.append(f"Waiting arc: {active_arcs[0]['arc_name']}") + elif len(active_arcs) > 1: + lines.append("Waiting arcs: reconcile ambiguous active-arc ownership after harvest") + elif decision_made: + first = min(decision_made, key=lambda item: item.get("number", 999999)) + lines.append( + f"Next: harvest decision-made issue #{first.get('number', '?')} " + f"({first.get('title', 'untitled')})" + ) + if len(active_arcs) == 1: + lines.append(f"After harvest: continue active arc {active_arcs[0]['arc_name']}") + elif len(active_arcs) == 1: + lines.append(f"Next: continue active arc {active_arcs[0]['arc_name']}") + elif len(active_arcs) > 1: + lines.append("Next: reconcile ambiguous active-arc ownership") + elif recommended: lines.append( f"Next: [{recommended['priority']}] {recommended['task']} " f"({recommended['status']}, {recommended['class']})" ) else: lines.append("Next: no current-stage work order is dispatchable") + if (active_arcs or decision_made) and fallback: + lines.append( + f"Ordinary fallback: [{fallback['priority']}] {fallback['task']} " + f"({fallback['status']}, {fallback['class']})" + ) for name, label in (("current", "Current lanes"), ("held", "Held"), ("staged", "Later stages")): rows = payload["work_orders"][name] lines.extend(["", f"{label} ({len(rows)}):"]) @@ -326,7 +565,15 @@ lines.extend(["", f"Runs ({len(payload['runs'])}):"]) if payload["runs"]: for row in payload["runs"]: - lines.append(f" {row.get('id', '?')} {row.get('status', '?')} phase={row.get('phase', '?')}") + arc_name = row.get("arc_name") + suffix = f" arc={arc_name}" if arc_name else "" + lines.append( + f" {row.get('id', '?')} {row.get('status', '?')} " + f"phase={row.get('phase', '?')}{suffix}" + ) + if arc_name: + lines.append(f" outcome: {row.get('arc_outcome', '')}") + lines.append(f" done when: {row.get('arc_done_when', '')}") else: lines.append(" none") lines.extend(["", f"Worktrees ({len(payload['worktrees'])}):"]) @@ -386,6 +633,11 @@ parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--json", action="store_true") parser.add_argument( + "--render-json", + action="store_true", + help="render one previously captured JSON snapshot from standard input", + ) + parser.add_argument( "--check", action="store_true", help="exit nonzero when project-operation records contradict each other", @@ -393,7 +645,19 @@ parser.add_argument("--offline", action="store_true", help="skip Tangled lookup") parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parent.parent) args = parser.parse_args() - payload = collect(args.root, args.offline) + if args.render_json: + try: + payload = json.load(sys.stdin) + except (json.JSONDecodeError, TypeError) as exc: + parser.error(f"invalid project-status snapshot: {exc}") + if not isinstance(payload, dict): + parser.error("invalid project-status snapshot: expected a JSON object") + try: + validate_snapshot(payload) + except ValueError as exc: + parser.error(f"invalid project-status snapshot: {exc}") + else: + payload = collect(args.root, args.offline) print(json.dumps(payload, indent=2, sort_keys=True) if args.json else human(payload)) return 0 if not args.check or payload["consistency"]["ok"] else 1 diff --git a/tools/tangled_issues.py b/tools/tangled_issues.py --- a/tools/tangled_issues.py +++ b/tools/tangled_issues.py @@ -28,6 +28,7 @@ TG_BIN = os.environ.get("MISALIGNED_TG_BIN", "tg") REPO_OWNER_DID = "did:plc:gfrmhdmjvxn2sjedzboeudef" REPO_DID = "did:plc:t53fxjacrmulx3e5d3sbdfui" +REPO_HANDLE = "cameron.stream/misaligned" CONSTELLATION_BASE = os.environ.get( "MISALIGNED_CONSTELLATION_BASE", "https://constellation.microcosm.blue" ).rstrip("/") @@ -63,8 +64,23 @@ raise IssueToolError(f"public Tangled record read failed for {url}: {error}") from error +def _repo_scoped_tg_args(args: Sequence[str]) -> list[str]: + """Address Misaligned explicitly when tg cannot parse the DID-only origin.""" + scoped = list(args) + if scoped == ["issue", "list"]: + return [*scoped, REPO_HANDLE] + if len(scoped) >= 2 and scoped[:2] in ( + ["issue", "view"], + ["issue", "create"], + ["issue", "comment"], + ["issue", "close"], + ): + return [*scoped, "--repo", REPO_HANDLE] + return scoped + + def _run_tg(args: Sequence[str], *, timeout: float = 30.0) -> Any: - command = [TG_BIN, "--json", *args] + command = [TG_BIN, "--json", *_repo_scoped_tg_args(args)] try: result = subprocess.run( command, @@ -443,6 +459,38 @@ ) return result + def edit( + self, issue: dict[str, Any], *, title: str | None, body: str | None + ) -> Any: + """Edit only after a repository-scoped read confirms the exact target.""" + issue_uri = str(issue.get("uri", "")) + author_did, collection, uri_rkey = _parse_at_uri(issue_uri) + rkey = str(issue.get("rkey", "")) + if ( + author_did != REPO_OWNER_DID + or collection != ISSUE_COLLECTION + or not rkey + or uri_rkey != rkey + ): + raise IssueToolError(f"issue {issue_uri or rkey!r} is not an authored Misaligned issue") + + # tg v0.2's issue-edit command accepts only an rkey; unlike view, comment, + # and close, it has no --repo flag. Re-resolve that rkey through the + # explicitly addressed Misaligned view immediately before the write so a + # DID-only Git origin can never make the edit target implicit. + confirmed = self.run_tg(["issue", "view", rkey]) + if not isinstance(confirmed, dict) or confirmed.get("rkey") != rkey: + raise IssueToolError(f"repository-scoped view did not confirm issue {rkey}") + + command = ["issue", "edit", rkey] + if title is not None: + command.extend(["--title", title]) + if body is not None: + command.extend(["--body", body]) + if len(command) == 3: + raise IssueToolError("edit requires --title, --body, or --body-file") + return self.run_tg(command) + def _body_argument(args: argparse.Namespace) -> str | None: if getattr(args, "body", None) is not None: @@ -534,15 +582,13 @@ issue = issues.resolve(args.selector) if args.command == "edit": - command = ["issue", "edit", issue["rkey"]] - if args.title is not None: - command.extend(["--title", args.title]) - body = _body_argument(args) - if body is not None: - command.extend(["--body", body]) - if len(command) == 3: - raise IssueToolError("edit requires --title, --body, or --body-file") - _json_dump(issues.run_tg(command)) + _json_dump( + issues.edit( + issue, + title=args.title, + body=_body_argument(args), + ) + ) return 0 if args.command == "comment": body = _body_argument(args) diff --git a/tools/task.sh b/tools/task.sh --- a/tools/task.sh +++ b/tools/task.sh @@ -73,10 +73,11 @@ usage() { cat <<'EOF' usage: - tools/task.sh start [--dry-run] + tools/task.sh start [--dry-run] \ + [--arc name --outcome text --done-when text] tools/task.sh status [--json|--offline] tools/task.sh check - tools/task.sh finish + tools/task.sh finish [--arc-next wiki/spec.md | --arc-verify command] tools/task.sh abandon finish requires a clean committed task branch and clean current primary main. @@ -97,12 +98,286 @@ printf '%s\n' "$wt" } +runs_dir_path() { + local runs_dir=${MISALIGNED_RUNS_DIR:-.agents/runs} + case "$runs_dir" in + /*) ;; + *) runs_dir="$primary/$runs_dir" ;; + esac + printf '%s\n' "$runs_dir" +} + +verify_finish_ownership() { + local task=$1 wt=$2 status_file runs_dir + runs_dir=$(runs_dir_path) + status_file="$runs_dir/$task/status.json" + python3 - "$status_file" "$task" "$wt" "${AGENT_ID:-}" <<'PY' +import json +from pathlib import Path +import sys + +status_path, task, expected_worktree, caller = sys.argv[1:] +if not caller: + raise SystemExit("FAIL: AGENT_ID is required to finish an owned task") +try: + payload = json.loads(Path(status_path).read_text(encoding="utf-8")) +except (OSError, json.JSONDecodeError) as exc: + raise SystemExit(f"FAIL: cannot verify task ownership from {status_path}: {exc}") +if not isinstance(payload, dict): + raise SystemExit(f"FAIL: {status_path} does not contain a JSON object") +if payload.get("id") != task: + raise SystemExit(f"FAIL: heartbeat declares id {payload.get('id', '?')}, not {task}") +if payload.get("status") != "running": + raise SystemExit(f"FAIL: heartbeat {task} is {payload.get('status', '?')}, not running") +owner = payload.get("agent_id") +if owner != caller: + raise SystemExit(f"FAIL: heartbeat {task} belongs to agent {owner or '?'}, not {caller}") +recorded_worktree = payload.get("worktree") +if not isinstance(recorded_worktree, str) or not recorded_worktree: + raise SystemExit(f"FAIL: heartbeat {task} has no owned worktree") +if Path(recorded_worktree).resolve() != Path(expected_worktree).resolve(): + raise SystemExit( + f"FAIL: heartbeat {task} owns {recorded_worktree}, not {expected_worktree}" + ) +fields = ("arc_name", "arc_outcome", "arc_done_when") +present = [field in payload for field in fields] +values = [payload.get(field) for field in fields] +if any(present) and not ( + all(present) + and all(isinstance(value, str) and value.strip() for value in values) +): + raise SystemExit("FAIL: arc metadata is partial or empty; refusing finish") +print("arc" if all(present) else "plain") +PY +} + +verify_arc_completion() { + local task=$1 wt=$2 verifier=$3 landed_revision=$4 status_file runs_dir + runs_dir=$(runs_dir_path) + status_file="$runs_dir/$task/status.json" + python3 - "$status_file" "$wt" "$verifier" "$landed_revision" <<'PY' +import json +import os +from pathlib import Path +import subprocess +import sys + +status_path, worktree, verifier, landed_revision = sys.argv[1:] +payload = json.loads(Path(status_path).read_text(encoding="utf-8")) +fields = ("arc_name", "arc_outcome", "arc_done_when") +present = [field in payload for field in fields] +if not any(present): + raise SystemExit(0) +values = [payload.get(field) for field in fields] +if not all(present) or not all(isinstance(value, str) and value.strip() for value in values): + raise SystemExit("FAIL: arc metadata is partial or empty; refusing completion") +if not verifier.strip(): + raise SystemExit( + "FAIL: arc task requires --arc-verify with a command that proves arc_done_when" + ) +environment = os.environ.copy() +environment.update( + MISALIGNED_ARC_NAME=values[0], + MISALIGNED_ARC_OUTCOME=values[1], + MISALIGNED_ARC_DONE_WHEN=values[2], + MISALIGNED_LANDED_REVISION=landed_revision, +) +completed = subprocess.run(["bash", "-c", verifier], cwd=worktree, env=environment) +if completed.returncode != 0: + raise SystemExit( + f"FAIL: arc completion verifier rejected {values[0]} at {landed_revision}" + ) +print(f"task: arc completion verified ({values[0]} at {landed_revision})") +PY +} + +preflight_arc_successor() { + local task=$1 wt=$2 requested=$3 successor next_spec next_task next_wt + local status_file runs_dir + successor=$(python3 - "$wt" "$requested" <<'PY' +from pathlib import Path +import sys + +root = Path(sys.argv[1]).resolve() +requested = Path(sys.argv[2]) +if requested.is_absolute(): + raise SystemExit("FAIL: --arc-next must be a repository-relative wiki/spec.md path") +candidate = (root / requested).resolve() +try: + relative = candidate.relative_to(root).as_posix() +except ValueError: + raise SystemExit("FAIL: --arc-next must stay inside the task repository") +if not relative.startswith("wiki/") or not relative.endswith(".md"): + raise SystemExit("FAIL: --arc-next must name a wiki/spec.md path") + +sys.dont_write_bytecode = True +sys.path.insert(0, str(root / "tools")) +import work_orders + +matches = [spec for spec in work_orders.load_specs(root) if spec.rel == relative] +if len(matches) != 1: + raise SystemExit(f"FAIL: --arc-next does not name one work-order spec: {relative}") +spec = matches[0] +if spec.status == "IMPLEMENTED": + raise SystemExit(f"FAIL: --arc-next work order is already IMPLEMENTED: {relative}") +if not spec.task or not spec.work_class or not spec.keys: + raise SystemExit(f"FAIL: --arc-next has incomplete work metadata: {relative}") +print(f"{relative}\t{spec.task}") +PY + ) || return 1 + next_spec=${successor%%$'\t'*} + next_task=${successor#*$'\t'} + case "$next_task" in + ""|*/*|*..*|*" "*|worktree-*) + echo "FAIL: --arc-next has invalid task slug: $next_task" >&2 + return 1 + ;; + esac + [ "$next_task" != "$task" ] || { + echo "FAIL: --arc-next resolves to the current task $task" >&2 + return 1 + } + next_wt=$(task_path "$next_task") + [ ! -e "$next_wt" ] || { + echo "FAIL: successor worktree path already exists: $next_wt" >&2 + return 1 + } + if git -C "$primary" show-ref --verify --quiet "refs/heads/worktree-$next_task"; then + echo "FAIL: successor branch worktree-$next_task already exists" >&2 + return 1 + fi + runs_dir=$(runs_dir_path) + status_file="$runs_dir/$next_task/status.json" + if [ -e "$status_file" ]; then + python3 - "$status_file" "$next_task" <<'PY' +import json +from pathlib import Path +import sys + +path = Path(sys.argv[1]) +task = sys.argv[2] +try: + payload = json.loads(path.read_text(encoding="utf-8")) +except (OSError, json.JSONDecodeError) as exc: + raise SystemExit(f"FAIL: successor heartbeat {task} is invalid: {exc}") +if not isinstance(payload, dict): + raise SystemExit(f"FAIL: successor heartbeat {task} is not a JSON object") +if payload.get("status") == "running": + raise SystemExit(f"FAIL: successor heartbeat {task} is already running") +PY + fi + printf '%s\t%s\n' "$next_spec" "$next_task" +} + +start_arc_successor() { + local old_task=$1 next_spec=$2 landed_revision=$3 status_file runs_dir + runs_dir=$(runs_dir_path) + status_file="$runs_dir/$old_task/status.json" + python3 - "$status_file" "$primary" "$next_spec" "$landed_revision" <<'PY' +import json +import os +from pathlib import Path +import subprocess +import sys + +status_path, primary, next_spec, landed_revision = sys.argv[1:] +payload = json.loads(Path(status_path).read_text(encoding="utf-8")) +fields = ("arc_name", "arc_outcome", "arc_done_when") +values = [payload[field] for field in fields] +owner = payload["agent_id"] +environment = os.environ.copy() +environment["AGENT_ID"] = owner +completed = subprocess.run( + [ + "bash", + str(Path(primary) / "tools/task.sh"), + "start", + next_spec, + "--base-revision", + landed_revision, + "--arc", + values[0], + "--outcome", + values[1], + "--done-when", + values[2], + ], + cwd=primary, + env=environment, +) +if completed.returncode != 0: + raise SystemExit(completed.returncode) +PY +} + +verify_arc_successor() { + local old_task=$1 next_task=$2 landed_revision=$3 next_wt old_status next_status runs_dir + runs_dir=$(runs_dir_path) + next_wt=$(task_path "$next_task") + old_status="$runs_dir/$old_task/status.json" + next_status="$runs_dir/$next_task/status.json" + python3 - "$old_status" "$next_status" "$next_wt" "$landed_revision" <<'PY' +import json +from pathlib import Path +import subprocess +import sys + +old_path, next_path, next_worktree, landed_revision = sys.argv[1:] +old = json.loads(Path(old_path).read_text(encoding="utf-8")) +new = json.loads(Path(next_path).read_text(encoding="utf-8")) +fields = ("arc_name", "arc_outcome", "arc_done_when") +if any(new.get(field) != old.get(field) for field in fields): + raise SystemExit("FAIL: successor heartbeat changed arc metadata") +if new.get("agent_id") != old.get("agent_id"): + raise SystemExit("FAIL: successor heartbeat changed arc ownership") +if new.get("status") != "running": + raise SystemExit("FAIL: successor heartbeat is not running") +if Path(str(new.get("worktree", ""))).resolve() != Path(next_worktree).resolve(): + raise SystemExit("FAIL: successor heartbeat names the wrong worktree") +head = subprocess.run( + ["git", "-C", next_worktree, "rev-parse", "HEAD"], + check=True, + text=True, + stdout=subprocess.PIPE, +).stdout.strip() +if head != landed_revision: + raise SystemExit( + f"FAIL: successor task started from {head}, not landed revision {landed_revision}" + ) +PY +} + cmd_start() { local spec=${1:-} dry_run=0 source_root=$primary + local arc_name="" arc_outcome="" arc_done_when="" + local arc_options=0 base_revision="" base_revision_set=0 shift || true [ -n "$spec" ] || { usage >&2; exit 2; } - if [ "${1:-}" = "--dry-run" ]; then dry_run=1; shift; fi - [ $# -eq 0 ] || { usage >&2; exit 2; } + while [ $# -gt 0 ]; do + case "$1" in + --dry-run) dry_run=1; shift ;; + --base-revision) + base_revision_set=$((base_revision_set + 1)); shift + base_revision="${1:-}"; shift || true + ;; + --arc|--arc-name) arc_options=$((arc_options + 1)); shift; arc_name="${1:-}"; shift || true ;; + --outcome|--arc-outcome) arc_options=$((arc_options + 1)); shift; arc_outcome="${1:-}"; shift || true ;; + --done-when|--arc-done-when) arc_options=$((arc_options + 1)); shift; arc_done_when="${1:-}"; shift || true ;; + *) usage >&2; exit 2 ;; + esac + done + if [ "$arc_options" -ne 0 ] && { + [ "$arc_options" -ne 3 ] || [ -z "$arc_name" ] || [ -z "$arc_outcome" ] || [ -z "$arc_done_when" ]; + }; then + echo "FAIL: arc metadata requires --arc, --outcome, and --done-when together" >&2 + exit 2 + fi + [ "$base_revision_set" -le 1 ] && { + [ "$base_revision_set" -eq 0 ] || [ -n "$base_revision" ]; + } || { + echo "FAIL: --base-revision requires one non-empty revision" >&2 + exit 2 + } [ -f "$source_root/tools/work_orders.py" ] || source_root=$script_root local task="" class="" key value local -a keys=() @@ -116,16 +391,31 @@ [ -n "$task" ] && [ -n "$class" ] || { echo "FAIL: incomplete metadata for $spec" >&2; exit 1; } local -a args=("$task" --class "$class") for value in "${keys[@]}"; do args+=(--key "$value"); done + if [ -n "$base_revision" ]; then args+=(--base-revision "$base_revision"); fi if [ "$dry_run" -eq 1 ]; then printf 'task start:' printf ' %q' "$primary/tools/worktree-new.sh" "${args[@]}" + printf '\n' + printf 'task heartbeat:' + printf ' %q' "$primary/tools/heartbeat.sh" start "$task" \ + --worktree "$(task_path "$task")" --phase boot + if [ "$arc_options" -eq 3 ]; then + printf ' %q' --arc-name "$arc_name" --arc-outcome "$arc_outcome" \ + --arc-done-when "$arc_done_when" + fi printf '\n' return 0 fi bash "$primary/tools/worktree-new.sh" "${args[@]}" local wt wt=$(task_path "$task") - bash "$wt/tools/heartbeat.sh" start "$task" --worktree "$wt" --phase boot + if [ "$arc_options" -eq 3 ]; then + bash "$wt/tools/heartbeat.sh" start "$task" --worktree "$wt" --phase boot \ + --arc-name "$arc_name" --arc-outcome "$arc_outcome" \ + --arc-done-when "$arc_done_when" + else + bash "$wt/tools/heartbeat.sh" start "$task" --worktree "$wt" --phase boot + fi } cmd_check() { @@ -144,9 +434,44 @@ } cmd_finish() { - local task=${1:-} wt branch dirty primary_dirty + local task=${1:-} wt branch dirty primary_dirty arc_verify="" arc_next="" + local arc_verify_set=0 arc_next_set=0 finish_kind successor="" next_task="" + local remote_main + shift || true [ -n "$task" ] || { usage >&2; exit 2; } + while [ $# -gt 0 ]; do + case "$1" in + --arc-next) + arc_next_set=$((arc_next_set + 1)); shift + arc_next="${1:-}"; shift || true + ;; + --arc-verify) + arc_verify_set=$((arc_verify_set + 1)); shift + arc_verify="${1:-}"; shift || true + ;; + *) usage >&2; exit 2 ;; + esac + done + [ "$arc_next_set" -le 1 ] && [ "$arc_verify_set" -le 1 ] || { + echo "FAIL: arc finish flags may be supplied only once" >&2 + exit 2 + } + if { [ "$arc_next_set" -eq 1 ] && [ -z "$arc_next" ]; } || \ + { [ "$arc_verify_set" -eq 1 ] && [ -z "$arc_verify" ]; }; then + echo "FAIL: arc finish flags require a non-empty value" >&2 + exit 2 + fi wt=$(require_worktree "$task") + finish_kind=$(verify_finish_ownership "$task" "$wt") + if [ "$finish_kind" = "arc" ]; then + [ $((arc_next_set + arc_verify_set)) -eq 1 ] || { + echo "FAIL: arc task requires exactly one of --arc-next or --arc-verify" >&2 + exit 2 + } + elif [ "$arc_next_set" -ne 0 ] || [ "$arc_verify_set" -ne 0 ]; then + echo "FAIL: --arc-next and --arc-verify are valid only for arc tasks" >&2 + exit 2 + fi branch=$(git -C "$wt" branch --show-current) [ "$branch" = "worktree-$task" ] || { echo "FAIL: $wt is on $branch, expected worktree-$task" >&2; exit 1; @@ -157,6 +482,11 @@ printf '%s\n' "$dirty" >&2 exit 1 } + if [ "$arc_next_set" -eq 1 ]; then + successor=$(preflight_arc_successor "$task" "$wt" "$arc_next") + arc_next=${successor%%$'\t'*} + next_task=${successor#*$'\t'} + fi bash "$wt/tools/heartbeat.sh" phase "$task" landing-wait acquire_landing_lock primary_dirty=$(git -C "$primary" status --porcelain) @@ -175,11 +505,44 @@ echo "FAIL: primary main is not current origin/main; refusing merge" >&2 exit 1 } + local task_revision + task_revision=$(git -C "$wt" rev-parse HEAD) + if [ "$arc_next_set" -eq 1 ]; then + bash "$wt/tools/heartbeat.sh" phase "$task" arc-next-preflight + [ "$(preflight_arc_successor "$task" "$wt" "$arc_next")" = "$successor" ] || { + echo "FAIL: successor work metadata changed during landing" >&2 + exit 1 + } + elif [ "$arc_verify_set" -eq 1 ]; then + bash "$wt/tools/heartbeat.sh" phase "$task" arc-verify + verify_arc_completion "$task" "$wt" "$arc_verify" "$task_revision" + fi + [ "$(git -C "$wt" rev-parse HEAD)" = "$task_revision" ] || { + echo "FAIL: finish preflight changed the task revision" >&2 + exit 1 + } + dirty=$(git -C "$wt" status --porcelain) + [ -z "$dirty" ] || { + echo "FAIL: finish preflight dirtied the task worktree" >&2 + printf '%s\n' "$dirty" >&2 + exit 1 + } bash "$wt/tools/heartbeat.sh" phase "$task" push - git -C "$primary" merge --ff-only "worktree-$task" + git -C "$primary" merge --ff-only "$task_revision" git -C "$primary" push origin main + remote_main=$(git -C "$wt" ls-remote --exit-code origin refs/heads/main \ + | python3 -c 'import sys; print(sys.stdin.read().split()[0])') + [ "$task_revision" = "$remote_main" ] || { + echo "FAIL: origin/main does not name the landed task revision" >&2 + exit 1 + } bash "$wt/tools/heartbeat.sh" phase "$task" site-publish publish_site + if [ "$arc_next_set" -eq 1 ]; then + bash "$wt/tools/heartbeat.sh" phase "$task" arc-next + start_arc_successor "$task" "$arc_next" "$task_revision" + verify_arc_successor "$task" "$next_task" "$task_revision" + fi bash "$wt/tools/heartbeat.sh" end "$task" --status ok release_landing_lock bash "$primary/tools/worktree-done.sh" "$task" diff --git a/tools/test_project_ops.py b/tools/test_project_ops.py --- a/tools/test_project_ops.py +++ b/tools/test_project_ops.py @@ -6,9 +6,12 @@ import json import importlib.util import os +import shlex +import shutil import subprocess import tempfile import unittest +from unittest import mock from pathlib import Path import scenario @@ -88,6 +91,171 @@ encoding="utf-8", ) return path + + def make_finish_repository(self) -> dict[str, object]: + source_repo = Path(__file__).resolve().parent.parent + fixture = self.root / "finish-fixture" + origin = fixture / "origin.git" + primary = fixture / "primary" + worktrees = fixture / "worktrees" + runs = fixture / "runs" + fixture.mkdir() + subprocess.run(["git", "init", "-q", "--bare", str(origin)], check=True) + subprocess.run( + ["git", "init", "-q", "--initial-branch=main", str(primary)], + check=True, + ) + subprocess.run( + ["git", "-C", str(primary), "config", "user.name", "Fixture"], + check=True, + ) + subprocess.run( + [ + "git", + "-C", + str(primary), + "config", + "user.email", + "fixture@example.com", + ], + check=True, + ) + subprocess.run( + ["git", "-C", str(primary), "remote", "add", "origin", str(origin)], + check=True, + ) + + tools = primary / "tools" + tools.mkdir() + for name in ( + "heartbeat.sh", + "task.sh", + "work_orders.py", + "worktree-done.sh", + "worktree-new.sh", + ): + shutil.copy2(source_repo / "tools" / name, tools / name) + for name, body in { + "check.sh": ( + "#!/usr/bin/env bash\n" + "set -euo pipefail\n" + "[ \"$#\" -eq 1 ] && [ \"$1\" = \"--land\" ]\n" + ), + "seed-cargo-target.sh": "#!/usr/bin/env bash\nset -euo pipefail\n", + "site-deploy.sh": ( + "#!/usr/bin/env bash\n" + "set -euo pipefail\n" + "printf '%s\\n' \"$SITE_SOURCE_REVISION\" > " + "\"$MISALIGNED_RUNS_DIR/published-revision\"\n" + ), + }.items(): + path = tools / name + path.write_text(body, encoding="utf-8") + path.chmod(0o755) + + wiki = primary / "wiki" + wiki.mkdir() + (wiki / "successor.md").write_text( + """# Spec: successor + +``` +Type: spec +Status: READY +Stage: Process +Work order: task-b +Work priority: 10 +Work class: process +Blocked by: none +Exclusive keys: + - tools/ +Design: + - wiki/successor.md#acceptance-criteria +Depends on: none +``` + +## Acceptance criteria + +1. Observable. +""", + encoding="utf-8", + ) + subprocess.run(["git", "-C", str(primary), "add", "tools", "wiki"], check=True) + subprocess.run( + ["git", "-C", str(primary), "commit", "-q", "-m", "fixture base"], + check=True, + ) + subprocess.run( + ["git", "-C", str(primary), "push", "-q", "-u", "origin", "main"], + check=True, + ) + + worktrees.mkdir() + task_worktree = worktrees / "task-a" + subprocess.run( + [ + "git", + "-C", + str(primary), + "worktree", + "add", + "-q", + "-b", + "worktree-task-a", + str(task_worktree), + "main", + ], + check=True, + ) + (task_worktree / "task-change.txt").write_text("task revision\n", encoding="utf-8") + subprocess.run( + ["git", "-C", str(task_worktree), "add", "task-change.txt"], check=True + ) + subprocess.run( + ["git", "-C", str(task_worktree), "commit", "-q", "-m", "task change"], + check=True, + ) + + env = { + **os.environ, + "AGENT_ID": "trace-agent", + "MISALIGNED_LANDING_LOCK": str(fixture / "landing.lock"), + "MISALIGNED_LANDING_WAIT": "0", + "MISALIGNED_RUNS_DIR": str(runs), + "MISALIGNED_WORKTREE_ROOT": str(worktrees), + } + heartbeat = subprocess.run( + [ + "bash", + "tools/heartbeat.sh", + "start", + "task-a", + "--worktree", + str(task_worktree), + "--phase", + "test", + "--arc-name", + "operations-continuity", + "--arc-outcome", + "one connected operator workflow", + "--arc-done-when", + "the declared proof passes", + ], + cwd=task_worktree, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + self.assertEqual(0, heartbeat.returncode, heartbeat.stdout) + return { + "env": env, + "origin": origin, + "primary": primary, + "runs": runs, + "task_worktree": task_worktree, + "worktrees": worktrees, + } def test_lane_classification_respects_blockers_and_stage(self) -> None: base = self.write_spec("base", status="IN PROGRESS", priority=10) @@ -220,15 +388,586 @@ self.assertTrue(any("declares id other" in error for error in checked["errors"])) self.assertTrue(any("bad json" in error for error in checked["errors"])) - def test_heartbeat_preserves_worktree_through_phase_and_end(self) -> None: + def test_project_status_collect_uses_override_run_store(self) -> None: + runs = self.root / "override-runs" + status = runs / "task-a/status.json" + status.parent.mkdir(parents=True) + status.write_text( + json.dumps( + { + "id": "task-a", + "status": "ok", + "updated_at": "2024-07-03T09:46:00Z", + } + ), + encoding="utf-8", + ) + + with mock.patch.dict( + os.environ, {"MISALIGNED_RUNS_DIR": str(runs)}, clear=False + ): + payload = project_status.collect(self.root, offline=True) + + self.assertEqual(["task-a"], [row["id"] for row in payload["runs"]]) + + def test_project_status_marks_non_object_run_record_invalid(self) -> None: + runs = self.root / "runs" + status = runs / "task-a/status.json" + status.parent.mkdir(parents=True) + status.write_text("[]\n", encoding="utf-8") + + rows = project_status.run_files(runs) + + self.assertEqual(1, len(rows)) + self.assertEqual("invalid", rows[0]["status"]) + self.assertEqual("task-a", rows[0]["record_name"]) + self.assertIn("must be a JSON object", rows[0]["parse_error"]) + + def test_project_status_rejects_partial_arc_metadata(self) -> None: + payload = { + "runs": [ + { + "record_name": "task-a", + "id": "task-a", + "status": "ok", + "updated_at": "2024-07-03T09:46:00Z", + "arc_name": "operations-continuity", + "arc_outcome": "one connected operator workflow", + } + ], + "worktrees": [], + } + checked = project_status.consistency(payload, now=1_720_000_000.0) + self.assertFalse(checked["ok"]) + self.assertTrue( + any("arc metadata must declare" in error for error in checked["errors"]), + checked, + ) + + payload["runs"][0].update( + arc_name="", + arc_outcome="", + arc_done_when="", + ) + checked = project_status.consistency(payload, now=1_720_000_000.0) + self.assertFalse(checked["ok"]) + self.assertTrue( + any("arc metadata must declare" in error for error in checked["errors"]), + checked, + ) + + def test_project_status_rejects_malformed_run_timestamp(self) -> None: + payload = { + "runs": [ + { + "record_name": "task-a", + "id": "task-a", + "status": "running", + "updated_at": "not-a-time", + "worktree": str(self.root / "task-a"), + } + ], + "worktrees": [], + } + checked = project_status.consistency(payload, now=1_720_000_000.0) + self.assertFalse(checked["ok"]) + self.assertTrue(any("invalid updated_at" in error for error in checked["errors"]), checked) + + def test_project_status_human_output_unfolds_arc_metadata(self) -> None: + payload = { + "recommended_next": { + "priority": 10, + "task": "unrelated", + "status": "READY", + "class": "process", + }, + "work_orders": {"current": [], "held": [], "staged": []}, + "runs": [ + { + "id": "task-a", + "status": "running", + "phase": "implement", + "arc_name": "operations-continuity", + "arc_outcome": "one connected operator workflow", + "arc_done_when": "every acceptance seam passes", + } + ], + "active_arcs": [ + { + "id": "task-a", + "status": "running", + "phase": "implement", + "arc_name": "operations-continuity", + "arc_outcome": "one connected operator workflow", + "arc_done_when": "every acceptance seam passes", + } + ], + "worktrees": [], + "issues": {"available": True, "items": []}, + "freshness": {"work_orders": True, "ledgers": True}, + "consistency": {"ok": True, "warnings": [], "errors": []}, + } + text = project_status.human(payload) + self.assertIn("arc=operations-continuity", text) + self.assertIn("Active arc: operations-continuity", text) + self.assertIn("outcome: one connected operator workflow", text) + self.assertIn("done when: every acceptance seam passes", text) + self.assertIn("Ordinary fallback: [10] unrelated", text) + + def test_active_arc_selection_requires_exact_owner_and_registered_worktree(self) -> None: + worktree = self.root / "task-a" + worktree.mkdir() + complete = { + "status": "running", + "agent_id": "trace-agent", + "worktree": str(worktree), + "arc_name": "operations-continuity", + "arc_outcome": "one connected operator workflow", + "arc_done_when": "every acceptance seam passes", + } + runs = [ + {**complete, "id": "owned"}, + {**complete, "id": "foreign", "agent_id": "other-agent"}, + {**complete, "id": "legacy", "agent_id": ""}, + {**complete, "id": "terminal", "status": "ok"}, + {**complete, "id": "partial", "arc_done_when": ""}, + { + **complete, + "id": "unregistered", + "worktree": str(self.root / "missing"), + }, + {**complete, "id": "bad-time", "updated_at": "not-a-time"}, + ] + for row in runs: + row.setdefault("record_name", row["id"]) + row.setdefault("updated_at", "2024-07-03T09:46:00Z") + worktrees = [{"path": str(worktree), "branch": "worktree-task-a"}] + self.assertEqual( + ["owned"], + [ + row["id"] + for row in project_status.active_arc_rows( + runs, worktrees, "trace-agent" + ) + ], + ) + self.assertEqual([], project_status.active_arc_rows(runs, worktrees, "")) + + def test_heartbeat_preserves_worktree_and_arc_through_phase_and_end(self) -> None: repo = Path(__file__).resolve().parent.parent runs = self.root / "runs" worktree = self.root / "task-worktree" worktree.mkdir() env = os.environ.copy() env["MISALIGNED_RUNS_DIR"] = str(runs) + env["AGENT_ID"] = "trace-agent" + start = [ + "start", + "task-a", + "--worktree", + str(worktree), + "--phase", + "boot", + "--arc-name", + "operations-continuity", + "--arc-outcome", + "one connected operator workflow", + "--arc-done-when", + "every acceptance seam passes", + ] + for command in (start, ["phase", "task-a", "test"]): + result = subprocess.run( + ["bash", "tools/heartbeat.sh", *command], + cwd=repo, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + self.assertEqual(0, result.returncode, result.stdout) + status = json.loads((runs / "task-a/status.json").read_text(encoding="utf-8")) + self.assertEqual("trace-agent", status["agent_id"]) + self.assertEqual(str(worktree), status["worktree"]) + self.assertEqual("operations-continuity", status["arc_name"]) + self.assertEqual("one connected operator workflow", status["arc_outcome"]) + self.assertEqual("every acceptance seam passes", status["arc_done_when"]) + + before = (runs / "task-a/status.json").read_bytes() + foreign_env = {**env, "AGENT_ID": "other-agent"} + foreign = subprocess.run( + ["bash", "tools/heartbeat.sh", "phase", "task-a", "foreign"], + cwd=repo, + env=foreign_env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + self.assertNotEqual(0, foreign.returncode, foreign.stdout) + self.assertIn("belongs to agent trace-agent", foreign.stdout) + self.assertEqual(before, (runs / "task-a/status.json").read_bytes()) + + ended = subprocess.run( + ["bash", "tools/heartbeat.sh", "end", "task-a", "--status", "ok"], + cwd=repo, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + self.assertEqual(0, ended.returncode, ended.stdout) + status = json.loads((runs / "task-a/status.json").read_text(encoding="utf-8")) + self.assertEqual("ok", status["status"]) + + def test_heartbeat_serializes_concurrent_updates_as_valid_json(self) -> None: + repo = Path(__file__).resolve().parent.parent + runs = self.root / "runs" + env = {**os.environ, "MISALIGNED_RUNS_DIR": str(runs), "AGENT_ID": "trace-agent"} + started = subprocess.run( + ["bash", "tools/heartbeat.sh", "start", "task-a", "--phase", "boot"], + cwd=repo, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + self.assertEqual(0, started.returncode, started.stdout) + processes = [ + subprocess.Popen( + ["bash", "tools/heartbeat.sh", "phase", "task-a", f"parallel-{index}"], + cwd=repo, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + for index in range(12) + ] + status_file = runs / "task-a/status.json" + live_reads = 0 + while any(process.poll() is None for process in processes): + json.loads(status_file.read_text(encoding="utf-8")) + live_reads += 1 + self.assertGreater(live_reads, 1) + for process in processes: + output, _ = process.communicate(timeout=30) + self.assertEqual(0, process.returncode, output) + payload = json.loads(status_file.read_text(encoding="utf-8")) + self.assertEqual("trace-agent", payload["agent_id"]) + self.assertIn(payload["phase"], {f"parallel-{index}" for index in range(12)}) + + def test_task_finish_guards_ownership_and_requires_arc_proof(self) -> None: + repo = Path(__file__).resolve().parent.parent + primary = self.root / "primary" + worktree = self.root / "task-a" + runs_dir = self.root / "runs" + status_dir = runs_dir / "task-a" + status_dir.mkdir(parents=True) + worktree.mkdir() + payload = { + "id": "task-a", + "status": "running", + "agent_id": "trace-agent", + "worktree": str(worktree), + "arc_name": "operations-continuity", + "arc_outcome": "one connected operator workflow", + "arc_done_when": "the declared proof passes", + } + (status_dir / "status.json").write_text(json.dumps(payload), encoding="utf-8") + source = ( + f'source tools/task.sh; primary="{primary}"; ' + f'verify_finish_ownership task-a "{worktree}"; ' + f'verify_arc_completion task-a "{worktree}" ' + "'test \"$MISALIGNED_ARC_NAME|$MISALIGNED_ARC_OUTCOME|" + "$MISALIGNED_ARC_DONE_WHEN|$MISALIGNED_LANDED_REVISION\" = " + "\"operations-continuity|one connected operator workflow|" + "the declared proof passes|deadbeef\"' deadbeef" + ) + finish_env = { + **os.environ, + "AGENT_ID": "trace-agent", + "MISALIGNED_RUNS_DIR": str(runs_dir), + } + accepted = subprocess.run( + ["bash", "-c", source], + cwd=repo, + env=finish_env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + self.assertEqual(0, accepted.returncode, accepted.stdout) + self.assertIn("arc completion verified", accepted.stdout) + + foreign = subprocess.run( + [ + "bash", + "-c", + f'source tools/task.sh; primary="{primary}"; ' + f'verify_finish_ownership task-a "{worktree}"', + ], + cwd=repo, + env={**finish_env, "AGENT_ID": "other-agent"}, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + self.assertNotEqual(0, foreign.returncode, foreign.stdout) + self.assertIn("belongs to agent trace-agent", foreign.stdout) + + missing_proof = subprocess.run( + [ + "bash", + "-c", + f'source tools/task.sh; primary="{primary}"; ' + f'verify_arc_completion task-a "{worktree}" "" deadbeef', + ], + cwd=repo, + env=finish_env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + self.assertNotEqual(0, missing_proof.returncode, missing_proof.stdout) + self.assertIn("requires --arc-verify", missing_proof.stdout) + + finish_body = (repo / "tools/task.sh").read_text(encoding="utf-8").split( + "cmd_finish() {", 1 + )[1].split("cmd_abandon() {", 1)[0] + merge_index = finish_body.index('merge --ff-only "$task_revision"') + self.assertLess( + finish_body.index('verify_finish_ownership "$task" "$wt"'), + merge_index, + ) + verifier_index = finish_body.index('verify_arc_completion "$task"') + self.assertLess( + finish_body.index('task_revision=$(git -C "$wt" rev-parse HEAD)'), + verifier_index, + ) + revision_guard = finish_body.index( + 'finish preflight changed the task revision' + ) + dirty_guard = finish_body.index( + 'finish preflight dirtied the task worktree' + ) + self.assertLess(verifier_index, revision_guard) + self.assertLess(revision_guard, dirty_guard) + self.assertLess(dirty_guard, merge_index) + self.assertNotIn('merge --ff-only "worktree-$task"', finish_body) + self.assertEqual(1, finish_body.count('verify_arc_completion "$task"')) + + def test_task_finish_rejected_arc_verifier_preserves_every_revision(self) -> None: + fixture = self.make_finish_repository() + env = fixture["env"] + origin = fixture["origin"] + primary = fixture["primary"] + task_worktree = fixture["task_worktree"] + self.assertIsInstance(env, dict) + self.assertIsInstance(origin, Path) + self.assertIsInstance(primary, Path) + self.assertIsInstance(task_worktree, Path) + + primary_head = subprocess.check_output( + ["git", "-C", str(primary), "rev-parse", "HEAD"], text=True + ).strip() + origin_main = subprocess.check_output( + ["git", "--git-dir", str(origin), "rev-parse", "refs/heads/main"], + text=True, + ).strip() + task_head = subprocess.check_output( + ["git", "-C", str(task_worktree), "rev-parse", "HEAD"], text=True + ).strip() + calls = self.root / "arc-verifier-calls" + verifier = ( + f"printf '%s\\n' \"$MISALIGNED_LANDED_REVISION\" >> " + f"{shlex.quote(str(calls))}; exit 17" + ) + + result = subprocess.run( + [ + "bash", + "tools/task.sh", + "finish", + "task-a", + "--arc-verify", + verifier, + ], + cwd=primary, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + + self.assertNotEqual(0, result.returncode, result.stdout) + self.assertIn("arc completion verifier rejected", result.stdout) + self.assertEqual([task_head], calls.read_text(encoding="utf-8").splitlines()) + self.assertEqual( + primary_head, + subprocess.check_output( + ["git", "-C", str(primary), "rev-parse", "HEAD"], text=True + ).strip(), + ) + self.assertEqual( + origin_main, + subprocess.check_output( + ["git", "--git-dir", str(origin), "rev-parse", "refs/heads/main"], + text=True, + ).strip(), + ) + self.assertEqual( + task_head, + subprocess.check_output( + ["git", "-C", str(task_worktree), "rev-parse", "HEAD"], text=True + ).strip(), + ) + self.assertEqual( + "", + subprocess.check_output( + ["git", "-C", str(task_worktree), "status", "--porcelain"], + text=True, + ), + ) + + def test_task_finish_arc_next_continues_from_landed_revision(self) -> None: + fixture = self.make_finish_repository() + env = fixture["env"] + origin = fixture["origin"] + primary = fixture["primary"] + runs = fixture["runs"] + task_worktree = fixture["task_worktree"] + worktrees = fixture["worktrees"] + self.assertIsInstance(env, dict) + self.assertIsInstance(origin, Path) + self.assertIsInstance(primary, Path) + self.assertIsInstance(runs, Path) + self.assertIsInstance(task_worktree, Path) + self.assertIsInstance(worktrees, Path) + task_head = subprocess.check_output( + ["git", "-C", str(task_worktree), "rev-parse", "HEAD"], text=True + ).strip() + + result = subprocess.run( + [ + "bash", + "tools/task.sh", + "finish", + "task-a", + "--arc-next", + "wiki/successor.md", + ], + cwd=primary, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + + self.assertEqual(0, result.returncode, result.stdout) + self.assertFalse(task_worktree.exists()) + successor = worktrees / "task-b" + self.assertTrue(successor.is_dir()) + self.assertEqual( + task_head, + subprocess.check_output( + ["git", "-C", str(primary), "rev-parse", "HEAD"], text=True + ).strip(), + ) + self.assertEqual( + task_head, + subprocess.check_output( + ["git", "--git-dir", str(origin), "rev-parse", "refs/heads/main"], + text=True, + ).strip(), + ) + self.assertEqual( + task_head, + subprocess.check_output( + ["git", "-C", str(successor), "rev-parse", "HEAD"], text=True + ).strip(), + ) + self.assertEqual( + "", + subprocess.check_output( + ["git", "-C", str(successor), "status", "--porcelain"], text=True + ), + ) + old_status = json.loads( + (runs / "task-a/status.json").read_text(encoding="utf-8") + ) + next_status = json.loads( + (runs / "task-b/status.json").read_text(encoding="utf-8") + ) + self.assertEqual("ok", old_status["status"]) + self.assertEqual("running", next_status["status"]) + self.assertEqual("trace-agent", next_status["agent_id"]) + self.assertEqual(str(successor), next_status["worktree"]) + for field in project_status.ARC_FIELDS: + self.assertEqual(old_status[field], next_status[field]) + + def test_heartbeat_rejects_partial_arc_metadata(self) -> None: + repo = Path(__file__).resolve().parent.parent + env = os.environ.copy() + env["MISALIGNED_RUNS_DIR"] = str(self.root / "runs") + result = subprocess.run( + [ + "bash", + "tools/heartbeat.sh", + "start", + "task-a", + "--arc-name", + "operations-continuity", + ], + cwd=repo, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + self.assertEqual(2, result.returncode, result.stdout) + self.assertIn("requires --arc-name, --arc-outcome, and --arc-done-when", result.stdout) + self.assertFalse((self.root / "runs/task-a/status.json").exists()) + + empty = subprocess.run( + [ + "bash", + "tools/heartbeat.sh", + "start", + "task-b", + "--arc-name", + "", + "--arc-outcome", + "", + "--arc-done-when", + "", + ], + cwd=repo, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + self.assertEqual(2, empty.returncode, empty.stdout) + self.assertIn("requires --arc-name, --arc-outcome, and --arc-done-when", empty.stdout) + self.assertFalse((self.root / "runs/task-b/status.json").exists()) + + def test_heartbeat_without_arc_omits_optional_fields(self) -> None: + repo = Path(__file__).resolve().parent.parent + runs = self.root / "runs" + env = os.environ.copy() + env["MISALIGNED_RUNS_DIR"] = str(runs) for command in ( - ["start", "task-a", "--worktree", str(worktree), "--phase", "boot"], + ["start", "task-a", "--phase", "boot"], ["phase", "task-a", "test"], ["end", "task-a", "--status", "ok"], ): @@ -243,7 +982,291 @@ ) self.assertEqual(0, result.returncode, result.stdout) status = json.loads((runs / "task-a/status.json").read_text(encoding="utf-8")) - self.assertEqual(str(worktree), status["worktree"]) + self.assertTrue(all(field not in status for field in project_status.ARC_FIELDS), status) + + def test_task_start_dry_run_forwards_complete_arc_metadata(self) -> None: + repo = Path(__file__).resolve().parent.parent + result = subprocess.run( + [ + "bash", + "tools/task.sh", + "start", + "wiki/process/agent-scale.md", + "--dry-run", + "--arc", + "operations-continuity", + "--outcome", + "one connected operator workflow", + "--done-when", + "every acceptance seam passes", + ], + cwd=repo, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + self.assertEqual(0, result.returncode, result.stdout) + self.assertIn("task heartbeat:", result.stdout) + self.assertIn("--arc-name operations-continuity", result.stdout) + self.assertIn("--arc-outcome one\\ connected\\ operator\\ workflow", result.stdout) + self.assertIn("--arc-done-when every\\ acceptance\\ seam\\ passes", result.stdout) + + partial = subprocess.run( + [ + "bash", + "tools/task.sh", + "start", + "wiki/process/agent-scale.md", + "--dry-run", + "--arc", + "operations-continuity", + ], + cwd=repo, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + self.assertEqual(2, partial.returncode, partial.stdout) + self.assertIn("requires --arc, --outcome, and --done-when", partial.stdout) + + empty = subprocess.run( + [ + "bash", + "tools/task.sh", + "start", + "wiki/process/agent-scale.md", + "--dry-run", + "--arc", + "", + "--outcome", + "", + "--done-when", + "", + ], + cwd=repo, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + self.assertEqual(2, empty.returncode, empty.stdout) + self.assertIn("requires --arc, --outcome, and --done-when", empty.stdout) + + def test_task_start_dry_run_without_arc_is_bash_32_safe(self) -> None: + repo = Path(__file__).resolve().parent.parent + result = subprocess.run( + [ + "bash", + "tools/task.sh", + "start", + "wiki/process/agent-scale.md", + "--dry-run", + ], + cwd=repo, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + self.assertEqual(0, result.returncode, result.stdout) + self.assertIn("task heartbeat:", result.stdout) + self.assertNotIn("--arc-name", result.stdout) + self.assertNotIn("unbound variable", result.stdout) + + def test_project_status_render_json_reuses_captured_snapshot(self) -> None: + repo = Path(__file__).resolve().parent.parent + payload = { + "recommended_next": None, + "ordinary_fallback": None, + "work_orders": {"current": [], "held": [], "staged": []}, + "runs": [], + "active_arcs": [], + "worktrees": [], + "issues": {"available": True, "items": []}, + "freshness": {"work_orders": True, "ledgers": True}, + "consistency": {"ok": True, "warnings": [], "errors": []}, + } + rendered = subprocess.run( + ["python3", "tools/project-status.py", "--render-json"], + cwd=repo, + input=json.dumps(payload), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + self.assertEqual(0, rendered.returncode, rendered.stdout) + self.assertIn("Active arc: none", rendered.stdout) + self.assertIn("Consistency: PASS", rendered.stdout) + + brief = (repo / "tools/tick-brief.sh").read_text(encoding="utf-8") + self.assertEqual(1, brief.count("project-status.py --json")) + self.assertNotIn("project-status.py --json --offline", brief) + self.assertIn("project-status.py --render-json", brief) + self.assertIn('payload.get("issues", {})', brief) + + def test_project_status_render_json_rejects_non_object_snapshot(self) -> None: + repo = Path(__file__).resolve().parent.parent + rendered = subprocess.run( + ["python3", "tools/project-status.py", "--render-json"], + cwd=repo, + input="[]\n", + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + + self.assertEqual(2, rendered.returncode, rendered.stdout) + self.assertIn("expected a JSON object", rendered.stdout) + self.assertNotIn("Traceback", rendered.stdout) + + def test_project_status_render_json_rejects_malformed_object_snapshots(self) -> None: + repo = Path(__file__).resolve().parent.parent + valid = { + "recommended_next": None, + "ordinary_fallback": None, + "work_orders": {"current": [], "held": [], "staged": []}, + "runs": [], + "active_arcs": [], + "worktrees": [], + "issues": {"available": True, "items": []}, + "freshness": {"work_orders": True, "ledgers": True}, + "consistency": {"ok": True, "warnings": [], "errors": []}, + } + malformed = [ + {}, + {**valid, "issues": []}, + { + **valid, + "work_orders": {"current": [{}], "held": [], "staged": []}, + }, + {**valid, "active_arcs": [{}]}, + ] + for payload in malformed: + with self.subTest(payload=payload): + rendered = subprocess.run( + ["python3", "tools/project-status.py", "--render-json"], + cwd=repo, + input=json.dumps(payload), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + self.assertEqual(2, rendered.returncode, rendered.stdout) + self.assertIn("invalid project-status snapshot", rendered.stdout) + self.assertNotIn("Traceback", rendered.stdout) + + def test_tick_doorways_preserve_active_arc_intake_order(self) -> None: + repo = Path(__file__).resolve().parent.parent + doorway = (repo / "AGENT.md").read_text(encoding="utf-8") + self.assertIn( + "`decision-made` harvest, one eligible current-agent active arc, the\n" + "findings queue, then a fresh audit", + doorway, + ) + for relative in ( + ".agents/skills/tick/SKILL.md", + ".claude/skills/tick/SKILL.md", + ): + skill = (repo / relative).read_text(encoding="utf-8") + self.assertIn( + "`decision-made` issues (harvest Cameron's answer into law/spec motion,\n" + " close), then continue one eligible current-agent active arc, then the\n" + " findings queue", + skill, + ) + + def test_project_status_harvests_decision_before_active_arc(self) -> None: + payload = { + "recommended_next": None, + "ordinary_fallback": None, + "work_orders": {"current": [], "held": [], "staged": []}, + "runs": [], + "active_arcs": [ + { + "id": "task-a", + "phase": "implement", + "arc_name": "operations-continuity", + "arc_outcome": "one connected operator workflow", + "arc_done_when": "every acceptance seam passes", + } + ], + "worktrees": [], + "issues": { + "available": True, + "items": [ + { + "number": 21, + "title": "Question already answered", + "rkey": "decision-21", + "labels": [{"name": "decision-made"}], + } + ], + }, + "freshness": {"work_orders": True, "ledgers": True}, + "consistency": {"ok": True, "warnings": [], "errors": []}, + } + rendered = project_status.human(payload) + self.assertIn( + "Next: harvest decision-made issue #21 (Question already answered)", + rendered, + ) + self.assertIn( + "After harvest: continue active arc operations-continuity", rendered + ) + + def test_project_status_does_not_treat_unavailable_decisions_as_empty(self) -> None: + payload = { + "recommended_next": None, + "ordinary_fallback": None, + "work_orders": {"current": [], "held": [], "staged": []}, + "runs": [], + "active_arcs": [ + { + "id": "task-a", + "phase": "implement", + "arc_name": "operations-continuity", + "arc_outcome": "one connected operator workflow", + "arc_done_when": "every acceptance seam passes", + } + ], + "worktrees": [], + "issues": { + "available": False, + "reason": "transport unavailable", + "items": [], + }, + "freshness": {"work_orders": True, "ledgers": True}, + "consistency": {"ok": True, "warnings": [], "errors": []}, + } + unavailable = project_status.human(payload) + self.assertIn( + "Next: decision-label state unavailable; restore the harvest read " + "before selecting work", + unavailable, + ) + self.assertIn("Waiting arc: operations-continuity", unavailable) + self.assertNotIn("Next: continue active arc", unavailable) + + payload["issues"] = {"available": True, "reason": None, "items": []} + available = project_status.human(payload) + self.assertIn("Next: continue active arc operations-continuity", available) + self.assertNotIn("Waiting arc:", available) + + def test_tick_brief_presents_intake_queues_in_binding_order(self) -> None: + repo = Path(__file__).resolve().parent.parent + brief = (repo / "tools/tick-brief.sh").read_text(encoding="utf-8") + harvest = brief.index('section "decision labels') + arc = brief.index('section "active arc') + findings = brief.index('section "findings queue') + fresh = brief.index('section "coverage, stalest first') + self.assertLess(harvest, arc) + self.assertLess(arc, findings) + self.assertLess(findings, fresh) def test_landing_lock_refuses_live_holder_and_reaps_dead_holder(self) -> None: repo = Path(__file__).resolve().parent.parent diff --git a/tools/test_tangled_issues.py b/tools/test_tangled_issues.py --- a/tools/test_tangled_issues.py +++ b/tools/test_tangled_issues.py @@ -69,6 +69,103 @@ class TangledIssueFixtures(unittest.TestCase): + def test_tg_commands_explicitly_address_did_only_origin(self) -> None: + self.assertEqual( + ["issue", "list", tangled_issues.REPO_HANDLE], + tangled_issues._repo_scoped_tg_args(["issue", "list"]), + ) + self.assertEqual( + [ + "issue", + "view", + "rkey", + "--repo", + tangled_issues.REPO_HANDLE, + ], + tangled_issues._repo_scoped_tg_args(["issue", "view", "rkey"]), + ) + self.assertEqual( + ["api", "com.atproto.server.getSession", "-X", "GET"], + tangled_issues._repo_scoped_tg_args( + ["api", "com.atproto.server.getSession", "-X", "GET"] + ), + ) + + def test_edit_confirms_repository_scoped_target_before_unscoped_tg_write(self) -> None: + calls = [] + + def run_tg(args): + calls.append(list(args)) + if args[:2] == ["issue", "view"]: + return {"rkey": "issue-rkey", "title": "before"} + if args[:2] == ["issue", "edit"]: + return {"rkey": "issue-rkey", "title": "after"} + raise AssertionError(args) + + issues = tangled_issues.Issues(FakeRecords(), run_tg=run_tg) + issue = { + "uri": ( + f"at://{tangled_issues.REPO_OWNER_DID}/" + f"{tangled_issues.ISSUE_COLLECTION}/issue-rkey" + ), + "rkey": "issue-rkey", + } + result = issues.edit(issue, title="after", body=None) + + self.assertEqual("after", result["title"]) + self.assertEqual( + [ + ["issue", "view", "issue-rkey"], + ["issue", "edit", "issue-rkey", "--title", "after"], + ], + calls, + ) + self.assertEqual( + [ + "issue", + "view", + "issue-rkey", + "--repo", + tangled_issues.REPO_HANDLE, + ], + tangled_issues._repo_scoped_tg_args(calls[0]), + ) + self.assertEqual(calls[1], tangled_issues._repo_scoped_tg_args(calls[1])) + + def test_edit_rejects_unconfirmed_or_foreign_target_without_writing(self) -> None: + calls = [] + + def run_tg(args): + calls.append(list(args)) + return {"rkey": "different-rkey"} + + issues = tangled_issues.Issues(FakeRecords(), run_tg=run_tg) + authored = { + "uri": ( + f"at://{tangled_issues.REPO_OWNER_DID}/" + f"{tangled_issues.ISSUE_COLLECTION}/issue-rkey" + ), + "rkey": "issue-rkey", + } + with self.assertRaisesRegex( + tangled_issues.IssueToolError, "repository-scoped view did not confirm" + ): + issues.edit(authored, title="after", body=None) + self.assertEqual([["issue", "view", "issue-rkey"]], calls) + + foreign = { + "uri": ( + "at://did:plc:someone-else/" + f"{tangled_issues.ISSUE_COLLECTION}/issue-rkey" + ), + "rkey": "issue-rkey", + } + with self.assertRaisesRegex( + tangled_issues.IssueToolError, "is not an authored Misaligned issue" + ): + issues.edit(foreign, title="after", body=None) + self.assertEqual([["issue", "view", "issue-rkey"]], calls) + def test_numeric_address_is_created_at_then_uri_order(self) -> None: records = FakeRecords() records.add_operation( diff --git a/tools/tick-brief.sh b/tools/tick-brief.sh --- a/tools/tick-brief.sh +++ b/tools/tick-brief.sh @@ -3,7 +3,8 @@ # Binding: wiki/process/agent-scale.md slice L; procedure wiki/process/tick.md. # # Emits, in intake order: recent commits, activity, project status, decision -# labels (tg-backed public records, best-effort), the findings queue, and the stalest coverage +# labels (tg-backed public records, best-effort), one eligible active arc, the findings +# queue, and the stalest coverage # rows from wiki/process/tick-ledger.md. Sections degrade to a one-line note # instead of failing the brief. set -uo pipefail @@ -16,42 +17,101 @@ printf '\n== %s ==\n' "$1" } +status_json="" +status_available=0 +if [ -f tools/project-status.py ]; then + if status_json=$(python3 tools/project-status.py --json 2>/dev/null); then + status_available=1 + fi +fi + section "recent commits" git log --oneline -15 || echo "(git log unavailable)" section "live worktrees" git worktree list || echo "(worktree list unavailable)" -section "project status" -if [ -f tools/project-status.py ]; then - python3 tools/project-status.py || echo "(project status failed)" +section "project status (same captured snapshot)" +if [ "$status_available" -eq 1 ]; then + printf '%s\n' "$status_json" | python3 tools/project-status.py --render-json \ + || echo "(project status snapshot rendering failed)" +elif [ -f tools/project-status.py ]; then + echo "(project status unavailable)" else echo "(tools/project-status.py missing)" fi section "decision labels (harvest decision-made first)" -if command -v tg >/dev/null 2>&1 && [ -f tools/tangled_issues.py ]; then - decision_json=$(python3 tools/tangled_issues.py list --state open 2>/dev/null) - decision_status=$? - if [ "$decision_status" -eq 0 ]; then - python3 -c ' +if [ "$status_available" -eq 1 ]; then + python3 -c ' import json, sys -issues = json.load(sys.stdin)["issues"] -rows = [] -for issue in issues: - labels = sorted(label["name"] for label in issue.get("labels", []) if label["name"].startswith("decision-")) - if labels: - rows.append(("decision-made" not in labels, issue["number"], labels, issue)) -for _, number, labels, issue in sorted(rows): - print("#{} [{}] {} ({})".format(number, ",".join(labels), issue["title"], issue["rkey"])) -if not rows: - print("(none)") -' <<< "$decision_json" || echo "(decision-label formatting failed)" - else - echo "(tg-backed decision-label read failed)" - fi +payload = json.load(sys.stdin) +state = payload.get("issues", {}) +if not state.get("available", False): + print("(tg-backed decision-label read failed: {})".format(state.get("reason", "unavailable"))) +else: + rows = [] + for issue in state.get("items", []): + labels = sorted( + label["name"] + for label in issue.get("labels", []) + if label["name"].startswith("decision-") + ) + if labels: + rows.append(("decision-made" not in labels, issue["number"], labels, issue)) + for _, number, labels, issue in sorted(rows): + print("#{} [{}] {} ({})".format(number, ",".join(labels), issue["title"], issue["rkey"])) + if not rows: + print("(none)") +' <<< "$status_json" || echo "(decision-label formatting failed)" +elif [ -f tools/project-status.py ]; then + echo "(project status unavailable)" else - echo "(tg or tools/tangled_issues.py unavailable)" + echo "(tools/project-status.py missing)" +fi + +section "active arc (continue before ordinary fallback)" +if [ "$status_available" -eq 1 ]; then + python3 -c ' +import json +import sys + +payload = json.load(sys.stdin) +arcs = payload.get("active_arcs", []) +issue_state = payload.get("issues", {}) +decision_made = sorted( + ( + item for item in issue_state.get("items", []) + if any(label.get("name") == "decision-made" for label in item.get("labels", [])) + ), + key=lambda item: item.get("number", 999999), +) +if not arcs: + if not issue_state.get("available", False): + print("(none selected; decision labels are unavailable, so intake is paused)") + elif decision_made: + print("(none selected; harvest decision-made issue #{} before the findings queue)".format(decision_made[0].get("number", "?"))) + else: + print("(none; continue to the findings queue)") +else: + for run in arcs: + print("{}: {} (phase={})".format(run.get("id", "?"), run["arc_name"], run.get("phase", "?"))) + print(" outcome: {}".format(run["arc_outcome"])) + print(" done when: {}".format(run["arc_done_when"])) + print(" worktree: {}".format(run.get("worktree", "?"))) + if len(arcs) > 1: + print("WARNING: more than one eligible active arc exists; reconcile ownership before dispatch") + elif not issue_state.get("available", False): + print(" intake: PAUSED; decision labels are unavailable, so the harvest queue is unknown") + elif decision_made: + print(" intake: harvest decision-made issue #{} before continuing this arc".format(decision_made[0].get("number", "?"))) + else: + print(" intake: continue this arc; the decision-made harvest queue is empty") +' <<< "$status_json" || echo "(active-arc formatting failed)" +elif [ -f tools/project-status.py ]; then + echo "(project status unavailable)" +else + echo "(tools/project-status.py missing)" fi section "findings queue (act on these before fresh audit)" diff --git a/tools/worktree-new.sh b/tools/worktree-new.sh --- a/tools/worktree-new.sh +++ b/tools/worktree-new.sh @@ -4,7 +4,7 @@ # # Usage: # tools/worktree-new.sh [--class ] \ -# [--key path]... [--no-seed] [--no-activity] +# [--key path]... [--base-revision revision] [--no-seed] [--no-activity] # # Creates (default; override root with MISALIGNED_WORKTREE_ROOT): # .Codex/worktrees/ on branch worktree- from origin/main @@ -39,11 +39,17 @@ seed=1 do_claim=1 keys=() +base_revision="" +base_revision_set=0 while [ $# -gt 0 ]; do case "$1" in --class) shift; class="${1:-}"; shift || true ;; --key) shift; keys+=("${1:-}"); shift || true ;; + --base-revision) + base_revision_set=$((base_revision_set + 1)); shift + base_revision="${1:-}"; shift || true + ;; --no-seed) seed=0; shift ;; --no-activity|--no-claim) do_claim=0; shift ;; -h|--help) usage ;; @@ -63,6 +69,12 @@ done [ -n "$task" ] || usage +[ "$base_revision_set" -le 1 ] && { + [ "$base_revision_set" -eq 0 ] || [ -n "$base_revision" ]; +} || { + echo "FAIL: --base-revision requires one non-empty revision" >&2 + exit 2 +} case "$task" in */*|*" "*|worktree-*) echo "FAIL: task name should be a short slug (e.g. dark-frame), not '$task'" >&2 @@ -74,6 +86,20 @@ if ! git rev-parse --verify origin/main >/dev/null 2>&1; then echo "FAIL: origin/main not available; fetch first" >&2 exit 1 +fi + +if [ -n "$base_revision" ]; then + base_revision=$(git rev-parse --verify "${base_revision}^{commit}" 2>/dev/null) || { + echo "FAIL: base revision is not a commit" >&2 + exit 1 + } + [ "$base_revision" = "$(git rev-parse --verify 'HEAD^{commit}')" ] && \ + [ "$base_revision" = "$(git rev-parse --verify 'origin/main^{commit}')" ] || { + echo "FAIL: base revision must be the exact current primary and origin/main revision" >&2 + exit 1 + } +else + base_revision=$(git rev-parse --verify 'origin/main^{commit}') fi wt="$worktree_root/$task" @@ -88,9 +114,9 @@ exit 1 fi -echo "worktree-new: creating $wt on $branch from origin/main" +echo "worktree-new: creating $wt on $branch from $base_revision" mkdir -p "$worktree_root" -git worktree add -b "$branch" "$wt" origin/main +git worktree add -b "$branch" "$wt" "$base_revision" if [ "$seed" -eq 1 ]; then echo "worktree-new: seeding cargo target" diff --git a/wiki/engineering/env.md b/wiki/engineering/env.md --- a/wiki/engineering/env.md +++ b/wiki/engineering/env.md @@ -79,6 +79,10 @@ | `MISALIGNED_RUST_GATE_LOCK` | `tools/check.sh` | path | Override the Rust gate lock directory (default `/tmp/misaligned-rust-gate.lock`). | | `MISALIGNED_LANDING_WAIT` | `tools/task.sh finish` | `0` or `1` (default `1`) | When another task owns the final landing lock, `1` queues; `0` fails immediately. | | `MISALIGNED_LANDING_LOCK` | `tools/task.sh finish` | path | Override the final rebase/check/merge/push lock directory (default `/tmp/misaligned-landing.lock`). | +| `MISALIGNED_ARC_NAME` | `tools/task.sh finish --arc-verify` | non-empty string | Internal verifier input: the exact active arc name from the task heartbeat. Set only while the explicit arc-completion verifier runs. | +| `MISALIGNED_ARC_OUTCOME` | `tools/task.sh finish --arc-verify` | non-empty string | Internal verifier input: the exact larger outcome from the task heartbeat. Set only while the explicit arc-completion verifier runs. | +| `MISALIGNED_ARC_DONE_WHEN` | `tools/task.sh finish --arc-verify` | non-empty string | Internal verifier input: the exact completion condition from the task heartbeat. Set only while the explicit arc-completion verifier runs. | +| `MISALIGNED_LANDED_REVISION` | `tools/task.sh finish --arc-verify` | Git revision | Internal verifier input: the committed task revision whose arc completion is being tested. Set only while the explicit arc-completion verifier runs. | | `MISALIGNED_RUNS_DIR` | `tools/heartbeat.sh` | path | Override run heartbeat dir (default `/.agents/runs`, gitignored). | | `MISALIGNED_WORKTREE_ROOT` | `tools/worktree-new.sh`, `tools/worktree-done.sh` | absolute path or repository-relative path | Override the canonical task-worktree root (default `/.Codex/worktrees`). Creation and cleanup helpers must receive the same override. | | `MISALIGNED_LEDGER_MODE` | `tools/ledger_index.sh` | `write` or `check` | Internal: write regenerated indexes or fail if stale (set by the script, not hand-used). | diff --git a/wiki/log/2026-08-01-arc-continuity.md b/wiki/log/2026-08-01-arc-continuity.md new file mode 100644 --- /dev/null +++ b/wiki/log/2026-08-01-arc-continuity.md @@ -0,0 +1,59 @@ +# Keep one autonomous outcome visible across task boundaries + +``` +Type: log +Status: COMPLETE +Date: 2026-08-01 +Perspective: process +``` + +Hourly autonomous work previously had exact bounded task heartbeats but no +machine-readable statement of the larger outcome a sequence of tasks was +advancing. A later tick could therefore see a healthy project and recommend an +unrelated work order even while Trace was still inside a coherent multi-task +arc. Keeping that context only in conversation or a branch name made it +invisible to project tooling and fragile across invocations. + +Heartbeat `status.json` records now accept one atomic three-field extension: + +- `arc_name` — stable concise identity; +- `arc_outcome` — the larger consequence being pursued; +- `arc_done_when` — the falsifiable evidence that ends the arc. + +The fields remain flat so the existing run record stays the sole coordination +object. Heartbeat phase and end transitions preserve all three. Project status +validates and displays them, while tick intake selects only one running arc +owned by the current agent and bound to a registered worktree. Foreign, +terminal, malformed, missing-worktree, or multiple eligible records do not +silently steer work. + +`tools/task.sh start` forwards the complete arc through normal and dry-run task +startup. `tools/tick-brief.sh` presents decision labels before the eligible arc, +and the project dashboard makes that same harvest-first priority explicit before +continuation. A failed decision-label read leaves that queue unknown and pauses +automatic selection while the rest of the brief remains visible; transport +failure cannot masquerade as permission to continue the arc. JSON keeps the ordinary +work-order fallback as a separate field while an arc or harvest owns the next action, +so structured clients retain both precedence and the deferred queue. Tangled commands +now address Misaligned explicitly; because `tg issue edit` accepts only a bare rkey, +the helper confirms that exact authored target through a repository-scoped read before +issuing the write. This keeps connected work continuous while retaining a hard +completion edge and the existing bounded one-act-per-tick discipline. + +Task finish now makes that boundary executable. A completion verifier runs once +against the exact clean task revision before main or origin can move; a +continuing arc instead starts one successor from the landed revision with the +same owner and arc fields. The disposable-repository regression also caught and +removed Python bytecode written by successor preflight, which had violated the +clean-worktree guarantee it was meant to protect. + +## Verification + +- `python3 tools/test_project_ops.py` +- `./tools/check.sh --docs` + +Defense: [agent-scale.md](../process/agent-scale.md) makes heartbeat runs the +current liveness truth and [tick.md](../process/tick.md) makes intake ordering +explicit. Carrying an atomic, owner-checked done-when arc on that same run lets +autonomous work preserve a coherent outcome without introducing another stale +status system. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -11,6 +11,11 @@ +## 2026-08-01 - Keep one autonomous outcome visible across task boundaries + +- Intent: (see session log) +- Log: [wiki/log/2026-08-01-arc-continuity.md](2026-08-01-arc-continuity.md) + ## 2026-07-31 - Human eyewitnesses are not device subscribers - Intent: Keep the self-similar observation law without inventing a shared membership store for physically different carriers. diff --git a/wiki/log/decisions.md b/wiki/log/decisions.md --- a/wiki/log/decisions.md +++ b/wiki/log/decisions.md @@ -25,6 +25,7 @@ - [2026-07-19](decisions/2026-07-19.md) - [2026-07-26](decisions/2026-07-26.md) - [2026-07-29](decisions/2026-07-29.md) +- [2026-08-01](decisions/2026-08-01.md) Append new decisions to the current date's volume. Never rewrite an older volume; supersede it in current law/spec and record the newer decision. diff --git a/wiki/process/agent-scale.md b/wiki/process/agent-scale.md --- a/wiki/process/agent-scale.md +++ b/wiki/process/agent-scale.md @@ -9,7 +9,9 @@ heartbeats; spec-owned work-order metadata plus generated ROADMAP status; human/JSON project status; dispatch-consistency fixtures; deterministic agent scenarios; safe task lifecycle wrapper with serialized final landing; - read-only environment doctor; and tick intake memory/briefing. + read-only environment doctor; and tick intake memory/briefing. On 2026-08-01, + heartbeat status gained optional all-or-none arc metadata so one active + multi-task outcome can remain visible across ordinary tick selection. Stage: Process Work order: project-operations Work priority: 5 @@ -65,7 +67,7 @@ | Packages | Workspace shape in [crate-workspace.md](../engineering/crate-workspace.md) (deferred) | | Docs gates | Fast, testable Python corpus/wiki engine with fixture contract | | Bevy evidence | Headless/offscreen land proof, not only interactive windows | -| Observability | Heartbeat files so "is it stuck?" is answerable | +| Observability | Heartbeat files answer "is it stuck?" and may name the larger outcome one task advances | | Dispatch truth | Specs own structured work metadata; ROADMAP receives a generated live index | | Project status | One human/JSON command combines work orders, runs, worktrees, issues, and freshness | | Scenario evidence | Deterministic agent scripts emit transcripts and machine-readable evidence | @@ -333,6 +335,10 @@ ```bash tools/heartbeat.sh start --worktree path --phase boot +tools/heartbeat.sh start --worktree path --phase boot \ + --arc-name operations-continuity \ + --arc-outcome 'one connected operator workflow' \ + --arc-done-when 'every acceptance seam passes' tools/heartbeat.sh phase 'check-land' tools/heartbeat.sh end --status ok|fail ``` @@ -342,12 +348,28 @@ files instead of empty stdout pipes. Dispatch prompts should call start/ phase/end around long runs (implement-gap contract). +A run may additionally carry the flat string fields `arc_name`, `arc_outcome`, +and `arc_done_when`. The three fields are optional but atomic: if one is +present, all three must be non-empty. `phase` and `end` preserve them exactly; +neither lifecycle transition may reconstruct or silently clear the arc. Arc +metadata is coordination context, not another lifecycle or status authority. +The run still owns `status`, `phase`, and its exact worktree, while the arc says +which larger outcome this bounded task advances and what evidence ends that +outcome. + ### Acceptance criteria (slice G) — HELD 2026-07-09 (tool + docs) 1. Documented convention + helper to start/update a run record — HELD. 2. implement-gap / prompts path documents heartbeat use — HELD (prompts README + implement-gap pointer; agents still must call the tool). 3. Paths are gitignored; convention is written in process docs — HELD. +4. Optional arc metadata is all-or-none, survives start/phase/end byte for + byte, and remains visible in project-status human and JSON output — HELD + 2026-08-01. +5. New heartbeat records persist the creating `$AGENT_ID`; active-arc intake + selects only an exact current-agent owner with complete metadata and a + registered worktree, while legacy unowned records remain valid but cannot + steer intake — HELD 2026-08-01. ## 8. Structured work orders and project status @@ -369,14 +391,24 @@ combines, without mutating the repository: - ready, active, blocked, and staged work orders in priority order; -- advisory activity and heartbeat/run state; +- heartbeat/run state, including valid optional arc context; - every worktree's dirty, ahead/behind, branch, and last-commit age; - Tangled decision issues when the local CLI is available/authenticated; - generated-index freshness and a short recommended-next-lane read. Human output is concise. `--json` emits the same facts for agents and other -tools. Network/auth failure degrades the issue field to `unavailable`; it does -not make local project status unusable. +tools. `--render-json` accepts only that complete typed snapshot shape at its +stdin trust boundary and rejects malformed nested objects with a focused error +rather than a traceback. Network/auth failure degrades the issue field to +`unavailable`; it does not make local project status unusable. + +An **active arc** is not inferred from prose or branch names. It is exactly a +`running` heartbeat with all three valid arc fields, an exact registered +worktree, and `agent_id` equal to the current `$AGENT_ID`. `project-status` +unfolds that context under the owning run and consistency-checks the all-or-none +shape. A missing agent identity, a foreign agent, a terminal run, or a partial +arc is never eligible to steer the current agent. More than one eligible arc is +an ambiguity warning, not permission to choose whichever looks convenient. ### Acceptance criteria (slice H) — HELD 2026-07-10 @@ -391,6 +423,9 @@ 4. At least one fixture proves a blocked spec cannot be recommended as the next lane and a later-stage READY spec remains staged rather than becoming an accidental B1 dispatch. +5. `--render-json` validates the complete nested snapshot schema and rejects + missing or mistyped work-order, arc, run, worktree, issue, freshness, or + consistency fields without a Python traceback. ## 9. Dispatch consistency gate @@ -468,16 +503,27 @@ ``` `start` resolves task/class/likely paths from the spec and creates the seeded -worktree with an activity record. `check` moves activity/heartbeat to checking +worktree with a heartbeat run. Optional `--arc-name`, `--arc-outcome`, and +`--arc-done-when` arguments are forwarded atomically to that run, including in +`--dry-run` output. `check` moves the heartbeat to checking and runs the land gate. `finish` requires a clean committed branch, rebases onto -current `origin/main`, reruns the land gate, fast-forwards a clean current +current `origin/main`, reruns the land gate, and verifies the exact task revision +against any persisted `arc_done_when` before it can fast-forward the clean current +primary checkout or push main. The verifier reads the same +`MISALIGNED_RUNS_DIR` override as heartbeat ownership, so an isolated run store +cannot split lifecycle truth. A false or malformed completion check leaves both +local and remote main untouched. An unfinished arc instead uses `--arc-next` +with one repository-relative work-order spec. Finish validates that successor +without dirtying the current task, then starts it from the exact landed revision +with the same owner and arc metadata before retiring the current heartbeat. +Once the completion or continuation boundary passes, finish fast-forwards the primary checkout, pushes main, ends the heartbeat, and removes the task worktree. One machine-local landing lock serializes that final -rebase/check/merge/push window. A failed rebase, check, relationship check, or -push stops before -cleanup. It never commits, stashes, resets, force-pushes, or guesses through a -conflict. `abandon` uses the existing clean-worktree refusal unless explicitly -handled by the operator through the lower-level helper. +rebase/check/verify/merge/push window. A failed rebase, check, relationship +check, arc completion check, or push stops before cleanup. It never commits, +stashes, resets, force-pushes, or guesses through a conflict. `abandon` uses the +existing clean-worktree refusal unless explicitly handled by the operator +through the lower-level helper. `tools/doctor.sh` performs read-only checks for required commands, Cargo workspace health, Tangled context/auth availability, worktree/activity state, @@ -499,6 +545,11 @@ refusal paths without creating or pushing a real task branch. 6. The final landing lock refuses a live holder, reaps a dead holder, and cannot be confused with advisory path activity. +7. Finish resolves ownership and arc metadata from one override-aware run store, + and any arc completion verifier runs exactly once against the task revision + before primary-main merge or remote push. +8. `--arc-next` preflights without dirtying the current task and starts exactly + one successor from the landed revision with unchanged owner and arc metadata. ## 12. Tick intake memory and briefing @@ -508,20 +559,35 @@ context, re-picking slices blind, and discarding surplus discovery. The state lives in `wiki/process/tick-ledger.md` (a coverage table and a findings queue, hand-edited by ticks per `wiki/process/tick.md`); the intake command -is `tools/tick-brief.sh`, which emits recent commits, activity, project -status, Tangled decision labels, the findings queue, and the stalest -coverage rows in one shot. Intake order is fixed: harvest `decision-made` -issues, then the findings queue, then a fresh audit of the stalest slice. +is `tools/tick-brief.sh`, which emits recent commits, project status, Tangled +decision labels, one eligible Trace-owned active arc, the findings queue, and +the stalest coverage rows in one shot. Project status, decision labels, and +arc guidance consume one captured issue snapshot, so a transport transition +cannot make adjacent sections disagree. Intake order is fixed: harvest a live +`decision-made` obligation first; otherwise continue the one valid active arc +before recommending another ordinary work order; only with no eligible arc does +intake proceed to the findings queue and then a fresh audit of the stalest +slice. An unavailable decision-label read leaves the highest-priority queue +unknown: the brief still renders lower sections for observability but pauses +automatic selection rather than treating transport failure as an empty harvest +queue. The brief never treats an invalid, foreign, terminal, or ambiguous arc +as selected work. Recurring mechanical finding classes are promoted into `tools/corpus_engine.py` checkers rather than re-found by ticks. ### Acceptance criteria (slice L) — HELD 2026-07-11 -1. `tools/tick-brief.sh` runs from a task worktree and degrades each - unavailable section (missing `tg` or `tools/tangled_issues.py`) to a note - instead of failing the brief. The helper reconstructs deterministic issue - numbers and joins public label-op state into structured JSON; `tg` remains - the authority for repository discovery and authenticated issue writes. +1. `tools/tick-brief.sh` runs from a task worktree, captures decision state + once, and degrades each unavailable section (missing `tg` or + `tools/tangled_issues.py`) to a note instead of failing the brief. The helper + reconstructs deterministic issue numbers and joins public label-op state + into structured JSON. It passes the canonical + `cameron.stream/misaligned` address to `tg` explicitly because the Git + origin is repository-DID-only and current `tg` builds cannot discover it. + The current `issue edit` command accepts only an rkey, so the helper must + first confirm that exact authored rkey through an explicitly repository-scoped + `issue view` immediately before the write; it may not make the target implicit. + `tg` remains the authority for issue transport and authenticated writes. 2. The coverage table renders stalest-first in the brief, and a quiet tick can record a `clean` verdict as its trace. 3. The findings queue holds one-line surplus findings that a later tick can @@ -529,6 +595,10 @@ 4. `wiki/process/tick.md` step 0 and the tick skill shims reference the brief and the queues, so a fresh audit is the fallback rather than the default. +5. The brief selects exactly one valid current-agent running arc before + ordinary work-order recommendations, degrades invalid/foreign/completed + records to no active arc, and warns rather than choosing when several arcs + are eligible — HELD 2026-08-01. ## Relationship to the crate workspace diff --git a/wiki/process/tick.md b/wiki/process/tick.md --- a/wiki/process/tick.md +++ b/wiki/process/tick.md @@ -12,16 +12,27 @@ ## Taking a tick 0. **Brief, then work the queues in order.** Run `tools/tick-brief.sh` for - the whole intake picture in one shot: recent commits, activity, project - status, decision labels, the findings queue, and the stalest coverage - rows. Then take the first queue that has work: + the whole intake picture in one shot: recent commits, project status, any + decision labels, one valid current-agent active arc, the findings queue, and + the stalest coverage rows. Then take the first queue that has work. If the + decision-label read is unavailable, the harvest queue is unknown: inspect the + lower sections, but pause automatic selection rather than treating failure as + an empty queue: 1. **Harvest** — a `decision-made` issue. Cameron already decided; converting his answer into law/spec motion is the highest value per tick. Convert, close, done. - 2. **Findings queue** — a recorded surplus finding in the + 2. **Continue the active arc** — when exactly one `running` heartbeat belongs + to the current `$AGENT_ID`, points at its registered worktree, and carries + all of `arc_name`, `arc_outcome`, and `arc_done_when`, continue that + outcome before recommending an unrelated work order. The done-when clause + is the exit test, not permission to keep an arc alive by momentum; task + finish must prove it on the exact task revision before main or the remote + can advance. A partial, foreign, completed, worktree-less, or ambiguous + set of arc records cannot steer intake. + 3. **Findings queue** — a recorded surplus finding in the [tick ledger](tick-ledger.md). Verify it is still true (the code or corpus may have moved since it was recorded), then act on it. - 3. **Fresh audit** — only when both queues are empty. Pick the stalest + 4. **Fresh audit** — only when every earlier queue is empty. Pick the stalest slice from the ledger's coverage table (or a slice the ledger has never seen). Fresh discovery is the fallback, not the default. 1. For a fresh audit: read the [corpus map](../overview.md) and the chosen diff --git a/.agents/skills/tick/SKILL.md b/.agents/skills/tick/SKILL.md --- a/.agents/skills/tick/SKILL.md +++ b/.agents/skills/tick/SKILL.md @@ -1,6 +1,6 @@ --- name: tick -description: Take a tick — the project heartbeat. Run the intake brief, work the queues (decision-made harvest, then the findings queue, then a fresh stalest-slice audit) for violations, contradictions, questions, bugs, or insecurities, then act on exactly one finding. Use at the start of repository work, when the user says "take a tick" or "/tick", or on autonomous loop fires. Do not run for a conversation-only design-companion turn. +description: Take a tick — the project heartbeat. Run the intake brief, work the queues (decision-made harvest, one eligible current-agent active arc, then the findings queue, then a fresh stalest-slice audit) for violations, contradictions, questions, bugs, or insecurities, then act on exactly one finding. Use at the start of repository work, when the user says "take a tick" or "/tick", or on autonomous loop fires. Do not run for a conversation-only design-companion turn. --- # Tick (skill shim) @@ -9,7 +9,8 @@ 0. Run `tools/tick-brief.sh`, then take the first queue with work: `decision-made` issues (harvest Cameron's answer into law/spec motion, - close), then the findings queue in `wiki/process/tick-ledger.md` + close), then continue one eligible current-agent active arc, then the + findings queue in `wiki/process/tick-ledger.md` (re-verify the line still holds, act, delete it), then — only when both are empty — a fresh audit of the stalest coverage-table slice. 1. For a fresh audit: read the corpus map and the law/spec/dependency slice. diff --git a/.claude/skills/tick/SKILL.md b/.claude/skills/tick/SKILL.md --- a/.claude/skills/tick/SKILL.md +++ b/.claude/skills/tick/SKILL.md @@ -1,6 +1,6 @@ --- name: tick -description: Take a tick — the project heartbeat. Run the intake brief, work the queues (decision-made harvest, then the findings queue, then a fresh stalest-slice audit) for violations, contradictions, questions, bugs, or insecurities, then act on exactly one finding. Use at the start of repository work, when the user says "take a tick" or "/tick", or on autonomous loop fires. Do not run for a conversation-only design-companion turn. +description: Take a tick — the project heartbeat. Run the intake brief, work the queues (decision-made harvest, one eligible current-agent active arc, then the findings queue, then a fresh stalest-slice audit) for violations, contradictions, questions, bugs, or insecurities, then act on exactly one finding. Use at the start of repository work, when the user says "take a tick" or "/tick", or on autonomous loop fires. Do not run for a conversation-only design-companion turn. --- # Tick (skill shim) @@ -9,7 +9,8 @@ 0. Run `tools/tick-brief.sh`, then take the first queue with work: `decision-made` issues (harvest Cameron's answer into law/spec motion, - close), then the findings queue in `wiki/process/tick-ledger.md` + close), then continue one eligible current-agent active arc, then the + findings queue in `wiki/process/tick-ledger.md` (re-verify the line still holds, act, delete it), then — only when both are empty — a fresh audit of the stalest coverage-table slice. 1. For a fresh audit: read the corpus map and the law/spec/dependency slice. diff --git a/wiki/log/decisions/2026-08-01.md b/wiki/log/decisions/2026-08-01.md new file mode 100644 --- /dev/null +++ b/wiki/log/decisions/2026-08-01.md @@ -0,0 +1,43 @@ +# Decisions — 2026-08-01 + +``` +Type: log +``` + +## Active task arcs are explicit heartbeat context + +### DECIDED + +- A heartbeat run may carry three flat optional fields: `arc_name`, + `arc_outcome`, and `arc_done_when`. They are all-or-none and remain identical + through heartbeat phase and end transitions. +- An arc is eligible to steer intake only while its run is `running`, belongs + to the current agent, and points at an exact registered worktree. Arc context + never replaces task status, phase, worktree identity, or the work-order + catalog. +- After a live human decision obligation, one eligible active arc outranks an + unrelated ordinary work-order recommendation. An unavailable decision-label + read leaves that higher-priority queue unknown and pauses automatic selection. + Several eligible arcs are an ambiguity to surface, not a ranking problem to + guess through. +- `arc_done_when` is a falsifiable exit condition. Once it holds, the run must + end normally; arc continuity cannot become an excuse for permanent scope. + Task finish proves that condition against the exact task revision before any + primary-main merge or remote push, using the same override-aware run store as + heartbeat ownership. A failed completion check is therefore transactional. +- Captured project-status JSON is an external snapshot at render time. Its full + nested typed shape is validated before rendering; malformed input is a focused + refusal, never an internal traceback or partially trusted status picture. + +### Rejected + +- **A separate arc registry.** Another persisted owner would drift from the + heartbeat and recreate the stale coordination ledgers already retired by the + project. +- **Inferring arcs from task names, branch names, or prose.** Those strings do + not prove ownership, liveness, or completion evidence. +- **Letting an arc outrank a recorded human decision.** Continuity protects + connected autonomous work; it does not postpone Cameron's explicit answer. + +Owner: [agent-scale.md](../../process/agent-scale.md) and +[tick.md](../../process/tick.md).