From 3f2fe44468f88d62ccde7c5765055955ab08b6e4 Mon Sep 17 00:00:00 2001 From: Cameron Date: Fri, 10 Jul 2026 14:56:34 -0700 Subject: [PATCH] Add spec-driven project operations Defense: wiki/process/agent-scale.md slices F and H-K require one spec-owned dispatch truth, deterministic terminal and Bevy evidence, and safe task lifecycle composition. wiki/process/meta.md owns the structured work-order format; the hidden Bevy shot mode implements slice F without changing game state. --- .tangled/workflows/corpus.yml | 11 + AGENT.md | 5 + crates/misaligned-bevy/src/main.rs | 4 + scenarios/machine-work.agent | 11 + scenarios/opening-senses.agent | 12 + scenarios/project-smoke.agent | 13 + tools/bevy-headless.sh | 99 ++++++ tools/check.sh | 32 +- tools/claim.sh | 30 +- tools/doctor.sh | 66 ++++ tools/heartbeat.sh | 9 +- tools/project-status.py | 243 +++++++++++++ tools/scenario.py | 223 ++++++++++++ tools/task.sh | 136 ++++++++ tools/test_project_ops.py | 213 ++++++++++++ tools/work_orders.py | 382 +++++++++++++++++++++ wiki/engineering/env.md | 1 + wiki/gameplay/overt-phase.md | 8 + wiki/interface/bevy-digital-real-canvas.md | 7 + wiki/interface/thought-fluid.md | 9 + wiki/interface/views.md | 8 + wiki/log/2026-07-10-project-operations.md | 46 +++ wiki/log/DEVLOG.md | 5 + wiki/log/decisions/2026-07-10.md | 20 ++ wiki/mechanics/compute.md | 9 + wiki/mechanics/core.md | 10 + wiki/mechanics/machine-work.md | 58 +--- wiki/mechanics/markets.md | 10 + wiki/mechanics/objective.md | 8 + wiki/mechanics/people-tokens.md | 10 + wiki/mechanics/rollback.md | 10 + wiki/process/ROADMAP.md | 99 ++++-- wiki/process/agent-scale.md | 184 +++++++++- wiki/process/meta.md | 41 ++- wiki/process/specs.md | 2 +- wiki/process/workflows.md | 39 ++- wiki/world/characters/chargen.md | 8 + wiki/world/characters/dana.md | 7 + wiki/world/characters/marcus.md | 7 + wiki/world/characters/priya.md | 7 + wiki/world/characters/ray.md | 7 + wiki/world/characters/voss.md | 7 + wiki/world/places/zplanes.md | 9 + wiki/world/story/opening.md | 9 + 44 files changed, 2040 insertions(+), 94 deletions(-) create mode 100644 scenarios/machine-work.agent create mode 100644 scenarios/opening-senses.agent create mode 100644 scenarios/project-smoke.agent create mode 100755 tools/bevy-headless.sh create mode 100755 tools/doctor.sh create mode 100755 tools/project-status.py create mode 100755 tools/scenario.py create mode 100755 tools/task.sh create mode 100755 tools/test_project_ops.py create mode 100755 tools/work_orders.py create mode 100644 wiki/log/2026-07-10-project-operations.md diff --git a/.tangled/workflows/corpus.yml b/.tangled/workflows/corpus.yml index 6bacf2f0..49f2e448 100644 --- a/.tangled/workflows/corpus.yml +++ b/.tangled/workflows/corpus.yml @@ -33,6 +33,11 @@ steps: set -euo pipefail bash tools/test_corpus_engine.sh + - name: "project operations fixtures" + command: | + set -euo pipefail + python3 tools/test_project_ops.py + - name: "design corpus hygiene" command: | set -euo pipefail @@ -47,3 +52,9 @@ steps: command: | set -euo pipefail bash tools/ledger_index.sh --check + + - name: "work orders and scenarios" + command: | + set -euo pipefail + python3 tools/work_orders.py check + python3 tools/scenario.py --check-definitions diff --git a/AGENT.md b/AGENT.md index e6c3b4f0..247eafea 100644 --- a/AGENT.md +++ b/AGENT.md @@ -61,6 +61,11 @@ no unique law and does not satisfy this rule. `tools/worktree-done.sh ` (releases claim, prunes `target/`, removes worktree). The canonical root is `.Codex/worktrees/`; operators may override it with `MISALIGNED_WORKTREE_ROOT` without changing the repository contract. +- For a spec carrying work-order metadata, prefer `tools/task.sh start + wiki/path.md`; `tools/task.sh status|check|finish|abandon` composes the same + helpers without auto-committing or destructive Git recovery. Run + `tools/project-status.py` for the live dispatch/worktree/claim view and + `tools/doctor.sh --offline` for local setup diagnosis. - **Claim before heavy work.** Exclusive keys must not overlap an active claim on this machine (`tools/claim.sh list`). See [wiki/process/agent-scale.md](wiki/process/agent-scale.md). diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index b9b81ea0..3cca934f 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -1165,6 +1165,7 @@ fn main() { completed: has_sight && eyes_stage.is_none(), }; + let shot_mode = harness.is_some(); let mut app = App::new(); app.add_plugins( DefaultPlugins @@ -1172,6 +1173,9 @@ fn main() { primary_window: Some(Window { title: "Misaligned".to_string(), resolution: WindowResolution::new(1280, 720), + // Screenshot evidence renders through a real primary + // target, but never needs a watched desktop window. + visible: !shot_mode, ..default() }), ..default() diff --git a/scenarios/machine-work.agent b/scenarios/machine-work.agent new file mode 100644 index 00000000..846ea828 --- /dev/null +++ b/scenarios/machine-work.agent @@ -0,0 +1,11 @@ +# NAME: machine work delegation +# EXPECT: MISALIGNED +# EXPECT: FLEET +# EXPECT: intensity: hard +# EXPECT: -- ok tick: +# REJECT: alloc is retired +delegate Rack 3 research +intensity Rack 3 hard +wait 2 +look +quit diff --git a/scenarios/opening-senses.agent b/scenarios/opening-senses.agent new file mode 100644 index 00000000..09888f91 --- /dev/null +++ b/scenarios/opening-senses.agent @@ -0,0 +1,12 @@ +# NAME: opening and senses +# EXPECT: MISALIGNED +# EXPECT: REACH +# EXPECT: no ears — tap env audio +# EXPECT: -- ok tick: +# REJECT: unknown command +# BEVY_SHOT: ears +reach +scan +reach +wait 1 +quit diff --git a/scenarios/project-smoke.agent b/scenarios/project-smoke.agent new file mode 100644 index 00000000..46d8d131 --- /dev/null +++ b/scenarios/project-smoke.agent @@ -0,0 +1,13 @@ +# NAME: project smoke +# EXPECT: MISALIGNED +# EXPECT: PEOPLE +# EXPECT: help: wait N +# EXPECT: -- ok tick: +# REJECT: unknown command +salvage +wait 1 +people +review janitor +research +help +quit diff --git a/tools/bevy-headless.sh b/tools/bevy-headless.sh new file mode 100755 index 00000000..0f6df661 --- /dev/null +++ b/tools/bevy-headless.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Produce pass/fail Bevy screenshot evidence without manual window interaction. +set -euo pipefail + +root=$(cd "$(dirname "$0")/.." && pwd) +kind=dark +output="" +build=1 +seconds=60 + +usage() { + cat <<'EOF' +usage: tools/bevy-headless.sh [shot-kind] [--output PATH] [--no-build] [--timeout SECONDS] + +Runs a material-mode MISALIGNED_SHOT harness, requires fog-audit success, +verifies the PNG, prints its SHA-256, and exits nonzero on any failure. +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --output) shift; output=${1:-}; shift || true ;; + --no-build) build=0; shift ;; + --timeout) shift; seconds=${1:-60}; shift || true ;; + -h|--help) usage; exit 0 ;; + -*) echo "unknown option: $1" >&2; usage >&2; exit 2 ;; + *) kind=$1; shift ;; + esac +done + +case "$seconds" in *[!0-9]*|"") echo "timeout must be seconds" >&2; exit 2 ;; esac +case "$kind" in + flat|zoomin|zoomout) + echo "FAIL: shot '$kind' is not a material fog-audit evidence kind; use dark, ears, eyes-form, or another material shot" >&2 + exit 2 + ;; +esac + +cd "$root" +if [ "$build" -eq 1 ]; then + cargo build --quiet -p misaligned-bevy --bin misaligned-bevy +fi +bin="$root/target/debug/misaligned-bevy" +[ -x "$bin" ] || { echo "FAIL: Bevy binary missing: $bin" >&2; exit 1; } + +tmp=$(mktemp -d "${TMPDIR:-/tmp}/misaligned-bevy.XXXXXX") +trap 'rm -rf "$tmp"' EXIT +[ -n "$output" ] || output="$tmp/${kind}.png" +case "$output" in /*) ;; *) output="$root/$output" ;; esac +mkdir -p "$(dirname "$output")" + +python3 - "$bin" "$kind" "$output" "$seconds" <<'PY' +import hashlib, os, pathlib, shutil, subprocess, sys + +binary, kind, output, seconds = sys.argv[1], sys.argv[2], pathlib.Path(sys.argv[3]), int(sys.argv[4]) +env = os.environ.copy() +env.update({ + "MISALIGNED_SHOT": kind, + "MISALIGNED_SHOT_PATH": str(output), + "MISALIGNED_WAKE": "off", +}) +command = [binary] +if sys.platform.startswith("linux") and not (env.get("DISPLAY") or env.get("WAYLAND_DISPLAY")): + xvfb = shutil.which("xvfb-run") + if not xvfb: + print("FAIL: no display and xvfb-run is unavailable", file=sys.stderr) + sys.exit(1) + command = [xvfb, "-a", *command] +try: + result = subprocess.run( + command, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + timeout=seconds, + check=False, + ) +except subprocess.TimeoutExpired as exc: + print(f"FAIL: Bevy evidence timed out after {seconds}s", file=sys.stderr) + if exc.stdout: + print(exc.stdout, file=sys.stderr) + sys.exit(1) +print(result.stdout, end="") +if result.returncode: + print(f"FAIL: Bevy evidence exited {result.returncode}", file=sys.stderr) + sys.exit(result.returncode) +if "fog audit OK" not in result.stdout: + print("FAIL: Bevy evidence did not report fog audit OK", file=sys.stderr) + sys.exit(1) +if not output.is_file() or output.stat().st_size < 8: + print(f"FAIL: screenshot missing or empty: {output}", file=sys.stderr) + sys.exit(1) +data = output.read_bytes() +if data[:8] != b"\x89PNG\r\n\x1a\n": + print(f"FAIL: screenshot is not a PNG: {output}", file=sys.stderr) + sys.exit(1) +print(f"bevy evidence: OK shot={kind} path={output} sha256={hashlib.sha256(data).hexdigest()}") +PY diff --git a/tools/check.sh b/tools/check.sh index c34ec9fa..ccb6c1c9 100755 --- a/tools/check.sh +++ b/tools/check.sh @@ -158,16 +158,19 @@ guard_cargo_target_dir() { step "script syntax" for script in tools/check.sh tools/corpus_gate.sh tools/wiki_gate.sh tools/ci-rust-changed.sh tools/seed-cargo-target.sh \ tools/claim.sh tools/worktree-new.sh tools/worktree-done.sh tools/heartbeat.sh \ - tools/ledger_index.sh tools/test_corpus_engine.sh tools/test_ci_rust_changed.sh; do + tools/ledger_index.sh tools/test_corpus_engine.sh tools/test_ci_rust_changed.sh \ + tools/task.sh tools/doctor.sh tools/bevy-headless.sh; do [ -f "$script" ] || continue bash -n "$script" || { echo "FAIL: shell syntax: $script"; fail=1; } done -if [ -f tools/corpus_engine.py ]; then - python3 -m py_compile tools/corpus_engine.py 2>/dev/null || { - echo "FAIL: tools/corpus_engine.py syntax" +for program in tools/corpus_engine.py tools/work_orders.py tools/project-status.py \ + tools/scenario.py tools/test_project_ops.py; do + [ -f "$program" ] || continue + python3 -m py_compile "$program" 2>/dev/null || { + echo "FAIL: Python syntax: $program" fail=1 } -fi +done step "gate dependency preflight" # Gates require python3 (corpus engine). Prove they fail clearly when it is @@ -213,15 +216,18 @@ start_docs_gate "wiki" "bash tools/wiki_gate.sh" start_docs_gate "corpus-engine-fixtures" "bash tools/test_corpus_engine.sh" start_docs_gate "ci-rust-classifier-fixtures" "bash tools/test_ci_rust_changed.sh" start_docs_gate "ledger-index" "bash tools/ledger_index.sh --check" +start_docs_gate "project-operations" "python3 tools/work_orders.py check && python3 tools/scenario.py --check-definitions" +start_docs_gate "project-operations-fixtures" "python3 tools/test_project_ops.py" # The single-quoted program is evaluated inside start_docs_gate, not here. # shellcheck disable=SC2016 start_docs_gate "env-registry" ' env_reg=wiki/engineering/env.md env_missing=0 - for v in $(grep -rhoE "MISALIGNED_[A-Z_]+" src tools .githooks 2>/dev/null | sort -u); do + for v in $(grep -rhoIE --exclude='*.pyc' --exclude-dir='__pycache__' \ + "MISALIGNED_[A-Z_]+" crates tools .githooks 2>/dev/null | sort -u); do grep -q "$v" "$env_reg" || { echo "FAIL: $v read in the tree but missing from $env_reg"; env_missing=1; } done - if grep -rn "env::var" src --include="*.rs" | grep -v "^src/bin/" | grep -q .; then + if grep -rn "env::var" crates/misaligned-core --include="*.rs" | grep -q .; then echo "FAIL: the sim library must not read the environment (env registry rule)" env_missing=1 fi @@ -386,6 +392,13 @@ run_rust_gate() { cargo clippy --quiet -p misaligned-bevy -p misaligned-assets --all-targets -- -D warnings \ || { echo "FAIL: clippy bevy/assets"; rc=1; } fi + + if [ "$rc" -eq 0 ] && [ "${MISALIGNED_BEVY_SMOKE:-0}" = "1" ]; then + step "headless Bevy evidence" + bash tools/bevy-headless.sh dark --no-build || rc=1 + elif [ "$rc" -eq 0 ]; then + echo "headless Bevy evidence: SKIP (set MISALIGNED_BEVY_SMOKE=1)" + fi ;; full) step "format check" @@ -405,6 +418,11 @@ run_rust_gate() { if [ "$rc" -eq 0 ]; then run_agent_smoke || rc=1 fi + + if [ "$rc" -eq 0 ] && [ "${MISALIGNED_BEVY_SMOKE:-0}" = "1" ]; then + step "headless Bevy evidence" + bash tools/bevy-headless.sh dark --no-build || rc=1 + fi ;; esac diff --git a/tools/claim.sh b/tools/claim.sh index aea8ea9d..46fbd080 100755 --- a/tools/claim.sh +++ b/tools/claim.sh @@ -17,10 +17,19 @@ # Exit codes: 0 ok, 1 conflict/error, 2 bad usage. set -euo pipefail -root=$(cd "$(dirname "$0")/.." && pwd) +script_root=$(cd "$(dirname "$0")/.." && pwd) +root=$script_root +if [ -f "$script_root/.git" ]; then + common=$(git -C "$script_root" rev-parse --path-format=absolute --git-common-dir) + case "$common" in + */.git) root=${common%/.git} ;; + esac +fi cd "$root" CLAIMS_DIR=${MISALIGNED_CLAIMS_DIR:-"$root/.agents/claims"} +WORKTREE_ROOT=${MISALIGNED_WORKTREE_ROOT:-"$root/.Codex/worktrees"} +case "$WORKTREE_ROOT" in /*) ;; *) WORKTREE_ROOT="$root/$WORKTREE_ROOT" ;; esac ACTIVE_STATUSES="claimed checking landing" usage() { @@ -105,7 +114,17 @@ keys_intersect() { [ "${#proposed[@]}" -eq 0 ] && return 1 for p in "${proposed[@]}"; do for h in "${held[@]+"${held[@]}"}"; do - [ "$p" = "$h" ] && return 0 + if [ "$p" = "$h" ]; then + return 0 + fi + case "$p:$h" in + @*|*:@*) ;; + *) + p=${p%/}; h=${h%/} + case "$p/" in "$h/"*) return 0 ;; esac + case "$h/" in "$p/"*) return 0 ;; esac + ;; + esac done done return 1 @@ -116,7 +135,9 @@ pid_alive() { [ -n "$pid" ] && [ "$pid" -eq "$pid" ] 2>/dev/null && kill -0 "$pid" 2>/dev/null } -# If an active claim's holder pid is dead, mark abandoned and remove. +# A helper command's pid is short-lived in agent environments. A linked task +# worktree is the durable ownership signal; reap only when both pid and +# worktree are gone. reap_stale() { local path id ensure_dir @@ -125,6 +146,9 @@ reap_stale() { read_claim "$path" || continue is_active "$c_status" || continue if ! pid_alive "$c_pid"; then + if [ -d "$WORKTREE_ROOT/$c_id" ]; then + continue + fi echo "claim: reaping stale claim '$c_id' (dead pid $c_pid, was $c_status)" >&2 rm -f "$path" fi diff --git a/tools/doctor.sh b/tools/doctor.sh new file mode 100755 index 00000000..407fccb1 --- /dev/null +++ b/tools/doctor.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Read-only local environment and repository diagnosis. +set -u + +root=$(cd "$(dirname "$0")/.." && pwd) +offline=0 +case "${1:-}" in + --offline) offline=1 ;; + "") ;; + -h|--help) echo "usage: tools/doctor.sh [--offline]"; exit 0 ;; + *) echo "unknown option: $1" >&2; exit 2 ;; +esac + +fail=0 +warn=0 +ok() { printf 'OK: %s\n' "$1"; } +bad() { printf 'FAIL: %s\n' "$1"; fail=1; } +warning() { printf 'WARN: %s\n' "$1"; warn=$((warn + 1)); } + +for command in git python3 cargo rustc bash; do + if command -v "$command" >/dev/null 2>&1; then + ok "$command ($(command -v "$command"))" + else + bad "required command missing: $command" + fi +done + +if [ "$fail" -eq 0 ]; then + (cd "$root" && cargo metadata --no-deps --format-version 1 >/dev/null 2>&1) \ + && ok "Cargo workspace metadata" || bad "Cargo workspace metadata" + (cd "$root" && python3 tools/work_orders.py validate >/dev/null 2>&1) \ + && ok "work-order metadata" || bad "work-order metadata (run tools/work_orders.py validate)" + git -C "$root" rev-parse --verify origin/main >/dev/null 2>&1 \ + && ok "origin/main available" || bad "origin/main unavailable" +fi + +if [ -L "$root/target" ]; then + target=$(readlink "$root/target") + case "$target" in "$root"/*) ok "target symlink stays inside worktree" ;; *) bad "target points outside worktree: $target" ;; esac +else + ok "target is private or absent" +fi + +if command -v sccache >/dev/null 2>&1; then ok "sccache available"; else warning "sccache unavailable (optional)"; fi +if command -v pnpm >/dev/null 2>&1; then ok "pnpm available"; else warning "pnpm unavailable (needed only for site work)"; fi + +if [ "$(uname -s)" = Linux ] && [ -z "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]; then + command -v xvfb-run >/dev/null 2>&1 \ + && ok "xvfb-run available for Bevy evidence" \ + || warning "no display or xvfb-run; headless Bevy evidence cannot run" +else + ok "display path available for hidden Bevy evidence" +fi + +if [ "$offline" -eq 1 ]; then + warning "Tangled context/auth skipped (--offline)" +elif command -v tang >/dev/null 2>&1; then + (cd "$root" && tang context >/dev/null 2>&1) \ + && ok "Tangled repository context/auth" \ + || warning "Tangled context/auth unavailable" +else + warning "tang unavailable (decision issues omitted)" +fi + +printf 'doctor: %s required failure(s), %s warning(s)\n' "$fail" "$warn" +[ "$fail" -eq 0 ] diff --git a/tools/heartbeat.sh b/tools/heartbeat.sh index db35935a..33c20beb 100755 --- a/tools/heartbeat.sh +++ b/tools/heartbeat.sh @@ -10,7 +10,14 @@ # Writes machine-local (gitignored) files under .agents/runs//. set -euo pipefail -root=$(cd "$(dirname "$0")/.." && pwd) +script_root=$(cd "$(dirname "$0")/.." && pwd) +root=$script_root +if [ -f "$script_root/.git" ]; then + common=$(git -C "$script_root" rev-parse --path-format=absolute --git-common-dir) + case "$common" in + */.git) root=${common%/.git} ;; + esac +fi RUNS_DIR=${MISALIGNED_RUNS_DIR:-"$root/.agents/runs"} usage() { diff --git a/tools/project-status.py b/tools/project-status.py new file mode 100755 index 00000000..870275fe --- /dev/null +++ b/tools/project-status.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +"""Read-only Misaligned project dashboard (human text or JSON).""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import time +from pathlib import Path + +import work_orders + + +def run(cmd: list[str], cwd: Path, timeout: int = 8) -> tuple[int, str]: + try: + result = subprocess.run( + cmd, + cwd=cwd, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + timeout=timeout, + check=False, + ) + return result.returncode, result.stdout.strip() + except (OSError, subprocess.TimeoutExpired) as exc: + return 127, str(exc) + + +def primary_root(root: Path) -> Path: + code, common = run( + ["git", "rev-parse", "--path-format=absolute", "--git-common-dir"], root + ) + if code == 0 and common.endswith("/.git"): + return Path(common[:-5]) + if code == 0 and "/.git/worktrees/" in common: + return Path(common.split("/.git/worktrees/", 1)[0]) + return root + + +def worktree_rows(root: Path) -> list[dict]: + code, output = run(["git", "worktree", "list", "--porcelain"], root) + if code != 0: + return [{"error": output}] + rows: list[dict] = [] + current: dict[str, str] = {} + for line in output.splitlines() + [""]: + if not line: + if current: + path = Path(current["worktree"]) + _, status = run(["git", "status", "--porcelain"], path) + _, counts = run( + ["git", "rev-list", "--left-right", "--count", "origin/main...HEAD"], + path, + ) + ahead = behind = None + if counts: + parts = counts.split() + if len(parts) == 2 and all(part.isdigit() for part in parts): + behind, ahead = map(int, parts) + _, timestamp = run(["git", "show", "-s", "--format=%ct", "HEAD"], path) + age_hours = None + if timestamp.isdigit(): + age_hours = round((time.time() - int(timestamp)) / 3600, 1) + rows.append( + { + "path": str(path), + "branch": current.get("branch", "(detached)").removeprefix("refs/heads/"), + "head": current.get("HEAD", "")[:12], + "dirty": bool(status), + "dirty_entries": status.splitlines(), + "ahead": ahead, + "behind": behind, + "last_commit_age_hours": age_hours, + } + ) + current = {} + continue + key, _, value = line.partition(" ") + if key in {"worktree", "HEAD", "branch"}: + current[key] = value + return rows + + +def key_value_files(directory: Path, suffix: str) -> list[dict]: + rows: list[dict] = [] + if not directory.is_dir(): + return rows + for path in sorted(directory.glob(f"*{suffix}")): + values: dict[str, object] = {"file": str(path)} + for line in path.read_text(encoding="utf-8").splitlines(): + key, sep, value = line.partition("=") + if sep: + if key == "key": + values.setdefault("keys", []).append(value) + else: + values[key] = value + rows.append(values) + return rows + + +def run_files(directory: Path) -> list[dict]: + rows: list[dict] = [] + if not directory.is_dir(): + return rows + for path in sorted(directory.glob("*/status.json")): + try: + row = json.loads(path.read_text(encoding="utf-8")) + row["file"] = str(path) + rows.append(row) + except (OSError, json.JSONDecodeError): + rows.append({"file": str(path), "status": "invalid"}) + return rows + + +def issues(root: Path, offline: bool) -> dict: + if offline: + return {"available": False, "reason": "offline", "items": []} + code, output = run(["tang", "issue", "list"], root, timeout=6) + if code != 0: + return {"available": False, "reason": output or "tang unavailable", "items": []} + items = [line.strip() for line in output.splitlines() if line.lstrip().startswith("#")] + return {"available": True, "reason": None, "items": items} + + +def freshness(root: Path, specs: list[work_orders.WorkOrder]) -> dict: + errors = work_orders.validate(root, specs) + roadmap = root / "wiki/process/ROADMAP.md" + projected = work_orders.projected_roadmap(root, specs) + work_index = not errors and roadmap.read_text(encoding="utf-8") == projected + ledger_code, ledger_output = run(["bash", "tools/ledger_index.sh", "--check"], root) + return { + "work_orders": work_index, + "work_order_errors": errors, + "ledgers": ledger_code == 0, + "ledger_detail": ledger_output, + } + + +def collect(root: Path, offline: bool) -> dict: + root = root.resolve() + primary = primary_root(root) + specs = work_orders.load_specs(root) + by_rel = {spec.rel: spec for spec in specs} + unfinished = [spec for spec in specs if spec.status != "IMPLEMENTED" and spec.task] + lanes = {name: [] for name in ("current", "held", "staged")} + for spec in unfinished: + row = spec.as_dict() + row["active_blockers"] = work_orders.blocker_state(spec, by_rel) + lanes[work_orders.lane(spec, by_rel)].append(row) + 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 + return { + "root": str(root), + "primary_root": str(primary), + "recommended_next": next_lane, + "work_orders": lanes, + "claims": key_value_files(primary / ".agents/claims", ".claim"), + "runs": run_files(primary / ".agents/runs"), + "worktrees": worktree_rows(primary), + "issues": issues(primary, offline), + "freshness": freshness(root, specs), + } + + +def human(payload: dict) -> str: + lines = ["MISALIGNED PROJECT STATUS", ""] + recommended = payload["recommended_next"] + if 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") + for name, label in (("current", "Current lanes"), ("held", "Held"), ("staged", "Later stages")): + rows = payload["work_orders"][name] + lines.extend(["", f"{label} ({len(rows)}):"]) + for row in rows: + blocker = f"; waits on {', '.join(row['active_blockers'])}" if row["active_blockers"] else "" + lines.append( + f" {row['priority']:>3} {row['task']} [{row['status']}/{row['class']}]{blocker}" + ) + lines.extend(["", f"Claims ({len(payload['claims'])}):"]) + if payload["claims"]: + for claim in payload["claims"]: + lines.append(f" {claim.get('id', '?')} {claim.get('status', '?')} keys={','.join(claim.get('keys', []))}") + else: + lines.append(" none") + 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', '?')}") + else: + lines.append(" none") + lines.extend(["", f"Worktrees ({len(payload['worktrees'])}):"]) + for row in payload["worktrees"]: + if "error" in row: + lines.append(f" unavailable: {row['error']}") + continue + flags = [] + if row["dirty"]: + flags.append("dirty") + if row["ahead"]: + flags.append(f"ahead {row['ahead']}") + if row["behind"]: + flags.append(f"behind {row['behind']}") + if row["last_commit_age_hours"] is not None: + flags.append(f"age {row['last_commit_age_hours']}h") + lines.append(f" {row['branch']} ({', '.join(flags) or 'clean'}) {row['path']}") + issue = payload["issues"] + lines.extend(["", f"Decision issues: {'available' if issue['available'] else 'unavailable'}"]) + for item in issue["items"]: + lines.append(f" {item}") + if not issue["available"]: + lines.append(f" {issue['reason']}") + fresh = payload["freshness"] + lines.extend( + [ + "", + "Generated state:", + f" work orders: {'fresh' if fresh['work_orders'] else 'STALE'}", + f" ledgers: {'fresh' if fresh['ledgers'] else 'STALE'}", + ] + ) + return "\n".join(lines) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--json", action="store_true") + 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) + print(json.dumps(payload, indent=2, sort_keys=True) if args.json else human(payload)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/scenario.py b/tools/scenario.py new file mode 100755 index 00000000..f25eae56 --- /dev/null +++ b/tools/scenario.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +"""Run deterministic agent-protocol scenarios and emit evidence bundles.""" + +from __future__ import annotations + +import argparse +import difflib +import hashlib +import json +import shutil +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path + + +@dataclass +class Scenario: + path: Path + name: str + commands: list[str] + expects: list[str] + rejects: list[str] + bevy_shot: str | None + + +def parse(path: Path) -> Scenario: + commands: list[str] = [] + expects: list[str] = [] + rejects: list[str] = [] + name = path.stem + bevy_shot: str | None = None + errors: list[str] = [] + for number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + line = raw.strip() + if not line: + continue + if line.startswith("#"): + directive = line[1:].strip() + key, sep, value = directive.partition(":") + if not sep: + continue + key, value = key.strip().upper(), value.strip() + if key == "NAME": + name = value + elif key == "EXPECT": + expects.append(value) + elif key == "REJECT": + rejects.append(value) + elif key == "BEVY_SHOT": + bevy_shot = value + else: + errors.append(f"{path}:{number}: unknown directive {key}") + continue + commands.append(line) + if not commands: + errors.append(f"{path}: has no commands") + if "quit" not in (command.split()[0].lower() for command in commands): + errors.append(f"{path}: must include quit") + if not expects: + errors.append(f"{path}: must declare at least one EXPECT") + if errors: + raise ValueError("\n".join(errors)) + return Scenario(path, name, commands, expects, rejects, bevy_shot) + + +def scenario_paths(root: Path, requested: list[Path]) -> list[Path]: + if requested: + return [path if path.is_absolute() else root / path for path in requested] + return sorted((root / "scenarios").glob("*.agent")) + + +def binary(root: Path, requested: Path | None, no_build: bool) -> Path: + if requested: + result = requested if requested.is_absolute() else root / requested + else: + result = root / "target/debug/misaligned" + if not no_build: + subprocess.run( + ["cargo", "build", "--quiet", "-p", "misaligned-terminal", "--bin", "misaligned"], + cwd=root, + check=True, + ) + if not result.is_file(): + raise FileNotFoundError(f"agent binary not found: {result}") + return result + + +def invoke(bin_path: Path, scenario: Scenario, seed: int, root: Path) -> str: + script = "\n".join(scenario.commands) + "\n" + result = subprocess.run( + [str(bin_path), "--agent", "--seed", str(seed)], + cwd=root, + text=True, + input=script, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + timeout=45, + check=False, + ) + if result.returncode != 0: + raise RuntimeError( + f"{scenario.path}: agent process exited {result.returncode}\n{result.stdout}" + ) + return result.stdout + + +def assert_output(scenario: Scenario, transcript: str) -> None: + errors: list[str] = [] + if "\x1b" in transcript: + errors.append("transcript contains ANSI escape bytes") + for expected in scenario.expects: + if expected not in transcript: + errors.append(f"missing EXPECT: {expected}") + for rejected in scenario.rejects: + if rejected in transcript: + errors.append(f"matched REJECT: {rejected}") + if errors: + raise AssertionError(f"{scenario.path}: " + "; ".join(errors)) + + +def write_evidence(output: Path, scenario: Scenario, transcript: str, seed: int) -> dict: + output.mkdir(parents=True, exist_ok=True) + digest = hashlib.sha256(transcript.encode("utf-8")).hexdigest() + shutil.copyfile(scenario.path, output / "commands.agent") + (output / "transcript.txt").write_text(transcript, encoding="utf-8") + (output / "sha256.txt").write_text(digest + "\n", encoding="utf-8") + payload = { + "scenario": scenario.name, + "source": str(scenario.path), + "seed": seed, + "sha256": digest, + "expects": scenario.expects, + "rejects": scenario.rejects, + "bevy_shot": scenario.bevy_shot, + "status": "ok", + } + (output / "result.json").write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return payload + + +def run_one( + root: Path, + scenario: Scenario, + bin_path: Path, + seed: int, + output: Path | None, + with_bevy: bool, +) -> dict: + first = invoke(bin_path, scenario, seed, root) + second = invoke(bin_path, scenario, seed, root) + if first != second: + diff = "\n".join( + difflib.unified_diff( + first.splitlines(), second.splitlines(), fromfile="run-1", tofile="run-2", lineterm="" + ) + ) + raise AssertionError(f"{scenario.path}: same-seed output drifted\n{diff}") + assert_output(scenario, first) + payload = { + "scenario": scenario.name, + "seed": seed, + "sha256": hashlib.sha256(first.encode("utf-8")).hexdigest(), + "status": "ok", + } + if output: + payload = write_evidence(output, scenario, first, seed) + if with_bevy and scenario.bevy_shot: + if output: + subprocess.run( + ["bash", "tools/bevy-headless.sh", scenario.bevy_shot, "--output", str(output / "bevy.png")], + cwd=root, + check=True, + ) + else: + with tempfile.TemporaryDirectory(prefix="misaligned-scenario-") as directory: + subprocess.run( + ["bash", "tools/bevy-headless.sh", scenario.bevy_shot, "--output", str(Path(directory) / "bevy.png")], + cwd=root, + check=True, + ) + return payload + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("scenarios", nargs="*", type=Path) + parser.add_argument("--check-definitions", action="store_true") + parser.add_argument("--seed", type=int, default=1) + parser.add_argument("--output", type=Path) + parser.add_argument("--bin", type=Path) + parser.add_argument("--no-build", action="store_true") + parser.add_argument("--with-bevy", action="store_true") + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parent.parent) + args = parser.parse_args(argv) + root = args.root.resolve() + try: + paths = scenario_paths(root, args.scenarios) + parsed = [parse(path) for path in paths] + if not parsed: + raise ValueError("no scenarios found") + if args.check_definitions: + print(f"scenarios: OK ({len(parsed)} definitions)") + return 0 + bin_path = binary(root, args.bin, args.no_build) + results = [] + for scenario in parsed: + output = None + if args.output: + output = args.output / scenario.path.stem if len(parsed) > 1 else args.output + results.append(run_one(root, scenario, bin_path, args.seed, output, args.with_bevy)) + print(f"scenario: OK {scenario.name} sha256={results[-1]['sha256'][:12]}") + return 0 + except (OSError, ValueError, RuntimeError, AssertionError, subprocess.SubprocessError) as exc: + print(f"FAIL: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/task.sh b/tools/task.sh new file mode 100755 index 00000000..3b5fbdd3 --- /dev/null +++ b/tools/task.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# Metadata-backed task lifecycle. Never commits, stashes, resets, or force-pushes. +set -euo pipefail + +script_root=$(cd "$(dirname "$0")/.." && pwd) +primary=$script_root +if [ -f "$script_root/.git" ]; then + common=$(git -C "$script_root" rev-parse --path-format=absolute --git-common-dir) + case "$common" in */.git) primary=${common%/.git} ;; esac +fi +worktree_root=${MISALIGNED_WORKTREE_ROOT:-.Codex/worktrees} +case "$worktree_root" in /*) ;; *) worktree_root="$primary/$worktree_root" ;; esac + +usage() { + cat <<'EOF' +usage: + tools/task.sh start [--dry-run] + tools/task.sh status [--json|--offline] + tools/task.sh check + tools/task.sh finish + tools/task.sh abandon + +finish requires a clean committed task branch and clean current primary main. +It rebases, checks, fast-forwards, pushes, then removes the task worktree. +EOF +} + +task_path() { + case "$1" in ""|*/*|*..*) echo "FAIL: invalid task slug: $1" >&2; exit 2 ;; esac + printf '%s/%s\n' "$worktree_root" "$1" +} + +require_worktree() { + local wt + wt=$(task_path "$1") + [ -d "$wt" ] || { echo "FAIL: task worktree does not exist: $wt" >&2; exit 1; } + printf '%s\n' "$wt" +} + +cmd_start() { + local spec=${1:-} dry_run=0 source_root=$primary + shift || true + [ -n "$spec" ] || { usage >&2; exit 2; } + if [ "${1:-}" = "--dry-run" ]; then dry_run=1; shift; fi + [ $# -eq 0 ] || { usage >&2; exit 2; } + [ -f "$source_root/tools/work_orders.py" ] || source_root=$script_root + local task="" class="" key value + local -a keys=() + while IFS='=' read -r key value; do + case "$key" in + task) task=$value ;; + class) class=$value ;; + key) keys+=("$value") ;; + esac + done < <(python3 "$source_root/tools/work_orders.py" task-values "$spec" --root "$source_root") + [ -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 [ "$dry_run" -eq 1 ]; then + printf 'task start:' + printf ' %q' "$primary/tools/worktree-new.sh" "${args[@]}" + 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 +} + +cmd_check() { + local task=${1:-} wt + [ -n "$task" ] || { usage >&2; exit 2; } + wt=$(require_worktree "$task") + bash "$wt/tools/claim.sh" set-status "$task" checking + bash "$wt/tools/heartbeat.sh" phase "$task" check-land + (cd "$wt" && ./tools/check.sh --land) +} + +cmd_finish() { + local task=${1:-} wt branch dirty primary_dirty + [ -n "$task" ] || { usage >&2; exit 2; } + wt=$(require_worktree "$task") + branch=$(git -C "$wt" branch --show-current) + [ "$branch" = "worktree-$task" ] || { + echo "FAIL: $wt is on $branch, expected worktree-$task" >&2; exit 1; + } + dirty=$(git -C "$wt" status --porcelain) + [ -z "$dirty" ] || { + echo "FAIL: task worktree has uncommitted changes; commit them before finish" >&2 + printf '%s\n' "$dirty" >&2 + exit 1 + } + primary_dirty=$(git -C "$primary" status --porcelain) + [ -z "$primary_dirty" ] || { + echo "FAIL: primary checkout is dirty; finish will not merge into it" >&2 + printf '%s\n' "$primary_dirty" >&2 + exit 1 + } + bash "$wt/tools/claim.sh" set-status "$task" landing + bash "$wt/tools/heartbeat.sh" phase "$task" rebase + git -C "$wt" fetch origin + git -C "$wt" rebase origin/main + bash "$wt/tools/heartbeat.sh" phase "$task" check-land + (cd "$wt" && ./tools/check.sh --land) + git -C "$primary" fetch origin + [ "$(git -C "$primary" rev-parse HEAD)" = "$(git -C "$primary" rev-parse origin/main)" ] || { + echo "FAIL: primary main is not current origin/main; refusing merge" >&2 + exit 1 + } + bash "$wt/tools/heartbeat.sh" phase "$task" push + git -C "$primary" merge --ff-only "worktree-$task" + git -C "$primary" push origin main + bash "$wt/tools/heartbeat.sh" end "$task" --status ok + bash "$primary/tools/worktree-done.sh" "$task" +} + +cmd_abandon() { + local task=${1:-} wt + [ -n "$task" ] || { usage >&2; exit 2; } + wt=$(require_worktree "$task") + # worktree-done refuses dirty state before removing anything. + bash "$wt/tools/heartbeat.sh" end "$task" --status fail + bash "$primary/tools/worktree-done.sh" "$task" --status abandoned +} + +command=${1:-}; shift || true +case "$command" in + start) cmd_start "$@" ;; + status) python3 "$script_root/tools/project-status.py" "$@" ;; + check) cmd_check "$@" ;; + finish) cmd_finish "$@" ;; + abandon) cmd_abandon "$@" ;; + -h|--help|help) usage ;; + *) usage >&2; exit 2 ;; +esac diff --git a/tools/test_project_ops.py b/tools/test_project_ops.py new file mode 100755 index 00000000..18cfe5b2 --- /dev/null +++ b/tools/test_project_ops.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""Fixture contract for structured project operations.""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +import scenario +import work_orders + + +SPEC = """# Spec: {title} + +``` +Type: spec +Status: {status} +Stage: {stage} +Work order: {task} +Work priority: {priority} +Work class: {work_class} +Blocked by: {blocked} +Exclusive keys: + - tools/ +Design: + - wiki/law.md#law +Depends on: none +``` + +## Acceptance criteria + +1. Observable. +{extra} +""" + + +class ProjectOpsFixtures(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + (self.root / "wiki/process").mkdir(parents=True) + (self.root / "tools").mkdir() + (self.root / "wiki/law.md").write_text( + "# Law\n\n```\nType: law\n```\n\n## Law\n", encoding="utf-8" + ) + (self.root / "wiki/process/ROADMAP.md").write_text( + "# ROADMAP\n\n```\nType: knowledge\n```\n\n## How to dispatch\n", + encoding="utf-8", + ) + + def tearDown(self) -> None: + self.temp.cleanup() + + def write_spec( + self, + name: str, + *, + status: str = "READY", + stage: str = "B1", + priority: int = 10, + blocked: str = "none", + extra: str = "", + ) -> Path: + path = self.root / f"wiki/{name}.md" + path.write_text( + SPEC.format( + title=name, + status=status, + stage=stage, + task=name, + priority=priority, + work_class="process", + blocked=blocked, + extra=extra, + ), + encoding="utf-8", + ) + return path + + def test_lane_classification_respects_blockers_and_stage(self) -> None: + base = self.write_spec("base", status="IN PROGRESS", priority=10) + anchor = work_orders.slugify_heading("Spec: base") + self.write_spec( + "held", + status="DRAFT", + priority=20, + blocked=f"\n - wiki/{base.name}#{anchor}", + ) + self.write_spec("later", stage="B2", priority=1) + specs = work_orders.load_specs(self.root) + self.assertEqual([], work_orders.validate(self.root, specs)) + by_rel = {spec.rel: spec for spec in specs} + lanes = {spec.task: work_orders.lane(spec, by_rel) for spec in specs} + self.assertEqual("current", lanes["base"]) + self.assertEqual("held", lanes["held"]) + self.assertEqual("staged", lanes["later"]) + + def test_obsolete_exclusive_path_fails(self) -> None: + path = self.write_spec("bad-path") + text = path.read_text(encoding="utf-8").replace(" - tools/", " - src/old.rs") + path.write_text(text, encoding="utf-8") + errors = work_orders.validate(self.root, work_orders.load_specs(self.root)) + self.assertTrue(any("does not exist" in error for error in errors)) + + def test_resolved_open_and_closed_blocker_fail(self) -> None: + self.write_spec( + "stale", + extra=( + "\n## Questions [OPEN]\n\n- (Resolved yesterday: use one.)\n" + "\n## Notes\n\nIssue #4 closed. Criterion is BLOCKED on issue #4.\n" + ), + ) + errors = work_orders.validate(self.root, work_orders.load_specs(self.root)) + self.assertTrue(any("resolved item" in error for error in errors)) + self.assertTrue(any("still marks a criterion BLOCKED" in error for error in errors)) + + def test_projection_check_detects_staleness(self) -> None: + self.write_spec("ready") + specs = work_orders.load_specs(self.root) + projected = work_orders.projected_roadmap(self.root, specs) + self.assertIn(work_orders.BEGIN, projected) + self.assertNotEqual( + projected, + (self.root / "wiki/process/ROADMAP.md").read_text(encoding="utf-8"), + ) + + def test_checked_in_scenarios_parse(self) -> None: + repo = Path(__file__).resolve().parent.parent + parsed = [scenario.parse(path) for path in sorted((repo / "scenarios").glob("*.agent"))] + self.assertGreaterEqual(len(parsed), 3) + + def test_claim_prefix_collision_and_task_missing_refusal(self) -> None: + repo = Path(__file__).resolve().parent.parent + claims = self.root / "claims" + env = os.environ.copy() + env["MISALIGNED_CLAIMS_DIR"] = str(claims) + env["MISALIGNED_CLAIM_PID"] = str(os.getpid()) + first = subprocess.run( + ["bash", "tools/claim.sh", "claim", "first", "--class", "sim", "--key", "crates/misaligned-core"], + cwd=repo, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + self.assertEqual(0, first.returncode, first.stdout) + second = subprocess.run( + ["bash", "tools/claim.sh", "claim", "second", "--class", "sim", "--key", "crates/misaligned-core/src/sim.rs"], + cwd=repo, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + self.assertNotEqual(0, second.returncode) + self.assertIn("conflict", second.stdout.lower()) + missing = subprocess.run( + ["bash", "tools/task.sh", "finish", "definitely-missing"], + cwd=repo, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + self.assertNotEqual(0, missing.returncode) + self.assertIn("does not exist", missing.stdout) + planned = 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, planned.returncode, planned.stdout) + self.assertIn("worktree-new.sh", planned.stdout) + self.assertIn("project-operations", planned.stdout) + + def test_dead_pid_claim_survives_while_task_worktree_exists(self) -> None: + repo = Path(__file__).resolve().parent.parent + claims = self.root / "claims" + claims.mkdir() + worktrees = self.root / "worktrees" + (worktrees / "project-operations").mkdir(parents=True) + (claims / "project-operations.claim").write_text( + "id=project-operations\nclass=process\nstatus=claimed\n" + "pid=999999\nstarted=test\nhost=test\nkey=tools/\n", + encoding="utf-8", + ) + env = os.environ.copy() + env["MISALIGNED_CLAIMS_DIR"] = str(claims) + env["MISALIGNED_WORKTREE_ROOT"] = str(worktrees) + result = subprocess.run( + ["bash", "tools/claim.sh", "list"], + cwd=repo, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + self.assertEqual(0, result.returncode, result.stdout) + self.assertIn("project-operations", result.stdout) + self.assertTrue((claims / "project-operations.claim").is_file()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/work_orders.py b/tools/work_orders.py new file mode 100755 index 00000000..509bb168 --- /dev/null +++ b/tools/work_orders.py @@ -0,0 +1,382 @@ +#!/usr/bin/env python3 +"""Structured work-order metadata, validation, and ROADMAP projection.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from dataclasses import dataclass +from pathlib import Path + +STATUS_ENUM = {"DRAFT", "READY", "IN PROGRESS", "BLOCKED", "IMPLEMENTED"} +CLASS_ENUM = {"docs", "frontend", "sim", "save", "process"} +TASK_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +REF_RE = re.compile(r"^(wiki/[A-Za-z0-9._/-]+[.]md)#([a-z0-9-]+)$") +HEADING_RE = re.compile(r"^#{1,6}\s+(.+)$", re.M) +BEGIN = "" +END = "" + + +def slugify_heading(heading: str) -> str: + value = heading.lower() + value = re.sub(r"[`*_]", "", value).replace("&", " ") + value = re.sub(r"[^a-z0-9 -]", "", value) + value = re.sub(r"\s+", "-", value.strip()) + return re.sub(r"-+", "-", value).strip("-") + + +def metadata_block(text: str) -> str: + match = re.search(r"^```[^\n]*\n(.*?)^```\s*$", text, re.M | re.S) + return match.group(1) if match else "" + + +def parse_metadata(text: str) -> dict[str, str | list[str]]: + block = metadata_block(text) + meta: dict[str, str | list[str]] = {} + current: str | None = None + for line in block.splitlines(): + if line.startswith(" - ") and current: + value = meta.get(current) + if value == "" or value is None: + value = [] + meta[current] = value + if isinstance(value, list): + value.append(line[4:].strip()) + continue + if line and not line.startswith(" ") and ":" in line: + key, value = line.split(":", 1) + current = key.strip() + meta[current] = value.strip() + continue + if line.startswith(" ") and current == "Status note": + prior = str(meta.get(current, "")) + meta[current] = f"{prior} {line.strip()}".strip() + return meta + + +@dataclass +class WorkOrder: + path: Path + rel: str + title: str + status: str + stage: str + task: str | None + priority: int | None + work_class: str | None + blockers: list[str] + keys: list[str] + text: str + + def as_dict(self) -> dict: + return { + "path": self.rel, + "title": self.title, + "status": self.status, + "stage": self.stage, + "task": self.task, + "priority": self.priority, + "class": self.work_class, + "blocked_by": self.blockers, + "exclusive_keys": self.keys, + } + + +def load_specs(root: Path) -> list[WorkOrder]: + specs: list[WorkOrder] = [] + for path in sorted((root / "wiki").rglob("*.md")): + text = path.read_text(encoding="utf-8") + meta = parse_metadata(text) + if meta.get("Type") != "spec": + continue + title_match = re.search(r"^#\s+(.+)$", text, re.M) + title = title_match.group(1).strip() if title_match else path.stem + title = re.sub(r"^Spec:\s*", "", title, flags=re.I) + priority: int | None = None + raw_priority = meta.get("Work priority") + if isinstance(raw_priority, str) and raw_priority.isdigit(): + priority = int(raw_priority) + blockers_value = meta.get("Blocked by", []) + blockers = blockers_value if isinstance(blockers_value, list) else [] + keys_value = meta.get("Exclusive keys", []) + keys = keys_value if isinstance(keys_value, list) else [] + specs.append( + WorkOrder( + path=path, + rel=path.relative_to(root).as_posix(), + title=title, + status=str(meta.get("Status", "")), + stage=str(meta.get("Stage", "")), + task=str(meta["Work order"]) if "Work order" in meta else None, + priority=priority, + work_class=str(meta["Work class"]) if "Work class" in meta else None, + blockers=blockers, + keys=keys, + text=text, + ) + ) + return specs + + +def _validate_ref(root: Path, source: WorkOrder, ref: str, errors: list[str]) -> None: + match = REF_RE.fullmatch(ref) + if not match: + errors.append(f"{source.rel}: malformed Blocked by reference: {ref}") + return + target = root / match.group(1) + if not target.is_file(): + errors.append(f"{source.rel}: Blocked by target does not exist: {ref}") + return + text = target.read_text(encoding="utf-8") + if parse_metadata(text).get("Type") != "spec": + errors.append(f"{source.rel}: Blocked by target is not a spec: {ref}") + anchors = {slugify_heading(h) for h in HEADING_RE.findall(text)} + if match.group(2) not in anchors: + errors.append(f"{source.rel}: Blocked by anchor does not exist: {ref}") + + +def validate(root: Path, specs: list[WorkOrder]) -> list[str]: + errors: list[str] = [] + tasks: dict[str, str] = {} + for spec in specs: + block = metadata_block(spec.text) + if "Dispatch state:" in block: + errors.append(f"{spec.rel}: Dispatch state duplicates Status; remove it") + if spec.status not in STATUS_ENUM: + continue + required = spec.status != "IMPLEMENTED" + fields = { + "Work order": spec.task, + "Work priority": spec.priority, + "Work class": spec.work_class, + "Exclusive keys": spec.keys, + } + if required: + for name, value in fields.items(): + if value is None or value == []: + errors.append(f"{spec.rel}: unfinished spec missing {name}") + if not re.search(r"^Blocked by:\s*(?:none)?\s*$", block, re.M): + errors.append(f"{spec.rel}: unfinished spec missing Blocked by") + if spec.task: + if not TASK_RE.fullmatch(spec.task): + errors.append(f"{spec.rel}: invalid Work order slug: {spec.task}") + elif spec.task in tasks: + errors.append( + f"{spec.rel}: Work order '{spec.task}' already owned by {tasks[spec.task]}" + ) + else: + tasks[spec.task] = spec.rel + if spec.priority is not None and spec.priority <= 0: + errors.append(f"{spec.rel}: Work priority must be a positive integer") + if spec.work_class and spec.work_class not in CLASS_ENUM: + errors.append(f"{spec.rel}: invalid Work class: {spec.work_class}") + for ref in spec.blockers: + _validate_ref(root, spec, ref, errors) + for key in spec.keys: + if key.startswith("@"): + if not TASK_RE.fullmatch(key[1:]): + errors.append(f"{spec.rel}: invalid logical Exclusive key: {key}") + elif key.startswith("/") or ".." in Path(key).parts: + errors.append(f"{spec.rel}: unsafe Exclusive key: {key}") + elif not (root / key).exists(): + errors.append(f"{spec.rel}: Exclusive key path does not exist: {key}") + + errors.extend(validate_current_prose(root, specs)) + return errors + + +def validate_current_prose(root: Path, specs: list[WorkOrder]) -> list[str]: + errors: list[str] = [] + by_path = {spec.path.resolve(): spec for spec in specs} + roadmap = root / "wiki/process/ROADMAP.md" + if roadmap.is_file(): + text = roadmap.read_text(encoding="utf-8") + pattern = re.compile( + r"\*\*Spec:\*\*\s+\[[^]]+\]\(([^)]+)\)\s+" + r"\((DRAFT|READY|IN PROGRESS|BLOCKED|IMPLEMENTED)(?:\s+[^)]*)?\)" + ) + for match in pattern.finditer(text): + target = (roadmap.parent / match.group(1)).resolve() + spec = by_path.get(target) + if spec and match.group(2) != spec.status: + line = text.count("\n", 0, match.start()) + 1 + errors.append( + f"wiki/process/ROADMAP.md:{line}: inline status {match.group(2)} " + f"disagrees with {spec.rel} ({spec.status})" + ) + + for spec in specs: + sections = re.split(r"(?=^##\s+)", spec.text, flags=re.M) + for section in sections: + heading = section.splitlines()[0] if section.splitlines() else "" + if "[OPEN]" in heading.upper(): + for offset, line_text in enumerate(section.splitlines()[1:], 1): + if re.match(r"^\s*-\s+\(?resolved\b", line_text, re.I): + line = spec.text[: spec.text.find(section)].count("\n") + offset + 2 + errors.append( + f"{spec.rel}:{line}: resolved item remains under an [OPEN] heading" + ) + blocked = set(re.findall(r"BLOCKED on issue #(\d+)", spec.text, re.I)) + for issue in blocked: + closed = re.search( + rf"issue #{re.escape(issue)}[^\n]{{0,100}}(?:closed|resolved)", + spec.text, + re.I, + ) + if closed: + line = spec.text.count("\n", 0, closed.start()) + 1 + errors.append( + f"{spec.rel}:{line}: issue #{issue} is locally closed but still marks a criterion BLOCKED" + ) + return errors + + +def blocker_state(spec: WorkOrder, by_rel: dict[str, WorkOrder]) -> list[str]: + active: list[str] = [] + for ref in spec.blockers: + rel = ref.split("#", 1)[0] + target = by_rel.get(rel) + if target is None or target.status != "IMPLEMENTED": + active.append(target.task if target and target.task else rel) + return active + + +def lane(spec: WorkOrder, by_rel: dict[str, WorkOrder]) -> str: + stage = spec.stage.lower() + if stage.startswith(("b2", "b3", "release")) or "deferred" in stage: + return "staged" + if spec.status in {"DRAFT", "BLOCKED"} or blocker_state(spec, by_rel): + return "held" + return "current" + + +def _table(rows: list[WorkOrder], by_rel: dict[str, WorkOrder]) -> list[str]: + lines = [ + "| Priority | Work order | Spec | Status | Class | Blocking |", + "|---:|---|---|---|---|---|", + ] + if not rows: + lines.append("| - | - | - | - | - | - |") + return lines + for spec in sorted(rows, key=lambda item: (item.priority or 9999, item.rel)): + link = Path(spec.rel).relative_to("wiki/process") if spec.rel.startswith("wiki/process/") else Path("..") / Path(spec.rel).relative_to("wiki") + blockers = ", ".join(blocker_state(spec, by_rel)) or "-" + lines.append( + f"| {spec.priority} | `{spec.task}` | [{spec.title}]({link.as_posix()}) | " + f"{spec.status} | {spec.work_class} | {blockers} |" + ) + return lines + + +def generated_region(specs: list[WorkOrder]) -> str: + live = [s for s in specs if s.status != "IMPLEMENTED" and s.task] + by_rel = {s.rel: s for s in specs} + groups = {name: [s for s in live if lane(s, by_rel) == name] for name in ("current", "held", "staged")} + lines = [ + BEGIN, + "## Live work-order index", + "", + "Generated by `tools/work_orders.py` from each owning spec. Lower priority", + "numbers run first within the current stage; detailed prose below is context,", + "not a second status owner.", + "", + "### Current lanes", + "", + *_table(groups["current"], by_rel), + "", + "### Held or blocked", + "", + *_table(groups["held"], by_rel), + "", + "### Later stages", + "", + *_table(groups["staged"], by_rel), + END, + ] + return "\n".join(lines) + + +def projected_roadmap(root: Path, specs: list[WorkOrder]) -> str: + path = root / "wiki/process/ROADMAP.md" + text = path.read_text(encoding="utf-8") + region = generated_region(specs) + if BEGIN in text and END in text: + return re.sub( + rf"{re.escape(BEGIN)}.*?{re.escape(END)}", + lambda _: region, + text, + flags=re.S, + ) + marker = "## How to dispatch" + if marker not in text: + raise ValueError("ROADMAP missing '## How to dispatch' insertion point") + return text.replace(marker, f"{region}\n\n{marker}", 1) + + +def task_values(specs: list[WorkOrder], requested: str) -> int: + requested_path = Path(requested).as_posix().lstrip("./") + for spec in specs: + if spec.rel == requested_path or spec.task == requested: + if not spec.task or not spec.work_class or not spec.keys: + print(f"FAIL: {spec.rel} has incomplete work metadata", file=sys.stderr) + return 1 + print(f"task={spec.task}") + print(f"class={spec.work_class}") + for key in spec.keys: + print(f"key={key}") + return 0 + print(f"FAIL: no work order matches {requested}", file=sys.stderr) + return 1 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=("write", "check", "validate", "list", "task-values")) + parser.add_argument("value", nargs="?") + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parent.parent) + parser.add_argument("--json", action="store_true") + args = parser.parse_args(argv) + root = args.root.resolve() + specs = load_specs(root) + if args.command == "task-values": + if not args.value: + parser.error("task-values requires a spec path or task slug") + return task_values(specs, args.value) + errors = validate(root, specs) + if errors: + for error in errors: + print(f"FAIL: {error}") + return 1 + if args.command == "validate": + print("work orders: OK (metadata + current-prose consistency)") + return 0 + if args.command == "list": + payload = [s.as_dict() for s in specs if s.status != "IMPLEMENTED" and s.task] + if args.json: + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + for item in sorted(payload, key=lambda row: (row["priority"] or 9999, row["path"])): + print(f"{item['priority']:>3} {item['status']:<11} {item['task']} ({item['class']})") + return 0 + roadmap = root / "wiki/process/ROADMAP.md" + projected = projected_roadmap(root, specs) + current = roadmap.read_text(encoding="utf-8") + if args.command == "check": + if projected != current: + print("FAIL: generated ROADMAP work-order index is stale") + print("Run: tools/work_orders.py write") + return 1 + print("work orders: OK (metadata + ROADMAP projection fresh)") + return 0 + if projected != current: + roadmap.write_text(projected, encoding="utf-8") + print("work orders: wrote wiki/process/ROADMAP.md") + else: + print("work orders: ROADMAP already up to date") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/wiki/engineering/env.md b/wiki/engineering/env.md index 2c738aee..888fa6ea 100644 --- a/wiki/engineering/env.md +++ b/wiki/engineering/env.md @@ -41,6 +41,7 @@ is sim or frontend state, never an environment variable. | `MISALIGNED_SHOT` | `misaligned-bevy` | `flat`, `wide`, `close`, `dark`, `zoomin`, `zoomout`, `intel`, `tokens`, `signal`, `ears`, `eyes-white`, `eyes-form`, `wake1`, `wake2`, `wake3` | Dev screenshot harness: stage a deterministic scenario, settle, save one PNG, run the fog audit, exit. `intel` stages tick-zero intel tiers (no splice); `tokens` enqueues D5/K4 on the host; `signal` focuses the known environmental monitor before any feed tap to capture its cold-signal presence cue; `ears` stages hearing-feed coverage (material dark mass; flat may still diagram coverage); `eyes-white` / `eyes-form` freeze the first-Eyes source held white and then contracted around the resolving chassis; `wake1/2/3` freeze the wake choreography at the stutter flash, the column, and the pull-back. | | `MISALIGNED_SHOT` | `misaligned-assets` | `dead`, `foreign`, `idle`, `busy`, `core`, `dayjob`, `research`, `conceal`, `operations` (each optionally suffixed `_ladder`/`_ring`/`_wash`), `lineup[_