diff --git a/plugins/planning/.claude-plugin/plugin.json b/plugins/planning/.claude-plugin/plugin.json index fb94f0501b..011b0c8c14 100644 --- a/plugins/planning/.claude-plugin/plugin.json +++ b/plugins/planning/.claude-plugin/plugin.json @@ -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", diff --git a/plugins/planning/CHANGELOG.md b/plugins/planning/CHANGELOG.md index 9b5fe2bb7e..96b500b422 100644 --- a/plugins/planning/CHANGELOG.md +++ b/plugins/planning/CHANGELOG.md @@ -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 diff --git a/plugins/planning/surface/README.md b/plugins/planning/surface/README.md index f6a6d19d25..5753cf87bb 100644 --- a/plugins/planning/surface/README.md +++ b/plugins/planning/surface/README.md @@ -70,7 +70,7 @@ Every command needs `--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": }`. 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": }`. 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 diff --git a/plugins/planning/surface/schema/questions.schema.json b/plugins/planning/surface/schema/questions.schema.json index 917bfef42a..5d8feea564 100644 --- a/plugins/planning/surface/schema/questions.schema.json +++ b/plugins/planning/surface/schema/questions.schema.json @@ -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" } } } diff --git a/plugins/planning/surface/server.py b/plugins/planning/surface/server.py index 8eeda7c6b1..6f5ce4a2b1 100644 --- a/plugins/planning/surface/server.py +++ b/plugins/planning/surface/server.py @@ -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, diff --git a/plugins/planning/surface/test_server.py b/plugins/planning/surface/test_server.py index adb3189a88..b9b2b8373c 100644 --- a/plugins/planning/surface/test_server.py +++ b/plugins/planning/surface/test_server.py @@ -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."""