devague 0.4.1__tar.gz → 0.5.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 (111) hide show
  1. {devague-0.4.1 → devague-0.5.0}/.claude/skills/spec-to-plan/scripts/spec-to-plan.sh +13 -41
  2. {devague-0.4.1 → devague-0.5.0}/.claude/skills/think/SKILL.md +16 -12
  3. {devague-0.4.1 → devague-0.5.0}/.claude/skills/think/scripts/think.sh +13 -47
  4. {devague-0.4.1 → devague-0.5.0}/CHANGELOG.md +14 -0
  5. {devague-0.4.1 → devague-0.5.0}/CLAUDE.md +9 -0
  6. {devague-0.4.1 → devague-0.5.0}/PKG-INFO +1 -1
  7. {devague-0.4.1 → devague-0.5.0}/devague/cli/_commands/capture.py +9 -1
  8. {devague-0.4.1 → devague-0.5.0}/devague/cli/_commands/converge.py +18 -7
  9. {devague-0.4.1 → devague-0.5.0}/devague/cli/_commands/export.py +2 -2
  10. {devague-0.4.1 → devague-0.5.0}/devague/cli/_commands/plan.py +20 -12
  11. {devague-0.4.1 → devague-0.5.0}/devague/cli/_frames.py +16 -1
  12. devague-0.5.0/devague/convergence.py +149 -0
  13. {devague-0.4.1 → devague-0.5.0}/devague/frame.py +45 -1
  14. {devague-0.4.1 → devague-0.5.0}/devague/plan_convergence.py +47 -3
  15. {devague-0.4.1 → devague-0.5.0}/devague/store.py +11 -1
  16. devague-0.5.0/docs/examples/contract-example.json +160 -0
  17. devague-0.5.0/docs/spec-contract.md +180 -0
  18. {devague-0.4.1 → devague-0.5.0}/pyproject.toml +1 -1
  19. {devague-0.4.1 → devague-0.5.0}/tests/test_cli_converge_export.py +7 -7
  20. {devague-0.4.1 → devague-0.5.0}/tests/test_cli_moves.py +27 -0
  21. {devague-0.4.1 → devague-0.5.0}/tests/test_cli_plan.py +2 -2
  22. devague-0.5.0/tests/test_contract.py +129 -0
  23. devague-0.5.0/tests/test_convergence.py +97 -0
  24. devague-0.5.0/tests/test_frame.py +111 -0
  25. devague-0.5.0/tests/test_offline.py +58 -0
  26. {devague-0.4.1 → devague-0.5.0}/tests/test_plan_convergence.py +14 -14
  27. {devague-0.4.1 → devague-0.5.0}/tests/test_store.py +43 -1
  28. {devague-0.4.1 → devague-0.5.0}/uv.lock +1 -1
  29. devague-0.4.1/devague/convergence.py +0 -72
  30. devague-0.4.1/tests/test_convergence.py +0 -62
  31. devague-0.4.1/tests/test_frame.py +0 -42
  32. {devague-0.4.1 → devague-0.5.0}/.claude/skills/cicd/SKILL.md +0 -0
  33. {devague-0.4.1 → devague-0.5.0}/.claude/skills/cicd/scripts/_resolve-nick.sh +0 -0
  34. {devague-0.4.1 → devague-0.5.0}/.claude/skills/cicd/scripts/portability-lint.sh +0 -0
  35. {devague-0.4.1 → devague-0.5.0}/.claude/skills/cicd/scripts/pr-reply.sh +0 -0
  36. {devague-0.4.1 → devague-0.5.0}/.claude/skills/cicd/scripts/pr-status.sh +0 -0
  37. {devague-0.4.1 → devague-0.5.0}/.claude/skills/cicd/scripts/workflow.sh +0 -0
  38. {devague-0.4.1 → devague-0.5.0}/.claude/skills/communicate/SKILL.md +0 -0
  39. {devague-0.4.1 → devague-0.5.0}/.claude/skills/communicate/scripts/fetch-issues.sh +0 -0
  40. {devague-0.4.1 → devague-0.5.0}/.claude/skills/communicate/scripts/mesh-message.sh +0 -0
  41. {devague-0.4.1 → devague-0.5.0}/.claude/skills/communicate/scripts/post-comment.sh +0 -0
  42. {devague-0.4.1 → devague-0.5.0}/.claude/skills/communicate/scripts/post-issue.sh +0 -0
  43. {devague-0.4.1 → devague-0.5.0}/.claude/skills/communicate/scripts/templates/skill-update-brief.md +0 -0
  44. {devague-0.4.1 → devague-0.5.0}/.claude/skills/doc-test-alignment/SKILL.md +0 -0
  45. {devague-0.4.1 → devague-0.5.0}/.claude/skills/doc-test-alignment/scripts/check.sh +0 -0
  46. {devague-0.4.1 → devague-0.5.0}/.claude/skills/run-tests/SKILL.md +0 -0
  47. {devague-0.4.1 → devague-0.5.0}/.claude/skills/run-tests/scripts/test.sh +0 -0
  48. {devague-0.4.1 → devague-0.5.0}/.claude/skills/sonarclaude/SKILL.md +0 -0
  49. {devague-0.4.1 → devague-0.5.0}/.claude/skills/sonarclaude/scripts/sonar.sh +0 -0
  50. {devague-0.4.1 → devague-0.5.0}/.claude/skills/spec-to-plan/SKILL.md +0 -0
  51. {devague-0.4.1 → devague-0.5.0}/.claude/skills/version-bump/SKILL.md +0 -0
  52. {devague-0.4.1 → devague-0.5.0}/.claude/skills/version-bump/scripts/bump.py +0 -0
  53. {devague-0.4.1 → devague-0.5.0}/.claude/skills.local.yaml.example +0 -0
  54. {devague-0.4.1 → devague-0.5.0}/.devague/current_plan +0 -0
  55. {devague-0.4.1 → devague-0.5.0}/.devague/frames/devague-now-ships-a-documented-spec-contract-every.json +0 -0
  56. {devague-0.4.1 → devague-0.5.0}/.devague/plans/devague-now-ships-a-documented-spec-contract-every.json +0 -0
  57. {devague-0.4.1 → devague-0.5.0}/.flake8 +0 -0
  58. {devague-0.4.1 → devague-0.5.0}/.github/workflows/publish.yml +0 -0
  59. {devague-0.4.1 → devague-0.5.0}/.github/workflows/security-checks.yml +0 -0
  60. {devague-0.4.1 → devague-0.5.0}/.github/workflows/tests.yml +0 -0
  61. {devague-0.4.1 → devague-0.5.0}/.gitignore +0 -0
  62. {devague-0.4.1 → devague-0.5.0}/.markdownlint-cli2.yaml +0 -0
  63. {devague-0.4.1 → devague-0.5.0}/.pre-commit-config.yaml +0 -0
  64. {devague-0.4.1 → devague-0.5.0}/LICENSE +0 -0
  65. {devague-0.4.1 → devague-0.5.0}/README.md +0 -0
  66. {devague-0.4.1 → devague-0.5.0}/culture.yaml +0 -0
  67. {devague-0.4.1 → devague-0.5.0}/devague/__init__.py +0 -0
  68. {devague-0.4.1 → devague-0.5.0}/devague/__main__.py +0 -0
  69. {devague-0.4.1 → devague-0.5.0}/devague/cli/__init__.py +0 -0
  70. {devague-0.4.1 → devague-0.5.0}/devague/cli/_commands/__init__.py +0 -0
  71. {devague-0.4.1 → devague-0.5.0}/devague/cli/_commands/confirm.py +0 -0
  72. {devague-0.4.1 → devague-0.5.0}/devague/cli/_commands/explain.py +0 -0
  73. {devague-0.4.1 → devague-0.5.0}/devague/cli/_commands/interrogate.py +0 -0
  74. {devague-0.4.1 → devague-0.5.0}/devague/cli/_commands/learn.py +0 -0
  75. {devague-0.4.1 → devague-0.5.0}/devague/cli/_commands/list_frames.py +0 -0
  76. {devague-0.4.1 → devague-0.5.0}/devague/cli/_commands/new.py +0 -0
  77. {devague-0.4.1 → devague-0.5.0}/devague/cli/_commands/park.py +0 -0
  78. {devague-0.4.1 → devague-0.5.0}/devague/cli/_commands/reject.py +0 -0
  79. {devague-0.4.1 → devague-0.5.0}/devague/cli/_commands/show.py +0 -0
  80. {devague-0.4.1 → devague-0.5.0}/devague/cli/_errors.py +0 -0
  81. {devague-0.4.1 → devague-0.5.0}/devague/cli/_output.py +0 -0
  82. {devague-0.4.1 → devague-0.5.0}/devague/cli/_plans.py +0 -0
  83. {devague-0.4.1 → devague-0.5.0}/devague/plan.py +0 -0
  84. {devague-0.4.1 → devague-0.5.0}/devague/plan_store.py +0 -0
  85. {devague-0.4.1 → devague-0.5.0}/devague/render/__init__.py +0 -0
  86. {devague-0.4.1 → devague-0.5.0}/devague/render/frame_md.py +0 -0
  87. {devague-0.4.1 → devague-0.5.0}/devague/render/plan_md.py +0 -0
  88. {devague-0.4.1 → devague-0.5.0}/devague/render/spec_md.py +0 -0
  89. {devague-0.4.1 → devague-0.5.0}/docs/plans/devague-now-ships-a-documented-spec-contract-every.md +0 -0
  90. {devague-0.4.1 → devague-0.5.0}/docs/reviews/spec-contract-frame-review.md +0 -0
  91. {devague-0.4.1 → devague-0.5.0}/docs/skill-sources.md +0 -0
  92. {devague-0.4.1 → devague-0.5.0}/docs/specs/devague-now-ships-a-documented-spec-contract-every.md +0 -0
  93. {devague-0.4.1 → devague-0.5.0}/docs/superpowers/plans/2026-05-22-specifix-onboarding.md +0 -0
  94. {devague-0.4.1 → devague-0.5.0}/docs/superpowers/plans/2026-05-23-devague-rename.md +0 -0
  95. {devague-0.4.1 → devague-0.5.0}/docs/superpowers/plans/2026-05-23-devague-working-backwards-engine.md +0 -0
  96. {devague-0.4.1 → devague-0.5.0}/docs/superpowers/specs/2026-05-22-specifix-onboarding-design.md +0 -0
  97. {devague-0.4.1 → devague-0.5.0}/docs/superpowers/specs/2026-05-23-devague-spec-to-plan-design.md +0 -0
  98. {devague-0.4.1 → devague-0.5.0}/docs/superpowers/specs/2026-05-23-devague-working-backwards-design.md +0 -0
  99. {devague-0.4.1 → devague-0.5.0}/sonar-project.properties +0 -0
  100. {devague-0.4.1 → devague-0.5.0}/tests/__init__.py +0 -0
  101. {devague-0.4.1 → devague-0.5.0}/tests/test_cli_affordances.py +0 -0
  102. {devague-0.4.1 → devague-0.5.0}/tests/test_cli_chassis.py +0 -0
  103. {devague-0.4.1 → devague-0.5.0}/tests/test_cli_errors.py +0 -0
  104. {devague-0.4.1 → devague-0.5.0}/tests/test_cli_output.py +0 -0
  105. {devague-0.4.1 → devague-0.5.0}/tests/test_package.py +0 -0
  106. {devague-0.4.1 → devague-0.5.0}/tests/test_plan.py +0 -0
  107. {devague-0.4.1 → devague-0.5.0}/tests/test_plan_store.py +0 -0
  108. {devague-0.4.1 → devague-0.5.0}/tests/test_render.py +0 -0
  109. {devague-0.4.1 → devague-0.5.0}/tests/test_render_plan.py +0 -0
  110. {devague-0.4.1 → devague-0.5.0}/tests/test_spec_to_plan_skill.py +0 -0
  111. {devague-0.4.1 → devague-0.5.0}/tests/test_think_skill.py +0 -0
@@ -108,7 +108,6 @@ cmd_status() {
108
108
  python3 - <<'PY'
109
109
  import json
110
110
  import os
111
- import re
112
111
  import sys
113
112
 
114
113
 
@@ -154,52 +153,25 @@ if conv is None:
154
153
  print("next move: devague plan show # inspect the plan")
155
154
  sys.exit(0)
156
155
 
157
- if conv.get("passed"):
156
+ if conv.get("ready_for_plan"):
158
157
  print("convergence: PASSED ✓")
158
+ for w in conv.get("warnings") or []:
159
+ print(f" ⚠ {w}")
159
160
  print("next move: devague plan export # write the buildable plan")
160
161
  sys.exit(0)
161
162
 
162
- missing = conv.get("missing") or []
163
- print(f"convergence: NOT passed — {len(missing)} gap(s):")
164
- for gap in missing:
165
- print(f" - {gap}")
166
-
167
-
168
- def suggest(gap):
169
- if "no tasks yet" in gap:
170
- return 'devague plan task "<summary>" --covers <c*/h*> --accept "<criterion>"'
171
- m = re.search(r"coverage target (\w+) ", gap)
172
- if m:
173
- tid = m.group(1)
174
- return (
175
- f'cover {tid}: devague plan task "<summary>" --covers {tid} --accept "<...>"'
176
- f" (or: devague plan cover <tN> --target {tid})"
177
- )
178
- m = re.search(r"task (t\d+) has no acceptance", gap)
179
- if m:
180
- return f'devague plan accept {m.group(1)} "<acceptance criterion>"'
181
- m = re.search(r"task (t\d+) still proposed", gap)
182
- if m:
183
- tid = m.group(1)
184
- return (
185
- f"this is an LLM proposal — the USER decides:"
186
- f" devague plan confirm {tid} (or: devague plan reject {tid})"
187
- )
188
- m = re.search(r"task (t\d+) depends on unknown task (t\d+)", gap)
189
- if m:
190
- return f"fix {m.group(1)}'s dependency on missing {m.group(2)} (add it, or drop the dep)"
191
- if "dependency cycle" in gap:
192
- return "break the dependency cycle: re-point one task's --dep so the graph is acyclic"
193
- m = re.search(r"blocking risk (r\d+)", gap)
194
- if m:
195
- return f"resolve {m.group(1)}: cover it with a task, or re-record it as non-blocking"
196
- return "devague plan show # inspect and decide"
197
-
198
-
199
- if missing:
163
+ blockers = conv.get("blockers") or []
164
+ print(f"convergence: NOT passed — {len(blockers)} gap(s):")
165
+ for b in blockers:
166
+ print(f" - {b}")
167
+ for w in conv.get("warnings") or []:
168
+ print(f" ⚠ {w}")
169
+
170
+ moves = conv.get("required_next_moves") or []
171
+ if moves:
200
172
  print()
201
173
  print("recommended next move (first gap):")
202
- print(f" {suggest(missing[0])}")
174
+ print(f" {moves[0]}")
203
175
  PY
204
176
  }
205
177
 
@@ -66,27 +66,31 @@ portable resolution and the `status` helper.
66
66
  | `learn` / `explain <move>` | Teach the method / explain one move. |
67
67
 
68
68
  Claim kinds: `announcement`, `audience`, `after_state`, `before_state`,
69
- `why_it_matters`, `boundary`, `success_signal`, `open_question`. Vagueness kinds:
70
- `unknown_nonblocking`, `unknown_blocking`, `out_of_scope`, `follow_up`.
69
+ `why_it_matters`, `boundary`, `success_signal`, `open_question`, `non_goal`,
70
+ `requirement`, `assumption`, `decision`. Vagueness kinds: `unknown_nonblocking`,
71
+ `unknown_blocking`, `out_of_scope`, `follow_up`.
71
72
 
72
73
  These are exactly the kinds the **shipped CLI enforces** (`CLAIM_KINDS` /
73
74
  `VAGUENESS_KINDS` in `devague/frame.py`) — the skill documents the surface as
74
- built, so every command here passes the CLI's `choices=` validation. A fuller
75
- proposed type/state set, plus the formal per-move input/output/transition
76
- contract, is tracked on the CLI side in
77
- [#5](https://github.com/agentculture/devague/issues/5); for the authoritative
75
+ built, so every command here passes the CLI's `choices=` validation. `requirement`
76
+ is spec-affecting (needs a confirmed honesty condition); `non_goal` / `decision`
77
+ are descriptive; an unconfirmed `assumption` is a convergence *warning*, not a
78
+ blocker. The formal entity model, the `(state × origin)` vocabulary, and the
79
+ per-move input/output/transition/error contract are documented in
80
+ [`docs/spec-contract.md`](../../../docs/spec-contract.md) (issue
81
+ [#5](https://github.com/agentculture/devague/issues/5)); for the authoritative
78
82
  live shape of any move, run it with `--json` (or `devague learn --json` /
79
- `devague explain <move>`). When the CLI's contract grows, re-sync this list.
83
+ `devague explain <move>`).
80
84
 
81
85
  ### `status` — the next-move helper
82
86
 
83
87
  `status` is a wrapper-only verb (the CLI has no `status`). It reads
84
88
  `converge --json` + `list --json` and prints where the current frame stands, the
85
- remaining gaps, and the recommended next move derived from the first gap.
86
- `converge --json` currently emits `{passed, missing}`, which is what the helper
87
- consumes; if [#5](https://github.com/agentculture/devague/issues/5) enriches that
88
- payload (e.g. structured `blockers` / `warnings` / `required_next_moves`),
89
- `status` will surface the richer fields then.
89
+ remaining gaps, and the recommended next move. `converge --json` emits the
90
+ structured result `{ready_for_spec, blockers, warnings, parked_items,
91
+ required_next_moves}` (issue [#5](https://github.com/agentculture/devague/issues/5));
92
+ the helper reads `ready_for_spec`, lists the `blockers` and `warnings`, and shows
93
+ `required_next_moves[0]` as the recommended move — no longer deriving it itself.
90
94
 
91
95
  ```text
92
96
  frame: my-feature (1 frame total)
@@ -114,7 +114,6 @@ cmd_status() {
114
114
  python3 - <<'PY'
115
115
  import json
116
116
  import os
117
- import re
118
117
  import sys
119
118
 
120
119
 
@@ -161,58 +160,25 @@ if conv is None:
161
160
  print("next move: devague show # inspect the frame")
162
161
  sys.exit(0)
163
162
 
164
- if conv.get("passed"):
163
+ if conv.get("ready_for_spec"):
165
164
  print("convergence: PASSED ✓")
165
+ for w in conv.get("warnings") or []:
166
+ print(f" ⚠ {w}")
166
167
  print("next move: devague export # write the buildable spec")
167
168
  sys.exit(0)
168
169
 
169
- missing = conv.get("missing") or []
170
- print(f"convergence: NOT passed — {len(missing)} gap(s):")
171
- for gap in missing:
172
- print(f" - {gap}")
173
-
174
-
175
- def suggest(gap):
176
- # Confirmation is a USER-only transition; a plain (user-origin) capture
177
- # is already confirmed, so never imply the agent should confirm its own
178
- # work. Spell out who confirms wherever a confirm is in play.
179
- m = re.search(r"missing confirmed '([a-z_]+)' claim", gap)
180
- if m:
181
- kind = m.group(1)
182
- return (f'devague capture --kind {kind} "<text>"'
183
- f' (a user capture auto-confirms; an --origin llm capture'
184
- f' then needs the USER to confirm it)')
185
- if "before_state" in gap and "why_it_matters" in gap:
186
- return 'devague capture --kind why_it_matters "<text>"'
187
- if "boundary" in gap:
188
- return 'devague capture --kind boundary "<text>"'
189
- if "success_signal" in gap:
190
- return 'devague capture --kind success_signal "<text>"'
191
- m = re.search(r"claim (c\d+) still proposed", gap)
192
- if m:
193
- cid = m.group(1)
194
- return (f'this is an LLM proposal — the USER decides:'
195
- f' devague confirm {cid} (or: devague reject {cid})')
196
- m = re.search(r"claim (c\d+) has no confirmed honesty condition", gap)
197
- if m:
198
- cid = m.group(1)
199
- return (f'devague interrogate {cid} --honesty "<what must be true>"'
200
- f' then the USER runs: devague confirm <hN>')
201
- m = re.search(r"blocking vagueness (v\d+)", gap)
202
- if m:
203
- return (f"resolve {m.group(1)}: capture+confirm the answer, "
204
- f"or re-park it as non-blocking")
205
- m = re.search(r"blocking hard question (q\d+) on (c\d+)", gap)
206
- if m:
207
- return (f"resolve {m.group(1)} on {m.group(2)}: answer it, then "
208
- f"capture/confirm the resulting claim")
209
- return "devague show # inspect and decide"
210
-
211
-
212
- if missing:
170
+ blockers = conv.get("blockers") or []
171
+ print(f"convergence: NOT passed — {len(blockers)} gap(s):")
172
+ for b in blockers:
173
+ print(f" - {b}")
174
+ for w in conv.get("warnings") or []:
175
+ print(f" ⚠ {w}")
176
+
177
+ moves = conv.get("required_next_moves") or []
178
+ if moves:
213
179
  print()
214
180
  print("recommended next move (first gap):")
215
- print(f" {suggest(missing[0])}")
181
+ print(f" {moves[0]}")
216
182
  PY
217
183
  }
218
184
 
@@ -5,6 +5,20 @@ 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.5.0] - 2026-05-23
9
+
10
+ ### Added
11
+
12
+ - Spec contract (#5): claim kinds non_goal / requirement / assumption / decision, each with a documented convergence-gate role (requirement is spec-affecting; non_goal/decision are descriptive; an unconfirmed assumption is a warning, not a blocker).
13
+ - Every frame carries a fail-closed schema_version: written on save, validated on load (a newer/unknown version is rejected with an actionable error); existing 0.4.0 frames still load.
14
+ - docs/spec-contract.md — the documented source of truth for the entity model, the (state x origin) vocabulary, the structured convergence result, and the per-move input/output/transition/validation-error contract — plus a test-verified worked example at docs/examples/contract-example.json.
15
+ - Contract test suite: claim provenance, honesty-condition confirmation, parking vagueness, structured convergence failure, lossless round-trip, schema versioning, and an offline-operation guarantee (no networking imports; a full session runs with sockets stubbed).
16
+
17
+ ### Changed
18
+
19
+ - BREAKING: converge --json now emits the structured result {ready_for_spec, blockers, warnings, parked_items, required_next_moves} (plans: ready_for_plan) instead of {passed, missing}. The /think and /spec-to-plan status helpers were updated in the same change; required_next_moves is now derived by the CLI. capture --json now includes origin.
20
+ - Frame loading raises distinct, actionable DevagueErrors (newer schema -> upgrade; malformed/hand-edited frame -> fix hint) instead of a generic 'invalid slug'.
21
+
8
22
  ## [0.4.1] - 2026-05-23
9
23
 
10
24
  ### Added
@@ -4,6 +4,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
4
4
 
5
5
  ## Status
6
6
 
7
+ **Spec contract landed (#5).** The entity model is documented in
8
+ `docs/spec-contract.md` (the source of truth for kinds, the `(state × origin)`
9
+ vocabulary, the structured convergence result, and the per-move I/O contract).
10
+ Claim kinds now include `non_goal` / `requirement` / `assumption` / `decision`;
11
+ every frame carries a fail-closed `schema_version`; and `converge --json` emits
12
+ the structured `{ready_for_spec, blockers, warnings, parked_items,
13
+ required_next_moves}` (plans: `ready_for_plan`) — a hard break from the old
14
+ `{passed, missing}`.
15
+
7
16
  **Spec→plan engine landed (v0.4.0).** Both deterministic engines now ship.
8
17
  The **frame engine** (idea→spec) — Frame domain model, JSON store, convergence
9
18
  gate, renderer registry, and the flat moves `new` / `capture` / `interrogate` /
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devague
3
- Version: 0.4.1
3
+ Version: 0.5.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
@@ -15,7 +15,15 @@ def cmd_capture(args: argparse.Namespace) -> int:
15
15
  claim = frame.add_claim(args.kind, args.text, origin=args.origin)
16
16
  store.save(frame)
17
17
  if getattr(args, "json", False):
18
- emit_result({"id": claim.id, "kind": claim.kind, "status": claim.status}, json_mode=True)
18
+ emit_result(
19
+ {
20
+ "id": claim.id,
21
+ "kind": claim.kind,
22
+ "origin": claim.origin,
23
+ "status": claim.status,
24
+ },
25
+ json_mode=True,
26
+ )
19
27
  else:
20
28
  emit_result(f"captured {claim.id} ({claim.kind}, {claim.status})", json_mode=False)
21
29
  return 0
@@ -13,20 +13,31 @@ from devague.convergence import evaluate
13
13
  def cmd_converge(args: argparse.Namespace) -> int:
14
14
  frame = resolve(args.frame)
15
15
  result = evaluate(frame)
16
- if result.passed and frame.status == "drafting":
16
+ if result.ready and frame.status == "drafting":
17
17
  frame.status = "converged"
18
18
  store.save(frame)
19
- elif not result.passed and frame.status == "converged":
19
+ elif not result.ready and frame.status == "converged":
20
20
  frame.status = "drafting"
21
21
  store.save(frame)
22
22
  if getattr(args, "json", False):
23
- emit_result({"passed": result.passed, "missing": result.missing}, json_mode=True)
24
- elif result.passed:
25
- emit_result("converged ✓", json_mode=False)
26
- else:
27
23
  emit_result(
28
- "not converged:\n" + "\n".join(f" - {m}" for m in result.missing), json_mode=False
24
+ {
25
+ "ready_for_spec": result.ready,
26
+ "blockers": result.blockers,
27
+ "warnings": result.warnings,
28
+ "parked_items": result.parked_items,
29
+ "required_next_moves": result.required_next_moves,
30
+ },
31
+ json_mode=True,
29
32
  )
33
+ elif result.ready:
34
+ msg = "converged ✓"
35
+ if result.warnings:
36
+ msg += "\nwarnings:\n" + "\n".join(f" - {w}" for w in result.warnings)
37
+ emit_result(msg, json_mode=False)
38
+ else:
39
+ lines = "\n".join(f" - {b}" for b in result.blockers)
40
+ emit_result("not converged:\n" + lines, json_mode=False)
30
41
  return 0
31
42
 
32
43
 
@@ -17,11 +17,11 @@ SPECS_DIR = Path("docs/specs")
17
17
  def cmd_export(args: argparse.Namespace) -> int:
18
18
  frame = resolve(args.frame)
19
19
  result = evaluate(frame)
20
- if not result.passed:
20
+ if not result.ready:
21
21
  raise DevagueError(
22
22
  EXIT_USER_ERROR,
23
23
  "frame has not converged; cannot export",
24
- "resolve: " + "; ".join(result.missing),
24
+ "resolve: " + "; ".join(result.blockers),
25
25
  )
26
26
  text = render.render(frame, args.format)
27
27
  SPECS_DIR.mkdir(parents=True, exist_ok=True)
@@ -69,7 +69,7 @@ def _live(plan: Plan):
69
69
  """Re-load the source frame and re-derive targets; guard against frame drift."""
70
70
  frame = _load_source_frame(plan.frame_slug)
71
71
  fres = evaluate_frame(frame)
72
- if not fres.passed:
72
+ if not fres.ready:
73
73
  raise DevagueError(
74
74
  EXIT_USER_ERROR,
75
75
  f"source frame '{frame.slug}' has regressed below convergence",
@@ -98,11 +98,11 @@ def _require_target(plan: Plan, target_id: str) -> None:
98
98
  def cmd_plan_new(args: argparse.Namespace) -> int:
99
99
  frame = resolve_frame(args.frame)
100
100
  result = evaluate_frame(frame)
101
- if not result.passed:
101
+ if not result.ready:
102
102
  raise DevagueError(
103
103
  EXIT_USER_ERROR,
104
104
  f"frame '{frame.slug}' has not converged; cannot start a plan",
105
- "resolve: " + "; ".join(result.missing),
105
+ "resolve: " + "; ".join(result.blockers),
106
106
  )
107
107
  if plan_store.path_for(frame.slug).exists():
108
108
  raise DevagueError(
@@ -234,19 +234,27 @@ def cmd_plan_converge(args: argparse.Namespace) -> int:
234
234
  _frame, targets = _live(plan)
235
235
  plan.targets = targets # refresh the snapshot from the live frame
236
236
  result = evaluate_plan(plan, targets=targets)
237
- if result.passed and plan.status == "drafting":
237
+ if result.ready and plan.status == "drafting":
238
238
  plan.status = "converged"
239
- elif not result.passed and plan.status == "converged":
239
+ elif not result.ready and plan.status == "converged":
240
240
  plan.status = "drafting"
241
241
  plan_store.save(plan)
242
242
  if getattr(args, "json", False):
243
- emit_result({"passed": result.passed, "missing": result.missing}, json_mode=True)
244
- elif result.passed:
245
- emit_result("converged ✓", json_mode=False)
246
- else:
247
243
  emit_result(
248
- "not converged:\n" + "\n".join(f" - {m}" for m in result.missing), json_mode=False
244
+ {
245
+ "ready_for_plan": result.ready,
246
+ "blockers": result.blockers,
247
+ "warnings": result.warnings,
248
+ "parked_items": result.parked_items,
249
+ "required_next_moves": result.required_next_moves,
250
+ },
251
+ json_mode=True,
249
252
  )
253
+ elif result.ready:
254
+ emit_result("converged ✓", json_mode=False)
255
+ else:
256
+ lines = "\n".join(f" - {b}" for b in result.blockers)
257
+ emit_result("not converged:\n" + lines, json_mode=False)
250
258
  return 0
251
259
 
252
260
 
@@ -255,11 +263,11 @@ def cmd_plan_export(args: argparse.Namespace) -> int:
255
263
  frame, targets = _live(plan)
256
264
  plan.targets = targets
257
265
  result = evaluate_plan(plan, targets=targets)
258
- if not result.passed:
266
+ if not result.ready:
259
267
  raise DevagueError(
260
268
  EXIT_USER_ERROR,
261
269
  "plan has not converged; cannot export",
262
- "resolve: " + "; ".join(result.missing),
270
+ "resolve: " + "; ".join(result.blockers),
263
271
  )
264
272
  plan.status = "exported"
265
273
  text = plan_md.render_plan(plan, frame)
@@ -16,14 +16,29 @@ def resolve(slug: str | None) -> Frame:
16
16
  "run 'devague new \"<announcement>\"' or pass --frame <slug>",
17
17
  )
18
18
  try:
19
- return store.load(slug)
19
+ store.validate_slug(slug)
20
20
  except ValueError as exc:
21
21
  raise DevagueError(
22
22
  EXIT_USER_ERROR,
23
23
  f"invalid frame slug: {slug!r}",
24
24
  "slugs are lowercase letters, digits, and hyphens — no path separators",
25
25
  ) from exc
26
+ try:
27
+ return store.load(slug)
28
+ except store.IncompatibleSchemaError as exc:
29
+ raise DevagueError(
30
+ EXIT_USER_ERROR,
31
+ str(exc),
32
+ "this frame 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 frame: {slug}", "run 'devague list' to see frames"
29
37
  ) from None
38
+ except ValueError as exc:
39
+ raise DevagueError(
40
+ EXIT_USER_ERROR,
41
+ f"frame {slug!r} is malformed: {exc}",
42
+ "the frame file was hand-edited or corrupted — "
43
+ "fix .devague/frames/<slug>.json or recreate the frame",
44
+ ) from exc
@@ -0,0 +1,149 @@
1
+ """The convergence gate: is a frame solid enough to export a buildable spec?"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+
8
+ from devague.frame import SPEC_AFFECTING_KINDS, Frame
9
+
10
+
11
+ @dataclass
12
+ class ConvergenceResult:
13
+ """Structured convergence verdict, shared by the frame and plan engines.
14
+
15
+ ``ready`` is the gate (no blockers). The CLI serializes it under an
16
+ engine-specific key (``ready_for_spec`` for frames, ``ready_for_plan`` for
17
+ plans). ``blockers`` hold convergence back; ``warnings`` do not;
18
+ ``parked_items`` are tracked-but-non-blocking unknowns; ``required_next_moves``
19
+ are derived from the blockers so an operator knows what to do next.
20
+ """
21
+
22
+ ready: bool
23
+ blockers: list[str] = field(default_factory=list)
24
+ warnings: list[str] = field(default_factory=list)
25
+ parked_items: list[str] = field(default_factory=list)
26
+ required_next_moves: list[str] = field(default_factory=list)
27
+
28
+
29
+ def _missing_required_kinds(confirmed_kinds: set[str]) -> list[str]:
30
+ """Required confirmed claims for an honest announcement frame."""
31
+ missing = [
32
+ f"missing confirmed '{required}' claim"
33
+ for required in ("announcement", "audience", "after_state")
34
+ if required not in confirmed_kinds
35
+ ]
36
+ if "before_state" not in confirmed_kinds and "why_it_matters" not in confirmed_kinds:
37
+ missing.append("missing 'before_state' or 'why_it_matters' claim")
38
+ if "boundary" not in confirmed_kinds:
39
+ missing.append("missing a 'boundary' / non-goal claim")
40
+ if "success_signal" not in confirmed_kinds:
41
+ missing.append("missing a 'success_signal' claim")
42
+ return missing
43
+
44
+
45
+ def _missing_claim_resolution(frame: Frame, confirmed: list) -> list[str]:
46
+ """No spec-affecting claim left proposed; each confirmed one is pressure-tested."""
47
+ missing = [
48
+ f"claim {c.id} still proposed (confirm or reject it)"
49
+ for c in frame.claims
50
+ if c.kind in SPEC_AFFECTING_KINDS and c.status == "proposed"
51
+ ]
52
+ missing += [
53
+ f"claim {c.id} has no confirmed honesty condition"
54
+ for c in confirmed
55
+ if c.kind in SPEC_AFFECTING_KINDS
56
+ and not any(h.status == "confirmed" for h in c.honesty_conditions)
57
+ ]
58
+ return missing
59
+
60
+
61
+ def _missing_open_uncertainty(frame: Frame) -> list[str]:
62
+ """No blocking vagueness or unresolved blocking hard question remains."""
63
+ missing = [
64
+ f"blocking vagueness {v.id} unresolved"
65
+ for v in frame.open_vagueness
66
+ if v.kind == "unknown_blocking"
67
+ ]
68
+ missing += [
69
+ f"blocking hard question {q.id} on {c.id} unresolved"
70
+ for c in frame.claims
71
+ for q in c.hard_questions
72
+ if q.blocking and not q.resolved
73
+ ]
74
+ return missing
75
+
76
+
77
+ def _assumption_warnings(frame: Frame) -> list[str]:
78
+ """Unconfirmed assumptions are soft: a warning, never a blocker (#5, h14)."""
79
+ return [
80
+ f"assumption {c.id} is unconfirmed — confirm it or it ships as a stated assumption"
81
+ for c in frame.claims
82
+ if c.kind == "assumption" and c.status != "confirmed"
83
+ ]
84
+
85
+
86
+ def _parked_items(frame: Frame) -> list[str]:
87
+ """Tracked, non-blocking open vagueness (everything but unknown_blocking)."""
88
+ return [f"[{v.kind}] {v.text}" for v in frame.open_vagueness if v.kind != "unknown_blocking"]
89
+
90
+
91
+ def suggest_move(blocker: str) -> str:
92
+ """Map a single blocker to the recommended next devague move.
93
+
94
+ Confirmation is a USER-only transition, so any confirm-related move spells
95
+ out who confirms — the agent must never imply it should confirm its own work.
96
+ """
97
+ m = re.search(r"missing confirmed '([a-z_]+)' claim", blocker)
98
+ if m:
99
+ kind = m.group(1)
100
+ return (
101
+ f'devague capture --kind {kind} "<text>" (a user capture '
102
+ f"auto-confirms; an --origin llm capture then needs the USER to confirm it)"
103
+ )
104
+ if "before_state" in blocker and "why_it_matters" in blocker:
105
+ return 'devague capture --kind why_it_matters "<text>"'
106
+ if "boundary" in blocker:
107
+ return 'devague capture --kind boundary "<text>"'
108
+ if "success_signal" in blocker:
109
+ return 'devague capture --kind success_signal "<text>"'
110
+ m = re.search(r"claim (c\d+) still proposed", blocker)
111
+ if m:
112
+ cid = m.group(1)
113
+ return (
114
+ f"this is an LLM proposal — the USER decides: devague confirm {cid} (or reject {cid})"
115
+ )
116
+ m = re.search(r"claim (c\d+) has no confirmed honesty condition", blocker)
117
+ if m:
118
+ cid = m.group(1)
119
+ return (
120
+ f'devague interrogate {cid} --honesty "<what must be true>"'
121
+ f" then USER: devague confirm <hN>"
122
+ )
123
+ m = re.search(r"blocking vagueness (v\d+)", blocker)
124
+ if m:
125
+ return f"resolve {m.group(1)}: capture+confirm the answer, or re-park it as non-blocking"
126
+ m = re.search(r"blocking hard question (q\d+) on (c\d+)", blocker)
127
+ if m:
128
+ return (
129
+ f"resolve {m.group(1)} on {m.group(2)}: answer it, then "
130
+ f"capture/confirm the resulting claim"
131
+ )
132
+ return "devague show # inspect and decide"
133
+
134
+
135
+ def evaluate(frame: Frame) -> ConvergenceResult:
136
+ confirmed = [c for c in frame.claims if c.status == "confirmed"]
137
+ confirmed_kinds = {c.kind for c in confirmed}
138
+ blockers = (
139
+ _missing_required_kinds(confirmed_kinds)
140
+ + _missing_claim_resolution(frame, confirmed)
141
+ + _missing_open_uncertainty(frame)
142
+ )
143
+ return ConvergenceResult(
144
+ ready=not blockers,
145
+ blockers=blockers,
146
+ warnings=_assumption_warnings(frame),
147
+ parked_items=_parked_items(frame),
148
+ required_next_moves=[suggest_move(b) for b in blockers],
149
+ )