devague 0.6.1__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.
- {devague-0.6.1 → devague-0.7.0}/CHANGELOG.md +15 -0
- {devague-0.6.1 → devague-0.7.0}/PKG-INFO +1 -1
- {devague-0.6.1 → devague-0.7.0}/devague/cli/_plans.py +16 -1
- {devague-0.6.1 → devague-0.7.0}/devague/frame.py +19 -1
- {devague-0.6.1 → devague-0.7.0}/devague/plan.py +25 -1
- {devague-0.6.1 → devague-0.7.0}/devague/plan_store.py +15 -1
- {devague-0.6.1 → devague-0.7.0}/devague/store.py +5 -0
- {devague-0.6.1 → devague-0.7.0}/docs/spec-contract.md +20 -2
- {devague-0.6.1 → devague-0.7.0}/pyproject.toml +1 -1
- {devague-0.6.1 → devague-0.7.0}/tests/test_cli_plan.py +23 -0
- {devague-0.6.1 → devague-0.7.0}/tests/test_frame.py +7 -0
- {devague-0.6.1 → devague-0.7.0}/tests/test_plan.py +51 -1
- {devague-0.6.1 → devague-0.7.0}/tests/test_plan_store.py +43 -1
- {devague-0.6.1 → devague-0.7.0}/tests/test_store.py +13 -0
- {devague-0.6.1 → devague-0.7.0}/uv.lock +1 -1
- {devague-0.6.1 → devague-0.7.0}/.claude/skills/cicd/SKILL.md +0 -0
- {devague-0.6.1 → devague-0.7.0}/.claude/skills/cicd/scripts/_resolve-nick.sh +0 -0
- {devague-0.6.1 → devague-0.7.0}/.claude/skills/cicd/scripts/portability-lint.sh +0 -0
- {devague-0.6.1 → devague-0.7.0}/.claude/skills/cicd/scripts/pr-reply.sh +0 -0
- {devague-0.6.1 → devague-0.7.0}/.claude/skills/cicd/scripts/pr-status.sh +0 -0
- {devague-0.6.1 → devague-0.7.0}/.claude/skills/cicd/scripts/workflow.sh +0 -0
- {devague-0.6.1 → devague-0.7.0}/.claude/skills/communicate/SKILL.md +0 -0
- {devague-0.6.1 → devague-0.7.0}/.claude/skills/communicate/scripts/fetch-issues.sh +0 -0
- {devague-0.6.1 → devague-0.7.0}/.claude/skills/communicate/scripts/mesh-message.sh +0 -0
- {devague-0.6.1 → devague-0.7.0}/.claude/skills/communicate/scripts/post-comment.sh +0 -0
- {devague-0.6.1 → devague-0.7.0}/.claude/skills/communicate/scripts/post-issue.sh +0 -0
- {devague-0.6.1 → devague-0.7.0}/.claude/skills/communicate/scripts/templates/skill-update-brief.md +0 -0
- {devague-0.6.1 → devague-0.7.0}/.claude/skills/doc-test-alignment/SKILL.md +0 -0
- {devague-0.6.1 → devague-0.7.0}/.claude/skills/doc-test-alignment/scripts/check.sh +0 -0
- {devague-0.6.1 → devague-0.7.0}/.claude/skills/run-tests/SKILL.md +0 -0
- {devague-0.6.1 → devague-0.7.0}/.claude/skills/run-tests/scripts/test.sh +0 -0
- {devague-0.6.1 → devague-0.7.0}/.claude/skills/sonarclaude/SKILL.md +0 -0
- {devague-0.6.1 → devague-0.7.0}/.claude/skills/sonarclaude/scripts/sonar.sh +0 -0
- {devague-0.6.1 → devague-0.7.0}/.claude/skills/spec-to-plan/SKILL.md +0 -0
- {devague-0.6.1 → devague-0.7.0}/.claude/skills/spec-to-plan/scripts/spec-to-plan.sh +0 -0
- {devague-0.6.1 → devague-0.7.0}/.claude/skills/think/SKILL.md +0 -0
- {devague-0.6.1 → devague-0.7.0}/.claude/skills/think/scripts/think.sh +0 -0
- {devague-0.6.1 → devague-0.7.0}/.claude/skills/version-bump/SKILL.md +0 -0
- {devague-0.6.1 → devague-0.7.0}/.claude/skills/version-bump/scripts/bump.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/.claude/skills.local.yaml.example +0 -0
- {devague-0.6.1 → devague-0.7.0}/.devague/current_plan +0 -0
- {devague-0.6.1 → devague-0.7.0}/.devague/frames/devague-0-6-0-ships-the-human-review-loop-devague.json +0 -0
- {devague-0.6.1 → devague-0.7.0}/.devague/frames/devague-now-ships-a-documented-spec-contract-every.json +0 -0
- {devague-0.6.1 → devague-0.7.0}/.devague/plans/devague-0-6-0-ships-the-human-review-loop-devague.json +0 -0
- {devague-0.6.1 → devague-0.7.0}/.devague/plans/devague-now-ships-a-documented-spec-contract-every.json +0 -0
- {devague-0.6.1 → devague-0.7.0}/.flake8 +0 -0
- {devague-0.6.1 → devague-0.7.0}/.github/workflows/publish.yml +0 -0
- {devague-0.6.1 → devague-0.7.0}/.github/workflows/security-checks.yml +0 -0
- {devague-0.6.1 → devague-0.7.0}/.github/workflows/tests.yml +0 -0
- {devague-0.6.1 → devague-0.7.0}/.gitignore +0 -0
- {devague-0.6.1 → devague-0.7.0}/.markdownlint-cli2.yaml +0 -0
- {devague-0.6.1 → devague-0.7.0}/.pre-commit-config.yaml +0 -0
- {devague-0.6.1 → devague-0.7.0}/CLAUDE.md +0 -0
- {devague-0.6.1 → devague-0.7.0}/LICENSE +0 -0
- {devague-0.6.1 → devague-0.7.0}/README.md +0 -0
- {devague-0.6.1 → devague-0.7.0}/culture.yaml +0 -0
- {devague-0.6.1 → devague-0.7.0}/devague/__init__.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/devague/__main__.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/devague/cli/__init__.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/devague/cli/_commands/__init__.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/devague/cli/_commands/capture.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/devague/cli/_commands/confirm.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/devague/cli/_commands/converge.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/devague/cli/_commands/explain.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/devague/cli/_commands/export.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/devague/cli/_commands/interrogate.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/devague/cli/_commands/learn.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/devague/cli/_commands/list_frames.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/devague/cli/_commands/new.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/devague/cli/_commands/park.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/devague/cli/_commands/plan.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/devague/cli/_commands/question.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/devague/cli/_commands/reject.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/devague/cli/_commands/review.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/devague/cli/_commands/show.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/devague/cli/_errors.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/devague/cli/_frames.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/devague/cli/_output.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/devague/convergence.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/devague/plan_convergence.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/devague/questions_io.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/devague/render/__init__.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/devague/render/frame_md.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/devague/render/plan_md.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/devague/render/review_md.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/devague/render/spec_md.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/docs/examples/contract-example.json +0 -0
- {devague-0.6.1 → devague-0.7.0}/docs/plans/devague-0-6-0-ships-the-human-review-loop-devague.md +0 -0
- {devague-0.6.1 → devague-0.7.0}/docs/plans/devague-now-ships-a-documented-spec-contract-every.md +0 -0
- {devague-0.6.1 → devague-0.7.0}/docs/reviews/spec-contract-frame-review.md +0 -0
- {devague-0.6.1 → devague-0.7.0}/docs/skill-sources.md +0 -0
- {devague-0.6.1 → devague-0.7.0}/docs/specs/devague-0-6-0-ships-the-human-review-loop-devague.md +0 -0
- {devague-0.6.1 → devague-0.7.0}/docs/specs/devague-now-ships-a-documented-spec-contract-every.md +0 -0
- {devague-0.6.1 → devague-0.7.0}/docs/superpowers/plans/2026-05-22-specifix-onboarding.md +0 -0
- {devague-0.6.1 → devague-0.7.0}/docs/superpowers/plans/2026-05-23-devague-rename.md +0 -0
- {devague-0.6.1 → devague-0.7.0}/docs/superpowers/plans/2026-05-23-devague-working-backwards-engine.md +0 -0
- {devague-0.6.1 → devague-0.7.0}/docs/superpowers/specs/2026-05-22-specifix-onboarding-design.md +0 -0
- {devague-0.6.1 → devague-0.7.0}/docs/superpowers/specs/2026-05-23-devague-spec-to-plan-design.md +0 -0
- {devague-0.6.1 → devague-0.7.0}/docs/superpowers/specs/2026-05-23-devague-working-backwards-design.md +0 -0
- {devague-0.6.1 → devague-0.7.0}/sonar-project.properties +0 -0
- {devague-0.6.1 → devague-0.7.0}/tests/__init__.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/tests/test_cli_affordances.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/tests/test_cli_chassis.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/tests/test_cli_converge_export.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/tests/test_cli_errors.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/tests/test_cli_moves.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/tests/test_cli_output.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/tests/test_cli_question.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/tests/test_cli_review.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/tests/test_contract.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/tests/test_convergence.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/tests/test_offline.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/tests/test_package.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/tests/test_plan_convergence.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/tests/test_render.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/tests/test_render_plan.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/tests/test_review_loop_integration.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/tests/test_review_loop_invariants.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/tests/test_spec_to_plan_skill.py +0 -0
- {devague-0.6.1 → devague-0.7.0}/tests/test_think_skill.py +0 -0
|
@@ -5,6 +5,21 @@ 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
|
+
|
|
8
23
|
## [0.6.1] - 2026-05-23
|
|
9
24
|
|
|
10
25
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devague
|
|
3
|
-
Version: 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
|
-
|
|
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=
|
|
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
|
|
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
|
|
|
@@ -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
|
|
157
|
-
`schema_version` is
|
|
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.
|
|
@@ -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
|
|
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")
|
|
@@ -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")
|
|
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
|
{devague-0.6.1 → devague-0.7.0}/.claude/skills/communicate/scripts/templates/skill-update-brief.md
RENAMED
|
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
|
|
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
|
|
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
|
{devague-0.6.1 → devague-0.7.0}/docs/plans/devague-0-6-0-ships-the-human-review-loop-devague.md
RENAMED
|
File without changes
|
{devague-0.6.1 → devague-0.7.0}/docs/plans/devague-now-ships-a-documented-spec-contract-every.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{devague-0.6.1 → devague-0.7.0}/docs/specs/devague-0-6-0-ships-the-human-review-loop-devague.md
RENAMED
|
File without changes
|
{devague-0.6.1 → devague-0.7.0}/docs/specs/devague-now-ships-a-documented-spec-contract-every.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{devague-0.6.1 → devague-0.7.0}/docs/superpowers/specs/2026-05-22-specifix-onboarding-design.md
RENAMED
|
File without changes
|
{devague-0.6.1 → devague-0.7.0}/docs/superpowers/specs/2026-05-23-devague-spec-to-plan-design.md
RENAMED
|
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
|