devague 0.6.0__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. {devague-0.6.0 → devague-0.7.0}/CHANGELOG.md +21 -0
  2. {devague-0.6.0 → devague-0.7.0}/PKG-INFO +1 -1
  3. {devague-0.6.0 → devague-0.7.0}/devague/cli/_plans.py +16 -1
  4. {devague-0.6.0 → devague-0.7.0}/devague/frame.py +19 -1
  5. {devague-0.6.0 → devague-0.7.0}/devague/plan.py +25 -1
  6. {devague-0.6.0 → devague-0.7.0}/devague/plan_store.py +15 -1
  7. {devague-0.6.0 → devague-0.7.0}/devague/render/spec_md.py +29 -3
  8. {devague-0.6.0 → devague-0.7.0}/devague/store.py +5 -0
  9. {devague-0.6.0 → devague-0.7.0}/docs/spec-contract.md +20 -2
  10. {devague-0.6.0 → devague-0.7.0}/docs/specs/devague-0-6-0-ships-the-human-review-loop-devague.md +16 -7
  11. {devague-0.6.0 → devague-0.7.0}/docs/specs/devague-now-ships-a-documented-spec-contract-every.md +2 -2
  12. {devague-0.6.0 → devague-0.7.0}/pyproject.toml +1 -1
  13. {devague-0.6.0 → devague-0.7.0}/tests/test_cli_plan.py +23 -0
  14. {devague-0.6.0 → devague-0.7.0}/tests/test_frame.py +7 -0
  15. {devague-0.6.0 → devague-0.7.0}/tests/test_plan.py +51 -1
  16. {devague-0.6.0 → devague-0.7.0}/tests/test_plan_store.py +43 -1
  17. {devague-0.6.0 → devague-0.7.0}/tests/test_render.py +26 -1
  18. {devague-0.6.0 → devague-0.7.0}/tests/test_store.py +13 -0
  19. {devague-0.6.0 → devague-0.7.0}/uv.lock +1 -1
  20. {devague-0.6.0 → devague-0.7.0}/.claude/skills/cicd/SKILL.md +0 -0
  21. {devague-0.6.0 → devague-0.7.0}/.claude/skills/cicd/scripts/_resolve-nick.sh +0 -0
  22. {devague-0.6.0 → devague-0.7.0}/.claude/skills/cicd/scripts/portability-lint.sh +0 -0
  23. {devague-0.6.0 → devague-0.7.0}/.claude/skills/cicd/scripts/pr-reply.sh +0 -0
  24. {devague-0.6.0 → devague-0.7.0}/.claude/skills/cicd/scripts/pr-status.sh +0 -0
  25. {devague-0.6.0 → devague-0.7.0}/.claude/skills/cicd/scripts/workflow.sh +0 -0
  26. {devague-0.6.0 → devague-0.7.0}/.claude/skills/communicate/SKILL.md +0 -0
  27. {devague-0.6.0 → devague-0.7.0}/.claude/skills/communicate/scripts/fetch-issues.sh +0 -0
  28. {devague-0.6.0 → devague-0.7.0}/.claude/skills/communicate/scripts/mesh-message.sh +0 -0
  29. {devague-0.6.0 → devague-0.7.0}/.claude/skills/communicate/scripts/post-comment.sh +0 -0
  30. {devague-0.6.0 → devague-0.7.0}/.claude/skills/communicate/scripts/post-issue.sh +0 -0
  31. {devague-0.6.0 → devague-0.7.0}/.claude/skills/communicate/scripts/templates/skill-update-brief.md +0 -0
  32. {devague-0.6.0 → devague-0.7.0}/.claude/skills/doc-test-alignment/SKILL.md +0 -0
  33. {devague-0.6.0 → devague-0.7.0}/.claude/skills/doc-test-alignment/scripts/check.sh +0 -0
  34. {devague-0.6.0 → devague-0.7.0}/.claude/skills/run-tests/SKILL.md +0 -0
  35. {devague-0.6.0 → devague-0.7.0}/.claude/skills/run-tests/scripts/test.sh +0 -0
  36. {devague-0.6.0 → devague-0.7.0}/.claude/skills/sonarclaude/SKILL.md +0 -0
  37. {devague-0.6.0 → devague-0.7.0}/.claude/skills/sonarclaude/scripts/sonar.sh +0 -0
  38. {devague-0.6.0 → devague-0.7.0}/.claude/skills/spec-to-plan/SKILL.md +0 -0
  39. {devague-0.6.0 → devague-0.7.0}/.claude/skills/spec-to-plan/scripts/spec-to-plan.sh +0 -0
  40. {devague-0.6.0 → devague-0.7.0}/.claude/skills/think/SKILL.md +0 -0
  41. {devague-0.6.0 → devague-0.7.0}/.claude/skills/think/scripts/think.sh +0 -0
  42. {devague-0.6.0 → devague-0.7.0}/.claude/skills/version-bump/SKILL.md +0 -0
  43. {devague-0.6.0 → devague-0.7.0}/.claude/skills/version-bump/scripts/bump.py +0 -0
  44. {devague-0.6.0 → devague-0.7.0}/.claude/skills.local.yaml.example +0 -0
  45. {devague-0.6.0 → devague-0.7.0}/.devague/current_plan +0 -0
  46. {devague-0.6.0 → devague-0.7.0}/.devague/frames/devague-0-6-0-ships-the-human-review-loop-devague.json +0 -0
  47. {devague-0.6.0 → devague-0.7.0}/.devague/frames/devague-now-ships-a-documented-spec-contract-every.json +0 -0
  48. {devague-0.6.0 → devague-0.7.0}/.devague/plans/devague-0-6-0-ships-the-human-review-loop-devague.json +0 -0
  49. {devague-0.6.0 → devague-0.7.0}/.devague/plans/devague-now-ships-a-documented-spec-contract-every.json +0 -0
  50. {devague-0.6.0 → devague-0.7.0}/.flake8 +0 -0
  51. {devague-0.6.0 → devague-0.7.0}/.github/workflows/publish.yml +0 -0
  52. {devague-0.6.0 → devague-0.7.0}/.github/workflows/security-checks.yml +0 -0
  53. {devague-0.6.0 → devague-0.7.0}/.github/workflows/tests.yml +0 -0
  54. {devague-0.6.0 → devague-0.7.0}/.gitignore +0 -0
  55. {devague-0.6.0 → devague-0.7.0}/.markdownlint-cli2.yaml +0 -0
  56. {devague-0.6.0 → devague-0.7.0}/.pre-commit-config.yaml +0 -0
  57. {devague-0.6.0 → devague-0.7.0}/CLAUDE.md +0 -0
  58. {devague-0.6.0 → devague-0.7.0}/LICENSE +0 -0
  59. {devague-0.6.0 → devague-0.7.0}/README.md +0 -0
  60. {devague-0.6.0 → devague-0.7.0}/culture.yaml +0 -0
  61. {devague-0.6.0 → devague-0.7.0}/devague/__init__.py +0 -0
  62. {devague-0.6.0 → devague-0.7.0}/devague/__main__.py +0 -0
  63. {devague-0.6.0 → devague-0.7.0}/devague/cli/__init__.py +0 -0
  64. {devague-0.6.0 → devague-0.7.0}/devague/cli/_commands/__init__.py +0 -0
  65. {devague-0.6.0 → devague-0.7.0}/devague/cli/_commands/capture.py +0 -0
  66. {devague-0.6.0 → devague-0.7.0}/devague/cli/_commands/confirm.py +0 -0
  67. {devague-0.6.0 → devague-0.7.0}/devague/cli/_commands/converge.py +0 -0
  68. {devague-0.6.0 → devague-0.7.0}/devague/cli/_commands/explain.py +0 -0
  69. {devague-0.6.0 → devague-0.7.0}/devague/cli/_commands/export.py +0 -0
  70. {devague-0.6.0 → devague-0.7.0}/devague/cli/_commands/interrogate.py +0 -0
  71. {devague-0.6.0 → devague-0.7.0}/devague/cli/_commands/learn.py +0 -0
  72. {devague-0.6.0 → devague-0.7.0}/devague/cli/_commands/list_frames.py +0 -0
  73. {devague-0.6.0 → devague-0.7.0}/devague/cli/_commands/new.py +0 -0
  74. {devague-0.6.0 → devague-0.7.0}/devague/cli/_commands/park.py +0 -0
  75. {devague-0.6.0 → devague-0.7.0}/devague/cli/_commands/plan.py +0 -0
  76. {devague-0.6.0 → devague-0.7.0}/devague/cli/_commands/question.py +0 -0
  77. {devague-0.6.0 → devague-0.7.0}/devague/cli/_commands/reject.py +0 -0
  78. {devague-0.6.0 → devague-0.7.0}/devague/cli/_commands/review.py +0 -0
  79. {devague-0.6.0 → devague-0.7.0}/devague/cli/_commands/show.py +0 -0
  80. {devague-0.6.0 → devague-0.7.0}/devague/cli/_errors.py +0 -0
  81. {devague-0.6.0 → devague-0.7.0}/devague/cli/_frames.py +0 -0
  82. {devague-0.6.0 → devague-0.7.0}/devague/cli/_output.py +0 -0
  83. {devague-0.6.0 → devague-0.7.0}/devague/convergence.py +0 -0
  84. {devague-0.6.0 → devague-0.7.0}/devague/plan_convergence.py +0 -0
  85. {devague-0.6.0 → devague-0.7.0}/devague/questions_io.py +0 -0
  86. {devague-0.6.0 → devague-0.7.0}/devague/render/__init__.py +0 -0
  87. {devague-0.6.0 → devague-0.7.0}/devague/render/frame_md.py +0 -0
  88. {devague-0.6.0 → devague-0.7.0}/devague/render/plan_md.py +0 -0
  89. {devague-0.6.0 → devague-0.7.0}/devague/render/review_md.py +0 -0
  90. {devague-0.6.0 → devague-0.7.0}/docs/examples/contract-example.json +0 -0
  91. {devague-0.6.0 → devague-0.7.0}/docs/plans/devague-0-6-0-ships-the-human-review-loop-devague.md +0 -0
  92. {devague-0.6.0 → devague-0.7.0}/docs/plans/devague-now-ships-a-documented-spec-contract-every.md +0 -0
  93. {devague-0.6.0 → devague-0.7.0}/docs/reviews/spec-contract-frame-review.md +0 -0
  94. {devague-0.6.0 → devague-0.7.0}/docs/skill-sources.md +0 -0
  95. {devague-0.6.0 → devague-0.7.0}/docs/superpowers/plans/2026-05-22-specifix-onboarding.md +0 -0
  96. {devague-0.6.0 → devague-0.7.0}/docs/superpowers/plans/2026-05-23-devague-rename.md +0 -0
  97. {devague-0.6.0 → devague-0.7.0}/docs/superpowers/plans/2026-05-23-devague-working-backwards-engine.md +0 -0
  98. {devague-0.6.0 → devague-0.7.0}/docs/superpowers/specs/2026-05-22-specifix-onboarding-design.md +0 -0
  99. {devague-0.6.0 → devague-0.7.0}/docs/superpowers/specs/2026-05-23-devague-spec-to-plan-design.md +0 -0
  100. {devague-0.6.0 → devague-0.7.0}/docs/superpowers/specs/2026-05-23-devague-working-backwards-design.md +0 -0
  101. {devague-0.6.0 → devague-0.7.0}/sonar-project.properties +0 -0
  102. {devague-0.6.0 → devague-0.7.0}/tests/__init__.py +0 -0
  103. {devague-0.6.0 → devague-0.7.0}/tests/test_cli_affordances.py +0 -0
  104. {devague-0.6.0 → devague-0.7.0}/tests/test_cli_chassis.py +0 -0
  105. {devague-0.6.0 → devague-0.7.0}/tests/test_cli_converge_export.py +0 -0
  106. {devague-0.6.0 → devague-0.7.0}/tests/test_cli_errors.py +0 -0
  107. {devague-0.6.0 → devague-0.7.0}/tests/test_cli_moves.py +0 -0
  108. {devague-0.6.0 → devague-0.7.0}/tests/test_cli_output.py +0 -0
  109. {devague-0.6.0 → devague-0.7.0}/tests/test_cli_question.py +0 -0
  110. {devague-0.6.0 → devague-0.7.0}/tests/test_cli_review.py +0 -0
  111. {devague-0.6.0 → devague-0.7.0}/tests/test_contract.py +0 -0
  112. {devague-0.6.0 → devague-0.7.0}/tests/test_convergence.py +0 -0
  113. {devague-0.6.0 → devague-0.7.0}/tests/test_offline.py +0 -0
  114. {devague-0.6.0 → devague-0.7.0}/tests/test_package.py +0 -0
  115. {devague-0.6.0 → devague-0.7.0}/tests/test_plan_convergence.py +0 -0
  116. {devague-0.6.0 → devague-0.7.0}/tests/test_render_plan.py +0 -0
  117. {devague-0.6.0 → devague-0.7.0}/tests/test_review_loop_integration.py +0 -0
  118. {devague-0.6.0 → devague-0.7.0}/tests/test_review_loop_invariants.py +0 -0
  119. {devague-0.6.0 → devague-0.7.0}/tests/test_spec_to_plan_skill.py +0 -0
  120. {devague-0.6.0 → devague-0.7.0}/tests/test_think_skill.py +0 -0
@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
5
5
  Format follows [Keep a Changelog](https://keepachangelog.com/). This project
6
6
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.7.0] - 2026-05-23
9
+
10
+ ### Added
11
+
12
+ - **Plan persistence hardening (#18).** Plans now carry an integer `schema_version` (`PLAN_SCHEMA_VERSION = 1`), written on save and checked on load — the plan-engine peer of the frame `schema_version` contract (#5). `plan_store.load` fails closed with a clean `DevagueError` (exit 1 + upgrade hint) when a plan declares a newer unsupported schema; pre-0.7.0 plans without the key load silently as the current schema.
13
+ - Loaded-object validation for plans: `Task.origin` / `Task.status` and `PlanRisk.kind` are now validated at construction (via `__post_init__`), so a hand-edited or corrupted plan file surfaces an actionable "malformed plan" `DevagueError` instead of a traceback. (Task/dep/cover *id* cross-references are deliberately not validated at load — coverage and acyclic-dependency checks already run against the live frame in `plan converge`.)
14
+
15
+ ### Changed
16
+
17
+ - `devague/cli/_plans.py` `resolve_plan` now distinguishes an invalid `--plan` slug, a newer-schema plan, a missing plan, and a malformed plan file — each with its own remediation hint (mirroring the frame `resolve`).
18
+
19
+ ### Fixed
20
+
21
+ - **Persistence integrity, both engines (PR #25 review).** `store.load` / `plan_store.load` now reject a file whose embedded `slug` disagrees with the requested slug (previously a tampered file could redirect a later `save` onto a different frame/plan). And `schema_version` is now parsed strictly via the shared `frame.parse_schema_version` — a non-integer value (`1.9`, `true`, `"1"`, `null`) is rejected instead of being silently coerced by `int()` (which truncated `1.9`→`1` and accepted `True`→`1`). Both guards were applied symmetrically to the frame engine to keep the persistence twins aligned.
22
+
23
+ ## [0.6.1] - 2026-05-23
24
+
25
+ ### Fixed
26
+
27
+ - `spec_md` now surfaces `requirement` **claim text** — the last remaining item of #21. Requirements render in a `## Requirements` section with their confirmed honesty conditions nested beneath each claim; honesty conditions on non-requirement claims move to a separate `## Honesty conditions` section (previously every honesty condition was dumped into one flat "Requirements / honesty conditions" list and the requirement claim text never rendered). Re-exported the committed specs to match. Closes #21.
28
+
8
29
  ## [0.6.0] - 2026-05-23
9
30
 
10
31
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devague
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: devague — turns a vague feature idea into a buildable spec, then a buildable plan.
5
5
  Project-URL: Homepage, https://github.com/agentculture/devague
6
6
  Project-URL: Issues, https://github.com/agentculture/devague/issues
@@ -16,14 +16,29 @@ def resolve_plan(slug: str | None) -> Plan:
16
16
  "run 'devague plan new --frame <slug>' or pass --plan <slug>",
17
17
  )
18
18
  try:
19
- return plan_store.load(slug)
19
+ plan_store.validate_slug(slug)
20
20
  except ValueError as exc:
21
21
  raise DevagueError(
22
22
  EXIT_USER_ERROR,
23
23
  f"invalid plan slug: {slug!r}",
24
24
  "slugs are lowercase letters, digits, and hyphens — no path separators",
25
25
  ) from exc
26
+ try:
27
+ return plan_store.load(slug)
28
+ except plan_store.IncompatiblePlanSchemaError as exc:
29
+ raise DevagueError(
30
+ EXIT_USER_ERROR,
31
+ str(exc),
32
+ "this plan was written by a newer devague — upgrade: 'uv tool install -U devague'",
33
+ ) from exc
26
34
  except FileNotFoundError:
27
35
  raise DevagueError(
28
36
  EXIT_USER_ERROR, f"no such plan: {slug}", "run 'devague plan list' to see plans"
29
37
  ) from None
38
+ except ValueError as exc:
39
+ raise DevagueError(
40
+ EXIT_USER_ERROR,
41
+ f"plan {slug!r} is malformed: {exc}",
42
+ "the plan file was hand-edited or corrupted — "
43
+ "fix .devague/plans/<slug>.json or recreate the plan",
44
+ ) from exc
@@ -200,6 +200,24 @@ def to_dict(frame: Frame) -> dict:
200
200
  return dataclasses.asdict(frame)
201
201
 
202
202
 
203
+ def parse_schema_version(d: dict, default: int) -> int:
204
+ """Read a persisted ``schema_version`` strictly.
205
+
206
+ A missing key means a pre-field artifact → treat as ``default`` (back-compat).
207
+ A present value must be a real ``int`` — ``bool`` and non-int types (float,
208
+ str, ``None``) are rejected rather than silently coerced (e.g. plain
209
+ ``int(1.9)`` would truncate to ``1`` and ``int(True)`` would yield ``1``), so
210
+ a malformed version surfaces as a clean error instead of loading as current.
211
+ Shared by the frame and plan engines (the persistence twins).
212
+ """
213
+ if "schema_version" not in d:
214
+ return default
215
+ v = d["schema_version"]
216
+ if isinstance(v, bool) or not isinstance(v, int):
217
+ raise ValueError(f"schema_version must be an integer, got {v!r}")
218
+ return v
219
+
220
+
203
221
  def from_dict(d: dict) -> Frame:
204
222
  claims = [
205
223
  Claim(
@@ -219,7 +237,7 @@ def from_dict(d: dict) -> Frame:
219
237
  slug=d["slug"],
220
238
  title=d["title"],
221
239
  # A 0.4.0 frame predates the field; treat it as the current schema.
222
- schema_version=int(d.get("schema_version", SCHEMA_VERSION)),
240
+ schema_version=parse_schema_version(d, SCHEMA_VERSION),
223
241
  status=d.get("status", "drafting"),
224
242
  created=d.get("created", ""),
225
243
  updated=d.get("updated", ""),
@@ -16,7 +16,18 @@ import dataclasses
16
16
  from dataclasses import dataclass, field
17
17
  from typing import Optional
18
18
 
19
- from devague.frame import SPEC_AFFECTING_KINDS, VAGUENESS_KINDS, Frame
19
+ from devague.frame import (
20
+ ORIGINS,
21
+ SPEC_AFFECTING_KINDS,
22
+ VAGUENESS_KINDS,
23
+ Frame,
24
+ parse_schema_version,
25
+ )
26
+
27
+ # Bump when the persisted plan shape changes incompatibly. `plan_store.load`
28
+ # fails closed on a plan whose schema_version is newer/unknown (see #18; the
29
+ # plan-engine peer of frame.SCHEMA_VERSION).
30
+ PLAN_SCHEMA_VERSION = 1
20
31
 
21
32
  TASK_STATUSES = ("proposed", "confirmed", "rejected")
22
33
  # Risks reuse the frame's open-vagueness kinds: a plan risk is the task-level peer of
@@ -35,6 +46,12 @@ class Task:
35
46
  deps: list[str] = field(default_factory=list) # task ids this task depends on
36
47
  covers: list[str] = field(default_factory=list) # frame claim/honesty ids (c*/h*)
37
48
 
49
+ def __post_init__(self) -> None:
50
+ if self.origin not in ORIGINS:
51
+ raise ValueError(f"unknown task origin: {self.origin!r}")
52
+ if self.status not in TASK_STATUSES:
53
+ raise ValueError(f"unknown task status: {self.status!r}")
54
+
38
55
 
39
56
  @dataclass
40
57
  class PlanRisk:
@@ -43,6 +60,10 @@ class PlanRisk:
43
60
  kind: str # one of RISK_KINDS
44
61
  task_id: Optional[str] = None
45
62
 
63
+ def __post_init__(self) -> None:
64
+ if self.kind not in RISK_KINDS:
65
+ raise ValueError(f"unknown plan risk kind: {self.kind!r}")
66
+
46
67
 
47
68
  @dataclass
48
69
  class CoverageTarget:
@@ -62,6 +83,7 @@ class Plan:
62
83
  slug: str
63
84
  title: str
64
85
  frame_slug: str
86
+ schema_version: int = PLAN_SCHEMA_VERSION
65
87
  status: str = "drafting" # drafting | converged | exported
66
88
  created: str = ""
67
89
  updated: str = ""
@@ -170,6 +192,8 @@ def from_dict(d: dict) -> Plan:
170
192
  slug=d["slug"],
171
193
  title=d["title"],
172
194
  frame_slug=d["frame_slug"],
195
+ # A pre-0.7.0 plan predates the field; treat it as the current schema.
196
+ schema_version=parse_schema_version(d, PLAN_SCHEMA_VERSION),
173
197
  status=d.get("status", "drafting"),
174
198
  created=d.get("created", ""),
175
199
  updated=d.get("updated", ""),
@@ -12,13 +12,17 @@ import json
12
12
  import time
13
13
  from pathlib import Path
14
14
 
15
- from devague.plan import Plan, from_dict, to_dict
15
+ from devague.plan import PLAN_SCHEMA_VERSION, Plan, from_dict, to_dict
16
16
  from devague.store import validate_slug
17
17
 
18
18
  PLANS_DIR = Path(".devague/plans")
19
19
  CURRENT_PLAN = Path(".devague/current_plan")
20
20
 
21
21
 
22
+ class IncompatiblePlanSchemaError(ValueError):
23
+ """A persisted plan declares a schema_version this devague cannot read."""
24
+
25
+
22
26
  def _now() -> str:
23
27
  return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
24
28
 
@@ -45,6 +49,16 @@ def load(slug: str) -> Plan:
45
49
  plan = from_dict(json.loads(p.read_text(encoding="utf-8")))
46
50
  validate_slug(plan.slug) # reject a tampered file whose internal slug escapes
47
51
  validate_slug(plan.frame_slug) # the linked frame slug must be safe to load too
52
+ if plan.slug != slug:
53
+ # The embedded slug drives save() and the current-plan pointer; a file
54
+ # whose internal slug disagrees with its filename could silently redirect
55
+ # a later save onto a different plan, so reject it.
56
+ raise ValueError(f"plan slug mismatch: file {slug!r} declares slug {plan.slug!r}")
57
+ if plan.schema_version > PLAN_SCHEMA_VERSION:
58
+ raise IncompatiblePlanSchemaError(
59
+ f"plan {slug!r} uses schema_version {plan.schema_version}, but this "
60
+ f"devague supports up to {PLAN_SCHEMA_VERSION}; upgrade devague to read it"
61
+ )
48
62
  return plan
49
63
 
50
64
 
@@ -27,8 +27,33 @@ def _before_after(frame: Frame) -> list[str]:
27
27
  return lines + [""]
28
28
 
29
29
 
30
- def _confirmed_honesty(frame: Frame) -> list[str]:
31
- return [h.text for c in frame.claims for h in c.honesty_conditions if h.status == "confirmed"]
30
+ def _requirements_block(frame: Frame) -> list[str]:
31
+ """Requirement claims (confirmed) with their confirmed honesty conditions nested."""
32
+ reqs = [c for c in frame.claims if c.kind == "requirement" and c.status == "confirmed"]
33
+ if not reqs:
34
+ return []
35
+ out = ["## Requirements", ""]
36
+ for c in reqs:
37
+ out.append(f"- {c.text}")
38
+ out += [f" - honesty: {h.text}" for h in c.honesty_conditions if h.status == "confirmed"]
39
+ return out + [""]
40
+
41
+
42
+ def _other_honesty(frame: Frame) -> list[str]:
43
+ """Confirmed honesty conditions on **confirmed** non-requirement claims.
44
+
45
+ The parent claim must be confirmed too: spec-md renders only confirmed claims
46
+ (see ``_texts``), so emitting honesty for a proposed/rejected claim would
47
+ leave an orphan bullet with no parent — inconsistent with the confirmed-only
48
+ export contract.
49
+ """
50
+ return [
51
+ h.text
52
+ for c in frame.claims
53
+ if c.kind != "requirement" and c.status == "confirmed"
54
+ for h in c.honesty_conditions
55
+ if h.status == "confirmed"
56
+ ]
32
57
 
33
58
 
34
59
  def _hard_questions(frame: Frame) -> list[str]:
@@ -49,7 +74,8 @@ def render_spec(frame: Frame) -> str:
49
74
  out += _section("Audience", _texts(frame, "audience"))
50
75
  out += _before_after(frame)
51
76
  out += _section("Why it matters", _texts(frame, "why_it_matters"))
52
- out += _section("Requirements / honesty conditions", _confirmed_honesty(frame))
77
+ out += _requirements_block(frame)
78
+ out += _section("Honesty conditions", _other_honesty(frame))
53
79
  out += _section("Success signals", _texts(frame, "success_signal"))
54
80
  out += _section("Scope / boundaries", _texts(frame, "boundary"))
55
81
  out += _section("Non-goals", _texts(frame, "non_goal"))
@@ -130,6 +130,11 @@ def load(slug: str) -> Frame:
130
130
  raise FileNotFoundError(slug)
131
131
  frame = from_dict(json.loads(p.read_text(encoding="utf-8")))
132
132
  validate_slug(frame.slug) # reject a tampered file whose internal slug escapes
133
+ if frame.slug != slug:
134
+ # The embedded slug drives save() and the current-frame pointer; a file
135
+ # whose internal slug disagrees with its filename could silently redirect
136
+ # a later save onto a different frame, so reject it.
137
+ raise ValueError(f"frame slug mismatch: file {slug!r} declares slug {frame.slug!r}")
133
138
  if frame.schema_version > SCHEMA_VERSION:
134
139
  raise IncompatibleSchemaError(
135
140
  f"frame {slug!r} uses schema_version {frame.schema_version}, but this "
@@ -153,8 +153,9 @@ the exit code is non-zero and `stderr` carries a `hint:` line.
153
153
  **Validation errors** (all raise a clean `DevagueError`, exit code 1): unknown
154
154
  claim kind / origin / status or vagueness kind (rejected at construction);
155
155
  unknown claim or honesty id on `confirm`/`reject`; an invalid `--frame` slug; a
156
- missing frame; a malformed or hand-edited frame file; a frame whose
157
- `schema_version` is too new.
156
+ missing frame; a malformed or hand-edited frame file (including one whose
157
+ embedded slug doesn't match the requested slug, or whose `schema_version` is not
158
+ an integer); a frame whose `schema_version` is too new.
158
159
 
159
160
  ## Anti-fabrication guarantee
160
161
 
@@ -178,3 +179,20 @@ targets derived from a converged frame, Tasks (`origin`/`status` like claims,
178
179
  plus `deps`, `covers`, `acceptance_criteria`), and PlanRisks. It reuses the same
179
180
  structured convergence result, serialized under `ready_for_plan`. See
180
181
  `docs/superpowers/specs/2026-05-23-devague-spec-to-plan-design.md`.
182
+
183
+ Plans carry the same persistence contract as frames. Every plan has an integer
184
+ `schema_version` (currently `1`, `PLAN_SCHEMA_VERSION`), written on save and
185
+ checked on load: `plan_store.load` **fails closed** with a clean `DevagueError`
186
+ (exit code 1, upgrade hint) when a plan declares a `schema_version` newer than
187
+ this devague supports. A pre-0.7.0 plan with no `schema_version` key loads
188
+ silently as the current schema. Loaded `Task.origin` / `Task.status` and
189
+ `PlanRisk.kind` are validated at construction; an invalid value surfaces as a
190
+ "malformed plan" `DevagueError` rather than a traceback. (Task/dep/cover **id**
191
+ cross-references are deliberately *not* validated at load — coverage and acyclic
192
+ dependency checks already run against the live frame in `plan converge`.)
193
+
194
+ Both `load`s also reject a file whose embedded slug disagrees with the requested
195
+ slug (so a tampered file can't silently redirect a later `save`), and parse
196
+ `schema_version` strictly via the shared `frame.parse_schema_version` — a
197
+ non-integer value is rejected rather than coerced. These guards are symmetric
198
+ across the frame and plan persistence twins.
@@ -15,7 +15,22 @@
15
15
 
16
16
  - The user-only confirmation step is devague's whole anti-fabrication guarantee; it must be ergonomic enough to do honestly at scale and support out-of-band review (read proposals somewhere comfortable like NotebookLM or a shared doc, then apply decisions), or it gets skipped or rubber-stamped.
17
17
 
18
- ## Requirements / honesty conditions
18
+ ## Requirements
19
+
20
+ - 'devague review' (and 'devague review --json') emits all proposed claims and proposed honesty conditions, with their ids, without requiring or triggering convergence.
21
+ - honesty: Running 'devague review' on a frame that has NOT converged still exits 0 and lists every proposed claim and proposed honesty condition with ids; it never invokes the convergence gate nor mutates any claim/condition state.
22
+ - Review output is clearly labelled unconfirmed and non-authoritative, visually distinct from the buildable spec that 'export' produces only after convergence.
23
+ - honesty: The review artifact carries an explicit 'nothing confirmed yet — non-authoritative' banner and is written to a path under `.devague/reviews/<slug>.md`, distinct from docs/specs/, so it cannot be mistaken for the buildable spec.
24
+ - 'devague confirm' accepts multiple claim/honesty ids in one invocation, and 'devague reject' likewise.
25
+ - honesty: 'devague confirm a b c' resolves every listed id in a single call (and 'reject a b c' likewise), and the handling of a batch containing an invalid/unknown id follows one defined, tested rule.
26
+ - Open questions / pending user decisions can be written as durable .devague working state (e.g. `.devague/questions/<slug>.md`), treated as uncommitted working state by default unless the user intentionally promotes one into docs.
27
+ - honesty: A pending question the CLI writes persists across runs under `.devague/questions/<slug>.md`, is treated as uncommitted working state by default, and a documented path exists to apply a confirmed decision back into the frame.
28
+ - No command in the review flow auto-confirms LLM-proposed content; every confirm/reject stays an explicit user action.
29
+ - honesty: An automated test asserts that no review-flow command (review, multi-id confirm/reject, any --json path) transitions an llm-origin proposed item to confirmed without an explicit user confirm naming that id.
30
+ - `devague confirm --from-review <file>` applies a reviewed decision set parsed from the review artifact; the artifact 'devague review' emits is documented and round-trippable (review -> edit decisions -> apply).
31
+ - honesty: A review artifact emitted by 'devague review' can be edited with confirm/reject decisions and fed to `devague confirm --from-review <file>` to apply exactly those decisions — proven by a round-trip test — and applying it still auto-confirms nothing the file did not mark confirmed.
32
+
33
+ ## Honesty conditions
19
34
 
20
35
  - At 0.6.0 release the announcement is literally true of the shipped CLI: 'devague review' exists, and a frame full of proposed items can be reviewed then bulk confirmed/rejected in one pass with no path that auto-confirms.
21
36
  - Both audiences are served: the operator gets a single review + bulk-decide path, and the LLM agent's proposals stay visibly 'proposed' until the operator explicitly acts.
@@ -24,12 +39,6 @@
24
39
  - The anti-fabrication guarantee is preserved exactly: ergonomics improve but no proposal becomes authoritative without an explicit user action, asserted by test.
25
40
  - The success signals are verified by the committed test suite, not asserted by hand.
26
41
  - 0.6.0 adds only review/confirm UX; the proposed-vs-confirmed state model and convergence gate from #5/#16 are unchanged — no new claim or condition states are introduced.
27
- - Running 'devague review' on a frame that has NOT converged still exits 0 and lists every proposed claim and proposed honesty condition with ids; it never invokes the convergence gate nor mutates any claim/condition state.
28
- - The review artifact carries an explicit 'nothing confirmed yet — non-authoritative' banner and is written to a path under `.devague/reviews/<slug>.md`, distinct from docs/specs/, so it cannot be mistaken for the buildable spec.
29
- - 'devague confirm a b c' resolves every listed id in a single call (and 'reject a b c' likewise), and the handling of a batch containing an invalid/unknown id follows one defined, tested rule.
30
- - A pending question the CLI writes persists across runs under `.devague/questions/<slug>.md`, is treated as uncommitted working state by default, and a documented path exists to apply a confirmed decision back into the frame.
31
- - An automated test asserts that no review-flow command (review, multi-id confirm/reject, any --json path) transitions an llm-origin proposed item to confirmed without an explicit user confirm naming that id.
32
- - A review artifact emitted by 'devague review' can be edited with confirm/reject decisions and fed to `devague confirm --from-review <file>` to apply exactly those decisions — proven by a round-trip test — and applying it still auto-confirms nothing the file did not mark confirmed.
33
42
 
34
43
  ## Success signals
35
44
 
@@ -15,7 +15,7 @@
15
15
 
16
16
  - An LLM and devague can only coordinate reliably around a contract that is documented, validated, and machine-readable — convergence must mean something, not vibes
17
17
 
18
- ## Requirements / honesty conditions
18
+ ## Honesty conditions
19
19
 
20
20
  - A frame round-trips losslessly (save then load yields an identical frame) including schema_version, the new claim types, and the structured convergence payload; existing 0.4.0 frames still load
21
21
  - Every move accepts and emits documented JSON, and the contract spells out per-move input / output / state-transition / validation-errors so an LLM can drive devague without guessing internal state
@@ -42,7 +42,7 @@
42
42
  - The claim-type vocabulary adds non_goal, requirement, assumption, and decision to the shipped set, each with a documented convergence-gate impact
43
43
  - Every frame carries a schema_version field; load validates by version so existing frames keep loading as the schema grows
44
44
 
45
- ## Non-goals
45
+ ## Scope / boundaries
46
46
 
47
47
  - Not a full PRD generator and not a fixed wizard; the move-driven, deterministic model stays
48
48
  - The local contract requires no GitHub, agents, or external services
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devague"
3
- version = "0.6.0"
3
+ version = "0.7.0"
4
4
  description = "devague — turns a vague feature idea into a buildable spec, then a buildable plan."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -278,6 +278,29 @@ def test_resolve_plan_invalid_slug(tmp_path, monkeypatch, capsys) -> None:
278
278
  assert "invalid plan slug" in capsys.readouterr().err
279
279
 
280
280
 
281
+ def test_resolve_plan_rejects_newer_schema(tmp_path, monkeypatch, capsys) -> None:
282
+ slug = _converged_plan(monkeypatch, tmp_path, capsys)
283
+ p = plan_store.path_for(slug)
284
+ raw = json.loads(p.read_text(encoding="utf-8"))
285
+ raw["schema_version"] = raw["schema_version"] + 99
286
+ p.write_text(json.dumps(raw), encoding="utf-8")
287
+ rc = main(["plan", "show", "--plan", slug])
288
+ assert rc == 1
289
+ err = capsys.readouterr().err
290
+ assert "schema_version" in err and "upgrade" in err
291
+
292
+
293
+ def test_resolve_plan_rejects_malformed_value(tmp_path, monkeypatch, capsys) -> None:
294
+ slug = _converged_plan(monkeypatch, tmp_path, capsys)
295
+ p = plan_store.path_for(slug)
296
+ raw = json.loads(p.read_text(encoding="utf-8"))
297
+ raw["tasks"][0]["origin"] = "alien"
298
+ p.write_text(json.dumps(raw), encoding="utf-8")
299
+ rc = main(["plan", "show", "--plan", slug])
300
+ assert rc == 1
301
+ assert "malformed" in capsys.readouterr().err
302
+
303
+
281
304
  # ── learn / explain ───────────────────────────────────────────────────────────
282
305
  def test_learn_and_explain(tmp_path, monkeypatch, capsys) -> None:
283
306
  monkeypatch.chdir(tmp_path)
@@ -98,6 +98,13 @@ def test_legacy_frame_without_schema_version_loads() -> None:
98
98
  assert f.schema_version == SCHEMA_VERSION
99
99
 
100
100
 
101
+ @pytest.mark.parametrize("bad", [1.9, True, "1", None])
102
+ def test_from_dict_rejects_non_integer_schema_version(bad) -> None:
103
+ # int() would silently coerce 1.9->1 / True->1; a malformed type must raise.
104
+ with pytest.raises(ValueError, match="schema_version"):
105
+ from_dict({"slug": "s", "title": "t", "schema_version": bad})
106
+
107
+
101
108
  def test_dataclasses_validate_enums() -> None:
102
109
  with pytest.raises(ValueError):
103
110
  Claim(id="c1", kind="bogus", text="x")
@@ -3,7 +3,15 @@ from __future__ import annotations
3
3
  import pytest
4
4
 
5
5
  from devague.frame import Frame
6
- from devague.plan import Plan, from_dict, targets_from_frame, to_dict
6
+ from devague.plan import (
7
+ PLAN_SCHEMA_VERSION,
8
+ Plan,
9
+ PlanRisk,
10
+ Task,
11
+ from_dict,
12
+ targets_from_frame,
13
+ to_dict,
14
+ )
7
15
 
8
16
 
9
17
  def _plan() -> Plan:
@@ -60,6 +68,48 @@ def test_set_status_transitions_and_reports_unknown() -> None:
60
68
  assert p.set_status("tX", "confirmed") is False
61
69
 
62
70
 
71
+ def test_plan_carries_schema_version() -> None:
72
+ p = _plan()
73
+ assert p.schema_version == PLAN_SCHEMA_VERSION
74
+ assert to_dict(p)["schema_version"] == PLAN_SCHEMA_VERSION
75
+ assert from_dict(to_dict(p)).schema_version == PLAN_SCHEMA_VERSION
76
+
77
+
78
+ def test_legacy_plan_without_schema_version_loads() -> None:
79
+ # A pre-0.7.0 plan has no schema_version key — it must still load.
80
+ p = from_dict({"slug": "s", "title": "t", "frame_slug": "s", "tasks": []})
81
+ assert p.schema_version == PLAN_SCHEMA_VERSION
82
+
83
+
84
+ def test_dataclasses_validate_enums() -> None:
85
+ with pytest.raises(ValueError):
86
+ Task(id="t1", summary="x", origin="alien")
87
+ with pytest.raises(ValueError):
88
+ Task(id="t1", summary="x", status="weird")
89
+ with pytest.raises(ValueError):
90
+ PlanRisk(id="r1", text="x", kind="nope")
91
+
92
+
93
+ def test_from_dict_rejects_malformed_enum_values() -> None:
94
+ # The load path reconstructs via from_dict, so a hand-edited bad value is caught.
95
+ with pytest.raises(ValueError):
96
+ from_dict(
97
+ {
98
+ "slug": "s",
99
+ "title": "t",
100
+ "frame_slug": "s",
101
+ "tasks": [{"id": "t1", "summary": "x", "origin": "alien"}],
102
+ }
103
+ )
104
+
105
+
106
+ @pytest.mark.parametrize("bad", [1.9, True, "1", None])
107
+ def test_from_dict_rejects_non_integer_schema_version(bad) -> None:
108
+ # int() would silently coerce 1.9->1 / True->1; a malformed type must raise.
109
+ with pytest.raises(ValueError, match="schema_version"):
110
+ from_dict({"slug": "s", "title": "t", "frame_slug": "s", "schema_version": bad})
111
+
112
+
63
113
  def test_roundtrip_preserves_nested_fields() -> None:
64
114
  p = _plan()
65
115
  t = p.add_task("core", origin="llm")
@@ -1,10 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
4
+
3
5
  import pytest
4
6
 
5
7
  from devague import plan_store, store
6
8
  from devague.frame import Frame
7
- from devague.plan import Plan
9
+ from devague.plan import PLAN_SCHEMA_VERSION, Plan
8
10
 
9
11
 
10
12
  def _plan() -> Plan:
@@ -65,3 +67,43 @@ def test_plan_coexists_with_same_slug_frame(tmp_path, monkeypatch) -> None:
65
67
  # Both persist independently in their own directories.
66
68
  assert store.list_slugs() == ["demo"]
67
69
  assert plan_store.list_slugs() == ["demo"]
70
+
71
+
72
+ def test_save_writes_schema_version(tmp_path, monkeypatch) -> None:
73
+ monkeypatch.chdir(tmp_path)
74
+ plan_store.save(_plan())
75
+ raw = json.loads(plan_store.path_for("demo").read_text(encoding="utf-8"))
76
+ assert raw["schema_version"] == PLAN_SCHEMA_VERSION
77
+ assert plan_store.load("demo").schema_version == PLAN_SCHEMA_VERSION
78
+
79
+
80
+ def test_load_rejects_newer_schema_version(tmp_path, monkeypatch) -> None:
81
+ monkeypatch.chdir(tmp_path)
82
+ plan_store.save(_plan())
83
+ p = plan_store.path_for("demo")
84
+ raw = json.loads(p.read_text(encoding="utf-8"))
85
+ raw["schema_version"] = PLAN_SCHEMA_VERSION + 99
86
+ p.write_text(json.dumps(raw), encoding="utf-8")
87
+ with pytest.raises(plan_store.IncompatiblePlanSchemaError, match="schema_version"):
88
+ plan_store.load("demo")
89
+
90
+
91
+ def test_load_legacy_plan_without_schema_version(tmp_path, monkeypatch) -> None:
92
+ monkeypatch.chdir(tmp_path)
93
+ plan_store.PLANS_DIR.mkdir(parents=True, exist_ok=True)
94
+ legacy = {"slug": "demo", "title": "Demo", "frame_slug": "demo", "tasks": []}
95
+ plan_store.path_for("demo").write_text(json.dumps(legacy), encoding="utf-8")
96
+ assert plan_store.load("demo").schema_version == PLAN_SCHEMA_VERSION
97
+
98
+
99
+ def test_load_rejects_slug_mismatch(tmp_path, monkeypatch) -> None:
100
+ # A file under demo.json whose internal slug is a *different* valid slug must
101
+ # be rejected, so a later save() can't be redirected onto another plan.
102
+ monkeypatch.chdir(tmp_path)
103
+ plan_store.save(_plan())
104
+ p = plan_store.path_for("demo")
105
+ raw = json.loads(p.read_text(encoding="utf-8"))
106
+ raw["slug"] = "other"
107
+ p.write_text(json.dumps(raw), encoding="utf-8")
108
+ with pytest.raises(ValueError, match="slug mismatch"):
109
+ plan_store.load("demo")
@@ -79,7 +79,8 @@ def test_spec_md_omits_empty_before_after_section() -> None:
79
79
  def _rich_frame() -> Frame:
80
80
  """A frame exercising the kinds added by the #5/#16 contract."""
81
81
  f = Frame(slug="r", title="Rich Feature")
82
- f.add_claim("announcement", "Shipped", origin="user")
82
+ ann = f.add_claim("announcement", "Shipped", origin="user")
83
+ f.add_honesty(ann, "must be honest", origin="user") # non-requirement honesty
83
84
  f.add_claim("boundary", "scope is X only", origin="user")
84
85
  f.add_claim("non_goal", "does not call an LLM", origin="user")
85
86
  f.add_claim("non_goal", "no external services", origin="user")
@@ -106,6 +107,18 @@ def test_spec_md_renders_non_goal_and_decision() -> None:
106
107
  assert_markdownlint_clean(out)
107
108
 
108
109
 
110
+ def test_spec_md_renders_requirement_claim_text_with_nested_honesty() -> None:
111
+ # #21 remaining item: requirement *claim* text must render, not only its honesty.
112
+ out = render.render(_rich_frame(), "spec-md")
113
+ assert "## Requirements" in out
114
+ assert "- review lists proposed items" in out # the requirement claim text
115
+ assert " - honesty: review never mutates state" in out # nested under it
116
+ # honesty on non-requirement claims still appears, in its own section
117
+ assert "## Honesty conditions" in out
118
+ assert "must be honest" in out # the announcement's honesty (non-requirement)
119
+ assert_markdownlint_clean(out)
120
+
121
+
109
122
  def test_frame_md_renders_non_goal_and_decision() -> None:
110
123
  out = render.render(_rich_frame(), "frame-md")
111
124
  for needle in (
@@ -140,6 +153,18 @@ def test_review_md_empty_when_no_proposals() -> None:
140
153
  assert_markdownlint_clean(out)
141
154
 
142
155
 
156
+ def test_spec_md_omits_honesty_for_unconfirmed_claims() -> None:
157
+ # #24 (Qodo): a proposed/rejected claim carrying a confirmed honesty must not
158
+ # leave an orphan honesty bullet — spec-md renders confirmed claims only.
159
+ f = Frame(slug="o", title="Orphan")
160
+ f.add_claim("announcement", "Shipped", origin="user") # confirmed
161
+ proposed = f.add_claim("audience", "maybe devs", origin="llm") # proposed
162
+ f.add_honesty(proposed, "honesty whose parent is unconfirmed", origin="user")
163
+ out = render.render(f, "spec-md")
164
+ assert "honesty whose parent is unconfirmed" not in out
165
+ assert "maybe devs" not in out # the proposed claim text is omitted too
166
+
167
+
143
168
  def test_unknown_format_raises() -> None:
144
169
  with pytest.raises(DevagueError):
145
170
  render.render(_frame(), "nope")
@@ -106,3 +106,16 @@ def test_load_legacy_frame_without_schema_version(tmp_path, monkeypatch) -> None
106
106
  legacy = {"slug": "demo", "title": "Demo", "claims": [], "open_vagueness": []}
107
107
  store.path_for("demo").write_text(json.dumps(legacy), encoding="utf-8")
108
108
  assert store.load("demo").schema_version == SCHEMA_VERSION
109
+
110
+
111
+ def test_load_rejects_slug_mismatch(tmp_path, monkeypatch) -> None:
112
+ # A file under demo.json whose internal slug is a *different* valid slug must
113
+ # be rejected, so a later save() can't be redirected onto another frame.
114
+ monkeypatch.chdir(tmp_path)
115
+ store.save(Frame(slug="demo", title="Demo"))
116
+ p = store.path_for("demo")
117
+ raw = json.loads(p.read_text(encoding="utf-8"))
118
+ raw["slug"] = "other"
119
+ p.write_text(json.dumps(raw), encoding="utf-8")
120
+ with pytest.raises(ValueError, match="slug mismatch"):
121
+ store.load("demo")
@@ -183,7 +183,7 @@ wheels = [
183
183
 
184
184
  [[package]]
185
185
  name = "devague"
186
- version = "0.6.0"
186
+ version = "0.7.0"
187
187
  source = { editable = "." }
188
188
 
189
189
  [package.dev-dependencies]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes