Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
be305c9
docs(spec): dp#51 deferrals + dp#60 design v1.0
SSFSKIM Aug 12, 2026
a1f1a4c
docs(spec): v1.1 — adversarial review adopted (value grammar, typed 4…
SSFSKIM Aug 12, 2026
c7bcfb4
docs(plan): dp#51 deferrals + dp#60 — 11 tasks
SSFSKIM Aug 12, 2026
59482ee
docs: spec v1.2 + plan hardening — plan review adopted (splitlines gr…
SSFSKIM Aug 12, 2026
d6b3404
fix(board): rightmost meta block + value grammar — gh meta writes sto…
SSFSKIM Aug 12, 2026
595e12b
docs(spec): v1.2.1 — drop the --> value check (fuzz-proven safe; torn…
SSFSKIM Aug 12, 2026
7eef696
fix(board): drop the --> value check; validate meta before apply_stat…
SSFSKIM Aug 12, 2026
8b62346
docs(spec): supersede pointer on the v1.1 grammar decision entry
SSFSKIM Aug 12, 2026
d62127b
refactor(board): splice and migrate through the shared meta_match helper
SSFSKIM Aug 12, 2026
6331e8d
fix(board): migration strips the meta block exactly once
SSFSKIM Aug 12, 2026
2f00f8e
fix(board-answer): qagent parks return to in-review — role stamp, nam…
SSFSKIM Aug 12, 2026
800c4ce
fix(review-dispatch): keep the run bearer 0600 across bookkeeping stamps
SSFSKIM Aug 12, 2026
7261b1f
fix(sweep): type the successor-claim failures — obsolete journals dro…
SSFSKIM Aug 12, 2026
c0f36ea
fix(sweep): lift before reconcile; one recovery attempt per ticket pe…
SSFSKIM Aug 12, 2026
31009ee
fix(sweep): the replay arm reads the tick ledger too — two journals f…
SSFSKIM Aug 12, 2026
59f2ea8
fix(sweep): an absent /tickets row never lifts a suppression
SSFSKIM Aug 12, 2026
c441455
fix(review-dispatch): unresolved bootstrap placeholders fail the rend…
SSFSKIM Aug 12, 2026
4a022c9
test(review-dispatch): pin binding values on both scale lanes; fence …
SSFSKIM Aug 12, 2026
c18de9b
test(reviewing-prs): bootstrap parity fence — four modes, pinned sent…
SSFSKIM Aug 12, 2026
52d7b8b
docs(spec): v1.2.2 — correct the scale-pair roster parenthetical (Tas…
SSFSKIM Aug 12, 2026
874c6e5
test(reviewing-prs): pin the manifest injection between mode strip an…
SSFSKIM Aug 12, 2026
f38f713
test(drills): anchor id assertions; compare unsubstituted argv (#51 c…
SSFSKIM Aug 12, 2026
7daa212
test(drills): name the exit status as the drift fence; close three mo…
SSFSKIM Aug 12, 2026
419d60c
docs(spec): v1.2.3 — content-based candidate walk (final-panel flow-b…
SSFSKIM Aug 12, 2026
dc89641
fix(board): choose the meta opener by interior content, not by position
SSFSKIM Aug 12, 2026
1a6622d
docs(spec): v1.2.4 — whole-interior candidate rule (convergence flow-…
SSFSKIM Aug 12, 2026
c71a867
fix(board): judge a meta opener's whole interior, not the adjacent gap
SSFSKIM Aug 12, 2026
50a3b13
fix(board): match parse_meta's line model, and fall back within the s…
SSFSKIM Aug 12, 2026
6a68689
test(board): property fuzz over the meta opener walk
SSFSKIM Aug 12, 2026
41dcdd7
docs(spec): Outcomes & Retrospective — dp#51 deferrals + dp#60 shipped
SSFSKIM Aug 12, 2026
e9fd9ad
Merge remote-tracking branch 'origin/main' into dp51-deferrals-dp60
SSFSKIM Aug 12, 2026
cdb94d8
release: v7.51.0 — dp#51 deferrals + dp#60 (meta grammar + opener wal…
SSFSKIM Aug 12, 2026
14e96b7
docs(spec): v1.2.5 — PR-65 panel flow-back (qagent parks stay parked;…
SSFSKIM Aug 13, 2026
d24637d
fix(board): a PR-less QAGENT park stays parked instead of demoting
SSFSKIM Aug 13, 2026
c86e08a
fix(sweep): the tick ledger and the attempts reset reach phase 4
SSFSKIM Aug 13, 2026
c9ff14f
fix(board): an indented trailing meta opener is a candidate
SSFSKIM Aug 13, 2026
d334e8e
fix(board): board-transition validates its meta write before any labe…
SSFSKIM Aug 13, 2026
d0cc88b
fix(sweep): every recovery attempt is ledgered, and the attempts rese…
SSFSKIM Aug 13, 2026
8184f76
fix(sweep): the reconcile reset lands before the seal, and only on an…
SSFSKIM Aug 13, 2026
622196a
fix(sweep): a failed attempts-reset refuses to seal its journal
SSFSKIM Aug 13, 2026
e6314d4
fix(sweep): an unreachable suppression path is not an absent counter
SSFSKIM Aug 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
{
"name": "doperpowers",
"description": "Emerges by humans, Converges by Agent",
"version": "7.50.1",
"version": "7.51.0",
"source": "./",
"author": {
"name": "SSFSKIM",
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "doperpowers",
"description": "Emerges by humans, Converges by Agents",
"version": "7.50.1",
"version": "7.51.0",
"author": {
"name": "SSFSKIM",
"email": "supremekim17@gmail.com"
Expand Down
2 changes: 1 addition & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "doperpowers",
"version": "7.50.1",
"version": "7.51.0",
"description": "A two-track software-development methodology for coding agents: a human-gated controlled track (brainstorm, plan, TDD, review, ship) plus an autonomous board loop for unattended, well-scoped work.",
"author": {
"name": "SSFSKIM",
Expand Down
351 changes: 351 additions & 0 deletions docs/doperpowers/plans/2026-08-12-dp51-deferrals-dp60.md

Large diffs are not rendered by default.

698 changes: 698 additions & 0 deletions docs/doperpowers/specs/2026-08-12-dp51-deferrals-dp60-design.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "doperpowers",
"version": "7.50.1",
"version": "7.51.0",
"description": "Doperpowers skills and runtime bootstrap for coding agents",
"type": "module",
"main": ".opencode/plugins/doperpowers.js",
Expand Down
32 changes: 31 additions & 1 deletion skills/implementing/scripts/implement-dispatch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,27 @@ PY
# (it writes these files; we only read them). A claim is the only way to learn
# WHICH ticket the server picked, so suppression can only be honored after the
# fact — by handing the run straight back.
_api_suppressed() { [ -f "${BOARD_SUPPRESS_DIR:-$DAEMON_HOME/board-suppress}/$1.json" ]; }
_api_suppress_dir() { echo "${BOARD_SUPPRESS_DIR:-$DAEMON_HOME/board-suppress}"; }
_api_suppressed() { [ -f "$(_api_suppress_dir)/$1.json" ]; }

# The same sweep's resume phase records every ticket it already attempted a
# recovery for THIS TICK. A replay that faulted leaves its ticket unowned, so
# the server can hand it to an ordinary lane claim moments later — a second
# attempt inside the one tick the ledger holds to one. Absent (a dispatcher run
# by hand, a phase asked for by name) it fences nothing.
_api_tick_ledgered() {
[ -n "${BOARD_RESUMED_LEDGER:-}" ] && [ -f "$BOARD_RESUMED_LEDGER" ] \
&& grep -qxF -- "$1" "$BOARD_RESUMED_LEDGER"
}

# A delivered recovery is a recovery, whichever phase delivered it: the failed
# cycle count is the sweep's ladder to an env-issue escalation, and a count
# left standing after a successful dispatch escalates a much later, unrelated
# fault two rungs early. Called from the bind side ONE LINE AHEAD of the
# journal's durable mark, so the only crash that can skip it is the one
# reconciliation still sees (`repaired`), which clears it there.
_api_attempts_clear() { rm -f "$(_api_suppress_dir)/.attempts-$1"; }
_claim_suppress_dir() { _api_suppress_dir; }

_api_end_run() { # <run-id> <reason> — best-effort release of a claimed run
T_RUN="$1" T_REASON="$2" _api_py - <<'PY' || true
Expand Down Expand Up @@ -268,6 +288,15 @@ PY
rm -f "$claims_dir/$nonce.json" "$body_file"
return 1
fi
if _api_tick_ledgered "$C_TICKET"; then
# Head-of-line, like suppression above: the server picks, so the only
# refusal available is to hand the run straight back. The next tick serves
# the ticket if it is still unowned.
echo "#$C_TICKET already had its one recovery attempt this tick — releasing run $C_RUN_ID; lane $lane stands down this tick"
_api_end_run "$C_RUN_ID" abandoned
rm -f "$claims_dir/$nonce.json" "$body_file"
return 1
fi

local role protocol_file decompose model name prompt spawn_out uuid
case "$lane" in
Expand Down Expand Up @@ -336,6 +365,7 @@ PY
# reconciliation skipped it (its whole point is the unbound-but-live case),
# nothing renewed the lease, and after the server reclaimed it the still
# running worker overlapped its replacement.
_api_attempts_clear "$C_TICKET"
_journal_write "$claims_dir/$nonce.json" "$lane" "$C_RUN_ID" 1 "$C_TICKET" "$name"

# Lane, role, nonce and the parent pin into the registry meta: the lane is
Expand Down
2 changes: 1 addition & 1 deletion skills/issue-tracker/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ checkout's repo.
| `board-map.sh [--write\|--serve\|--stop]` | human telemetry. `--write` renders **`BOARD.html`** (interactive layered-DAG: pan/zoom, node detail, state filter, epic collapse — plus a kanban view toggle) and **`BOARD.md`** (table) into the gitignored render dir. `--serve` additionally serves the render dir on 127.0.0.1 (per-repo port; `$BOARD_PORT` overrides) and opens the board over http — served tabs **hot-reload**: every later render (explicit `--write`, or the automatic one each mutating script fires while the server is up) appears without a manual refresh. `--stop` kills the server. No argument prints the table. Prefer `--serve` when a human will keep the board open |
| `board-show.sh <n>` | node + issue URL + bound daemon |
| `board-bind.sh <uuid> <n>` | record which daemon owns the ticket (in the daemon registry) |
| `board-answer.sh <n> <answers \| --posted>` | the wake ritual's `needs-human` relay: posts the answers as an `[answers]` comment (the ticket is the record), returns the ticket to the state it parked FROM — the `pre-park:` meta the park recorded, and when the park entered from a state `PRE_PARK` does not cover, the bound worker's own lane (`in-design` for an ARCHITECT, else `in-progress`) — and resumes the BOUND session with the answers verbatim — park = pause, not death. Refuses unbound / mid-turn sessions (fresh dispatch is the fallback). Blocks for the worker's turn: bg shell |
| `board-answer.sh <n> <answers \| --posted>` | the wake ritual's `needs-human` relay: posts the answers as an `[answers]` comment (the ticket is the record), returns the ticket to the state it parked FROM — the `pre-park:` meta the park recorded, and when the park entered from a state `PRE_PARK` does not cover, the bound worker's own lane (`in-design` for an ARCHITECT, `in-review` for a QAGENT with the ticket's own `pr:` re-supplied, else `in-progress`) — and resumes the BOUND session with the answers verbatim — park = pause, not death. Refuses unbound / mid-turn sessions (fresh dispatch is the fallback), and refuses a review-lane return whose ticket carries no `pr:` (the answers still post; the ticket stays parked until the link is restored). Blocks for the worker's turn: bg shell |
| `board-answer.sh <n> <answers> --to <state>` | API binding only, and only for a park **nobody is bound to**: the server has no run whose lane it could return the ticket to, answers `409 no-return-mapping`, and `--to` is how the human names the disposition themselves (the server refuses it on a bound park — a bound park's return state is the server's) |
| `board-reconcile.sh` | read-only catch-up: the wake queue (parked tickets), orphaned tickets, dispatchables, then a lint pass |
| `board-sweep.sh` | the unattended tick (cron/launchd, ~5 min — arming: `references/sweep-setup.md`): bounded auto-recovery of dead/stalled workers (resume with a nudge, 3 attempts, then park `needs-human`), board-driven cancel of live workers on terminal tickets, `implement-dispatch.sh --sweep` + `review-dispatch.sh --sweep`, land dispatch on the human Approve signal, the `needs-human` answer relay (a fresh ticket comment resumes the bound worker — comment from anywhere, the sweep does the rest), then the reconcile report into its log |
Expand Down
188 changes: 179 additions & 9 deletions skills/issue-tracker/scripts/_board.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,10 @@
SURFACE_NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")

META_RE = re.compile(r"\n?<!-- board:meta\n(.*?)\n-->\s*$", re.S)
# Every boundary `str.splitlines()` honours — meta_match must cut interior lines
# exactly where parse_meta will, or a value folded on U+2028 (or CR, \v, \x85…)
# hides a `-->` and the prose behind it inside one apparently-legal line.
LINE_SEP_RE = re.compile("\r\n|[\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029]")
META_KEYS = ("spawned-by", "relates-to", "branch", "pr", "plan", "pre-park",
"parent-pin", "note")

Expand Down Expand Up @@ -235,9 +239,113 @@ def graphql(query, **variables):


# ── board:meta body block ────────────────────────────────────────────────
def _block_line(line):
"""True for a line that could legally sit INSIDE a meta block — a
`key: value` whose key is one of ours. Blank lines, prose and a quoted
example's `-->` are all False."""
return ":" in line and line.split(":", 1)[0].strip() in META_KEYS


def meta_match(body):
"""The META_RE match on the body's REAL trailing block, chosen by content.

Every META_RE match ends at end-of-string (`\\s*$`), so the openers compete
and only the start differs. Two prose shapes make both the leftmost and the
rightmost opener wrong:

- QUOTED (#60): the prose documents the block, so a marker-shaped example
sits above the real one. Leftmost anchors on the example and its lazy
middle runs to the real `-->` — a leftmost strip deletes the prose
between them.
- LEGACY-NESTED: a pre-grammar client stored a meta VALUE containing a
verbatim `<!-- board:meta`, so the real block's interior holds a second
opener. Rightmost anchors on THAT — a rightmost strip cuts inside the
block and leaves half of it behind as prose.

- QUOTED-NESTED: the two composed — prose that quotes a legacy-nested
example, with the real block further down.

So neither end wins by position; the interior decides. Since every match
runs to the same closer, a candidate opener is the real one iff its WHOLE
interior — every line from its opener to that closer — could sit inside a
block: a known-key `key: value` (`_block_line`) or a line-start nested
marker. A blank line, prose, or an intermediate `-->` cannot, so the choice
is the FIRST opener standing after the LAST such line. Judging only the gap
between adjacent candidates is not enough: in QUOTED-NESTED that gap holds
the quoted example's own entries and reads legal, and the quoted opener
wins (spec v1.2.4).

When no opener clears the last illegal line the block is noncanonical — an
unknown key, a comment, hand-edited spacing — and the fall back is to the
FIRST opener of the last run of candidates, i.e. the one that opened the
segment the illegal line landed in. Taking the LAST opener instead (the old
rightmost behavior) reopens shape B whenever a legacy block carries BOTH a
nested marker and an unknown key: the unknown key fences off every
candidate, and the nested opener wins.

The pass splits lines on every separator `str.splitlines()` honours, not
`\\n` alone. parse_meta reads the block that way, so a quoted example that
uses U+2028 (or CR, or \\v) would otherwise fold its `-->` and the prose
after it into one interior line that reads legal — the quoted opener wins
and the next meta write truncates the body.

One classification pass over the span plus two regex scans: the walk is
linear in the body, where the old rightmost loop re-ran the end-anchored
regex per marker (O(N²) on a marker-dense body).

The returned start never includes META_RE's optional leading `\\n`: the
search is anchored at the chosen opener, where `\\n?` matches empty.
Byte-offset consumers (strip_meta, board-body.sh's splice) therefore keep
the separator newline and must normalize it themselves — strip_meta's
`.rstrip("\\n")` does."""
body = body or ""
m0 = META_RE.search(body)
if not m0:
return None
head = len("<!-- board:meta\n")
# The closer is unique — `\n-->` with nothing but whitespace behind it can
# occur at only one offset — and m0's lazy middle ends exactly there.
close = m0.end(1)
first = m0.start() + (1 if body[m0.start()] == "\n" else 0)
# One pass over the span: each candidate opener is recorded with the fence
# standing at the time — the offset past the last line so far that cannot be
# block interior — so the fallback can find the segment it opened.
opens, fence, pos = [], first, first
while pos < close:
sep = LINE_SEP_RE.search(body, pos, close)
line = body[pos:sep.start() if sep else close]
marker = line.lstrip()
if marker == "<!-- board:meta":
# INDENTATION DOES NOT DISQUALIFY AN OPENER. META_RE's opener is
# unanchored, so a block nested under a list item or a quote is a
# real trailing block; admitting only column-zero openers left an
# indented REAL block out of the candidate set, and a column-zero
# QUOTED example above it then won by fallback — a destructive
# strip of the prose between them (PR-65 panel). The candidate
# offset is the `<`, not the line start, so every byte consumer
# keeps the indent on the prose side, where it was written.
# Indented QUOTED examples stay excluded exactly as before: their
# own closer line (` -->`) carries no colon, so it fences.
cand = pos + (len(line) - len(marker))
# A nested marker is legal interior either way. It is a CANDIDATE
# only when a real `\n` follows — META_RE cannot match otherwise —
# and the closer still leaves room for a block to open here; a
# trailing `<!-- board:meta\n-->` has none.
if sep is not None and sep.group() == "\n" and cand + head <= close:
opens.append((cand, fence))
elif not _block_line(line):
fence = (sep.end() if sep else close)
pos = sep.end() if sep else close
for start, _ in opens:
if start >= fence:
return META_RE.search(body, start)
floor = opens[-1][1]
return META_RE.search(body, next(s for s, _ in opens if s >= floor))


def parse_meta(body):
"""The trailing `<!-- board:meta ... -->` block → dict (absent keys omitted)."""
m = META_RE.search(body or "")
m = meta_match(body)
meta = {}
if not m:
return meta
Expand All @@ -254,7 +362,8 @@ def parse_meta(body):
def strip_meta(body):
"""The body WITHOUT its trailing board:meta block — the ticket's own text,
with the board's bookkeeping removed."""
return META_RE.sub("", body or "").rstrip("\n")
m = meta_match(body)
return ((body or "")[:m.start()] if m else (body or "")).rstrip("\n")


def contract_hash(body):
Expand All @@ -270,17 +379,75 @@ def contract_hash(body):
return hashlib.sha256(strip_meta(body).encode("utf-8")).hexdigest()[:12]


def render_body(body, meta):
"""Body with its meta block replaced by `meta` (dropped when meta is empty).
Everything outside the block is preserved byte-for-byte."""
base = strip_meta(body)
meta = {k: v for k, v in meta.items() if v}
def clean_meta(meta):
"""Meta values normalized to the block's grammar — one line, no opening
marker — and refused when they cannot live there. Empty values dropped.

parse_meta reads the block line-wise, so a multi-line value is silent
corruption at best and a forged key at worst; `<!-- board:meta` inside the
real block would defeat meta_match's rightmost rule outright, and is
unrepresentable, so it dies (loud beats mangled). `-->` is NOT refused: the
collapse leaves every value behind its `key: ` prefix, and META_RE requires
the closer at line start, so an arrow can never terminate the block early
(fuzz-proven, spec v1.2.1). Refusing it bricked every stored note carrying
an ASCII arrow, since update_meta re-renders every key it parsed.

Split out of render_body so a caller that makes OTHER remote writes first
can validate ahead of them — see check_meta_write."""
clean = {}
for k, v in meta.items():
if not v:
continue
# EVERY separator parse_meta's splitlines() would honour — \r\n alone
# leaves \v, \f, \x1c–\x1e, \x85, U+2028/U+2029 injectable as keys.
v = " ".join(str(v).splitlines())
if "<!-- board:meta" in v:
die("meta value %r cannot carry a board:meta marker token" % k)
clean[k] = v
return clean


def check_meta_write(body, updates):
"""Refuse an illegal meta write BEFORE the caller's first remote write.

update_meta re-renders every key it parsed out of the existing block, so a
refusal on ANY of them — new or stored — must be raised against the MERGED
result, and it must be raised before anything else has moved on GitHub:
nothing rolls a label write back, and a relabelled ticket carrying a stale
note reads as a real one that board-lint cannot flag.

apply_state calls this at its own top; a caller that writes labels of its
own first (board-transition.sh: ensure_labels + the surface re-match) calls
it earlier still, with the same `updates` it will hand apply_state."""
merged = parse_meta(body)
merged.update(updates)
clean_meta(merged)


def compose_body(base, meta):
"""`base` — prose the caller has ALREADY stripped — plus a rendered meta
block. Strips nothing.

Split out of render_body for the caller that builds a new prose before
rendering (strip, append, render). Feeding such a base back through a
stripping renderer strips twice, and once the first strip is correct the
second one lands on prose: a marker-shaped example that happens to END the
base still satisfies META_RE's `\\s*$`, so it is deleted as if it were the
block (#60 on the migration path, task-2 review I1)."""
base = (base or "").rstrip("\n")
meta = clean_meta(meta)
if not meta:
return base + ("\n" if base else "")
block = "\n".join("%s: %s" % (k, meta[k]) for k in META_KEYS if k in meta)
return "%s\n\n<!-- board:meta\n%s\n-->\n" % (base, block)


def render_body(body, meta):
"""Body with its meta block replaced by `meta` (dropped when meta is empty).
Everything outside the block is preserved byte-for-byte."""
return compose_body(strip_meta(body), meta)


def _nums(val):
"""'#12 #7' / '12,7' → ['12', '7'] (issue-number refs in a meta value)."""
return re.findall(r"\d+", val or "")
Expand Down Expand Up @@ -833,14 +1000,17 @@ def apply_state(tickets, tid, to, why, extra_meta=None, bookkeeping=False):
extra_meta lets the caller fold branch/pr into the same body write."""
n = tickets[tid]
old = n["state"]
updates = {"note": why or None}
updates.update(extra_meta or {})
# Validate the meta write BEFORE the label write (idempotent — a caller
# that writes labels of its own ahead of this one has already run it).
check_meta_write(n["body"], updates)
if to in TERMINAL:
# strip status labels first so a closed issue never carries one
edit_labels(tid, remove=[STATUS_PREFIX + s for s in n["status_labels"]])
close(tid, to)
else:
set_state_label(tid, n, to)
updates = {"note": why or None}
updates.update(extra_meta or {})
update_meta(tid, n, **updates)
if why:
if bookkeeping:
Expand Down
Loading