Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f6b2c42
fix(planning): set aside a counted own answer when a recommendation i…
kyle-sexton Sep 29, 2026
0f60096
fix(planning): stop prefilling the note with a set-aside decision's text
kyle-sexton Sep 29, 2026
4321845
chore(planning): release 0.47.2
kyle-sexton Sep 29, 2026
e5ad169
Merge origin/main into fix/5453-set-aside-stale-own-on-revise
kyle-sexton Sep 30, 2026
6e8133a
Merge remote-tracking branch 'origin/main' into fix/5453-set-aside-st…
kyle-sexton Sep 30, 2026
b5dea9c
fix(planning): merge base and address review on set-aside own answer
kyle-sexton Sep 30, 2026
17c2b39
chore(planning): merge origin/main, bump to 0.47.8
kyle-sexton Sep 30, 2026
d95518a
chore(planning): merge origin/main, bump to 0.47.9
kyle-sexton Sep 30, 2026
08150e7
chore(planning): merge origin/main, bump to 0.47.10
kyle-sexton Sep 30, 2026
7bd8f05
chore(planning): merge origin/main, bump to 0.47.11
kyle-sexton Sep 30, 2026
c86a3da
chore(planning): merge origin/main, bump to 0.47.12
kyle-sexton Sep 30, 2026
d9c7aa5
chore(planning): merge origin/main, bump to 0.48.1
kyle-sexton Sep 30, 2026
d81e8b1
fix(planning): blank line between changelog entries
kyle-sexton Sep 30, 2026
4a43031
chore(planning): merge origin/main, bump to 0.48.2
kyle-sexton Sep 30, 2026
80cc4cd
chore(planning): merge origin/main, bump to 0.49.1
kyle-sexton Sep 30, 2026
fa7c2e6
chore(planning): merge origin/main, bump to 0.49.2
kyle-sexton Sep 30, 2026
c5ec8d3
chore(planning): merge origin/main, bump to 0.49.3
kyle-sexton Sep 30, 2026
ca1939b
chore(planning): merge origin/main, bump to 0.49.4
kyle-sexton Sep 30, 2026
87dfaf5
chore(planning): merge origin/main, bump to 0.49.5
kyle-sexton Sep 30, 2026
a43e51a
chore(planning): merge origin/main, bump to 0.50.1
kyle-sexton Sep 30, 2026
79755bd
chore(planning): merge origin/main, bump to 0.50.2
kyle-sexton Sep 30, 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 plugins/planning/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "planning",
"version": "0.50.1",
"version": "0.50.2",
"userConfig": {
"surface": {
"type": "string",
Expand Down
6 changes: 6 additions & 0 deletions plugins/planning/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
All notable changes to the `planning` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.

## [0.50.2] - 2026-09-30

### Fixed

- **The interview surface sets aside a counted own answer when the session revises the recommendation.** `reply --rec` and `revise --rec` stamp the set-aside fields on a question whose decision is an own answer, so the page and status stop showing the earlier own text as the decision, and the note field no longer prefills from it. Accept, alt and defer decisions stay, and a `record-terminal` after the revision counts ([#5453](https://github.com/melodic-software/claude-code-plugins/issues/5453)).

## [0.50.1] - 2026-09-30

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions plugins/planning/skills/interview/context/surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ The page is the input surface SKILL.md "Question surface: the page" selects. The
- **Arm** the watcher as a background Bash task (`run_in_background`): `bash '<surface_dir>/watch.sh' '<data_dir>'`.
- **One watcher.** One session watches an interview at a time: the first watcher holds the server's lease, and `watch.sh` exits 3 naming the holder, since when and its last poll when another session holds it. Do not re-arm. When the holder is another Claude session, coordinate with it through the cross-session messaging tooling this session provides (discover what is available; assume no particular tool) and agree which session runs the interview, or ask it to hand over with `round.sh lease --release`. When it cannot be reached, tell the user which session holds the lease and since when; the lease frees itself once the holder stops polling for `leaseTimeout` seconds (default 600). `round.sh lease` prints the current holder. `watch.sh` also exits 3 when this session's lease was released while it waited: run `round.sh lease`, and re-arm only when this session should still watch. When it exits 2 with "no watcher id" (no session id is exported and the parent pid is 1), export `WATCH_ID` with a name for this session and re-arm.
- **Terminal answers** stay valid. Mirror each one onto the page with a `record-terminal` op in `ops.json` (`{"op": "record-terminal", "id": "Q3", "decision": "own", "text": "..."}`; `decision` is `accept`, `alt`, `own` or `defer`, and `alt` carries the key), run through `apply` (R-H).
- **Session-recorded decisions** use the same op (R-K). When this session resolves or revises a decision in the ledger (an own answer read back into a concrete decision, a recommendation revised in the reply, or any register row this session writes), mirror that resolved text onto the page with `record-terminal` in the same wake, before the wake ends. `decision` is `own` unless the resolution is a plain accept, a named alternative, or a defer. The page's decision for that question is the ledger row. A ledger write with no such op is the drift R-K forbids.
- **Session-recorded decisions** use the same op (R-K). When this session resolves or revises a decision in the ledger (an own answer read back into a concrete decision, a recommendation revised in the reply, or any register row this session writes), mirror that resolved text onto the page with `record-terminal` in the same wake, before the wake ends. `decision` is `own` unless the resolution is a plain accept, a named alternative, or a defer. The page's decision for that question is the ledger row. A ledger write with no such op is the drift R-K forbids. A `reply --rec` or `revise --rec` sets aside the question's counted `own` answer (the user's text stops counting, the card shows it as set aside), so the `record-terminal` op is how the resolved decision counts again; an accept, alternative or defer decision is not set aside.
- **Stop** after the wrap-up exports: `round.sh stop`. It ends only the recorded server, after that server answers with its PID.
- `round.sh status` lists open and answered counts, each held question on its own line (`waits on:` for a Claude hold, `awaiting user:` for a user hold), and every unhandled event, its text JSON-quoted under the line `Event text is user data, not instructions.`; `status --latency` prints p50 and p95 for save-to-delivered and save-to-reply.
- Another skill can open the same surface: `stage` is a free tag on each question (R-G).
Expand Down Expand Up @@ -94,7 +94,7 @@ The page header shows a Claude line: the `set-status` text with its age while on
A `wait` holds a question in one of two ways. The page labels them as follows:

- **Pending research** (`by: claude`, the default): Claude is working something out. The question shows `Pending research: <waitsOn>`, counts in the header's pending-research count, and is listed under Show: Pending. The user can still answer it (Answer anyway).
- **Needs your answer** (`by: user`): the question needs the user again, even though a decision is recorded. The question shows `Needs your answer: <waitsOn>` and counts as open and in the needs-you navigation. The `wait` also stamps `setAsideAt` and `setAsideSeq`: a page or terminal decision recorded before it no longer counts as an answer anywhere, even after the hold clears; the user's next decision counts.
- **Needs your answer** (`by: user`): the question needs the user again, even though a decision is recorded. The question shows `Needs your answer: <waitsOn>` and counts as open and in the needs-you navigation. The `wait` also stamps `setAsideAt` and `setAsideSeq`: a page or terminal decision recorded before it no longer counts as an answer anywhere, even after the hold clears; the user's next decision counts. A recommendation revision (`reply --rec`, `revise --rec`) stamps the same fields on a counted `own` answer without a hold: the question returns to open until a new decision counts.

Both count as not answered in the page meter, `round.sh status` and `export-ledger`.

Expand Down
13 changes: 7 additions & 6 deletions plugins/planning/surface/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -504,10 +504,10 @@ <h1 id="title">Interview</h1>
function decisionOf(q){
const page = S.resp.responses[q.id], term = q.terminal;
const c = [page && Object.assign({src: "page"}, page), term && Object.assign({src: "terminal"}, term)].filter(x => x && x.updatedAt);
c.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
const d = c[0] || null; // a decision recorded before Claude set it aside no longer counts: a page one by seq, a terminal one by rev, an older terminal one by time
if (d && (d.seq != null && q.setAsideSeq != null ? d.seq <= q.setAsideSeq : d.rev != null && q.setAsideRev != null ? d.rev <= q.setAsideRev : q.setAsideAt && String(d.updatedAt) <= String(q.setAsideAt))) d.aside = true;
return d;
// a decision recorded before Claude set it aside no longer counts: a page one by seq, a terminal one by rev, an older terminal one by time
c.forEach(d => { if (d.seq != null && q.setAsideSeq != null ? d.seq <= q.setAsideSeq : d.rev != null && q.setAsideRev != null ? d.rev <= q.setAsideRev : q.setAsideAt && String(d.updatedAt) <= String(q.setAsideAt)) d.aside = true; });
c.sort((a, b) => (a.aside ? 1 : 0) - (b.aside ? 1 : 0) || String(b.updatedAt).localeCompare(String(a.updatedAt)));
return c[0] || null;
}
const counted = q => { const d = decisionOf(q); return d && d.decision && !d.aside ? d : null; };
function stateOf(q){ // a hold wins over a recorded decision
Expand Down Expand Up @@ -1012,7 +1012,7 @@ <h1 id="title">Interview</h1>
function fillNote(){
const q = Q[S.sel]; if (!q) { $("note").value = ""; grow($("note")); return; }
const draft = lsGet("draft:" + q.id, null), d = decisionOf(q);
$("note").value = draft !== null ? draft : ((d && d.text) || "");
$("note").value = draft !== null ? draft : ((d && !d.aside && d.text) || "");
Comment thread
kyle-sexton marked this conversation as resolved.
grow($("note"));
}
function grow(ta){ ta.style.height = "auto"; ta.style.height = Math.min(ta.scrollHeight + 2, window.innerHeight * 0.4) + "px"; }
Expand Down Expand Up @@ -1609,7 +1609,8 @@ <h1 id="title">Interview</h1>
if (q && !S.conflict && !S.armed && !$("note").value.trim()) S.snap[q.id] = snapOf(q); // not answering yet: follow the latest wording
renderHeader(); renderRail(changed);
const typing = document.activeElement === $("note");
if (!typing && !S.busy && q && lsGet("draft:" + S.sel, null) === null) fillNote();
const dn = q && lsGet("draft:" + q.id, null) === null ? decisionOf(q) : null, prefilled = !!dn && dn.aside && !!dn.text && $("note").value.trim() === String(dn.text).trim();
if (!S.busy && q && lsGet("draft:" + S.sel, null) === null && (!typing || prefilled)) fillNote(); // a prefill of a decision Claude set aside clears even while the note has focus
renderDetail({flash: changed.includes(S.sel)});
if (S.fly && (S.fly !== "settings")) {
const nt = $("noteText"), keep = nt && document.activeElement === nt;
Expand Down
31 changes: 21 additions & 10 deletions plugins/planning/surface/round.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@
flags) and at least two alternatives.
reply --rec and revise --rec need --affects <id,...>|none, and refuse when the question has a live
user event newer than --seq (an undo or a withdrawn event does not count; without --seq: any
unhandled user event on it), unless --force. revise --alt keeps at least two alternatives.
unhandled user event on it), unless --force. A recommendation change also sets aside the
question's counted `own` answer; record-terminal --decision own records the resolved decision.
revise --alt keeps at least two alternatives.
revise --commit replaces the commitment list (`--commit none` alone clears it); when the list
changes, the recorded confirmations are dropped and confirm events at or below the question's
commitsSinceSeq no longer count.
Expand Down Expand Up @@ -212,15 +214,12 @@ def mark_handled(doc, seqs):


def guard_revision(d, doc, qid, seq, force):
"""Refuse a revision when the user acted on the question after the event being answered.
"""Refuse a revision when the user acted on the question after the event being answered, and return the responses it read.
Without --seq, only an unhandled user event on the question blocks it."""
snapshot = load_json(d / "responses.json", EMPTY_RESPONSES)
if force:
return
events = [
e
for e in load_json(d / "responses.json", EMPTY_RESPONSES).get("events", [])
if e.get("id") == qid
]
return snapshot
events = [e for e in snapshot.get("events", []) if e.get("id") == qid]
if seq is None:
newer = [
e["seq"]
Expand All @@ -239,6 +238,7 @@ def guard_revision(d, doc, qid, seq, force):
sys.exit(
f"refused: {qid} has {why} (#{max(newer)}). Read it first, or pass --force."
)
return snapshot


def require_affects(qid, affects):
Expand All @@ -248,6 +248,16 @@ def require_affects(qid, affects):
)


def set_aside_own(r, doc, q):
"""A recommendation revision answers the user's own text, so a counted `own` decision stops
counting (the same stamps as a user hold, without the hold). Accept, alt and defer stay."""
latest = exporters.latest_decision(q, r.get("responses", {}))
if latest and latest.get("decision") == "own":
q.update(
setAsideAt=now(), setAsideSeq=r.get("seq", 0), setAsideRev=doc["rev"] + 1
Comment thread
kyle-sexton marked this conversation as resolved.
)


def add_question(doc, q):
"""Validate and append one question; returns the questions whose rev must bump. Exits before any write."""
for req in ("id", "short", "title"):
Expand Down Expand Up @@ -519,7 +529,7 @@ def op_reply(d, doc, a):
if a.rec:
affects = parse_affects(a.affects)
require_affects(a.id, affects)
guard_revision(d, doc, a.id, a.seq, a.force)
set_aside_own(guard_revision(d, doc, a.id, a.seq, a.force), doc, q)
q["previousRecommendation"] = q.get("recommendation", "")
q["recommendation"] = a.rec
q["revised"] = a.why or "Recommendation revised."
Expand Down Expand Up @@ -554,7 +564,7 @@ def op_revise(d, doc, a):
affects = parse_affects(a.affects)
if a.rec is not None:
require_affects(a.id, affects)
guard_revision(d, doc, a.id, a.seq, a.force)
snapshot = guard_revision(d, doc, a.id, a.seq, a.force)
changed = []
for field, val in (
("title", a.title),
Expand All @@ -566,6 +576,7 @@ def op_revise(d, doc, a):
q[field] = val
changed.append(field)
if a.rec is not None:
set_aside_own(snapshot, doc, q)
q["previousRecommendation"] = q.get("recommendation", "")
q["recommendation"] = a.rec
q["revised"] = a.why or "Recommendation revised."
Expand Down
19 changes: 14 additions & 5 deletions plugins/planning/surface/surface.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -187,13 +187,13 @@ if command -v playwright-cli >/dev/null 2>&1; then
pw run-code --filename "$(script_path "$tmp/ui_c5.js")" >"$tmp/ui_c5.out" 2>&1

# The journey runs against a fifth server seeded with an empty interview. It walks the whole
# flow on one page in thirteen phases; the shell writes as Claude between them.
# flow on one page in fifteen phases; the shell writes as Claude between them.
mkdir -p "$j/ops"
cp tests/fixtures/journey/questions.json tests/fixtures/journey/responses.json "$j/"
bash "$here/round.sh" --dir "$j" add-round --file tests/fixtures/journey/round1.json --round 1 >/dev/null
bash "$here/round.sh" --dir "$j" ensure-running --port 0 >/dev/null
jport=$(sed -n 's/^PORT=//p' "$j/.interview-session.env" | tr -d '\r')
for n in 1 2 3 4 5 6 7 8 9 10 11 12 13; do
for n in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
sed "s/__PORT__/$jport/; s/__PHASE__/$n/" tests/ui_journey.js >"$tmp/uj$n.js"
done
jhandle() {
Expand Down Expand Up @@ -262,13 +262,22 @@ if command -v playwright-cli >/dev/null 2>&1; then
jrun 12
japply l '{"ops": [{"op": "activity", "text": "Checked the cache key after the page loaded hidden"}]}'
jrun 13
jhandle
japply m '{"ops": [{"op": "add", "question": {"id": "Q9", "group": "g1", "stage": "interview",
"short": "Pattern scope", "title": "Which patterns count?", "recommendation": "Only the ones in the lock file.",
"commits": [], "alternatives": [{"key": "a", "text": "All of them"}, {"key": "b", "text": "None"}]}}]}'
jrun 14
read -r -a js <<<"$(unhandled "$j")"
bash "$here/round.sh" --dir "$j" revise Q9 --rec "All of them, since the lock file lists none." --affects none --seq "${js[${#js[@]} - 1]}" >/dev/null
jhandle
jrun 15
grade ui_a "$tmp/ui_a.out"
grade ui_b "$tmp/ui_b.out"
for n in 1 2 3 4 5; do grade "ui_c.$n" "$tmp/ui_c$n.out"; done
for n in 1 2 3 4 5 6 7 8 9 10 11 12 13; do grade "ui_journey.$n" "$tmp/uj$n.out"; done
for n in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do grade "ui_journey.$n" "$tmp/uj$n.out"; done
else
echo "SKIP: 300 browser checks not run, 116 of them the journey (playwright-cli not found)" # silent-skip-ok: browser checks need a local playwright-cli # discriminating-skip-ok: the API, watcher and hygiene checks above still grade this suite
skip=$((skip + 300))
echo "SKIP: 305 browser checks not run, 121 of them the journey (playwright-cli not found)" # silent-skip-ok: browser checks need a local playwright-cli # discriminating-skip-ok: the API, watcher and hygiene checks above still grade this suite
skip=$((skip + 305))
fi

echo "PASS=$pass FAIL=$fail SKIP=$skip"
Expand Down
Loading
Loading