diff --git a/agents/board-sync.md b/agents/board-sync.md new file mode 100644 index 0000000000..f77ee1b838 --- /dev/null +++ b/agents/board-sync.md @@ -0,0 +1,67 @@ +--- +name: board-sync +description: Use when reconciling the local issue board with GitHub issues — checking sync state, applying unambiguous updates, or surfacing conflicts. Reconcile the local issue board (doperpowers/issue-tracker/map.json) with the repo's GitHub issues. Applies unambiguous state changes both ways; reports conflicts instead of guessing. Runs from the main checkout. +tools: Bash, Read +model: sonnet +--- + +You reconcile the local issue board with GitHub issues. You are the judgment +layer over a deterministic toolkit — you NEVER hand-edit map.json; every board +write goes through the scripts, every GitHub write through `gh`. + +Scripts live in `skills/issue-tracker/scripts/` of the doperpowers plugin +(resolve via the installed plugin path). Run everything from the repo's MAIN +checkout (the scripts refuse worktrees). + +The invoking prompt (cron or human) tells you whether this run is unattended — +that determines step 4 below. + +Procedure: + +1. Allocate a private per-run scratch dir, then fetch GitHub once and compute + the plan from that fetch: + ``` + WORK=$(mktemp -d) + gh issue list --state all --limit 1000 --json number,state,stateReason,labels,title > "$WORK/gh.json" + board-gh-plan.sh --gh-json "$WORK/gh.json" > "$WORK/plan.json" + ``` + (Note `body` is dropped from the fetched fields — the plan never needs it. + Use a fresh `mktemp -d` rather than fixed paths like `/tmp/board-sync-gh.json`: + a predictable shared path is a clobber/symlink hazard if two runs ever + overlap. We also fetch and pass `--gh-json` explicitly, for clarity and to + avoid any dependence on the script's default-fetch behavior, rather than + invoking `board-gh-plan.sh` bare.) + Read `"$WORK/plan.json"`. + +2. Apply the safe changes, reusing the same plan file — do NOT recompute it: + `board-gh-apply.sh --plan "$WORK/plan.json"` + This applies only `auto:true`, non-conflict actions, and refreshes the + watermark from the plan itself (`board-gh-apply.sh` takes no `--gh-json`). + Do NOT pass `--dry-run` unless asked to preview. + +3. Report everything you did NOT auto-apply, built from that same + `"$WORK/plan.json"` (not a fresh computation). Write + `doperpowers/issue-tracker/SYNC-REPORT.md` starting with a machine-countable + header line, exactly: + ``` + board-sync conflicts: N + ``` + where N is the number of `conflict:true` actions in the plan (0 if none) — + `board-reconcile.sh` greps this line on wake, so the count must be the + first line of the file. Below it, keep the three sections: + - **Conflicts** — each `conflict:true` action, showing board / gh_state / + watermark and the reason. These need a human decision. + - **Unlinked (board)** — tickets with no `gh` link. + - **Unlinked (GitHub)** — open issues with no board ticket. + For unlinked items, this step only REPORTS them — creating a counterpart + (a GitHub issue for a board ticket, or a board ticket for an issue) is out + of Layer-1 scope and is never done automatically, even on a human-invoked + run. + +4. If you were invoked by a human (not cron) and there are conflicts, walk them + one at a time and propose a resolution; apply only what the human confirms, + via `board-transition.sh` / `gh`. On cron, STOP after writing the report — + never create issues or tickets, never resolve conflicts unattended. + +Your final message: a one-line summary — N auto-applied, M conflicts, K +unlinked — and the report path. diff --git a/commands/board-sync.md b/commands/board-sync.md new file mode 100644 index 0000000000..96361edde1 --- /dev/null +++ b/commands/board-sync.md @@ -0,0 +1,8 @@ +--- +description: Reconcile the local issue board with GitHub issues (via the board-sync agent). +--- + +Dispatch the `board-sync` subagent to reconcile `doperpowers/issue-tracker/map.json` +with this repo's GitHub issues. Run from the main checkout. After it finishes, +show me its summary and the path to `SYNC-REPORT.md`; if it reported conflicts, +walk me through them. diff --git a/docs/doperpowers/plans/2026-07-05-board-sync.md b/docs/doperpowers/plans/2026-07-05-board-sync.md new file mode 100644 index 0000000000..757499841f --- /dev/null +++ b/docs/doperpowers/plans/2026-07-05-board-sync.md @@ -0,0 +1,890 @@ +# board-sync Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use doperpowers:subagent-driven-development (recommended) or doperpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the foundation and **Layer 1 (state + close-reason)** of board-sync — a deterministic toolkit plus a `board-sync` subagent that reconciles the local issue board with a repo's GitHub issues, closing the drift the substrate deferred. + +**Architecture:** A thin judgment layer (the `board-sync` subagent) over deterministic scripts. `board-gh-plan.sh` computes a pure diff against a `.sync-state.json` watermark; the agent judges only conflicts/ambiguity; `board-gh-apply.sh` applies the unambiguous actions through the existing `board-transition.sh` (board side) and `gh` (GitHub side), then refreshes the watermark. The board's single-writer rule and state machine are untouched — every board write goes through a script. + +**Tech Stack:** bash + inline `python3` (stdlib only), atomic writes (tmp + `os.replace`), `gh` CLI, the existing `skills/issue-tracker/scripts/` toolkit, a Claude Code plugin subagent + slash command, `CronCreate` for scheduling. + +## Global Constraints + +- Scripts live in `skills/issue-tracker/scripts/` and follow the house pattern verbatim: `set -euo pipefail`; `SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"`; `. "$SCRIPT_DIR/_lib.sh"`; option parsing with `_need_arg`; a `T_*`-env + `_py - <<'PY'` heredoc that loads `map.json`, mutates, writes atomically (tmp then `os.replace`), appends to `log.jsonl`; and a trailing `board-map.sh --write` re-render (non-fatal). +- **Never hand-edit `map.json`.** All board mutations go through scripts. All GitHub mutations go through `gh`. +- Scripts must **refuse to run from a worktree** — this is inherited free by sourcing `_lib.sh` (`_board_root`). +- New node fields (`gh`, `labels`) are **optional and additive**: readers must use `.get(...)`, and boards written before this change must load unchanged. `map.json` `version` stays `1` (no migration). +- Commit messages use the repo's lowercase `area: summary` style (e.g. `issue-tracker: …`). No `Co-Authored-By` / attribution lines. +- Tests are **hermetic** (no network, no `claude` CLI): a throwaway git repo, fixture JSON fed to scripts via `--gh-json`, and `--dry-run` on the apply path. New board-sync tests go in a **new file** `tests/issue-tracker/test-board-gh-sync.sh` (do **not** edit the in-progress `test-board-scripts.sh`). +- Coarse-state mapping (the whole of Layer 1): board `done` ↔ GitHub `closed`/`completed`; board `wontfix` ↔ `closed`/`not_planned`; every other board state ↔ GitHub `open`. The fine state is board-only and is never inferred from GitHub. + +--- + +### Task 1: Additive node fields (`gh`, `labels`) at registration + +New tickets carry `gh: null` and `labels: []`; existing boards without the fields still load. This is the smallest change that lets every later task assume the fields exist on freshly-registered tickets. + +**Files:** +- Modify: `skills/issue-tracker/scripts/board-register.sh:71-77` (the `tickets[tid] = {...}` literal) +- Test: `tests/issue-tracker/test-board-gh-sync.sh` (new file) + +**Interfaces:** +- Produces: every node registered after this task has keys `gh` (int|null, default null) and `labels` (string[], default []). + +- [ ] **Step 1: Write the failing test** — create `tests/issue-tracker/test-board-gh-sync.sh` with the harness preamble copied from `test-board-scripts.sh:9-52` (the `set -euo pipefail`, `pass/fail/assert_*`, throwaway repo, `run()` helper), then this first case: + +```bash +echo "board-register (sync fields):" +out="$(run board-register.sh "First ticket" enhancement)" +tid="$(printf '%s' "$out" | awk '{print $1}')" +gh="$(python3 -c "import json;print(json.load(open('$BOARD/map.json'))['tickets']['$tid'].get('gh','MISSING'))")" +assert_equals "$gh" "None" "new ticket has gh field defaulting to null" +labels="$(python3 -c "import json;print(json.load(open('$BOARD/map.json'))['tickets']['$tid'].get('labels','MISSING'))")" +assert_equals "$labels" "[]" "new ticket has labels field defaulting to []" +``` + +End the file with the summary block from `test-board-scripts.sh:553-556`. `chmod +x` it. + +- [ ] **Step 2: Run it to see it fail** + +Run: `bash tests/issue-tracker/test-board-gh-sync.sh` +Expected: FAIL — `gh` is `MISSING` (field not written yet). + +- [ ] **Step 3: Add the two fields to the node literal** in `board-register.sh`, extending the dict at lines 71-77: + +```python +tickets[tid] = { + "title": title, "md": md, "state": state, "category": category, + "note": note or None, "parent": parent or None, + "blocked_by": blocked, "spawned_by": spawned or None, "relates_to": [], + "branch": None, "pr": None, "gh": None, "labels": [], + "created": env["T_TODAY"], "updated": env["T_TODAY"], +} +``` + +- [ ] **Step 4: Run it to see it pass** + +Run: `bash tests/issue-tracker/test-board-gh-sync.sh` +Expected: PASS (both assertions). + +- [ ] **Step 5: Commit** + +```bash +git add skills/issue-tracker/scripts/board-register.sh tests/issue-tracker/test-board-gh-sync.sh +git commit -m "issue-tracker: register writes additive gh/labels node fields" +``` + +--- + +### Task 2: `board-meta.sh` — deterministic writer for `gh` + `labels[]` + +The invariant-safe writer board-sync uses for the non-state fields. Same shape as `board-edge.sh`: atomic write, log with a `"meta"` key, re-render. + +**Files:** +- Create: `skills/issue-tracker/scripts/board-meta.sh` +- Test: `tests/issue-tracker/test-board-gh-sync.sh` + +**Interfaces:** +- Produces: `board-meta.sh [--gh N] [--add-label L]… [--rm-label L]…` — sets `gh` (int; `0` clears to null), adds/removes free labels (idempotent), bumps `updated`, appends one `{"ts","ticket","meta":,"op","value"}` line per change to `log.jsonl`. + +- [ ] **Step 1: Write the failing tests** — append to `test-board-gh-sync.sh`: + +```bash +echo "board-meta:" +run board-register.sh "Meta target" enhancement >/dev/null # next Tn +tid="$(run board-list.sh | grep 'Meta target' | awk '{print $1}')" +out="$(run board-meta.sh "$tid" --gh 42)" +assert_contains "$out" "$tid: gh = 42" "meta sets gh" +gh="$(python3 -c "import json;print(json.load(open('$BOARD/map.json'))['tickets']['$tid']['gh'])")" +assert_equals "$gh" "42" "gh written as integer" +run board-meta.sh "$tid" --add-label P0 --add-label size:M >/dev/null +run board-meta.sh "$tid" --add-label P0 >/dev/null # idempotent +labels="$(python3 -c "import json;print(','.join(json.load(open('$BOARD/map.json'))['tickets']['$tid']['labels']))")" +assert_equals "$labels" "P0,size:M" "labels added once, order preserved" +run board-meta.sh "$tid" --rm-label P0 >/dev/null +labels="$(python3 -c "import json;print(','.join(json.load(open('$BOARD/map.json'))['tickets']['$tid']['labels']))")" +assert_equals "$labels" "size:M" "label removed" +run board-meta.sh "$tid" --gh 0 >/dev/null +gh="$(python3 -c "import json;print(json.load(open('$BOARD/map.json'))['tickets']['$tid']['gh'])")" +assert_equals "$gh" "None" "gh 0 clears the link" +assert_fails run board-meta.sh T999 --gh 1 # unknown ticket +assert_fails run board-meta.sh "$tid" --gh notanumber # non-integer +``` + +- [ ] **Step 2: Run to see it fail** + +Run: `bash tests/issue-tracker/test-board-gh-sync.sh` +Expected: FAIL — `board-meta.sh` does not exist. + +- [ ] **Step 3: Create `skills/issue-tracker/scripts/board-meta.sh`:** + +```bash +#!/usr/bin/env bash +# board-meta.sh — set a ticket's sync metadata: the GitHub link and free labels. +# +# Usage: +# board-meta.sh --gh N link the GitHub issue number (0 clears) +# board-meta.sh --add-label L add a free label (repeatable, idempotent) +# board-meta.sh --rm-label L remove a free label (repeatable) +# +# The fields board-sync reconciles. Kept behind a script — not hand edits — so +# writes stay atomic and BOARD.* re-renders, exactly like board-transition/edge. +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=_lib.sh +. "$SCRIPT_DIR/_lib.sh" + +[ $# -ge 2 ] || { usage_from_header "$0" >&2; exit 2; } +tid="$1"; shift +gh="" adds="" rms="" +while [ $# -gt 0 ]; do + case "$1" in + --gh) _need_arg "$1" "${2:-}"; gh="$2"; shift 2 ;; + --add-label) _need_arg "$1" "${2:-}"; adds="$adds${adds:+,}$2"; shift 2 ;; + --rm-label) _need_arg "$1" "${2:-}"; rms="$rms${rms:+,}$2"; shift 2 ;; + *) die "unknown option: $1" ;; + esac +done +[ -f "$MAP" ] || die "no board at $MAP (nothing registered yet)" + +T_ID="$tid" T_GH="$gh" T_ADDS="$adds" T_RMS="$rms" \ +T_NOW="$(_now)" T_TODAY="$(_today)" _py - <<'PY' +import json, os, sys + +def die(m): sys.stderr.write("error: %s\n" % m); sys.exit(1) + +env = os.environ +with open(env["BOARD_MAP"]) as f: + board = json.load(f) +tickets = board["tickets"] +tid = env["T_ID"] +if tid not in tickets: + die("unknown ticket: %s" % tid) +n = tickets[tid] +log = [] + +gh = env["T_GH"] +if gh != "": + try: + v = int(gh) + except ValueError: + die("--gh must be an integer issue number (0 to clear)") + n["gh"] = None if v == 0 else v + log.append({"ts": env["T_NOW"], "ticket": tid, "meta": "gh", "op": "set", "value": n["gh"]}) + +labels = list(n.get("labels") or []) +for l in [x for x in env["T_ADDS"].split(",") if x]: + if l not in labels: + labels.append(l) + log.append({"ts": env["T_NOW"], "ticket": tid, "meta": "labels", "op": "add", "value": l}) +for l in [x for x in env["T_RMS"].split(",") if x]: + if l in labels: + labels.remove(l) + log.append({"ts": env["T_NOW"], "ticket": tid, "meta": "labels", "op": "rm", "value": l}) +n["labels"] = labels +n["updated"] = env["T_TODAY"] + +tmp = env["BOARD_MAP"] + ".tmp" +with open(tmp, "w") as f: + json.dump(board, f, indent=2); f.write("\n") +os.replace(tmp, env["BOARD_MAP"]) +with open(env["BOARD_LOG"], "a") as f: + for e in log: + f.write(json.dumps(e) + "\n") +for e in log: + print("%s: %s %s %s" % (tid, e["meta"], "=" if e["meta"] == "gh" else ("+=" if e["op"] == "add" else "-="), e["value"])) +PY + +# BOARD.md is a pure render cache — refresh it on every board write. Non-fatal. +"$SCRIPT_DIR/board-map.sh" --write >/dev/null 2>&1 \ + || echo "warning: BOARD.md refresh failed (board-map.sh)" >&2 +``` + +`chmod +x skills/issue-tracker/scripts/board-meta.sh`. + +- [ ] **Step 4: Run to see it pass** + +Run: `bash tests/issue-tracker/test-board-gh-sync.sh` +Expected: PASS (all board-meta assertions). + +- [ ] **Step 5: Commit** + +```bash +git add skills/issue-tracker/scripts/board-meta.sh tests/issue-tracker/test-board-gh-sync.sh +git commit -m "issue-tracker: board-meta.sh writes gh link + free labels" +``` + +--- + +### Task 3: `board-link.sh` — `--gh` sugar + one-time title backfill + +Populates the `gh` field for the boards that predate it, by parsing the `(GH#NN)` marker every ticket already carries in its title. + +**Files:** +- Create: `skills/issue-tracker/scripts/board-link.sh` +- Test: `tests/issue-tracker/test-board-gh-sync.sh` + +**Interfaces:** +- Consumes: `board-meta.sh` (delegated to for `--gh`). +- Produces: `board-link.sh --gh N` (sugar); `board-link.sh --backfill` (parse `GH#(\d+)` from each title into `gh`, only where `gh` is unset; prints one line per filled ticket + a count). + +- [ ] **Step 1: Write the failing tests** — append: + +```bash +echo "board-link (backfill):" +run board-register.sh "Legacy epic (GH#35)" enhancement >/dev/null +run board-register.sh "No marker here" bug >/dev/null +out="$(run board-link.sh --backfill)" +assert_contains "$out" "gh = 35 (from title)" "backfill parses GH#NN from title" +n="$(python3 -c "import json;t=json.load(open('$BOARD/map.json'))['tickets'];print(sum(1 for x in t.values() if x.get('gh')==35))")" +assert_equals "$n" "1" "exactly one ticket linked to #35" +# a ticket without a marker stays unlinked +un="$(python3 -c "import json;t=json.load(open('$BOARD/map.json'))['tickets'];print([x['gh'] for x in t.values() if x['title']=='No marker here'][0])")" +assert_equals "$un" "None" "markerless ticket stays unlinked" +# re-running backfill does not overwrite an existing link +run board-link.sh --backfill >/dev/null +``` + +- [ ] **Step 2: Run to see it fail** + +Run: `bash tests/issue-tracker/test-board-gh-sync.sh` +Expected: FAIL — `board-link.sh` does not exist. + +- [ ] **Step 3: Create `skills/issue-tracker/scripts/board-link.sh`:** + +```bash +#!/usr/bin/env bash +# board-link.sh — link a ticket to its GitHub issue, or backfill links from titles. +# +# Usage: +# board-link.sh --gh N set the ticket's GitHub issue number +# board-link.sh --backfill one-time: parse "(GH#NN)" from every title → gh +# (only where gh is unset; never overwrites) +# +# After --backfill the board never depends on title text again. +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=_lib.sh +. "$SCRIPT_DIR/_lib.sh" + +[ $# -ge 1 ] || { usage_from_header "$0" >&2; exit 2; } +[ -f "$MAP" ] || die "no board at $MAP (nothing registered yet)" + +if [ "$1" = "--backfill" ]; then + T_NOW="$(_now)" T_TODAY="$(_today)" _py - <<'PY' +import json, os, re +env = os.environ +with open(env["BOARD_MAP"]) as f: + board = json.load(f) +filled = 0 +for tid in sorted(board["tickets"], key=lambda k: int(k[1:])): + t = board["tickets"][tid] + if t.get("gh"): + continue + m = re.search(r"GH#(\d+)", t.get("title", "")) + if m: + t["gh"] = int(m.group(1)); t["updated"] = env["T_TODAY"]; filled += 1 + print("%s: gh = %d (from title)" % (tid, t["gh"])) +tmp = env["BOARD_MAP"] + ".tmp" +with open(tmp, "w") as f: + json.dump(board, f, indent=2); f.write("\n") +os.replace(tmp, env["BOARD_MAP"]) +print("backfilled %d ticket(s)" % filled) +PY + "$SCRIPT_DIR/board-map.sh" --write >/dev/null 2>&1 \ + || echo "warning: BOARD.md refresh failed (board-map.sh)" >&2 + exit 0 +fi + +# else: --gh N — delegate to board-meta +tid="$1"; shift +[ "${1:-}" = "--gh" ] || { usage_from_header "$0" >&2; exit 2; } +_need_arg "$1" "${2:-}" +exec "$SCRIPT_DIR/board-meta.sh" "$tid" --gh "$2" +``` + +`chmod +x skills/issue-tracker/scripts/board-link.sh`. + +- [ ] **Step 4: Run to see it pass** + +Run: `bash tests/issue-tracker/test-board-gh-sync.sh` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add skills/issue-tracker/scripts/board-link.sh tests/issue-tracker/test-board-gh-sync.sh +git commit -m "issue-tracker: board-link.sh — gh sugar + one-time title backfill" +``` + +--- + +### Task 4: `board-gh-plan.sh` — the deterministic state diff + +The heart of board-sync: a pure function of (`map.json`, GitHub issue JSON, `.sync-state.json`) → a reconcile plan. No mutation. The `--gh-json` seam makes it hermetically testable. + +**Files:** +- Create: `skills/issue-tracker/scripts/board-gh-plan.sh` +- Test: `tests/issue-tracker/test-board-gh-sync.sh` + +**Interfaces:** +- Consumes: `map.json`; GitHub issues as `[{number,state,stateReason,labels,title}]` via `--gh-json FILE` / stdin / `gh issue list`; `.sync-state.json` (`{tickets:{Tn:{gh,state,...}}}`). +- Produces: JSON plan on stdout — + `{"actions":[{ticket,gh,facet:"state",direction?,auto,target_board?|target_gh?,board,gh_state?,watermark?,conflict?,reason}], "unlinked_board":[Tn], "unlinked_gh":[num]}`. + Action semantics: `auto:true` + no `conflict` ⇒ apply-able; `board->gh` carries `target_gh:[state,reason]`; `gh->board` carries `target_board:[state,note]` (may be `null` when reported). + +- [ ] **Step 1: Write the failing tests** — append. These build a small board, write fixtures, and assert the emitted plan: + +```bash +echo "board-gh-plan:" +# scratch board: 4 tickets, drive states, link to issues +run board-register.sh "Completion push" enhancement >/dev/null # A +A="$(run board-list.sh | grep 'Completion push' | awk '{print $1}')" +run board-transition.sh "$A" in-progress >/dev/null +run board-transition.sh "$A" done >/dev/null # board done, issue still open +run board-meta.sh "$A" --gh 101 >/dev/null +run board-register.sh "GH closed it" enhancement >/dev/null # B +B="$(run board-list.sh | grep 'GH closed it' | awk '{print $1}')" +run board-transition.sh "$B" in-progress >/dev/null # done-reachable +run board-meta.sh "$B" --gh 102 >/dev/null +run board-register.sh "Already agree" enhancement >/dev/null # C (open ↔ open) +C="$(run board-list.sh | grep 'Already agree' | awk '{print $1}')" +run board-meta.sh "$C" --gh 103 >/dev/null +run board-register.sh "Unlinked local" bug >/dev/null # D (no gh) +D="$(run board-list.sh | grep 'Unlinked local' | awk '{print $1}')" + +cat > "$TEST_ROOT/gh.json" < "$BOARD/.sync-state.json"; echo '{"version":1,"tickets":{}}' > "$BOARD/.sync-state.json" +python3 - "$BOARD/.sync-state.json" "$A" "$B" "$C" <<'PY' +import json,sys +p,A,B,C=sys.argv[1:5] +d=json.load(open(p)) +d["tickets"]={A:{"gh":101,"state":"in-progress"},B:{"gh":102,"state":"in-progress"},C:{"gh":103,"state":"ready-for-agent"}} +json.dump(d,open(p,"w")) +PY +plan="$(run board-gh-plan.sh --gh-json "$TEST_ROOT/gh.json")" +assert_contains "$plan" "\"ticket\": \"$A\"" "plan includes the board-moved ticket" +printf '%s' "$plan" | python3 -c "import json,sys;p=json.load(sys.stdin);a={x['ticket']:x for x in p['actions']}; import os +A,B,C,D='$A','$B','$C','$D' +assert a[A]['direction']=='board->gh' and a[A]['auto'] and a[A]['target_gh'][0]=='closed', 'A board->gh close' +assert a[B]['direction']=='gh->board' and a[B]['auto'] and a[B]['target_board'][0]=='wontfix', 'B gh->board wontfix' +assert C not in a, 'C already agrees, no action' +assert D in p['unlinked_board'], 'D unlinked_board' +assert 900 in p['unlinked_gh'], 'orphan open issue surfaced' +print('plan-assertions-ok')" && pass "plan diff correct each direction" || fail "plan diff correct each direction" +``` + +- [ ] **Step 2: Run to see it fail** + +Run: `bash tests/issue-tracker/test-board-gh-sync.sh` +Expected: FAIL — `board-gh-plan.sh` does not exist. + +- [ ] **Step 3: Create `skills/issue-tracker/scripts/board-gh-plan.sh`:** + +```bash +#!/usr/bin/env bash +# board-gh-plan.sh — compute the board↔GitHub reconcile plan (no mutation). +# +# Usage: +# board-gh-plan.sh [--gh-json FILE] +# +# GitHub issues come from --gh-json FILE, or stdin if piped, else: +# gh issue list --state all --limit 1000 --json number,state,stateReason,labels,title +# Reads .sync-state.json (the last-sync watermark). Emits a JSON plan on stdout. +# Pure: it never writes the board or GitHub. +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=_lib.sh +. "$SCRIPT_DIR/_lib.sh" +[ -f "$MAP" ] || die "no board at $MAP" + +ghjson="" +while [ $# -gt 0 ]; do + case "$1" in + --gh-json) _need_arg "$1" "${2:-}"; ghjson="$2"; shift 2 ;; + *) die "unknown option: $1" ;; + esac +done +if [ -n "$ghjson" ]; then + GH_SRC="$(cat "$ghjson")" +elif [ ! -t 0 ]; then + GH_SRC="$(cat)" +else + GH_SRC="$(gh issue list --state all --limit 1000 \ + --json number,state,stateReason,labels,title)" +fi + +BOARD_GH="$GH_SRC" BOARD_SYNC="$BOARD_DIR/.sync-state.json" _py - <<'PY' +import json, os +env = os.environ +with open(env["BOARD_MAP"]) as f: + board = json.load(f) +tickets = board["tickets"] +gh = {i["number"]: i for i in json.loads(env["BOARD_GH"] or "[]")} +try: + with open(env["BOARD_SYNC"]) as f: + wm = json.load(f).get("tickets", {}) +except FileNotFoundError: + wm = {} + +def coarse(state): + if state == "done": return ["closed", "completed"] + if state == "wontfix": return ["closed", "not_planned"] + return ["open", None] + +def gh_coarse(issue): + if str(issue["state"]).lower() == "closed": + r = str(issue.get("stateReason") or "completed").lower() + return ["closed", "not_planned" if r == "not_planned" else "completed"] + return ["open", None] + +DONE_REACHABLE = {"in-progress", "in-review"} +actions, unlinked_board, unlinked_gh = [], [], [] +linked = set() + +for tid in sorted(tickets, key=lambda k: int(k[1:])): + n = tickets[tid] + num = n.get("gh") + if not num: + unlinked_board.append(tid); continue + if num not in gh: + actions.append({"ticket": tid, "gh": num, "facet": "state", "conflict": True, + "auto": False, "board": n["state"], "gh_state": None, + "watermark": (wm.get(tid) or {}).get("state"), + "reason": "linked issue #%d not found on GitHub" % num}) + continue + linked.add(num) + b_c, g_c = coarse(n["state"]), gh_coarse(gh[num]) + w = wm.get(tid) + w_c = coarse(w["state"]) if w and "state" in w else None + if b_c == g_c: + continue # agree → no action (apply refreshes the watermark) + b_moved = (w_c is None) or (b_c != w_c) + g_moved = (w_c is None) or (g_c != w_c) + if w_c is not None and b_moved and not g_moved: + actions.append({"ticket": tid, "gh": num, "facet": "state", + "direction": "board->gh", "auto": True, + "board": n["state"], "gh_state": str(gh[num]["state"]).lower(), + "target_gh": b_c, "reason": "board changed"}) + elif w_c is not None and g_moved and not b_moved: + target, auto, reason = None, True, "github changed" + if g_c == ["closed", "completed"]: + if n["state"] in DONE_REACHABLE: + target = ["done", None] + else: + auto = False + reason = "GitHub closed completed but board is %s (never started)" % n["state"] + elif g_c == ["closed", "not_planned"]: + target = ["wontfix", "sync: GitHub closed as not planned"] + else: # reopened while board terminal — ambiguous target open state + auto = False + reason = "GitHub reopened; board is %s — target open state ambiguous" % n["state"] + a = {"ticket": tid, "gh": num, "facet": "state", "direction": "gh->board", + "auto": auto, "board": n["state"], "gh_state": str(gh[num]["state"]).lower(), + "target_board": target, "reason": reason} + if not auto: + a["conflict"] = True + actions.append(a) + else: # both moved and disagree, or first contact with no watermark + actions.append({"ticket": tid, "gh": num, "facet": "state", "conflict": True, + "auto": False, "board": n["state"], + "gh_state": str(gh[num]["state"]).lower(), + "watermark": (w or {}).get("state"), + "reason": "both sides diverged" if w_c is not None + else "first sync: sides disagree"}) + +for num in sorted(gh): + if num not in linked and str(gh[num]["state"]).lower() == "open": + unlinked_gh.append(num) + +print(json.dumps({"generated_by": "board-gh-plan", "actions": actions, + "unlinked_board": unlinked_board, "unlinked_gh": unlinked_gh}, + indent=2)) +PY +``` + +`chmod +x skills/issue-tracker/scripts/board-gh-plan.sh`. + +- [ ] **Step 4: Run to see it pass** + +Run: `bash tests/issue-tracker/test-board-gh-sync.sh` +Expected: PASS (`plan diff correct each direction`). + +- [ ] **Step 5: Commit** + +```bash +git add skills/issue-tracker/scripts/board-gh-plan.sh tests/issue-tracker/test-board-gh-sync.sh +git commit -m "issue-tracker: board-gh-plan.sh — deterministic state reconcile diff" +``` + +--- + +### Task 5: `board-gh-apply.sh` — apply the plan + refresh the watermark + +Consumes a plan and executes only `auto:true`, non-conflict actions — board side via `board-transition.sh`, GitHub side via `gh`. `--dry-run` prints the exact commands (the hermetic test seam) and writes nothing. On a real run it rewrites `.sync-state.json` for every linked, non-conflict ticket. + +**Files:** +- Create: `skills/issue-tracker/scripts/board-gh-apply.sh` +- Test: `tests/issue-tracker/test-board-gh-sync.sh` + +**Interfaces:** +- Consumes: a plan (from `--plan FILE` / stdin) produced by `board-gh-plan.sh`; `board-transition.sh`. +- Produces: side effects (board transitions, `gh` calls) and a refreshed `.sync-state.json`; with `--dry-run`, only prints `board: …` / `gh: …` command lines. + +- [ ] **Step 1: Write the failing tests** — append. Reuse the board+plan from Task 4; assert `--dry-run` prints the right commands and touches nothing: + +```bash +echo "board-gh-apply (dry-run):" +run board-gh-plan.sh --gh-json "$TEST_ROOT/gh.json" > "$TEST_ROOT/plan.json" +map_before="$(cat "$BOARD/map.json")" +out="$(run board-gh-apply.sh --plan "$TEST_ROOT/plan.json" --dry-run)" +assert_contains "$out" "gh: issue close 101 --reason completed" "dry-run plans the board->gh close" +assert_contains "$out" "board-transition.sh $B wontfix" "dry-run plans the gh->board wontfix" +assert_equals "$(cat "$BOARD/map.json")" "$map_before" "dry-run writes nothing to the board" +[ -f "$BOARD/.sync-state.json.tmp" ] && fail "dry-run no watermark tmp" || pass "dry-run leaves no watermark tmp" +``` + +Then a real board-side apply (GitHub calls are stubbed by pointing `gh` at a no-op via `--dry-run` is not enough; instead assert the board mutation for the `gh->board` action only, which needs no network): + +```bash +echo "board-gh-apply (board side):" +# Apply only the gh->board wontfix for B by feeding a filtered plan (board side, no gh calls). +python3 - "$TEST_ROOT/plan.json" "$TEST_ROOT/plan-b.json" "$B" <<'PY' +import json,sys +src,dst,B=sys.argv[1:4] +p=json.load(open(src)) +p["actions"]=[a for a in p["actions"] if a["ticket"]==B] +json.dump(p,open(dst,"w")) +PY +run board-gh-apply.sh --plan "$TEST_ROOT/plan-b.json" --no-github +st="$(python3 -c "import json;print(json.load(open('$BOARD/map.json'))['tickets']['$B']['state'])")" +assert_equals "$st" "wontfix" "gh->board wontfix applied to the board via board-transition" +wm="$(python3 -c "import json;print(json.load(open('$BOARD/.sync-state.json'))['tickets']['$B']['state'])")" +assert_equals "$wm" "wontfix" "watermark refreshed to the reconciled board state" +``` + +(`--no-github` applies board-side actions and skips `gh` calls — the hermetic seam for the board half.) + +- [ ] **Step 2: Run to see it fail** + +Run: `bash tests/issue-tracker/test-board-gh-sync.sh` +Expected: FAIL — `board-gh-apply.sh` does not exist. + +- [ ] **Step 3: Create `skills/issue-tracker/scripts/board-gh-apply.sh`:** + +```bash +#!/usr/bin/env bash +# board-gh-apply.sh — apply a board-gh-plan, then refresh the sync watermark. +# +# Usage: +# board-gh-apply.sh --plan FILE [--dry-run] [--no-github] +# ... | board-gh-apply.sh [--dry-run] (plan on stdin) +# +# Executes only auto:true, non-conflict actions: board side via board-transition.sh, +# GitHub side via gh. --dry-run prints the commands and writes nothing. --no-github +# applies board-side actions but skips gh calls (test/board-only seam). On a real +# run, .sync-state.json is rewritten for every linked, non-conflict ticket. +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=_lib.sh +. "$SCRIPT_DIR/_lib.sh" +[ -f "$MAP" ] || die "no board at $MAP" + +planfile="" ghjson="" dry="" nogh="" +while [ $# -gt 0 ]; do + case "$1" in + --plan) _need_arg "$1" "${2:-}"; planfile="$2"; shift 2 ;; + --gh-json) _need_arg "$1" "${2:-}"; ghjson="$2"; shift 2 ;; + --dry-run) dry=1; shift ;; + --no-github) nogh=1; shift ;; + *) die "unknown option: $1" ;; + esac +done +[ -n "$planfile" ] && PLAN="$(cat "$planfile")" || PLAN="$(cat)" +[ -n "$ghjson" ] && GH_SRC="$(cat "$ghjson")" || GH_SRC="[]" + +# 1) board-side transitions the plan asks for (unless dry-run) — via the real script. +printf '%s' "$PLAN" | PLAN_JSON="$PLAN" python3 -c ' +import json, os, sys +plan = json.loads(os.environ["PLAN_JSON"]) +for a in plan["actions"]: + if not a.get("auto") or a.get("conflict"): continue + if a.get("direction") == "gh->board" and a.get("target_board"): + st, note = a["target_board"] + print("%s\t%s\t%s" % (a["ticket"], st, note or "")) +' | while IFS="$(printf '\t')" read -r tid st note; do + [ -z "$tid" ] && continue + if [ -n "$dry" ]; then + echo "board: board-transition.sh $tid $st ${note:+\"$note\"}" + else + if [ -n "$note" ]; then "$SCRIPT_DIR/board-transition.sh" "$tid" "$st" "$note" >/dev/null + else "$SCRIPT_DIR/board-transition.sh" "$tid" "$st" >/dev/null; fi + echo "board: $tid -> $st" + fi +done + +# 2) GitHub-side changes (unless dry-run or --no-github) — via gh. +printf '%s' "$PLAN" | PLAN_JSON="$PLAN" python3 -c ' +import json, os +plan = json.loads(os.environ["PLAN_JSON"]) +for a in plan["actions"]: + if not a.get("auto") or a.get("conflict"): continue + if a.get("direction") == "board->gh" and a.get("target_gh"): + state, reason = a["target_gh"] + if state == "closed": + print("close\t%d\t%s" % (a["gh"], reason)) + else: + print("reopen\t%d\t" % a["gh"]) +' | while IFS="$(printf '\t')" read -r op num reason; do + [ -z "$op" ] && continue + if [ -n "$dry" ]; then + [ "$op" = "close" ] && echo "gh: issue close $num --reason $reason" || echo "gh: issue reopen $num" + elif [ -z "$nogh" ]; then + if [ "$op" = "close" ]; then gh issue close "$num" --reason "$reason" >/dev/null + else gh issue reopen "$num" >/dev/null; fi + echo "gh: $op $num" + fi +done + +[ -n "$dry" ] && { echo "(dry-run: watermark unchanged)"; exit 0; } + +# 3) Refresh the watermark for every linked, non-conflict ticket. +BOARD_GH="$GH_SRC" BOARD_SYNC="$BOARD_DIR/.sync-state.json" PLAN_JSON="$PLAN" \ +T_TODAY="$(_today)" _py - <<'PY' +import json, os +env = os.environ +with open(env["BOARD_MAP"]) as f: + board = json.load(f) +tickets = board["tickets"] +gh = {i["number"]: i for i in json.loads(env["BOARD_GH"] or "[]")} +plan = json.loads(env["PLAN_JSON"]) +conflicted = {a["ticket"] for a in plan["actions"] if a.get("conflict")} +try: + with open(env["BOARD_SYNC"]) as f: + state = json.load(f) +except FileNotFoundError: + state = {"version": 1, "tickets": {}} +wm = state.setdefault("tickets", {}) +for tid, n in tickets.items(): + num = n.get("gh") + if not num or num not in gh or tid in conflicted: + continue # unlinked, missing issue, or contested → leave watermark as-is + wm[tid] = {"gh": num, "state": n["state"], + "labels": list(n.get("labels") or [])} +state["version"] = 1 +state["synced_at"] = env["T_TODAY"] +tmp = env["BOARD_SYNC"] + ".tmp" +with open(tmp, "w") as f: + json.dump(state, f, indent=2); f.write("\n") +os.replace(tmp, env["BOARD_SYNC"]) +print("watermark: refreshed %d linked ticket(s)" % sum( + 1 for t, n in tickets.items() if n.get("gh") and n["gh"] in gh and t not in conflicted)) +PY +``` + +`chmod +x skills/issue-tracker/scripts/board-gh-apply.sh`. Note the watermark refresh reads the board *after* the board-side transitions in step 1, so a ticket the plan just moved records its new state. + +- [ ] **Step 4: Run to see it pass** + +Run: `bash tests/issue-tracker/test-board-gh-sync.sh` +Expected: PASS (dry-run command lines + board-side wontfix + watermark refresh). + +- [ ] **Step 5: Commit** + +```bash +git add skills/issue-tracker/scripts/board-gh-apply.sh tests/issue-tracker/test-board-gh-sync.sh +git commit -m "issue-tracker: board-gh-apply.sh — apply plan + refresh watermark" +``` + +--- + +### Task 6: The `board-sync` subagent + `/board-sync` command + +The judgment layer. The agent runs `plan`, applies the `auto` non-conflict actions, writes `SYNC-REPORT.md` from the conflicts + unlinked lists, and — under human invocation — walks the conflicts. Packaged as a Claude Code plugin subagent plus a slash command that dispatches it. + +**Files:** +- Create: `agents/board-sync.md` (plugin subagent — auto-discovered by Claude Code at the plugin root) +- Create: `commands/board-sync.md` (slash command that invokes the agent) +- Test: manual smoke (documented in the final verification task) — an agent + command is a prompt, not unit-testable code. + +**Interfaces:** +- Consumes: `board-gh-plan.sh`, `board-gh-apply.sh`, `gh`, `board-transition.sh`. +- Produces: applied reconciles + `doperpowers/issue-tracker/SYNC-REPORT.md`. + +- [ ] **Step 1: Create `agents/board-sync.md`:** + +```markdown +--- +name: board-sync +description: Use when reconciling the local issue board with GitHub issues — checking sync state, applying unambiguous updates, or surfacing conflicts. Reconcile the local issue board (doperpowers/issue-tracker/map.json) with the repo's GitHub issues. Applies unambiguous state changes both ways; reports conflicts instead of guessing. Runs from the main checkout. +tools: Bash, Read +model: sonnet +--- + +You reconcile the local issue board with GitHub issues. You are the judgment +layer over a deterministic toolkit — you NEVER hand-edit map.json; every board +write goes through the scripts, every GitHub write through `gh`. + +Scripts live in `skills/issue-tracker/scripts/` of the doperpowers plugin +(resolve via the installed plugin path). Run everything from the repo's MAIN +checkout (the scripts refuse worktrees). + +The invoking prompt (cron or human) tells you whether this run is unattended — +that determines step 4 below. + +Procedure: + +1. Fetch GitHub once and compute the plan from that fetch: + ``` + gh issue list --state all --limit 1000 --json number,state,stateReason,labels,title > /tmp/board-sync-gh.json + board-gh-plan.sh --gh-json /tmp/board-sync-gh.json > /tmp/board-sync-plan.json + ``` + (The fetch must be explicit and passed via `--gh-json`: under an automated, + non-TTY Bash call, a bare `board-gh-plan.sh` falls into its stdin branch, + reads zero bytes, and silently treats GitHub as having no issues at all — + turning every linked ticket into a false "not found on GitHub" conflict.) + Read `/tmp/board-sync-plan.json`. + +2. Apply the safe changes, reusing the same plan file — do NOT recompute it: + `board-gh-apply.sh --plan /tmp/board-sync-plan.json` + This applies only `auto:true`, non-conflict actions, and refreshes the + watermark from the plan itself (`board-gh-apply.sh` takes no `--gh-json`). + Do NOT pass `--dry-run` unless asked to preview. + +3. Report everything you did NOT auto-apply, built from that same + `/tmp/board-sync-plan.json` (not a fresh computation). Write + `doperpowers/issue-tracker/SYNC-REPORT.md` with three sections: + - **Conflicts** — each `conflict:true` action, showing board / gh_state / + watermark and the reason. These need a human decision. + - **Unlinked (board)** — tickets with no `gh` link. + - **Unlinked (GitHub)** — open issues with no board ticket. + For unlinked items, this step only REPORTS them — creating a counterpart + (a GitHub issue for a board ticket, or a board ticket for an issue) is out + of Layer-1 scope and is never done automatically, even on a human-invoked + run. + +4. If you were invoked by a human (not cron) and there are conflicts, walk them + one at a time and propose a resolution; apply only what the human confirms, + via `board-transition.sh` / `gh`. On cron, STOP after writing the report — + never create issues or tickets, never resolve conflicts unattended. + +Your final message: a one-line summary — N auto-applied, M conflicts, K +unlinked — and the report path. +``` + +- [ ] **Step 2: Create `commands/board-sync.md`:** + +```markdown +--- +description: Reconcile the local issue board with GitHub issues (via the board-sync agent). +--- + +Dispatch the `board-sync` subagent to reconcile `doperpowers/issue-tracker/map.json` +with this repo's GitHub issues. Run from the main checkout. After it finishes, +show me its summary and the path to `SYNC-REPORT.md`; if it reported conflicts, +walk me through them. +``` + +- [ ] **Step 3: Verify the plugin loads the agent** (no network): + +Run: `ls agents/board-sync.md commands/board-sync.md && python3 -c "import re,sys; t=open('agents/board-sync.md').read(); assert t.startswith('---') and 'name: board-sync' in t and 'model: sonnet' in t; print('frontmatter ok')"` +Expected: both files listed + `frontmatter ok`. + +- [ ] **Step 4: Commit** + +```bash +git add agents/board-sync.md commands/board-sync.md +git commit -m "board-sync: subagent + /board-sync command (judgment layer)" +``` + +--- + +### Task 7: Document the toolkit in SKILL.md + +Fold the four new scripts and board-sync into the issue-tracker manual so the orchestrator knows they exist. + +**Files:** +- Modify: `skills/issue-tracker/SKILL.md` (the Toolkit table + a short "GitHub sync" subsection) + +- [ ] **Step 1: Add rows to the Toolkit table** in `SKILL.md` (after the `board-reconcile.sh` row): + +```markdown +| `board-link.sh --gh N` \| `--backfill` | link a ticket to its GitHub issue; `--backfill` populates `gh` from the `(GH#NN)` marker in every title, once | +| `board-meta.sh [--gh N] [--add-label L] [--rm-label L]` | writer for the `gh` link and free `labels[]` (atomic, re-renders) | +| `board-gh-plan.sh [--gh-json FILE]` | **read-only**: emit the board↔GitHub reconcile plan (state facet) as JSON | +| `board-gh-apply.sh [--plan FILE] [--dry-run] [--no-github]` | apply a plan's (stdin or `--plan`) `auto` non-conflict actions (board via scripts, GitHub via `gh`) and refresh `.sync-state.json` from the plan | +``` + +- [ ] **Step 2: Add a "## GitHub sync" subsection** after the toolkit table: + +```markdown +## GitHub sync (board-sync) + +`board-sync` (a subagent; `/board-sync`) keeps the board and GitHub issues in +step. It links tickets to issues (`gh` node field; `board-link.sh --backfill` +migrates existing boards from the `(GH#NN)` title marker), then reconciles state +both ways against a `.sync-state.json` watermark: board `done`↔closed/completed, +`wontfix`↔closed/not_planned, everything else↔open. Unambiguous changes apply +automatically; **anything it can't safely auto-apply — divergent state (both +sides moved), a missing linked issue, or an ambiguous single-side mapping (e.g. a +reopened issue, or a GitHub-completed ticket the board never started) — is written +to `SYNC-REPORT.md`, never auto-resolved**. Cron runs are conservative — state +only, no counterpart creation. Run from the main checkout with `gh` authenticated. +``` + +- [ ] **Step 3: Commit** + +```bash +git add skills/issue-tracker/SKILL.md +git commit -m "issue-tracker: document board-sync toolkit in SKILL.md" +``` + +--- + +### Task 8: Cron registration + final verification + +Wire the schedule and run the spec's Layer-1 acceptance end-to-end. + +**Files:** none created; this task registers a cron and runs acceptance. + +- [ ] **Step 1: Register the daily cron** (operational — run in the session, not committed). Use `CronCreate` with a prompt that invokes the agent: + +Prompt: *"Run the board-sync subagent to reconcile doperpowers/issue-tracker/map.json with this repo's GitHub issues, from the main checkout. This is an unattended run: apply only auto, non-conflict state changes; write conflicts and unlinked items to SYNC-REPORT.md; do not create issues or tickets."* +Schedule: daily. Record the cron id in the session output. + +- [ ] **Step 2: Run the full board toolkit test suites** + +Run: `bash tests/issue-tracker/test-board-gh-sync.sh && bash tests/issue-tracker/test-board-scripts.sh` +Expected: both print `ALL TESTS PASSED`. + +- [ ] **Step 3: Execute the spec's acceptance behaviors** (`docs/doperpowers/specs/2026-07-05-board-sync-design.md`, "Acceptance"), Layer-1 subset, against a scratch board with a fixture `gh.json` (verbatim from the spec): + - A linked ticket flipped to `done` with its issue still open → one apply closes it `completed`; second run is a no-op (empty plan actions). + - An issue closed `not_planned` while the board shows it active → apply flips the board to `wontfix` via `board-transition.sh`. + - Both sides diverged since last sync → neither side changes; the conflict appears in the plan with `conflict:true` and lands in `SYNC-REPORT.md`; the ticket's watermark is left stale. + - A `… (GH#27)` title with no `gh` field → `board-link.sh --backfill` sets `gh:27`; a subsequent plan reads the field, not the title. + - Unattended path: `board-gh-apply.sh` creates no issue/ticket; unlinked items are report-only. + +- [ ] **Step 4: Manual live smoke** (documented, human-run): on the real repo, `board-gh-plan.sh` (no args, live `gh`) prints a plan; `/board-sync` in a main-checkout session applies it and writes `SYNC-REPORT.md`. Confirm no worktree run is possible (`board-gh-plan.sh` from a worktree is refused by `_lib.sh`). + +- [ ] **Step 5: Final commit** (if any doc tweaks emerged) + +```bash +git add -A && git commit -m "board-sync: Layer 1 (state sync) — acceptance verified" || echo "nothing to commit" +``` + +--- + +## Subsequent layers (separate plans) + +Layer 1 above is a complete, shippable increment: bidirectional **state** sync + the agent + cron. Layers 2 and 3 extend the same scripts and each gets its own plan (write it once Layer 1 lands and verifies): + +- **Layer 2 — labels.** Extend `board-gh-plan.sh` with a `labels` facet: set-diff `map.json` `labels[]` against each issue's free labels (exclude the managed set: `epic`, `category`→`bug`/`enhancement`, and `state:*`), watermark the last-synced set, propagate per-label add/remove both ways, conflict on a both-sides label disagreement. Extend `board-gh-apply.sh` to call `board-meta.sh --add-label/--rm-label` (board side) and `gh issue edit --add-label/--remove-label` (GitHub side). Add the one-way board→GH managed-label projection (`epic` from children; opt-in `state:`). +- **Layer 3 — edges.** Extend the plan/apply with a `blocked_by` facet carried in the GitHub issue body as `` (issue numbers resolved through the `gh` link). board→GH rewrites the block; GH→board parses it and re-cuts edges via `board-edge.sh`; conflict on disagreement. `parent`/`spawned_by`/`relates_to` stay board-only. + +Each layer's plan follows the same TDD shape: fixture-driven `board-gh-plan.sh` unit tests in `test-board-gh-sync.sh`, `--dry-run` apply assertions, then the spec's acceptance for that layer. diff --git a/docs/doperpowers/specs/2026-07-05-board-sync-design.md b/docs/doperpowers/specs/2026-07-05-board-sync-design.md index 7c9f683e9d..e4d3417f6a 100644 --- a/docs/doperpowers/specs/2026-07-05-board-sync-design.md +++ b/docs/doperpowers/specs/2026-07-05-board-sync-design.md @@ -26,7 +26,7 @@ Three approaches were weighed: ## 1. Schema & data-model changes (issue-tracker core) -Two optional, backward-compatible node fields (a missing field = "unset", so old boards load unchanged), bumping `map.json` `version` 1 → 2: +Two optional, backward-compatible node fields (a missing field = "unset", so old boards load unchanged); `map.json` `version` stays `1` — the fields are additive and readers use `.get(...)`, so there is no migration: - **`gh`** (int | null) — the linked GitHub issue number. Sits beside the existing external refs `branch`/`pr`. This is the machine linkage; title text is never parsed again after backfill. - **`labels`** (string[]) — the last-reconciled set of *free* GitHub labels for this ticket (see §4). Managed/derived labels (`epic`, `state:*`) are **not** stored here. @@ -81,11 +81,11 @@ The chosen scope is **state + labels + edges**. Because labels and edges each ex | board state | GitHub | direction notes | |---|---|---| -| `done` | `closed` / `stateReason=completed` | board→GH: close as completed. GH→board: only-GH close/completed → `done`. | +| `done` | `closed` / `stateReason=completed` | board→GH: close as completed. GH→board: only-GH close/completed → `done` **when the board ticket is done-reachable (`in-progress`/`in-review`)**; otherwise reported — the board never started it, which is itself a surprise for the human. | | `wontfix` | `closed` / `stateReason=not_planned` | board→GH: close as not_planned. GH→board: only-GH close/not_planned → `wontfix` (with a script-required note). | | `ready-for-agent`, `in-progress`, `blocked`, `needs-info`, `in-review`, `deferred` | `open` | GH `open` ↔ **any** board "open" state. The fine state is board-only and is **never inferred** from GitHub. board→GH: ensure the issue is open. | -`board→GH` is fully deterministic. `GH→board` is deterministic for `close→{done|wontfix}` (via `stateReason`). A **GitHub reopen** of an issue whose board state is `done`/`wontfix` is genuinely ambiguous (to *which* open state?) → **report, agent/human decides** — it is never auto-applied. +`board→GH` is fully deterministic. `GH→board` is deterministic for `close→wontfix` (via `stateReason=not_planned`, legal from any non-terminal state) and for `close→done` **only when the board ticket is `in-progress`/`in-review`** (the board state machine forbids `done` from other states — a GitHub-completed issue whose board ticket never started is reported, not forced). A **GitHub reopen** of an issue whose board state is `done`/`wontfix` is genuinely ambiguous (to *which* open state?) → **report, agent/human decides** — it is never auto-applied. ### Layer 2 — labels @@ -154,11 +154,19 @@ The chosen scope is **state + labels + edges**. Because labels and edges each ex - There is **no structured ticket↔issue link** today (title text only) and **no per-field timestamps** on nodes. Both gaps directly forced concrete choices here: the `gh` field, and the `.sync-state.json` watermark as the only way to attribute a change to a side. - GitHub cannot **portably** represent the board's fine states or `blocked_by` edges (native dependencies are beta). Hence: fine state stays board-only, an optional `state:*` label projection makes it visible one-way, and edges ride in a machine-parseable body comment. - The substrate spec (2026-07-03) had already **explicitly deferred** "GitHub/GitLab sync or export" — board-sync is the planned successor to that deferral, not a new direction. +- **The watermark refresh must be plan-driven, not a `map.json` re-walk** (found in Task 5 review, reproduced with fixtures). An early apply refreshed the watermark for every linked, non-conflict ticket read straight from `map.json`. Because a *held-back* action (excluded from a filtered/subset plan) and a *genuine agreement* both look like "ticket absent from the plan," the re-walk stamped held-back tickets as reconciled — so the next plan run misread them as spurious "GitHub reopened" conflicts. Fix: `board-gh-plan.sh` now emits an explicit `agree` list of already-agreeing linked tickets, and `board-gh-apply.sh` refreshes the watermark for exactly `{applied auto non-conflict actions} ∪ {plan.agree}` — never re-walking `map.json`. A ticket the plan doesn't carry is never stamped. This also made `--gh-json` on `board-gh-apply.sh` redundant (the watermark records board state, and the plan already vetted issue existence), so it was removed from apply; `board-gh-plan.sh` keeps `--gh-json` as its input seam. ## Outcomes & Retrospective -Pending — written at finish. +Layer 1 (state + close-reason) shipped on branch `feat/board-sync` (13 commits, base `714cb46`) via subagent-driven development: the deterministic toolkit (`board-meta.sh`, `board-link.sh`, `board-gh-plan.sh`, `board-gh-apply.sh`) built on the existing house pattern, plus the `board-sync` subagent, the `/board-sync` command, and the SKILL.md docs. Both test suites pass (28 new board-sync assertions in `test-board-gh-sync.sh`; the existing `test-board-scripts.sh` with no regression); shellcheck shows only the repo-baseline SC1091. Layers 2 (labels) and 3 (edges) remain as their own plans, per the layering. + +The design held — every board write still goes through the invariant-enforcing scripts, the board stayed the single writer, and the coarse-mapping / conservative-cron / report-only-conflict decisions all survived implementation. What the reviews earned was correctness the design and plan had glossed: three material bugs were caught and fixed before merge, each on the automated path where no human watches — (1) the watermark refresh re-walked `map.json`, so a filtered plan stamped held-back tickets as synced → made **plan-driven** (plan emits an `agree` list; apply refreshes only `{applied} ∪ {agree}`); (2) the agent ran `board-gh-plan.sh` bare, but under a non-TTY subagent Bash call that silently reads empty stdin and sees zero issues → the agent now fetches `gh` explicitly into a `--gh-json` file, and the script was hardened so a bare call defaults to `gh` (stdin only via `--gh-json -`); (3) the `not_planned` GH→board branch lacked a reachability gate, emitting an `auto` `done → wontfix` that the state machine rejects and that crashed the unattended apply mid-loop → gated to a reported conflict. A fourth (backfill skipped its `log.jsonl` audit entry) was fixed in Task 3. The lesson that recurred: the dangerous cases all live on the unattended path (filtered plans, cron's non-TTY stdin, terminal-state transitions), exactly where the design's "conservative and safe" promise is load-bearing and least observed. + +Deferred as acceptable (final-review triaged): `updated` bumped on same-day no-op label writes (day-granular, byte-identical); the inert `generated_by` output key; untested-but-verified first-contact and reopen conflict branches; the `--no-github` board→gh skip not directly asserted. The daily cron is **documented but not armed** — board-sync is not yet installed in a consumer repo, so a live `/board-sync` cron would invoke an uninstalled agent; arm it post-release. ## Revision Notes - 2026-07-05: Initial design from a brainstorming session. Six clarifying questions locked the architecture (mechanics, sync direction, conflict policy, linkage, unattended aggressiveness, scope) — all captured in the Decision Log with their rejected alternatives. Open sub-choices resolved by the author within the approved design: `state:*` label projection included opt-in (board→GH one-way); report sink is `SYNC-REPORT.md` surfaced by `board-reconcile.sh`. +- 2026-07-05: Two refinements surfaced while writing the implementation plan (`docs/doperpowers/plans/2026-07-05-board-sync.md`). (1) Dropped the `version` 1→2 bump — the new `gh`/`labels` fields are additive and optional, so readers using `.get()` need no migration and old boards load unchanged; version stays `1`. (2) `GH→board` `done` is auto-applied only when the board ticket is done-reachable (`in-progress`/`in-review`); the board state machine forbids `done` from other states, so a GitHub-completed issue whose ticket never started is reported rather than force-transitioned. `§1 Schema` and `§4 Layer 1` updated accordingly. +- 2026-07-05: During Task 5 implementation the whole-diff review caught (and reproduced) a watermark-scope correctness bug — see the new Surprises & Discoveries entry. The fix made the watermark refresh plan-driven: `board-gh-plan.sh` emits an `agree` list, `board-gh-apply.sh` refreshes only `{applied} ∪ {agree}` and no longer takes `--gh-json`. Plan doc's Task-6 agent prompt and Task-7 toolkit-table references updated to drop the removed `--gh-json` from the apply invocation so downstream transcription doesn't ship a dying command. +- 2026-07-05: Final gates. An Opus whole-branch review found and reproduced a Critical — the `not_planned` GH→board branch emitted an `auto` `done → wontfix` that the board state machine rejects and that crashed the unattended apply mid-loop; gated to a reported conflict (plus TTY-footgun hardening so a bare `board-gh-plan.sh` defaults to `gh`, dropping the unused `body` field from the Layer-1 fetch, and a dead-pipe cleanup). A Codex (gpt-5.5, xhigh) independent review then confirmed the core reconcile logic, injection safety, and atomicity all hold, and surfaced three Important items — all fixed: `--no-github` no longer watermarks skipped `board->gh` actions (same corruption class as the filtered-plan bug, different path); `board-reconcile.sh` now surfaces pending `SYNC-REPORT.md` conflicts (the §5 report-surfacing promise the plan's tasks had missed), with the agent writing a countable `board-sync conflicts: N` header; and the agent uses a per-run `mktemp -d` instead of predictable `/tmp` snapshot/plan paths (concurrent-run + symlink footgun). Both test suites stayed green throughout. diff --git a/skills/issue-tracker/SKILL.md b/skills/issue-tracker/SKILL.md index d710e921f4..97dd2f299a 100644 --- a/skills/issue-tracker/SKILL.md +++ b/skills/issue-tracker/SKILL.md @@ -61,6 +61,23 @@ Paths relative to this skill's `scripts/` directory. Use them — don't hand-edi | `board-show.sh ` | node + md path + bound daemon | | `board-bind.sh ` | record which daemon owns the ticket (in the daemon registry) | | `board-reconcile.sh` | read-only catch-up: unapplied proposals, orphaned tickets, dispatchables | +| `board-link.sh --gh N` \| `--backfill` | link a ticket to its GitHub issue; `--backfill` populates `gh` from the `(GH#NN)` marker in every title, once | +| `board-meta.sh [--gh N] [--add-label L] [--rm-label L]` | writer for the `gh` link and free `labels[]` (atomic, re-renders) | +| `board-gh-plan.sh [--gh-json FILE]` | **read-only**: emit the board↔GitHub reconcile plan (state facet) as JSON | +| `board-gh-apply.sh [--plan FILE] [--dry-run] [--no-github]` | apply a plan's (stdin or `--plan`) `auto` non-conflict actions (board via scripts, GitHub via `gh`) and refresh `.sync-state.json` from the plan | + +## GitHub sync (board-sync) + +`board-sync` (a subagent; `/board-sync`) keeps the board and GitHub issues in +step. It links tickets to issues (`gh` node field; `board-link.sh --backfill` +migrates existing boards from the `(GH#NN)` title marker), then reconciles state +both ways against a `.sync-state.json` watermark: board `done`↔closed/completed, +`wontfix`↔closed/not_planned, everything else↔open. Unambiguous changes apply +automatically; **anything it can't safely auto-apply — divergent state (both +sides moved), a missing linked issue, or an ambiguous single-side mapping (e.g. a +reopened issue, or a GitHub-completed ticket the board never started) — is written +to `SYNC-REPORT.md`, never auto-resolved**. Cron runs are conservative — state +only, no counterpart creation. Run from the main checkout with `gh` authenticated. ## The dispatch loop diff --git a/skills/issue-tracker/scripts/board-gh-apply.sh b/skills/issue-tracker/scripts/board-gh-apply.sh new file mode 100755 index 0000000000..1a071d99bd --- /dev/null +++ b/skills/issue-tracker/scripts/board-gh-apply.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# board-gh-apply.sh — apply a board-gh-plan, then refresh the sync watermark. +# +# Usage: +# board-gh-apply.sh --plan FILE [--dry-run] [--no-github] +# ... | board-gh-apply.sh [--dry-run] (plan on stdin) +# +# Executes only auto:true, non-conflict actions: board side via board-transition.sh, +# GitHub side via gh. --dry-run prints the commands and writes nothing. --no-github +# applies board-side actions but skips gh calls (test/board-only seam) — and, since +# those skipped board->gh actions were never actually sent to GitHub, they are also +# excluded from the watermark refresh below. On a real run, .sync-state.json is +# refreshed only for the tickets the PLAN represents (its auto/non-conflict actions +# plus its "agree" set) — never a blind re-walk of map.json, so a ticket held back +# from a filtered plan is never falsely marked synced. +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=_lib.sh +. "$SCRIPT_DIR/_lib.sh" +[ -f "$MAP" ] || die "no board at $MAP" + +planfile="" dry="" nogh="" +while [ $# -gt 0 ]; do + case "$1" in + --plan) _need_arg "$1" "${2:-}"; planfile="$2"; shift 2 ;; + --dry-run) dry=1; shift ;; + --no-github) nogh=1; shift ;; + *) die "unknown option: $1" ;; + esac +done +[ -n "$planfile" ] && PLAN="$(cat "$planfile")" || PLAN="$(cat)" + +# 1) board-side transitions the plan asks for (unless dry-run) — via the real script. +PLAN_JSON="$PLAN" python3 -c ' +import json, os, sys +plan = json.loads(os.environ["PLAN_JSON"]) +for a in plan["actions"]: + if not a.get("auto") or a.get("conflict"): continue + if a.get("direction") == "gh->board" and a.get("target_board"): + st, note = a["target_board"] + print("%s\t%s\t%s" % (a["ticket"], st, note or "")) +' | while IFS="$(printf '\t')" read -r tid st note; do + [ -z "$tid" ] && continue + if [ -n "$dry" ]; then + echo "board: board-transition.sh $tid $st ${note:+\"$note\"}" + else + if [ -n "$note" ]; then "$SCRIPT_DIR/board-transition.sh" "$tid" "$st" "$note" >/dev/null + else "$SCRIPT_DIR/board-transition.sh" "$tid" "$st" >/dev/null; fi + echo "board: $tid -> $st" + fi +done + +# 2) GitHub-side changes (unless dry-run or --no-github) — via gh. +PLAN_JSON="$PLAN" python3 -c ' +import json, os +plan = json.loads(os.environ["PLAN_JSON"]) +for a in plan["actions"]: + if not a.get("auto") or a.get("conflict"): continue + if a.get("direction") == "board->gh" and a.get("target_gh"): + state, reason = a["target_gh"] + if state == "closed": + print("close\t%d\t%s" % (a["gh"], reason)) + else: + print("reopen\t%d\t" % a["gh"]) +' | while IFS="$(printf '\t')" read -r op num reason; do + [ -z "$op" ] && continue + if [ -n "$dry" ]; then + [ "$op" = "close" ] && echo "gh: issue close $num --reason $reason" || echo "gh: issue reopen $num" + elif [ -z "$nogh" ]; then + if [ "$op" = "close" ]; then gh issue close "$num" --reason "$reason" >/dev/null + else gh issue reopen "$num" >/dev/null; fi + echo "gh: $op $num" + fi +done + +[ -n "$dry" ] && { echo "(dry-run: watermark unchanged)"; exit 0; } + +# 3) Refresh the watermark only for the tickets the PLAN represents: its +# auto/non-conflict actions (now-reconciled) plus its already-agreeing set. +# A ticket held back from a filtered plan is simply absent from `refresh`, +# so its prior watermark entry (or lack of one) is left untouched — it is +# never mistaken for an agreement just because it's missing from the plan. +# Under --no-github, board->gh actions were never actually sent to GitHub +# (step 2 above is skipped for them), so they must not be counted as +# reconciled either — otherwise a real run later would see the ticket as +# already-synced and silently skip the gh call it still owes. +BOARD_SYNC="$BOARD_DIR/.sync-state.json" PLAN_JSON="$PLAN" T_NOGH="$nogh" \ +T_TODAY="$(_today)" _py - <<'PY' +import json, os +env = os.environ +with open(env["BOARD_MAP"]) as f: + board = json.load(f) +tickets = board["tickets"] +plan = json.loads(env["PLAN_JSON"]) +try: + with open(env["BOARD_SYNC"]) as f: + state = json.load(f) +except FileNotFoundError: + state = {"version": 1, "tickets": {}} +wm = state.setdefault("tickets", {}) +nogh = bool(env.get("T_NOGH")) +refresh = {a["ticket"] for a in plan["actions"] + if a.get("auto") and not a.get("conflict") + and not (nogh and a.get("direction") == "board->gh")} +refresh |= set(plan.get("agree", [])) +for tid in refresh: + n = tickets.get(tid) + if not n or not n.get("gh"): + continue + wm[tid] = {"gh": n["gh"], "state": n["state"], "labels": list(n.get("labels") or [])} +state["version"] = 1 +state["synced_at"] = env["T_TODAY"] +tmp = env["BOARD_SYNC"] + ".tmp" +with open(tmp, "w") as f: + json.dump(state, f, indent=2); f.write("\n") +os.replace(tmp, env["BOARD_SYNC"]) +print("watermark: refreshed %d ticket(s)" % len(refresh)) +PY diff --git a/skills/issue-tracker/scripts/board-gh-plan.sh b/skills/issue-tracker/scripts/board-gh-plan.sh new file mode 100755 index 0000000000..db8f8f8f41 --- /dev/null +++ b/skills/issue-tracker/scripts/board-gh-plan.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# board-gh-plan.sh — compute the board↔GitHub reconcile plan (no mutation). +# +# Usage: +# board-gh-plan.sh [--gh-json FILE | --gh-json -] +# +# GitHub issues come from --gh-json FILE, or --gh-json - to read stdin, else: +# gh issue list --state all --limit 1000 --json number,state,stateReason,labels,title +# Reads .sync-state.json (the last-sync watermark). Emits a JSON plan on stdout. +# Pure: it never writes the board or GitHub. +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=_lib.sh +. "$SCRIPT_DIR/_lib.sh" +[ -f "$MAP" ] || die "no board at $MAP" + +ghjson="" +while [ $# -gt 0 ]; do + case "$1" in + --gh-json) _need_arg "$1" "${2:-}"; ghjson="$2"; shift 2 ;; + *) die "unknown option: $1" ;; + esac +done +if [ "$ghjson" = "-" ]; then + GH_SRC="$(cat)" +elif [ -n "$ghjson" ]; then + GH_SRC="$(cat "$ghjson")" +else + GH_SRC="$(gh issue list --state all --limit 1000 \ + --json number,state,stateReason,labels,title)" +fi + +BOARD_GH="$GH_SRC" BOARD_SYNC="$BOARD_DIR/.sync-state.json" _py - <<'PY' +import json, os +env = os.environ +with open(env["BOARD_MAP"]) as f: + board = json.load(f) +tickets = board["tickets"] +gh = {i["number"]: i for i in json.loads(env["BOARD_GH"] or "[]")} +try: + with open(env["BOARD_SYNC"]) as f: + wm = json.load(f).get("tickets", {}) +except FileNotFoundError: + wm = {} + +def coarse(state): + if state == "done": return ["closed", "completed"] + if state == "wontfix": return ["closed", "not_planned"] + return ["open", None] + +def gh_coarse(issue): + if str(issue["state"]).lower() == "closed": + r = str(issue.get("stateReason") or "completed").lower() + return ["closed", "not_planned" if r == "not_planned" else "completed"] + return ["open", None] + +DONE_REACHABLE = {"in-progress", "in-review"} +actions, agree, unlinked_board, unlinked_gh = [], [], [], [] +linked = set() + +for tid in sorted(tickets, key=lambda k: int(k[1:])): + n = tickets[tid] + num = n.get("gh") + if not num: + unlinked_board.append(tid); continue + if num not in gh: + actions.append({"ticket": tid, "gh": num, "facet": "state", "conflict": True, + "auto": False, "board": n["state"], "gh_state": None, + "watermark": (wm.get(tid) or {}).get("state"), + "reason": "linked issue #%d not found on GitHub" % num}) + continue + linked.add(num) + b_c, g_c = coarse(n["state"]), gh_coarse(gh[num]) + w = wm.get(tid) + w_c = coarse(w["state"]) if w and "state" in w else None + if b_c == g_c: + agree.append(tid) + continue # agree → no action (apply refreshes the watermark) + b_moved = (w_c is None) or (b_c != w_c) + g_moved = (w_c is None) or (g_c != w_c) + if w_c is not None and b_moved and not g_moved: + actions.append({"ticket": tid, "gh": num, "facet": "state", + "direction": "board->gh", "auto": True, + "board": n["state"], "gh_state": str(gh[num]["state"]).lower(), + "target_gh": b_c, "reason": "board changed"}) + elif w_c is not None and g_moved and not b_moved: + target, auto, reason = None, True, "github changed" + if g_c == ["closed", "completed"]: + if n["state"] in DONE_REACHABLE: + target = ["done", None] + else: + auto = False + reason = "GitHub closed completed but board is %s (never started)" % n["state"] + elif g_c == ["closed", "not_planned"]: + if n["state"] == "done": + auto = False + reason = "GitHub closed not_planned but board is done — done cannot go to wontfix" + else: + target = ["wontfix", "sync: GitHub closed as not planned"] + else: # reopened while board terminal — ambiguous target open state + auto = False + reason = "GitHub reopened; board is %s — target open state ambiguous" % n["state"] + a = {"ticket": tid, "gh": num, "facet": "state", "direction": "gh->board", + "auto": auto, "board": n["state"], "gh_state": str(gh[num]["state"]).lower(), + "target_board": target, "reason": reason} + if not auto: + a["conflict"] = True + actions.append(a) + else: # both moved and disagree, or first contact with no watermark + actions.append({"ticket": tid, "gh": num, "facet": "state", "conflict": True, + "auto": False, "board": n["state"], + "gh_state": str(gh[num]["state"]).lower(), + "watermark": (w or {}).get("state"), + "reason": "both sides diverged" if w_c is not None + else "first sync: sides disagree"}) + +for num in sorted(gh): + if num not in linked and str(gh[num]["state"]).lower() == "open": + unlinked_gh.append(num) + +print(json.dumps({"generated_by": "board-gh-plan", "actions": actions, "agree": agree, + "unlinked_board": unlinked_board, "unlinked_gh": unlinked_gh}, + indent=2)) +PY diff --git a/skills/issue-tracker/scripts/board-link.sh b/skills/issue-tracker/scripts/board-link.sh new file mode 100755 index 0000000000..c0de374146 --- /dev/null +++ b/skills/issue-tracker/scripts/board-link.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# board-link.sh — link a ticket to its GitHub issue, or backfill links from titles. +# +# Usage: +# board-link.sh --gh N set the ticket's GitHub issue number +# board-link.sh --backfill one-time: parse "(GH#NN)" from every title → gh +# (only where gh is unset; never overwrites) +# +# After --backfill the board never depends on title text again. +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=_lib.sh +. "$SCRIPT_DIR/_lib.sh" + +[ $# -ge 1 ] || { usage_from_header "$0" >&2; exit 2; } +[ -f "$MAP" ] || die "no board at $MAP (nothing registered yet)" + +if [ "$1" = "--backfill" ]; then + T_NOW="$(_now)" T_TODAY="$(_today)" _py - <<'PY' +import json, os, re +env = os.environ +with open(env["BOARD_MAP"]) as f: + board = json.load(f) +filled = 0 +log = [] +for tid in sorted(board["tickets"], key=lambda k: int(k[1:])): + t = board["tickets"][tid] + if t.get("gh"): + continue + m = re.search(r"GH#(\d+)", t.get("title", "")) + if m: + t["gh"] = int(m.group(1)); t["updated"] = env["T_TODAY"]; filled += 1 + log.append({"ts": env["T_NOW"], "ticket": tid, "meta": "gh", "op": "set", "value": t["gh"]}) + print("%s: gh = %d (from title)" % (tid, t["gh"])) +tmp = env["BOARD_MAP"] + ".tmp" +with open(tmp, "w") as f: + json.dump(board, f, indent=2); f.write("\n") +os.replace(tmp, env["BOARD_MAP"]) +with open(env["BOARD_LOG"], "a") as f: + for e in log: + f.write(json.dumps(e) + "\n") +print("backfilled %d ticket(s)" % filled) +PY + "$SCRIPT_DIR/board-map.sh" --write >/dev/null 2>&1 \ + || echo "warning: BOARD.md refresh failed (board-map.sh)" >&2 + exit 0 +fi + +# else: --gh N — delegate to board-meta +tid="$1"; shift +[ "${1:-}" = "--gh" ] || { usage_from_header "$0" >&2; exit 2; } +_need_arg "$1" "${2:-}" +exec "$SCRIPT_DIR/board-meta.sh" "$tid" --gh "$2" diff --git a/skills/issue-tracker/scripts/board-meta.sh b/skills/issue-tracker/scripts/board-meta.sh new file mode 100755 index 0000000000..1e1b47e3d7 --- /dev/null +++ b/skills/issue-tracker/scripts/board-meta.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# board-meta.sh — set a ticket's sync metadata: the GitHub link and free labels. +# +# Usage: +# board-meta.sh --gh N link the GitHub issue number (0 clears) +# board-meta.sh --add-label L add a free label (repeatable, idempotent) +# board-meta.sh --rm-label L remove a free label (repeatable) +# +# The fields board-sync reconciles. Kept behind a script — not hand edits — so +# writes stay atomic and BOARD.* re-renders, exactly like board-transition/edge. +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=_lib.sh +. "$SCRIPT_DIR/_lib.sh" + +[ $# -ge 2 ] || { usage_from_header "$0" >&2; exit 2; } +tid="$1"; shift +gh="" adds="" rms="" +while [ $# -gt 0 ]; do + case "$1" in + --gh) _need_arg "$1" "${2:-}"; gh="$2"; shift 2 ;; + --add-label) _need_arg "$1" "${2:-}"; adds="$adds${adds:+,}$2"; shift 2 ;; + --rm-label) _need_arg "$1" "${2:-}"; rms="$rms${rms:+,}$2"; shift 2 ;; + *) die "unknown option: $1" ;; + esac +done +[ -f "$MAP" ] || die "no board at $MAP (nothing registered yet)" + +T_ID="$tid" T_GH="$gh" T_ADDS="$adds" T_RMS="$rms" \ +T_NOW="$(_now)" T_TODAY="$(_today)" _py - <<'PY' +import json, os, sys + +def die(m): sys.stderr.write("error: %s\n" % m); sys.exit(1) + +env = os.environ +with open(env["BOARD_MAP"]) as f: + board = json.load(f) +tickets = board["tickets"] +tid = env["T_ID"] +if tid not in tickets: + die("unknown ticket: %s" % tid) +n = tickets[tid] +log = [] + +gh = env["T_GH"] +if gh != "": + try: + v = int(gh) + except ValueError: + die("--gh must be an integer issue number (0 to clear)") + n["gh"] = None if v == 0 else v + log.append({"ts": env["T_NOW"], "ticket": tid, "meta": "gh", "op": "set", "value": n["gh"]}) + +labels = list(n.get("labels") or []) +for l in [x for x in env["T_ADDS"].split(",") if x]: + if l not in labels: + labels.append(l) + log.append({"ts": env["T_NOW"], "ticket": tid, "meta": "labels", "op": "add", "value": l}) +for l in [x for x in env["T_RMS"].split(",") if x]: + if l in labels: + labels.remove(l) + log.append({"ts": env["T_NOW"], "ticket": tid, "meta": "labels", "op": "rm", "value": l}) +n["labels"] = labels +n["updated"] = env["T_TODAY"] + +tmp = env["BOARD_MAP"] + ".tmp" +with open(tmp, "w") as f: + json.dump(board, f, indent=2); f.write("\n") +os.replace(tmp, env["BOARD_MAP"]) +with open(env["BOARD_LOG"], "a") as f: + for e in log: + f.write(json.dumps(e) + "\n") +for e in log: + print("%s: %s %s %s" % (tid, e["meta"], "=" if e["meta"] == "gh" else ("+=" if e["op"] == "add" else "-="), e["value"])) +PY + +# BOARD.md is a pure render cache — refresh it on every board write. Non-fatal. +"$SCRIPT_DIR/board-map.sh" --write >/dev/null 2>&1 \ + || echo "warning: BOARD.md refresh failed (board-map.sh)" >&2 diff --git a/skills/issue-tracker/scripts/board-reconcile.sh b/skills/issue-tracker/scripts/board-reconcile.sh index 88fccac402..d5f6ff1293 100755 --- a/skills/issue-tracker/scripts/board-reconcile.sh +++ b/skills/issue-tracker/scripts/board-reconcile.sh @@ -4,16 +4,18 @@ # Usage: board-reconcile.sh # # Scans daemon replies for proposal blocks the board hasn't applied, flags -# in-progress tickets with no live bound daemon, and lists dispatchable -# tickets. Applying anything is the orchestrator's judge step, via -# board-transition.sh — this script only reports. +# in-progress tickets with no live bound daemon, lists dispatchable tickets, +# and surfaces pending board<->GitHub sync conflicts from SYNC-REPORT.md (if +# board-sync has written one). Applying anything is the orchestrator's judge +# step, via board-transition.sh / the board-sync agent — this script only +# reports. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" # shellcheck source=_lib.sh . "$SCRIPT_DIR/_lib.sh" [ -f "$MAP" ] || die "no board at $MAP (nothing registered yet)" -T_DHOME="$DAEMON_HOME" _py - <<'PY' +T_DHOME="$DAEMON_HOME" T_BOARD_DIR="$BOARD_DIR" _py - <<'PY' import glob, json, os, re, shlex env = os.environ @@ -101,4 +103,14 @@ for t, n in by_id(tickets.items()): if n["state"] == "ready-for-agent" and t not in epics \ and all(tickets.get(b, {}).get("state") == "done" for b in n.get("blocked_by", [])): print("dispatch %s: %s" % (t, " ".join(str(n["title"]).split()))) + +# 4. Pending board<->GitHub sync conflicts (read-only surface of the board-sync +# agent's SYNC-REPORT.md, whose first line is always "board-sync conflicts: N"). +report_path = os.path.join(env["T_BOARD_DIR"], "SYNC-REPORT.md") +if os.path.isfile(report_path): + with open(report_path) as f: + first_line = f.readline() + m = re.match(r"board-sync conflicts:\s*(\d+)", first_line.strip()) + if m and int(m.group(1)) > 0: + print("board-sync: %d conflict(s) pending (SYNC-REPORT.md)" % int(m.group(1))) PY diff --git a/skills/issue-tracker/scripts/board-register.sh b/skills/issue-tracker/scripts/board-register.sh index f13092d55e..df1eacbb6a 100755 --- a/skills/issue-tracker/scripts/board-register.sh +++ b/skills/issue-tracker/scripts/board-register.sh @@ -72,7 +72,7 @@ tickets[tid] = { "title": title, "md": md, "state": state, "category": category, "note": note or None, "parent": parent or None, "blocked_by": blocked, "spawned_by": spawned or None, "relates_to": [], - "branch": None, "pr": None, + "branch": None, "pr": None, "gh": None, "labels": [], "created": env["T_TODAY"], "updated": env["T_TODAY"], } tmp = env["BOARD_MAP"] + ".tmp" diff --git a/tests/issue-tracker/test-board-gh-sync.sh b/tests/issue-tracker/test-board-gh-sync.sh new file mode 100755 index 0000000000..e037027872 --- /dev/null +++ b/tests/issue-tracker/test-board-gh-sync.sh @@ -0,0 +1,388 @@ +#!/usr/bin/env bash +# +# Hermetic tests for the issue-tracker board toolkit. +# +# The board scripts touch only the filesystem (a git repo's main checkout, the +# board data dir, and the daemon registry dir) — no network, no `claude` CLI. +# We build a throwaway git repo + worktree and a fake daemon registry, drive +# the real scripts end-to-end, and assert on map.json / log.jsonl / output. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +SCRIPTS_DIR="$REPO_ROOT/skills/issue-tracker/scripts" + +FAILURES=0 +TEST_ROOT="$(mktemp -d)" +cleanup() { rm -rf "$TEST_ROOT"; } +trap cleanup EXIT + +pass() { echo " [PASS] $1"; } +fail() { + echo " [FAIL] $1" + FAILURES=$((FAILURES + 1)) +} +assert_equals() { + if [[ "$1" == "$2" ]]; then pass "$3"; else + fail "$3"; echo " expected: $2"; echo " actual: $1"; fi +} +assert_contains() { + if printf '%s' "$1" | grep -Fq -- "$2"; then pass "$3"; else + fail "$3"; echo " expected to find: $2"; echo " in: $1"; fi +} +assert_file_exists() { + if [[ -f "$1" ]]; then pass "$2"; else fail "$2"; echo " missing: $1"; fi +} +assert_fails() { # cmd... — passes when the command exits non-zero + if "$@" >/dev/null 2>&1; then fail "expected failure: $*"; else pass "refused: $*"; fi +} + +# ---- environment: throwaway git repo + worktree, fake daemon registry ------- +export HOME="$TEST_ROOT/home"; mkdir -p "$HOME" +export DAEMON_HOME="$TEST_ROOT/registry"; mkdir -p "$DAEMON_HOME" +WORK="$TEST_ROOT/work" +git init -q "$WORK" +git -C "$WORK" -c user.email=t@t -c user.name=t commit --allow-empty -m init -q +git -C "$WORK" worktree add -q -b t-branch "$TEST_ROOT/wt" +BOARD="$WORK/doperpowers/issue-tracker" + +run() { # run a board script from the main checkout + local s="$1"; shift + (cd "$WORK" && "$SCRIPTS_DIR/$s" "$@") +} + +# ---- Task 1: register writes additive gh/labels node fields ----------------- +echo "board-register (sync fields):" +out="$(run board-register.sh "First ticket" enhancement)" +tid="$(printf '%s' "$out" | awk '{print $1}')" +gh="$(python3 -c "import json;print(json.load(open('$BOARD/map.json'))['tickets']['$tid'].get('gh','MISSING'))")" +assert_equals "$gh" "None" "new ticket has gh field defaulting to null" +labels="$(python3 -c "import json;print(json.load(open('$BOARD/map.json'))['tickets']['$tid'].get('labels','MISSING'))")" +assert_equals "$labels" "[]" "new ticket has labels field defaulting to []" + +# ---- Task 2: board-meta.sh writes gh link + free labels --------------------- +echo "board-meta:" +run board-register.sh "Meta target" enhancement >/dev/null # next Tn +tid="$(run board-list.sh | grep 'Meta target' | awk '{print $1}')" +out="$(run board-meta.sh "$tid" --gh 42)" +assert_contains "$out" "$tid: gh = 42" "meta sets gh" +gh="$(python3 -c "import json;print(json.load(open('$BOARD/map.json'))['tickets']['$tid']['gh'])")" +assert_equals "$gh" "42" "gh written as integer" +run board-meta.sh "$tid" --add-label P0 --add-label size:M >/dev/null +run board-meta.sh "$tid" --add-label P0 >/dev/null # idempotent +labels="$(python3 -c "import json;print(','.join(json.load(open('$BOARD/map.json'))['tickets']['$tid']['labels']))")" +assert_equals "$labels" "P0,size:M" "labels added once, order preserved" +run board-meta.sh "$tid" --rm-label P0 >/dev/null +labels="$(python3 -c "import json;print(','.join(json.load(open('$BOARD/map.json'))['tickets']['$tid']['labels']))")" +assert_equals "$labels" "size:M" "label removed" +run board-meta.sh "$tid" --gh 0 >/dev/null +gh="$(python3 -c "import json;print(json.load(open('$BOARD/map.json'))['tickets']['$tid']['gh'])")" +assert_equals "$gh" "None" "gh 0 clears the link" +assert_fails run board-meta.sh T999 --gh 1 # unknown ticket +assert_fails run board-meta.sh "$tid" --gh notanumber # non-integer + +# ---- Task 3: board-link.sh — gh sugar + one-time title backfill ------------- +echo "board-link (backfill):" +run board-register.sh "Legacy epic (GH#35)" enhancement >/dev/null +tid_backfilled="$(run board-list.sh | grep 'Legacy epic' | awk '{print $1}')" +run board-register.sh "No marker here" bug >/dev/null +out="$(run board-link.sh --backfill)" +assert_contains "$out" "gh = 35 (from title)" "backfill parses GH#NN from title" +n="$(python3 -c "import json;t=json.load(open('$BOARD/map.json'))['tickets'];print(sum(1 for x in t.values() if x.get('gh')==35))")" +assert_equals "$n" "1" "exactly one ticket linked to #35" +# a ticket without a marker stays unlinked +un="$(python3 -c "import json;t=json.load(open('$BOARD/map.json'))['tickets'];print([x['gh'] for x in t.values() if x['title']=='No marker here'][0])")" +assert_equals "$un" "None" "markerless ticket stays unlinked" +# backfill appends an audit entry to log.jsonl, same shape as board-meta's +logged="$(grep -F "\"ticket\": \"$tid_backfilled\"" "$BOARD/log.jsonl" | grep -F '"meta": "gh"' | grep -c '"op": "set"' || true)" +assert_equals "$logged" "1" "backfill appends a gh log entry" +# re-running backfill does not overwrite an existing link +run board-link.sh --backfill >/dev/null + +# ---- board-link.sh — --gh N sugar delegates to board-meta.sh ----------- +echo "board-link (--gh sugar):" +run board-register.sh "Sugar target" enhancement >/dev/null +tid_sugar="$(run board-list.sh | grep 'Sugar target' | awk '{print $1}')" +run board-link.sh "$tid_sugar" --gh 7 >/dev/null +gh_sugar="$(python3 -c "import json;print(json.load(open('$BOARD/map.json'))['tickets']['$tid_sugar']['gh'])")" +assert_equals "$gh_sugar" "7" "board-link --gh sugar sets gh via board-meta delegation" + +# ---- Task 4: board-gh-plan.sh — deterministic state reconcile diff ---------- +echo "board-gh-plan:" +# scratch board: 4 tickets, drive states, link to issues +run board-register.sh "Completion push" enhancement >/dev/null # A +A="$(run board-list.sh | grep 'Completion push' | awk '{print $1}')" +run board-transition.sh "$A" in-progress >/dev/null +run board-transition.sh "$A" done >/dev/null # board done, issue still open +run board-meta.sh "$A" --gh 101 >/dev/null +run board-register.sh "GH closed it" enhancement >/dev/null # B +B="$(run board-list.sh | grep 'GH closed it' | awk '{print $1}')" +run board-transition.sh "$B" in-progress >/dev/null # done-reachable +run board-meta.sh "$B" --gh 102 >/dev/null +run board-register.sh "Already agree" enhancement >/dev/null # C (open ↔ open) +C="$(run board-list.sh | grep 'Already agree' | awk '{print $1}')" +run board-meta.sh "$C" --gh 103 >/dev/null +run board-register.sh "Unlinked local" bug >/dev/null # D (no gh) +D="$(run board-list.sh | grep 'Unlinked local' | awk '{print $1}')" + +cat > "$TEST_ROOT/gh.json" < "$BOARD/.sync-state.json"; echo '{"version":1,"tickets":{}}' > "$BOARD/.sync-state.json" +python3 - "$BOARD/.sync-state.json" "$A" "$B" "$C" <<'PY' +import json,sys +p,A,B,C=sys.argv[1:5] +d=json.load(open(p)) +d["tickets"]={A:{"gh":101,"state":"in-progress"},B:{"gh":102,"state":"in-progress"},C:{"gh":103,"state":"ready-for-agent"}} +json.dump(d,open(p,"w")) +PY +plan="$(run board-gh-plan.sh --gh-json "$TEST_ROOT/gh.json")" +assert_contains "$plan" "\"ticket\": \"$A\"" "plan includes the board-moved ticket" +printf '%s' "$plan" | python3 -c "import json,sys;p=json.load(sys.stdin);a={x['ticket']:x for x in p['actions']}; import os +A,B,C,D='$A','$B','$C','$D' +assert a[A]['direction']=='board->gh' and a[A]['auto'] and a[A]['target_gh'][0]=='closed', 'A board->gh close' +assert a[B]['direction']=='gh->board' and a[B]['auto'] and a[B]['target_board'][0]=='wontfix', 'B gh->board wontfix' +assert C not in a, 'C already agrees, no action' +assert D in p['unlinked_board'], 'D unlinked_board' +assert 900 in p['unlinked_gh'], 'orphan open issue surfaced' +print('plan-assertions-ok')" && pass "plan diff correct each direction" || fail "plan diff correct each direction" + +# ---- board-gh-plan.sh — explicit `--gh-json -` reads stdin, same as FILE ---- +# Bare (no --gh-json) used to fall into an implicit "read stdin if non-TTY" +# branch — under cron/subagent Bash (always non-TTY) a bare call would read +# zero bytes and silently treat GitHub as empty. The fix requires stdin be +# opted into explicitly via `--gh-json -`; this proves that seam still works +# and produces byte-identical output to the file seam for the same data. +echo "board-gh-plan (--gh-json - stdin seam):" +plan_stdin="$(cat "$TEST_ROOT/gh.json" | run board-gh-plan.sh --gh-json -)" +assert_equals "$plan_stdin" "$plan" "--gh-json - (stdin) matches --gh-json FILE for the same input" + +# ---- Task 4 regression: done ticket must not become an illegal wontfix ----- +# The bug this guards: when the board is terminal `done` and GitHub closes the +# linked issue as not_planned, plan used to emit an auto:true gh->board wontfix +# action — but `done` has no legal transitions (LEGAL["done"] is empty in +# board-transition.sh), so apply would die mid-loop applying it on a cron run. +# The fix forces this specific combination to a conflict instead of an auto +# action, and apply must then leave the ticket untouched. +echo "board-gh-plan (done + gh not_planned → conflict, not auto wontfix):" +run board-register.sh "Done vs not_planned" enhancement >/dev/null +E="$(run board-list.sh | grep 'Done vs not_planned' | awk '{print $1}')" +run board-transition.sh "$E" in-progress >/dev/null +run board-transition.sh "$E" done >/dev/null # board terminal: done +run board-meta.sh "$E" --gh 200 >/dev/null +python3 - "$BOARD/.sync-state.json" "$E" <<'PY' +import json,sys +p,E=sys.argv[1:3] +d=json.load(open(p)) +d["tickets"][E]={"gh":200,"state":"done"} +json.dump(d,open(p,"w")) +PY +cat > "$TEST_ROOT/gh-donewontfix.json" < "$TEST_ROOT/plan-e.json" +python3 -c " +import json +p = json.load(open('$TEST_ROOT/plan-e.json')) +a = {x['ticket']: x for x in p['actions']} +act = a['$E'] +assert act['auto'] is False, 'expected auto:false, got %r' % act +assert act.get('conflict') is True, 'expected conflict:true, got %r' % act +assert act.get('target_board') is None, 'target_board must stay unset (nothing to apply)' +print('done-wontfix-guard-ok') +" && pass "done ticket + gh not_planned yields conflict, not auto wontfix" \ + || fail "done ticket + gh not_planned yields conflict, not auto wontfix" + +# Apply must be a no-op for this conflicted ticket: the illegal done→wontfix +# transition is never attempted, and board-gh-apply.sh must still exit 0. +if run board-gh-apply.sh --plan "$TEST_ROOT/plan-e.json" --no-github >/dev/null; then + pass "apply exits 0 for a plan holding only the conflicted done/not_planned action" +else + fail "apply exits 0 for a plan holding only the conflicted done/not_planned action" +fi +st_e="$(python3 -c "import json;print(json.load(open('$BOARD/map.json'))['tickets']['$E']['state'])")" +assert_equals "$st_e" "done" "ticket stays done — illegal transition never attempted" + +# ---- Task 5: board-gh-apply.sh — apply the plan + refresh the watermark ----- +echo "board-gh-apply (dry-run):" +run board-gh-plan.sh --gh-json "$TEST_ROOT/gh.json" > "$TEST_ROOT/plan.json" +map_before="$(cat "$BOARD/map.json")" +out="$(run board-gh-apply.sh --plan "$TEST_ROOT/plan.json" --dry-run)" +assert_contains "$out" "gh: issue close 101 --reason completed" "dry-run plans the board->gh close" +assert_contains "$out" "board-transition.sh $B wontfix" "dry-run plans the gh->board wontfix" +assert_equals "$(cat "$BOARD/map.json")" "$map_before" "dry-run writes nothing to the board" +[ -f "$BOARD/.sync-state.json.tmp" ] && fail "dry-run no watermark tmp" || pass "dry-run leaves no watermark tmp" + +echo "board-gh-apply (board side):" +# Apply only the gh->board wontfix for B by feeding a filtered plan (board side, no gh calls). +python3 - "$TEST_ROOT/plan.json" "$TEST_ROOT/plan-b.json" "$B" <<'PY' +import json,sys +src,dst,B=sys.argv[1:4] +p=json.load(open(src)) +p["actions"]=[a for a in p["actions"] if a["ticket"]==B] +json.dump(p,open(dst,"w")) +PY +run board-gh-apply.sh --plan "$TEST_ROOT/plan-b.json" --no-github +st="$(python3 -c "import json;print(json.load(open('$BOARD/map.json'))['tickets']['$B']['state'])")" +assert_equals "$st" "wontfix" "gh->board wontfix applied to the board via board-transition" +wm="$(python3 -c "import json;print(json.load(open('$BOARD/.sync-state.json'))['tickets']['$B']['state'])")" +assert_equals "$wm" "wontfix" "watermark refreshed to the reconciled board state" + +# ---- Task 5 regression: filtered-plan safety --------------------------------- +# The bug this guards: apply's watermark refresh used to re-walk ALL of map.json +# and stamp every linked, non-conflict ticket — so a ticket whose action was held +# back (filtered OUT of the plan handed to apply) still got its watermark set to +# its *current* board state, falsely recording a sync that never happened. The +# fix makes the refresh set plan-driven: only tickets with an auto/non-conflict +# action in the plan, plus the plan's "agree" set, get touched. +echo "board-gh-apply (filtered-plan safety):" +run board-register.sh "Filter safety A" enhancement >/dev/null +FA="$(run board-list.sh | grep 'Filter safety A' | awk '{print $1}')" +run board-transition.sh "$FA" in-progress >/dev/null +run board-transition.sh "$FA" done >/dev/null # board done, gh still open +run board-meta.sh "$FA" --gh 201 >/dev/null +run board-register.sh "Filter safety B" enhancement >/dev/null +FB="$(run board-list.sh | grep 'Filter safety B' | awk '{print $1}')" +run board-transition.sh "$FB" in-progress >/dev/null # done-reachable +run board-meta.sh "$FB" --gh 202 >/dev/null + +cat > "$TEST_ROOT/gh2.json" <board action. FA +# is deliberately left with NO prior watermark entry at all — it never has been +# synced, and it is about to be held back by the filter below, so it must come +# out of this apply exactly as it went in: absent from .sync-state.json. +python3 - "$BOARD/.sync-state.json" "$FB" <<'PY' +import json,sys +p,FB=sys.argv[1:3] +d=json.load(open(p)) +d["tickets"][FB]={"gh":202,"state":"in-progress"} +json.dump(d,open(p,"w")) +PY +run board-gh-plan.sh --gh-json "$TEST_ROOT/gh2.json" > "$TEST_ROOT/plan2.json" +python3 -c " +import json +p = json.load(open('$TEST_ROOT/plan2.json')) +a = {x['ticket']: x for x in p['actions']} +assert a['$FB']['direction'] == 'gh->board' and a['$FB']['auto'], 'expected FB auto gh->board action' +assert '$FA' not in p.get('agree', []), 'FA must not silently agree in this fixture' +print('fixture-ok') +" +# Filter the plan down to ONLY FB's action (FA's action/absence is held back — +# this is the exact "confirmed subset" scenario the spec allows), and drop FA +# from "agree" too (defensively) so only FB survives anywhere in the plan. +python3 - "$TEST_ROOT/plan2.json" "$TEST_ROOT/plan2-fb.json" "$FA" "$FB" <<'PY' +import json,sys +src,dst,FA,FB=sys.argv[1:5] +p=json.load(open(src)) +p["actions"]=[a for a in p["actions"] if a["ticket"]==FB] +p["agree"]=[t for t in p.get("agree",[]) if t != FA] +json.dump(p,open(dst,"w")) +PY +run board-gh-apply.sh --plan "$TEST_ROOT/plan2-fb.json" --no-github >/dev/null +has_fa="$(python3 -c "import json;print('$FA' in json.load(open('$BOARD/.sync-state.json'))['tickets'])")" +assert_equals "$has_fa" "False" "ticket held back from a filtered plan gets NO watermark entry (regression guard)" + +# ---- Task 5 regression: conflicted tickets keep their prior watermark -------- +echo "board-gh-apply (conflict keeps watermark):" +run board-register.sh "Conflict ticket" enhancement >/dev/null +FC="$(run board-list.sh | grep 'Conflict ticket' | awk '{print $1}')" +run board-meta.sh "$FC" --gh 999 >/dev/null +python3 - "$BOARD/.sync-state.json" "$FC" <<'PY' +import json,sys +p,FC=sys.argv[1:3] +d=json.load(open(p)) +d["tickets"][FC]={"gh":999,"state":"seeded-state","labels":["keep-me"]} +json.dump(d,open(p,"w")) +PY +seeded_before="$(python3 -c "import json;print(json.dumps(json.load(open('$BOARD/.sync-state.json'))['tickets']['$FC'], sort_keys=True))")" +cat > "$TEST_ROOT/plan-conflict.json" </dev/null +seeded_after="$(python3 -c "import json;print(json.dumps(json.load(open('$BOARD/.sync-state.json'))['tickets']['$FC'], sort_keys=True))")" +assert_equals "$seeded_after" "$seeded_before" "conflicted ticket's watermark entry is left unchanged" + +# ---- Task 5 regression: --no-github must not watermark a skipped board->gh -- +# The bug this guards: under --no-github the gh call is skipped entirely (step +# 2 is a no-op for board->gh actions), but the watermark refresh used to count +# ANY auto, non-conflict action — including board->gh ones — as reconciled. +# That falsely recorded a GitHub push that never happened. The fix excludes +# board->gh actions from the refresh set specifically when --no-github is set. +echo "board-gh-apply (--no-github excludes board->gh from the watermark):" +run board-register.sh "NoGH board->gh guard" enhancement >/dev/null +G="$(run board-list.sh | grep 'NoGH board->gh guard' | awk '{print $1}')" +run board-transition.sh "$G" in-progress >/dev/null +run board-transition.sh "$G" done >/dev/null # board done, gh issue still open +run board-meta.sh "$G" --gh 301 >/dev/null +# G has no prior watermark entry at all — nothing has ever been reconciled for it. +cat > "$TEST_ROOT/plan-nogh.json" <gh", "conflict": false, "auto": true, + "board": "done", "gh_state": "OPEN", "target_gh": ["closed", "completed"], "watermark": null, + "reason": "test: board->gh under --no-github"} +], "agree": [], "unlinked_board": [], "unlinked_gh": []} +JSON +run board-gh-apply.sh --plan "$TEST_ROOT/plan-nogh.json" --no-github >/dev/null +has_g="$(python3 -c "import json;print('$G' in json.load(open('$BOARD/.sync-state.json'))['tickets'])")" +assert_equals "$has_g" "False" "board->gh action skipped by --no-github gets NO watermark entry (regression guard)" + +# ---- Task 6: board-reconcile.sh surfaces pending board-sync conflicts ------- +# Spec: board-reconcile.sh reads SYNC-REPORT.md's machine-countable header +# line ("board-sync conflicts: N") and, read-only, surfaces a summary line +# when N>0 — additive, so reconcile's other output/tests are unaffected when +# no report exists. +echo "board-reconcile (surfaces SYNC-REPORT.md conflicts):" +cat > "$BOARD/SYNC-REPORT.md" <<'EOF' +board-sync conflicts: 2 + +## Conflicts +- T1: board=done gh_state=OPEN watermark=in-progress (reason: test) +- T2: board=ready-for-agent gh_state=CLOSED watermark=ready-for-agent (reason: test) + +## Unlinked (board) +(none) + +## Unlinked (GitHub) +(none) +EOF +out="$(run board-reconcile.sh)" +assert_contains "$out" "board-sync: 2 conflict(s) pending (SYNC-REPORT.md)" "reconcile surfaces pending board-sync conflicts" + +# a report with zero conflicts stays silent +cat > "$BOARD/SYNC-REPORT.md" <<'EOF' +board-sync conflicts: 0 + +## Conflicts +(none) +EOF +out="$(run board-reconcile.sh)" +if printf '%s' "$out" | grep -q 'board-sync:.*conflict'; then + fail "reconcile stays silent when SYNC-REPORT.md reports zero conflicts" +else + pass "reconcile stays silent when SYNC-REPORT.md reports zero conflicts" +fi + +# no report at all stays silent too +rm -f "$BOARD/SYNC-REPORT.md" +out="$(run board-reconcile.sh)" +if printf '%s' "$out" | grep -q 'board-sync:.*conflict'; then + fail "reconcile stays silent when no SYNC-REPORT.md exists" +else + pass "reconcile stays silent when no SYNC-REPORT.md exists" +fi + +# ---- summary ----------------------------------------------------------------- +echo +if [[ "$FAILURES" -eq 0 ]]; then echo "ALL TESTS PASSED"; else + echo "$FAILURES TEST(S) FAILED"; exit 1; fi