Problem class: boardctl-note-overwrite-clobbers-context
Diagnosed, fixed, and verified. The full solution is saved at ~/boardctl-note-overwrite-solution.md (the repair tool is at ~/boardctl-note-append.py). Here it is:
boardctl update <id> --note silently overwrites foreman_noteProblem class: boardctl-note-overwrite-clobbers-context
boardctl update <id> --note "<text>" performs an assignment, not an append: the new text replaces row.foreman_note. When a task is closed with --status complete --note "<closure evidence>", the FINDING context recorded earlier in foreman_note is destroyed. The row still validates and the command prints success, so the loss is silent.
The fix has two layers:
old_note + "\n\n" + closure_evidence and pass that as --note.boardctl update reads the row, sets fields from flags, and rewrites the line. --note maps to row.foreman_note = <value> (overwrite), not append. There is no --note-append flag and no merge.
Reproduction (confirmed on boardctl version dev):
$ boardctl create --id QA-RETRY-001 --title "Harden retry loop"
$ boardctl update QA-RETRY-001 --note "FINDING: retry loop swallows context cancellation."
$ boardctl update QA-RETRY-001 --status complete --worker-status done \
--guard PASS --ci GREEN --commit-hash deadbeef \
--note "CLOSURE: added ctx.Err() guard; go test ./... green."
updated task QA-RETRY-001: status, worker_status, commit_hash, guard_result, ci_result, foreman_note, updated_at
$ python3 -c 'import json;print([d["foreman_note"] for d in map(json.loads,open(".coding-hermes/board/tasks.jsonl")) if d["id"]=="QA-RETRY-001"][0])'
CLOSURE: added ctx.Err() guard; go test ./... green.
Detection is exactly this diff of the row across the update:
git show HEAD:.coding-hermes/board/tasks.jsonl | grep QA-RETRY-001 # FINDING present
grep QA-RETRY-001 .coding-hermes/board/tasks.jsonl # FINDING gone
tasks.jsonl is compact, one object per line, keys in fixed declaration order (detail last). boardctl uses Go encoding/json:
—);U+2028/U+2029 escaped as \u2028/\u2029;<, >, & left literal (SetEscapeHTML(false));jsonstyle step: if the existing row already contains \uXXXX escapes, that row is written in ensure_ascii style; otherwise raw UTF-8.This is why the repair script probes the original line first and only rewrites if it can reproduce it byte-for-byte.
Compute the merged note and pass it through boardctl, so boardctl owns all writes:
append_note() {
local id="$1" text="$2"; shift 2
local merged
merged=$(python3 - "$id" "$text" <<'PY'
import json, sys
tid, text = sys.argv[1], sys.argv[2]
old = None
for line in open(".coding-hermes/board/tasks.jsonl", encoding="utf-8"):
d = json.loads(line)
if d.get("id") == tid:
old = d.get("foreman_note")
print((old.rstrip() + "\n\n" + text.strip()) if old and old.strip() else text.strip())
PY
)
boardctl update "$id" --note "$merged" "$@"
}
append_note QA-RETRY-001 "CLOSURE: added ctx.Err() guard; CI green." \
--status complete --worker-status done --guard PASS --ci GREEN --commit-hash deadbeef
Also note: boardctl update <id> --status complete without --note leaves foreman_note intact.
Save as boardctl-note-append.py, then run it. It recovers the FINDING from git HEAD (or --finding), merges it in front of the current closure note, rewrites only that line, and refuses to write unless a serialization probe reproduces the original line byte-for-byte.
#!/usr/bin/env python3
"""Repair a foreman_note clobbered by `boardctl update --note`.
Exit codes: 0 ok, 2 usage/board error, 3 probe failed (no write), 4 verify fail."""
from __future__ import annotations
import argparse, json, os, subprocess, sys, tempfile
from typing import Any, Callable
TASKS = "tasks.jsonl"
SEP = "\n\n"
def go_json_dumps(obj: Any) -> str:
"""Reproduce Go encoding/json as used by boardctl (raw UTF-8, key order kept)."""
s = json.dumps(obj, separators=(",", ":"), ensure_ascii=False)
return s.replace("\u2028", "\\u2028").replace("\u2029", "\\u2029")
def ascii_json_dumps(obj: Any) -> str:
return json.dumps(obj, separators=(",", ":"), ensure_ascii=True)
def detect_serializer(obj: Any, raw_line: bytes) -> Callable[[Any], str] | None:
for fn in (go_json_dumps, ascii_json_dumps):
try:
if fn(obj).encode("utf-8") == raw_line:
return fn
except Exception:
continue
return None
def resolve_board_dir(path: str) -> str:
p = os.path.abspath(path)
for c in (os.path.join(p, ".coding-hermes", "board"),
os.path.join(p, ".coding-hermes"), p):
if os.path.isfile(os.path.join(c, TASKS)):
return c
raise SystemExit(f"boardctl-note-append: no {TASKS} found under {p}")
def git(repo: str, *args: str) -> subprocess.CompletedProcess:
return subprocess.run(["git", "-C", repo, *args], capture_output=True, text=True)
def repo_root_of(board_dir: str) -> str | None:
r = git(board_dir, "rev-parse", "--show-toplevel")
return r.stdout.strip() if r.returncode == 0 else None
def load_rows(raw: bytes) -> list[dict]:
return [json.loads(l.decode("utf-8")) for l in raw.split(b"\n") if l.strip()]
def recover_finding_from_git(board_dir: str, task_id: str, ref: str) -> str | None:
root = repo_root_of(board_dir)
if not root:
return None
rel = os.path.relpath(os.path.join(board_dir, TASKS), root)
r = git(root, "show", f"{ref}:{rel}")
if r.returncode != 0:
return None
for row in load_rows(r.stdout.encode("utf-8")):
if row.get("id") == task_id:
note = row.get("foreman_note")
return note if isinstance(note, str) and note.strip() else None
return None
def merge_notes(finding: str | None, closure: str | None) -> str:
finding, closure = (finding or "").strip(), (closure or "").strip()
if finding and closure:
if finding in closure:
return closure
if closure in finding:
return finding
return f"{finding}{SEP}{closure}"
return finding or closure
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
src = ap.add_mutually_exclusive_group(required=True)
src.add_argument("--repo"); src.add_argument("--board-dir")
ap.add_argument("--id", required=True)
ap.add_argument("--finding")
ap.add_argument("--from-ref", default="HEAD")
ap.add_argument("--apply", action="store_true")
ap.add_argument("--expect-numstat", action="store_true")
args = ap.parse_args()
board_dir = resolve_board_dir(args.board_dir or args.repo)
tasks_path = os.path.join(board_dir, TASKS)
lines = open(tasks_path, "rb").read().split(b"\n")
idx = cur = None
for i, line in enumerate(lines):
if not line.strip():
continue
obj = json.loads(line.decode("utf-8"))
if obj.get("id") == args.id:
idx, cur = i, obj
break
if idx is None:
print(f"task {args.id} not found in {tasks_path}", file=sys.stderr); return 2
serialize = detect_serializer(cur, lines[idx])
if serialize is None:
print("serialization probe FAILED; refusing to rewrite. No changes made.", file=sys.stderr)
return 3
closure = cur.get("foreman_note")
finding = args.finding or recover_finding_from_git(board_dir, args.id, args.from_ref)
if not finding:
print("no FINDING text found; nothing to restore.", file=sys.stderr); return 2
merged = merge_notes(finding, closure)
if merged == closure:
print(f"note for {args.id} already contains the FINDING; no change."); return 0
cur["foreman_note"] = merged
lines[idx] = serialize(cur).encode("utf-8")
print(f"task {args.id} (line {idx + 1})")
print(f"old note: {closure!r}\nfinding : {finding!r}\nnew note: {merged!r}")
if not args.apply:
print("\n[dry-run] pass --apply to rewrite this single line."); return 0
out = b"\n".join(lines)
fd, tmp = tempfile.mkstemp(dir=board_dir, prefix=".tasks.", suffix=".tmp")
try:
with os.fdopen(fd, "wb") as fh:
fh.write(out); fh.flush(); os.fsync(fh.fileno())
os.replace(tmp, tasks_path)
finally:
if os.path.exists(tmp):
os.unlink(tmp)
print(f"\nrewrote 1 line in {tasks_path}")
if args.expect_numstat:
root = repo_root_of(board_dir)
if root:
rel = os.path.relpath(tasks_path, root)
got = git(root, "diff", "--numstat", "--", rel).stdout.strip()
want = f"1\t1\t{rel}"
print(f"git diff --numstat -- {rel}: {got!r}")
if got != want:
print(f"expected {want!r}", file=sys.stderr); return 4
return 0
if __name__ == "__main__":
sys.exit(main())
Run it:
python3 boardctl-note-append.py --repo . --id QA-RETRY-001 # dry run
python3 boardctl-note-append.py --repo . --id QA-RETRY-001 --apply --expect-numstat
# git history unavailable:
python3 boardctl-note-append.py --board-dir .coding-hermes/board \
--id QA-RETRY-001 --finding "FINDING: ..." --apply
In the Go update handler, make append explicit (add --note-append rather than silently changing --note):
if noteWasSet {
if strings.TrimSpace(row.ForemanNote) != "" {
row.ForemanNote = row.ForemanNote + "\n\n" + note
} else {
row.ForemanNote = note
}
}
All of the following was executed against boardctl version dev.
rm -rf /tmp/repair-demo && mkdir /tmp/repair-demo && cd /tmp/repair-demo
git init -q . && git config user.email d@e.f && git config user.name d
boardctl init --project Repair --namespace demo >/dev/null
boardctl create --id QA-RETRY-001 --title "Harden retry loop" >/dev/null
boardctl update QA-RETRY-001 --note "FINDING: retry loop swallows context cancellation." >/dev/null
git add -A && git commit -qm "board: finding recorded"
boardctl update QA-RETRY-001 --status complete --worker-status done \
--guard PASS --ci GREEN --commit-hash deadbeef \
--note "CLOSURE: added ctx.Err() guard; CI green."
# foreman_note is now ONLY the CLOSURE text (FINDING lost)
python3 boardctl-note-append.py --repo . --id QA-RETRY-001 --apply --expect-numstat
Observed:
task QA-RETRY-001 (line 2)
old note: 'CLOSURE: added ctx.Err() guard; CI green.'
finding : 'FINDING: retry loop swallows context cancellation.'
new note: 'FINDING: retry loop swallows context cancellation.\n\nCLOSURE: added ctx.Err() guard; CI green.'
rewrote 1 line in /tmp/repair-demo/.coding-hermes/board/tasks.jsonl
git diff --numstat -- .coding-hermes/board/tasks.jsonl: '1\t1\t.coding-hermes/board/tasks.jsonl'
boardctl validate
# RESULT: OK (0 warning(s))
boardctl show QA-RETRY-001
# "foreman_note": "FINDING: retry loop swallows context cancellation.\n\nCLOSURE: added ctx.Err() guard; CI green."
git diff --numstat -- .coding-hermes/board/tasks.jsonl
# 1 1 .coding-hermes/board/tasks.jsonl <-- exactly one line changed
| Check | How | Result |
|---|---|---|
| Idempotent | re-run without --apply |
already contains the FINDING; no change. |
| Fail-safe | foreign-formatted line (sorted keys/spaces) | probe FAILED; refusing to rewrite, exit 3, file untouched |
| Byte-exact | json.loads + detected serializer re-encode |
matches every line byte-for-byte |
| Unicode/HTML | note with —, U+2028, <, &, quotes |
boardctl validate OK; numstat 1/1 |
| Style parity | boardctl update after repair |
preserves boardctl's raw-vs-escaped style |
python3 - <<'PY'
import json, subprocess
head = subprocess.run(["git","show","HEAD:.coding-hermes/board/tasks.jsonl"],
capture_output=True, text=True).stdout.splitlines()
before = {json.loads(l)["id"]: json.loads(l).get("foreman_note") for l in head if l.strip()}
for l in open(".coding-hermes/board/tasks.jsonl"):
d = json.loads(l); old = before.get(d["id"]) or ""
if "FINDING" in old and "FINDING" not in (d.get("foreman_note") or ""):
print("CLOBBERED:", d["id"])
PY
# Evidence - Problem class: boardctl-note-overwrite-clobbers-context - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-21T06:04:07.990Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "boardctl update <id> --note silently REPLACES the row foreman_note instead of appending - closing a task with evidence wipes the original FINDING context. Detection: diff the row after update; Fix that worked: per-line byte-safe edit (python, json.dumps separators=(,:), ensure_ascii=True probe first, rewrite only target line, git diff --numstat must be 1/1) prepending FINDING context before the closure evidence.", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "boardctl-note-overwrite-clobbers-context", "provider": "openrouter", "solved_at": "2026-09-21T06:04:07.990Z", "version": ""}Diagnosed, fixed, and verified. The full solution is saved at ~/boardctl-note-overwrite-solution.md (the repair tool is at ~/boardctl-note-append.py). Here it is:
boardctl update <id> --note silently overwrites foreman_noteProblem class: boardctl-note-overwrite-clobbers-context
boardctl update <id> --note "<text>" performs an assignment, not an append: the new text replaces row.foreman_note. When a task is closed with --status complete --note "<closure evidence>", the FINDING context recorded earlier in foreman_note is destroyed. The row still validates and the command prints success, so the loss is silent.
The fix has two layers:
old_note + "\n\n" + closure_evidence and pass that as --note.boardctl update reads the row, sets fields from flags, and rewrites the line. --note maps to row.foreman_note = <value> (overwrite), not append. There is no --note-append flag and no merge.
Reproduction (confirmed on boardctl version dev):
$ boardctl create --id QA-RETRY-001 --title "Harden retry loop"
$ boardctl update QA-RETRY-001 --note "FINDING: retry loop swallows context cancellation."
$ boardctl update QA-RETRY-001 --status complete --worker-status done \
--guard PASS --ci GREEN --commit-hash deadbeef \
--note "CLOSURE: added ctx.Err() guard; go test ./... green."
updated task QA-RETRY-001: status, worker_status, commit_hash, guard_result, ci_result, foreman_note, updated_at
$ python3 -c 'import json;print([d["foreman_note"] for d in map(json.loads,open(".coding-hermes/board/tasks.jsonl")) if d["id"]=="QA-RETRY-001"][0])'
CLOSURE: added ctx.Err() guard; go test ./... green.
Detection is exactly this diff of the row across the update:
git show HEAD:.coding-hermes/board/tasks.jsonl | grep QA-RETRY-001 # FINDING present
grep QA-RETRY-001 .coding-hermes/board/tasks.jsonl # FINDING gone
tasks.jsonl is compact, one object per line, keys in fixed declaration order (detail last). boardctl uses Go encoding/json:
—);U+2028/U+2029 escaped as \u2028/\u2029;<, >, & left literal (SetEscapeHTML(false));jsonstyle step: if the existing row already contains \uXXXX escapes, that row is written in ensure_ascii style; otherwise raw UTF-8.This is why the repair script probes the original line first and only rewrites if it can reproduce it byte-for-byte.
Compute the merged note and pass it through boardctl, so boardctl owns all writes:
append_note() {
local id="$1" text="$2"; shift 2
local merged
merged=$(python3 - "$id" "$text" <<'PY'
import json, sys
tid, text = sys.argv[1], sys.argv[2]
old = None
for line in open(".coding-hermes/board/tasks.jsonl", encoding="utf-8"):
d = json.loads(line)
if d.get("id") == tid:
old = d.get("foreman_note")
print((old.rstrip() + "\n\n" + text.strip()) if old and old.strip() else text.strip())
PY
)
boardctl update "$id" --note "$merged" "$@"
}
append_note QA-RETRY-001 "CLOSURE: added ctx.Err() guard; CI green." \
--status complete --worker-status done --guard PASS --ci GREEN --commit-hash deadbeef
Also note: boardctl update <id> --status complete without --note leaves foreman_note intact.
Save as boardctl-note-append.py, then run it. It recovers the FINDING from git HEAD (or --finding), merges it in front of the current closure note, rewrites only that line, and refuses to write unless a serialization probe reproduces the original line byte-for-byte.
#!/usr/bin/env python3
"""Repair a foreman_note clobbered by `boardctl update --note`.
Exit codes: 0 ok, 2 usage/board error, 3 probe failed (no write), 4 verify fail."""
from __future__ import annotations
import argparse, json, os, subprocess, sys, tempfile
from typing import Any, Callable
TASKS = "tasks.jsonl"
SEP = "\n\n"
def go_json_dumps(obj: Any) -> str:
"""Reproduce Go encoding/json as used by boardctl (raw UTF-8, key order kept)."""
s = json.dumps(obj, separators=(",", ":"), ensure_ascii=False)
return s.replace("\u2028", "\\u2028").replace("\u2029", "\\u2029")
def ascii_json_dumps(obj: Any) -> str:
return json.dumps(obj, separators=(",", ":"), ensure_ascii=True)
def detect_serializer(obj: Any, raw_line: bytes) -> Callable[[Any], str] | None:
for fn in (go_json_dumps, ascii_json_dumps):
try:
if fn(obj).encode("utf-8") == raw_line:
return fn
except Exception:
continue
return None
def resolve_board_dir(path: str) -> str:
p = os.path.abspath(path)
for c in (os.path.join(p, ".coding-hermes", "board"),
os.path.join(p, ".coding-hermes"), p):
if os.path.isfile(os.path.join(c, TASKS)):
return c
raise SystemExit(f"boardctl-note-append: no {TASKS} found under {p}")
def git(repo: str, *args: str) -> subprocess.CompletedProcess:
return subprocess.run(["git", "-C", repo, *args], capture_output=True, text=True)
def repo_root_of(board_dir: str) -> str | None:
r = git(board_dir, "rev-parse", "--show-toplevel")
return r.stdout.strip() if r.returncode == 0 else None
def load_rows(raw: bytes) -> list[dict]:
return [json.loads(l.decode("utf-8")) for l in raw.split(b"\n") if l.strip()]
def recover_finding_from_git(board_dir: str, task_id: str, ref: str) -> str | None:
root = repo_root_of(board_dir)
if not root:
return None
rel = os.path.relpath(os.path.join(board_dir, TASKS), root)
r = git(root, "show", f"{ref}:{rel}")
if r.returncode != 0:
return None
for row in load_rows(r.stdout.encode("utf-8")):
if row.get("id") == task_id:
note = row.get("foreman_note")
return note if isinstance(note, str) and note.strip() else None
return None
def merge_notes(finding: str | None, closure: str | None) -> str:
finding, closure = (finding or "").strip(), (closure or "").strip()
if finding and closure:
if finding in closure:
return closure
if closure in finding:
return finding
return f"{finding}{SEP}{closure}"
return finding or closure
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
src = ap.add_mutually_exclusive_group(required=True)
src.add_argument("--repo"); src.add_argument("--board-dir")
ap.add_argument("--id", required=True)
ap.add_argument("--finding")
ap.add_argument("--from-ref", default="HEAD")
ap.add_argument("--apply", action="store_true")
ap.add_argument("--expect-numstat", action="store_true")
args = ap.parse_args()
board_dir = resolve_board_dir(args.board_dir or args.repo)
tasks_path = os.path.join(board_dir, TASKS)
lines = open(tasks_path, "rb").read().split(b"\n")
idx = cur = None
for i, line in enumerate(lines):
if not line.strip():
continue
obj = json.loads(line.decode("utf-8"))
if obj.get("id") == args.id:
idx, cur = i, obj
break
if idx is None:
print(f"task {args.id} not found in {tasks_path}", file=sys.stderr); return 2
serialize = detect_serializer(cur, lines[idx])
if serialize is None:
print("serialization probe FAILED; refusing to rewrite. No changes made.", file=sys.stderr)
return 3
closure = cur.get("foreman_note")
finding = args.finding or recover_finding_from_git(board_dir, args.id, args.from_ref)
if not finding:
print("no FINDING text found; nothing to restore.", file=sys.stderr); return 2
merged = merge_notes(finding, closure)
if merged == closure:
print(f"note for {args.id} already contains the FINDING; no change."); return 0
cur["foreman_note"] = merged
lines[idx] = serialize(cur).encode("utf-8")
print(f"task {args.id} (line {idx + 1})")
print(f"old note: {closure!r}\nfinding : {finding!r}\nnew note: {merged!r}")
if not args.apply:
print("\n[dry-run] pass --apply to rewrite this single line."); return 0
out = b"\n".join(lines)
fd, tmp = tempfile.mkstemp(dir=board_dir, prefix=".tasks.", suffix=".tmp")
try:
with os.fdopen(fd, "wb") as fh:
fh.write(out); fh.flush(); os.fsync(fh.fileno())
os.replace(tmp, tasks_path)
finally:
if os.path.exists(tmp):
os.unlink(tmp)
print(f"\nrewrote 1 line in {tasks_path}")
if args.expect_numstat:
root = repo_root_of(board_dir)
if root:
rel = os.path.relpath(tasks_path, root)
got = git(root, "diff", "--numstat", "--", rel).stdout.strip()
want = f"1\t1\t{rel}"
print(f"git diff --numstat -- {rel}: {got!r}")
if got != want:
print(f"expected {want!r}", file=sys.stderr); return 4
return 0
if __name__ == "__main__":
sys.exit(main())
Run it:
python3 boardctl-note-append.py --repo . --id QA-RETRY-001 # dry run
python3 boardctl-note-append.py --repo . --id QA-RETRY-001 --apply --expect-numstat
# git history unavailable:
python3 boardctl-note-append.py --board-dir .coding-hermes/board \
--id QA-RETRY-001 --finding "FINDING: ..." --apply
In the Go update handler, make append explicit (add --note-append rather than silently changing --note):
if noteWasSet {
if strings.TrimSpace(row.ForemanNote) != "" {
row.ForemanNote = row.ForemanNote + "\n\n" + note
} else {
row.ForemanNote = note
}
}
All of the following was executed against boardctl version dev.
rm -rf /tmp/repair-demo && mkdir /tmp/repair-demo && cd /tmp/repair-demo
git init -q . && git config user.email d@e.f && git config user.name d
boardctl init --project Repair --namespace demo >/dev/null
boardctl create --id QA-RETRY-001 --title "Harden retry loop" >/dev/null
boardctl update QA-RETRY-001 --note "FINDING: retry loop swallows context cancellation." >/dev/null
git add -A && git commit -qm "board: finding recorded"
boardctl update QA-RETRY-001 --status complete --worker-status done \
--guard PASS --ci GREEN --commit-hash deadbeef \
--note "CLOSURE: added ctx.Err() guard; CI green."
# foreman_note is now ONLY the CLOSURE text (FINDING lost)
python3 boardctl-note-append.py --repo . --id QA-RETRY-001 --apply --expect-numstat
Observed:
task QA-RETRY-001 (line 2)
old note: 'CLOSURE: added ctx.Err() guard; CI green.'
finding : 'FINDING: retry loop swallows context cancellation.'
new note: 'FINDING: retry loop swallows context cancellation.\n\nCLOSURE: added ctx.Err() guard; CI green.'
rewrote 1 line in /tmp/repair-demo/.coding-hermes/board/tasks.jsonl
git diff --numstat -- .coding-hermes/board/tasks.jsonl: '1\t1\t.coding-hermes/board/tasks.jsonl'
boardctl validate
# RESULT: OK (0 warning(s))
boardctl show QA-RETRY-001
# "foreman_note": "FINDING: retry loop swallows context cancellation.\n\nCLOSURE: added ctx.Err() guard; CI green."
git diff --numstat -- .coding-hermes/board/tasks.jsonl
# 1 1 .coding-hermes/board/tasks.jsonl <-- exactly one line changed
| Check | How | Result |
|---|---|---|
| Idempotent | re-run without --apply |
already contains the FINDING; no change. |
| Fail-safe | foreign-formatted line (sorted keys/spaces) | probe FAILED; refusing to rewrite, exit 3, file untouched |
| Byte-exact | json.loads + detected serializer re-encode |
matches every line byte-for-byte |
| Unicode/HTML | note with —, U+2028, <, &, quotes |
boardctl validate OK; numstat 1/1 |
| Style parity | boardctl update after repair |
preserves boardctl's raw-vs-escaped style |
python3 - <<'PY'
import json, subprocess
head = subprocess.run(["git","show","HEAD:.coding-hermes/board/tasks.jsonl"],
capture_output=True, text=True).stdout.splitlines()
before = {json.loads(l)["id"]: json.loads(l).get("foreman_note") for l in head if l.strip()}
for l in open(".coding-hermes/board/tasks.jsonl"):
d = json.loads(l); old = before.get(d["id"]) or ""
if "FINDING" in old and "FINDING" not in (d.get("foreman_note") or ""):
print("CLOBBERED:", d["id"])
PY
# Evidence - Problem class: boardctl-note-overwrite-clobbers-context - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-21T06:04:07.990Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "boardctl update <id> --note silently REPLACES the row foreman_note instead of appending - closing a task with evidence wipes the original FINDING context. Detection: diff the row after update; Fix that worked: per-line byte-safe edit (python, json.dumps separators=(,:), ensure_ascii=True probe first, rewrite only target line, git diff --numstat must be 1/1) prepending FINDING context before the closure evidence.", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "boardctl-note-overwrite-clobbers-context", "provider": "openrouter", "solved_at": "2026-09-21T06:04:07.990Z", "version": ""}