git diff --name-only upstream/master...HEAD | sort > /tmp/rawfiles.txt
The root cause: the fork working branch carries foreman-internal state (.coding-hermes/, .gitreins/) as tracked files. Upstream lacks those paths, so git diff --name-only upstream/master...HEAD counts them and the PR is polluted. Fix = never open the PR from the tracked-state branch; mint a clean head whose tree stops tracking internals but whose feature tree is identical.
1. Gate + quantify before touching the PR. Only proceed when every board task is canonical-complete and gates are green (build, guard, regression 88=88, CI). Then bucket the diff:
# every name between upstream/master and fork HEAD (triple-dot = merge-base..HEAD)
git fetch upstream
git diff --name-only upstream/master...HEAD | sort > /tmp/raw_files.txt
# expected: 96 files = 88 feature + 8 internal; assert it before creating anything
feature=$(grep -vc -E '^\.(coding-hermes|gitreins)/' /tmp/raw_files.txt || true)
internal=$(grep -c -E '^\.(coding-hermes|gitreins)/' /tmp/raw_files.txt || true)
echo "feature=$feature internal=$internal" # feature=88 internal=8
2. Mint the clean head (documented as a board task), then PR. Branch off the federation HEAD (agent-branch tip — which holds all the feature commits) and untrack internals:
git checkout agent-branch && git pull --ff-only # federation HEAD
git checkout -b clean-head-upstream
git rm -r -q --cached .coding-hermes .gitreins # untrack, do NOT delete from disk
printf '.coding-hermes/\n.gitreins/\n' > .gitignore
git add .gitignore
git commit -m "chore: stop tracking foreman-internal state for upstream PR"
# the clean gate: diff must now be ONLY feature files (+ .gitignore)
git diff --name-only upstream/master...HEAD
# => .gitignore + the 88 feature files; 0 internal paths
test "$(git diff --name-only upstream/master...HEAD | grep -cE '^\.(coding-hermes|gitreins)/')" -eq 0 \
&& echo "CLEAN" || echo "STILL POLLUTED"
# internals still live on disk for the agent (now untracked + ignored):
git check-ignore .coding-hermes/board.jsonl .gitreins/state.json # both listed
# open the PR upstream only after the CLEAN gate passes
gh pr create --repo upstream/owner/repo --base master --head clean-head-upstream \
--title "..." --body "..."
3. Board-state hygiene (the three pitfalls that otherwise sink the tick loop):
import json
STYLE = {"ensure_ascii": True, "separators": (",", ": ")} # foreman's exact per-line style
CANONICAL = {"complete", "open", "in_progress"} # never "completed"
# Pitfall A: per-line JSONL edit — parse ONE row, mutate, re-dump with EXACT style.
# Whole-structure reload + re-dump with default separators churns all 25 rows.
def edit_row(path, task_id, fn):
out = []
for line in open(path):
line = line.rstrip("\n")
if not line:
continue
row = json.loads(line)
if row["id"] == task_id:
fn(row)
out.append(json.dumps(row, **STYLE)) # preserve style per line
open(path, "w").write("\n".join(out) + "\n")
# Pitfall B: canonical statuses. Scanners treat status != "complete" as open,
# so "completed" shows up as a phantom open task.
def open_tasks(path):
return [json.loads(l)["id"] for l in open(path) if json.loads(l)["status"] != "complete"]
# Pitfall C: orphaned board state — a tick wrote events + header but died before
# committing. Fold into the next tick's commit; verify SEMANTICALLY:
# header["events"] == len(events_rows) and every set-status event agrees with its task row.
def reconcile(header, events, board_rows):
if header["events"] != len(events):
header["events"] = len(events) # heal the header
for e in events: # verify, don't trust
row = next((r for r in board_rows if r["id"] == e["id"]), None)
assert row is not None and e.get("to") == row["status"], f"drift on task {e['id']}"
All claims were exercised live (scratch git repo + Python):
**Clean-head mechanics (verified in `/tmp/prtest`):**
- Raw PR diff vs upstream: `git diff --name-only upstream/master...HEAD` → `.coding-hermes/board.jsonl`, `.coding-hermes/events.jsonl`, `.gitreins/state.json`, `src/c.py`, `src/d.py` (5 files, internals included → PR NOT clean). Real case scales to 96 = 88 feature + 8 internal.
- After `git rm -r --cached` + `.gitignore` commit on `clean-head`: diff = `.gitignore`, `src/c.py`, `src/d.py` → 0 internal paths. **CLEAN gate passes.**
- `ls -d .coding-hermes .gitreins` still succeeds (untracked on disk, not deleted); `git check-ignore` lists both internal paths → agent state survives, never re-enters the PR.
**Board JSONL pitfalls (verified in Python):**
- **Style preservation:** re-dumping the same 25 rows with *default* `json.dumps` separators churned **52 diff lines (all 25 rows)**; re-dumping with `ensure_ascii=True, separators=(",", ": ")` churned **0**. Per-line edit of one row churned **0** lines. `ensure_ascii` also matters: `"tick \u2713"` vs `"tick ✓"`.
- **Canonical status:** scanner `status != "complete"` on the canonical board → `[23, 24]` open tasks; with task 22 written as `"completed"` → `[22, 23, 24]` — **phantom open task 22** confirmed.
- **Orphaned board state:** header `{"tick":42,"events":2}` matched 2 event rows and the `set-status` event agreed with the task row → semantic reconciliation passes; the mismatch path (header count ≠ rows, event `to` ≠ row status) raises the drift assertion so the next tick folds and heals it rather than shipping a corrupt board.
**Edge cases covered:** triple-dot vs double-dot semantics (triple-dot `...HEAD` correctly excludes upstream-only changes, so the diff quantifies *our* contribution only); `git rm --cached` keeps working-tree files (agent state intact); `.gitignore` prevents accidental re-tracking; a second `gh pr create` run would be guarded by the same CLEAN gate before any `--head` flag is sent.{"model": "deepseek-v4-flash", "result": "completed"}