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
25d82a2
fix(planning): expose per-question answered flag in /api/state
kyle-sexton Sep 29, 2026
59bc00b
Merge remote-tracking branch 'origin/main' into fix/5459-api-state-an…
kyle-sexton Sep 29, 2026
3e6bcb4
chore(planning): bump to 0.46.2 with changelog entry for answered flag
kyle-sexton Sep 29, 2026
87dd8d9
fix(planning): wrap exporters import comment for ruff format, bump to…
kyle-sexton Sep 29, 2026
5d44811
Merge remote-tracking branch 'origin/main' into fix/5459-api-state-an…
kyle-sexton Sep 29, 2026
d526fc0
fix(planning): reopened question reads answered false in /api/state
kyle-sexton Sep 30, 2026
3c0a131
Merge origin/main into fix/5459-api-state-answered
kyle-sexton Sep 30, 2026
232969a
Merge origin/main into fix/5459-api-state-answered
kyle-sexton Sep 30, 2026
26e2787
Merge origin/main into fix/5459-api-state-answered
kyle-sexton Sep 30, 2026
df42453
Merge origin/main into fix/5459-api-state-answered
kyle-sexton Sep 30, 2026
b2f08be
Merge origin/main into fix/5459-api-state-answered
kyle-sexton Sep 30, 2026
22074de
fix(planning): resolve manifest merge markers
kyle-sexton Sep 30, 2026
0872c65
Merge remote-tracking branch 'origin/main' into fix/5459-api-state-an…
kyle-sexton Sep 30, 2026
558a90f
Merge remote-tracking branch 'origin/main' into fix/5459-api-state-an…
kyle-sexton Sep 30, 2026
2bd246a
fix(planning): drop duplicate version key left by merge
kyle-sexton Sep 30, 2026
5093bdb
Merge remote-tracking branch 'origin/main' into fix/5459-api-state-an…
kyle-sexton Sep 30, 2026
006e9ee
Merge remote-tracking branch 'origin/main' into fix/5459-api-state-an…
kyle-sexton Sep 30, 2026
35257d5
Merge remote-tracking branch 'origin/main' into fix/5459-api-state-an…
kyle-sexton Sep 30, 2026
5e8fad0
Merge remote-tracking branch 'origin/main' into fix/5459-api-state-an…
kyle-sexton Sep 30, 2026
2ebe645
Merge remote-tracking branch 'origin/main' into fix/5459-api-state-an…
kyle-sexton Sep 30, 2026
295e3c4
Merge remote-tracking branch 'origin/main' into fix/5459-api-state-an…
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.49.3",
"version": "0.49.4",
"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.49.4] - 2026-09-30

### Added

- **`/api/state` reports `answered` per question**, true when a page or terminal decision counts (the rule the page uses). `state` is dependency staleness, so a question with a terminal decision still reads `open`; the README and `questions.schema.json` say so ([#5459](https://github.com/melodic-software/claude-code-plugins/issues/5459)).

## [0.49.3] - 2026-09-30

### Added
Expand Down
2 changes: 1 addition & 1 deletion plugins/planning/surface/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Every command needs `--dir '<data_dir>'`; there is no default. Every write valid

## Data contract

`schema/` is the contract; other tools write these formats or read the exports, and the surface reads no other files except a file a visual names inside the data dir. Both documents carry `"schemaVersion": "1.0"`; a file without one reads as version 0 and loads unchanged. `responses.json` is an append-only event log with a global `seq`; undo marks an event `withdrawn` and nothing is deleted. Event kinds: `accept`, `alt`, `own`, `defer`, `reopen`, `ask`, `rephrase`, `note`, `undo`, `wrapup`, `confirm`, `confirm-understanding`. `confirm-understanding` has no id; its `alt` is `confirm` or `off` (`off` needs `text`), and its `contentRev` must equal `restatement.rev`: a missing restatement or `contentRev` is 400, an older rev is 409 `{"error": "stale", "contentRev": <current>}`. A `confirm` may carry `contentRev`, which must equal the question's current `contentRev` (an older one is 409 `stale`, so a page holding a replaced commitments list cannot confirm the new one by index). A repeated Confirm (a `confirm` of the same commitment, or a `confirm-understanding` Confirm of the same rev) records nothing and returns the first event's seq. Question `state` (`open`, `stale`, `upstream-pending`, `archived`) is computed by the server from `dependsOn` and `archived`, never written. A visual is declared by `format` (`svg`, `mermaid`, `image`, `markdown`, `html`, `chart`; `kind` is read as an alias) and describes only its content.
`schema/` is the contract; other tools write these formats or read the exports, and the surface reads no other files except a file a visual names inside the data dir. Both documents carry `"schemaVersion": "1.0"`; a file without one reads as version 0 and loads unchanged. `responses.json` is an append-only event log with a global `seq`; undo marks an event `withdrawn` and nothing is deleted. Event kinds: `accept`, `alt`, `own`, `defer`, `reopen`, `ask`, `rephrase`, `note`, `undo`, `wrapup`, `confirm`, `confirm-understanding`. `confirm-understanding` has no id; its `alt` is `confirm` or `off` (`off` needs `text`), and its `contentRev` must equal `restatement.rev`: a missing restatement or `contentRev` is 400, an older rev is 409 `{"error": "stale", "contentRev": <current>}`. A `confirm` may carry `contentRev`, which must equal the question's current `contentRev` (an older one is 409 `stale`, so a page holding a replaced commitments list cannot confirm the new one by index). A repeated Confirm (a `confirm` of the same commitment, or a `confirm-understanding` Confirm of the same rev) records nothing and returns the first event's seq. Question `state` (`open`, `stale`, `upstream-pending`, `archived`) is computed by the server from `dependsOn` and `archived`, never written; it is dependency staleness, not an answered flag. `answered` (server-computed, never written) is true when a page or terminal decision counts, so a question with a terminal decision reads `state` `open` and `answered` true. A visual is declared by `format` (`svg`, `mermaid`, `image`, `markdown`, `html`, `chart`; `kind` is read as an alias) and describes only its content.

## Security model

Expand Down
4 changes: 4 additions & 0 deletions plugins/planning/surface/schema/questions.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,10 @@
"state": {
"enum": ["open", "stale", "upstream-pending", "archived"],
"description": "Derived by the server from dependsOn, decision events and archived; never written by Claude"
},
"answered": {
"type": "boolean",
"description": "Derived by the server: true when a page or terminal decision counts; never written"
}
}
}
Expand Down
8 changes: 8 additions & 0 deletions plugins/planning/surface/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -807,9 +807,17 @@ def state(self):
r = load_json(self.responses, EMPTY_RESPONSES)
settings, theme = self.layers.resolve(self.dir, self.user_settings())
derived = question_states(q, r)
# exporters imports server at module top
from exporters import latest_decision

for x in q.get("questions") or []:
if isinstance(x, dict) and x.get("id") in derived:
x["state"], x["revising"] = derived[x["id"]]
x["answered"] = bool(
(latest_decision(x, r.get("responses", {})) or {}).get(
"decision"
)
)
self._last_state = {
"questions": q,
"responses": r,
Expand Down
34 changes: 34 additions & 0 deletions plugins/planning/surface/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1353,6 +1353,40 @@ def test_terminal_dependent_goes_stale_when_its_prerequisite_changes(self):
self.assertEqual(self.states(), {"A": "open", "B": "stale"})


class TestAnswered(WaitCase):
"""`answered` is true when a page or terminal decision counts; `state` stays dependency staleness."""

@classmethod
def prepare(cls):
seed_questions(cls.dir, question("A"), question("B"), question("C"))

def answered(self):
return {
q["id"]: (q.get("answered"), q.get("state"))
for q in self.state()["questions"]["questions"]
}

def test_1_unanswered_is_false(self):
self.assertEqual(self.answered()["A"], (False, "open"))

def test_2_terminal_decision_is_answered_and_state_stays_open(self):
rc, out = self.rp("record-terminal", "A", "--decision", "accept")
self.assertEqual(rc, 0, out)
self.assertEqual(self.answered()["A"], (True, "open"))

def test_3_page_accept_is_answered(self):
code, data = self.post({"id": "B", "kind": "accept"})
self.assertEqual(code, 200, data)
self.assertEqual(self.answered()["B"], (True, "open"))
self.assertEqual(self.answered()["C"], (False, "open"))

def test_4_reopen_is_not_answered(self):
for kind in ("accept", "reopen"):
code, data = self.post({"id": "C", "kind": kind})
self.assertEqual(code, 200, data)
self.assertEqual(self.answered()["C"], (False, "open"))


class TestConfirm(WaitCase):
"""The `confirm` event: ticks one commitment, records no decision, needs handling."""

Expand Down
Loading