devague 0.5.1__tar.gz → 0.6.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 (123) hide show
  1. {devague-0.5.1 → devague-0.6.0}/.claude/skills/think/SKILL.md +3 -1
  2. {devague-0.5.1 → devague-0.6.0}/CHANGELOG.md +15 -0
  3. {devague-0.5.1 → devague-0.6.0}/CLAUDE.md +12 -2
  4. {devague-0.5.1 → devague-0.6.0}/PKG-INFO +45 -1
  5. devague-0.6.0/README.md +85 -0
  6. {devague-0.5.1 → devague-0.6.0}/devague/cli/__init__.py +4 -0
  7. devague-0.6.0/devague/cli/_commands/confirm.py +96 -0
  8. devague-0.6.0/devague/cli/_commands/question.py +80 -0
  9. devague-0.6.0/devague/cli/_commands/reject.py +15 -0
  10. devague-0.6.0/devague/cli/_commands/review.py +63 -0
  11. devague-0.6.0/devague/questions_io.py +64 -0
  12. {devague-0.5.1 → devague-0.6.0}/devague/render/__init__.py +2 -0
  13. devague-0.6.0/devague/render/review_md.py +76 -0
  14. {devague-0.5.1 → devague-0.6.0}/devague/store.py +52 -0
  15. {devague-0.5.1 → devague-0.6.0}/pyproject.toml +1 -1
  16. {devague-0.5.1 → devague-0.6.0}/tests/test_cli_moves.py +32 -0
  17. devague-0.6.0/tests/test_cli_question.py +70 -0
  18. devague-0.6.0/tests/test_cli_review.py +153 -0
  19. {devague-0.5.1 → devague-0.6.0}/tests/test_render.py +20 -0
  20. devague-0.6.0/tests/test_review_loop_integration.py +66 -0
  21. devague-0.6.0/tests/test_review_loop_invariants.py +72 -0
  22. {devague-0.5.1 → devague-0.6.0}/uv.lock +1 -1
  23. devague-0.5.1/README.md +0 -41
  24. devague-0.5.1/devague/cli/_commands/confirm.py +0 -38
  25. devague-0.5.1/devague/cli/_commands/reject.py +0 -19
  26. {devague-0.5.1 → devague-0.6.0}/.claude/skills/cicd/SKILL.md +0 -0
  27. {devague-0.5.1 → devague-0.6.0}/.claude/skills/cicd/scripts/_resolve-nick.sh +0 -0
  28. {devague-0.5.1 → devague-0.6.0}/.claude/skills/cicd/scripts/portability-lint.sh +0 -0
  29. {devague-0.5.1 → devague-0.6.0}/.claude/skills/cicd/scripts/pr-reply.sh +0 -0
  30. {devague-0.5.1 → devague-0.6.0}/.claude/skills/cicd/scripts/pr-status.sh +0 -0
  31. {devague-0.5.1 → devague-0.6.0}/.claude/skills/cicd/scripts/workflow.sh +0 -0
  32. {devague-0.5.1 → devague-0.6.0}/.claude/skills/communicate/SKILL.md +0 -0
  33. {devague-0.5.1 → devague-0.6.0}/.claude/skills/communicate/scripts/fetch-issues.sh +0 -0
  34. {devague-0.5.1 → devague-0.6.0}/.claude/skills/communicate/scripts/mesh-message.sh +0 -0
  35. {devague-0.5.1 → devague-0.6.0}/.claude/skills/communicate/scripts/post-comment.sh +0 -0
  36. {devague-0.5.1 → devague-0.6.0}/.claude/skills/communicate/scripts/post-issue.sh +0 -0
  37. {devague-0.5.1 → devague-0.6.0}/.claude/skills/communicate/scripts/templates/skill-update-brief.md +0 -0
  38. {devague-0.5.1 → devague-0.6.0}/.claude/skills/doc-test-alignment/SKILL.md +0 -0
  39. {devague-0.5.1 → devague-0.6.0}/.claude/skills/doc-test-alignment/scripts/check.sh +0 -0
  40. {devague-0.5.1 → devague-0.6.0}/.claude/skills/run-tests/SKILL.md +0 -0
  41. {devague-0.5.1 → devague-0.6.0}/.claude/skills/run-tests/scripts/test.sh +0 -0
  42. {devague-0.5.1 → devague-0.6.0}/.claude/skills/sonarclaude/SKILL.md +0 -0
  43. {devague-0.5.1 → devague-0.6.0}/.claude/skills/sonarclaude/scripts/sonar.sh +0 -0
  44. {devague-0.5.1 → devague-0.6.0}/.claude/skills/spec-to-plan/SKILL.md +0 -0
  45. {devague-0.5.1 → devague-0.6.0}/.claude/skills/spec-to-plan/scripts/spec-to-plan.sh +0 -0
  46. {devague-0.5.1 → devague-0.6.0}/.claude/skills/think/scripts/think.sh +0 -0
  47. {devague-0.5.1 → devague-0.6.0}/.claude/skills/version-bump/SKILL.md +0 -0
  48. {devague-0.5.1 → devague-0.6.0}/.claude/skills/version-bump/scripts/bump.py +0 -0
  49. {devague-0.5.1 → devague-0.6.0}/.claude/skills.local.yaml.example +0 -0
  50. {devague-0.5.1 → devague-0.6.0}/.devague/current_plan +0 -0
  51. {devague-0.5.1 → devague-0.6.0}/.devague/frames/devague-0-6-0-ships-the-human-review-loop-devague.json +0 -0
  52. {devague-0.5.1 → devague-0.6.0}/.devague/frames/devague-now-ships-a-documented-spec-contract-every.json +0 -0
  53. {devague-0.5.1 → devague-0.6.0}/.devague/plans/devague-0-6-0-ships-the-human-review-loop-devague.json +0 -0
  54. {devague-0.5.1 → devague-0.6.0}/.devague/plans/devague-now-ships-a-documented-spec-contract-every.json +0 -0
  55. {devague-0.5.1 → devague-0.6.0}/.flake8 +0 -0
  56. {devague-0.5.1 → devague-0.6.0}/.github/workflows/publish.yml +0 -0
  57. {devague-0.5.1 → devague-0.6.0}/.github/workflows/security-checks.yml +0 -0
  58. {devague-0.5.1 → devague-0.6.0}/.github/workflows/tests.yml +0 -0
  59. {devague-0.5.1 → devague-0.6.0}/.gitignore +0 -0
  60. {devague-0.5.1 → devague-0.6.0}/.markdownlint-cli2.yaml +0 -0
  61. {devague-0.5.1 → devague-0.6.0}/.pre-commit-config.yaml +0 -0
  62. {devague-0.5.1 → devague-0.6.0}/LICENSE +0 -0
  63. {devague-0.5.1 → devague-0.6.0}/culture.yaml +0 -0
  64. {devague-0.5.1 → devague-0.6.0}/devague/__init__.py +0 -0
  65. {devague-0.5.1 → devague-0.6.0}/devague/__main__.py +0 -0
  66. {devague-0.5.1 → devague-0.6.0}/devague/cli/_commands/__init__.py +0 -0
  67. {devague-0.5.1 → devague-0.6.0}/devague/cli/_commands/capture.py +0 -0
  68. {devague-0.5.1 → devague-0.6.0}/devague/cli/_commands/converge.py +0 -0
  69. {devague-0.5.1 → devague-0.6.0}/devague/cli/_commands/explain.py +0 -0
  70. {devague-0.5.1 → devague-0.6.0}/devague/cli/_commands/export.py +0 -0
  71. {devague-0.5.1 → devague-0.6.0}/devague/cli/_commands/interrogate.py +0 -0
  72. {devague-0.5.1 → devague-0.6.0}/devague/cli/_commands/learn.py +0 -0
  73. {devague-0.5.1 → devague-0.6.0}/devague/cli/_commands/list_frames.py +0 -0
  74. {devague-0.5.1 → devague-0.6.0}/devague/cli/_commands/new.py +0 -0
  75. {devague-0.5.1 → devague-0.6.0}/devague/cli/_commands/park.py +0 -0
  76. {devague-0.5.1 → devague-0.6.0}/devague/cli/_commands/plan.py +0 -0
  77. {devague-0.5.1 → devague-0.6.0}/devague/cli/_commands/show.py +0 -0
  78. {devague-0.5.1 → devague-0.6.0}/devague/cli/_errors.py +0 -0
  79. {devague-0.5.1 → devague-0.6.0}/devague/cli/_frames.py +0 -0
  80. {devague-0.5.1 → devague-0.6.0}/devague/cli/_output.py +0 -0
  81. {devague-0.5.1 → devague-0.6.0}/devague/cli/_plans.py +0 -0
  82. {devague-0.5.1 → devague-0.6.0}/devague/convergence.py +0 -0
  83. {devague-0.5.1 → devague-0.6.0}/devague/frame.py +0 -0
  84. {devague-0.5.1 → devague-0.6.0}/devague/plan.py +0 -0
  85. {devague-0.5.1 → devague-0.6.0}/devague/plan_convergence.py +0 -0
  86. {devague-0.5.1 → devague-0.6.0}/devague/plan_store.py +0 -0
  87. {devague-0.5.1 → devague-0.6.0}/devague/render/frame_md.py +0 -0
  88. {devague-0.5.1 → devague-0.6.0}/devague/render/plan_md.py +0 -0
  89. {devague-0.5.1 → devague-0.6.0}/devague/render/spec_md.py +0 -0
  90. {devague-0.5.1 → devague-0.6.0}/docs/examples/contract-example.json +0 -0
  91. {devague-0.5.1 → devague-0.6.0}/docs/plans/devague-0-6-0-ships-the-human-review-loop-devague.md +0 -0
  92. {devague-0.5.1 → devague-0.6.0}/docs/plans/devague-now-ships-a-documented-spec-contract-every.md +0 -0
  93. {devague-0.5.1 → devague-0.6.0}/docs/reviews/spec-contract-frame-review.md +0 -0
  94. {devague-0.5.1 → devague-0.6.0}/docs/skill-sources.md +0 -0
  95. {devague-0.5.1 → devague-0.6.0}/docs/spec-contract.md +0 -0
  96. {devague-0.5.1 → devague-0.6.0}/docs/specs/devague-0-6-0-ships-the-human-review-loop-devague.md +0 -0
  97. {devague-0.5.1 → devague-0.6.0}/docs/specs/devague-now-ships-a-documented-spec-contract-every.md +0 -0
  98. {devague-0.5.1 → devague-0.6.0}/docs/superpowers/plans/2026-05-22-specifix-onboarding.md +0 -0
  99. {devague-0.5.1 → devague-0.6.0}/docs/superpowers/plans/2026-05-23-devague-rename.md +0 -0
  100. {devague-0.5.1 → devague-0.6.0}/docs/superpowers/plans/2026-05-23-devague-working-backwards-engine.md +0 -0
  101. {devague-0.5.1 → devague-0.6.0}/docs/superpowers/specs/2026-05-22-specifix-onboarding-design.md +0 -0
  102. {devague-0.5.1 → devague-0.6.0}/docs/superpowers/specs/2026-05-23-devague-spec-to-plan-design.md +0 -0
  103. {devague-0.5.1 → devague-0.6.0}/docs/superpowers/specs/2026-05-23-devague-working-backwards-design.md +0 -0
  104. {devague-0.5.1 → devague-0.6.0}/sonar-project.properties +0 -0
  105. {devague-0.5.1 → devague-0.6.0}/tests/__init__.py +0 -0
  106. {devague-0.5.1 → devague-0.6.0}/tests/test_cli_affordances.py +0 -0
  107. {devague-0.5.1 → devague-0.6.0}/tests/test_cli_chassis.py +0 -0
  108. {devague-0.5.1 → devague-0.6.0}/tests/test_cli_converge_export.py +0 -0
  109. {devague-0.5.1 → devague-0.6.0}/tests/test_cli_errors.py +0 -0
  110. {devague-0.5.1 → devague-0.6.0}/tests/test_cli_output.py +0 -0
  111. {devague-0.5.1 → devague-0.6.0}/tests/test_cli_plan.py +0 -0
  112. {devague-0.5.1 → devague-0.6.0}/tests/test_contract.py +0 -0
  113. {devague-0.5.1 → devague-0.6.0}/tests/test_convergence.py +0 -0
  114. {devague-0.5.1 → devague-0.6.0}/tests/test_frame.py +0 -0
  115. {devague-0.5.1 → devague-0.6.0}/tests/test_offline.py +0 -0
  116. {devague-0.5.1 → devague-0.6.0}/tests/test_package.py +0 -0
  117. {devague-0.5.1 → devague-0.6.0}/tests/test_plan.py +0 -0
  118. {devague-0.5.1 → devague-0.6.0}/tests/test_plan_convergence.py +0 -0
  119. {devague-0.5.1 → devague-0.6.0}/tests/test_plan_store.py +0 -0
  120. {devague-0.5.1 → devague-0.6.0}/tests/test_render_plan.py +0 -0
  121. {devague-0.5.1 → devague-0.6.0}/tests/test_spec_to_plan_skill.py +0 -0
  122. {devague-0.5.1 → devague-0.6.0}/tests/test_store.py +0 -0
  123. {devague-0.5.1 → devague-0.6.0}/tests/test_think_skill.py +0 -0
@@ -58,7 +58,9 @@ portable resolution and the `status` helper.
58
58
  | `new "<announcement>"` | Start a frame from the announcement (the first move). Seeds an auto-confirmed `announcement` claim. |
59
59
  | `capture --kind <kind> "<text>"` | Record + classify a claim. `--origin llm` lands it as `proposed`. |
60
60
  | `interrogate <id> --honesty "…"` | Attach an honesty condition (what must be true). Also `--hard-question`, `--risk`, `--contradicts`, `--blocking`. |
61
- | `confirm <id>` / `reject <id>` | Resolve a claim (`c*`) or honesty condition (`h*`). **User-only decision.** |
61
+ | `confirm <id> [<id>…]` / `reject <id> [<id>…]` | Resolve one or more claims (`c*`) / honesty conditions (`h*`) in one **transactional** call. **User-only decision.** Also `confirm --from-review <file>` to apply an edited review artifact. |
62
+ | `review` | List every **proposed** (unconfirmed) claim + honesty condition with ids (`--json` too); writes a non-authoritative artifact to `.devague/reviews/<slug>.md`. Un-gated; never mutates. |
63
+ | `question "<text>"` | Record / list / `--resolve` a pending user decision as durable working state in `.devague/questions/<slug>.md`. |
62
64
  | `park "<text>" --kind <kind>` | Move uncertainty into first-class open vagueness instead of forcing an answer. |
63
65
  | `converge` | Evaluate the gate; list remaining gaps. |
64
66
  | `export` | Write the buildable spec to `docs/specs/` — only after `converge` passes. |
@@ -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.6.0] - 2026-05-23
9
+
10
+ ### Added
11
+
12
+ - **Human Review Loop (#17).** Makes the user-only confirmation step ergonomic at scale, preserving the anti-fabrication guarantee.
13
+ - `devague review` (+ `--json`) lists every proposed (unconfirmed) claim and honesty condition with ids — un-gated by convergence and without mutating state — and persists a non-authoritative artifact to `.devague/reviews/<slug>.md`.
14
+ - `confirm` / `reject` now accept multiple ids in one transactional call (any unknown id ⇒ nothing changes).
15
+ - `confirm --from-review <file>` applies a reviewed decision set: each item is emitted with a `pending` marker the human edits to `confirm`/`reject`; `pending` lines are never auto-confirmed (round-trippable artifact).
16
+ - `devague question` records / lists / resolves pending user decisions as durable working state in `.devague/questions/<slug>.md`.
17
+ - devague manages `.gitignore` so `.devague/reviews/` and `.devague/questions/` stay uncommitted working state by default.
18
+
19
+ ### Changed
20
+
21
+ - `confirm --json` now emits `{confirmed, rejected}` (lists) instead of `{id, status}`, reflecting the multi-id, transactional batch.
22
+
8
23
  ## [0.5.1] - 2026-05-23
9
24
 
10
25
  ### 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
+ **Human Review Loop landed (0.6.0, #17).** `devague review` (+ `--json`) lists
8
+ every proposed claim + honesty condition with ids — un-gated by convergence,
9
+ never mutating state — and writes a non-authoritative artifact to
10
+ `.devague/reviews/<slug>.md`. `confirm` / `reject` now take multiple ids in one
11
+ **transactional** call; `confirm --from-review <file>` applies an edited review
12
+ artifact (`pending` lines are never auto-confirmed). `devague question` records
13
+ pending decisions in `.devague/questions/<slug>.md`. devague manages
14
+ `.gitignore` so `reviews/` and `questions/` stay uncommitted working state.
15
+
7
16
  **Spec contract landed (#5).** The entity model is documented in
8
17
  `docs/spec-contract.md` (the source of truth for kinds, the `(state × origin)`
9
18
  vocabulary, the structured convergence result, and the per-move I/O contract).
@@ -16,8 +25,9 @@ required_next_moves}` (plans: `ready_for_plan`) — a hard break from the old
16
25
  **Spec→plan engine landed (v0.4.0).** Both deterministic engines now ship.
17
26
  The **frame engine** (idea→spec) — Frame domain model, JSON store, convergence
18
27
  gate, renderer registry, and the flat moves `new` / `capture` / `interrogate` /
19
- `confirm` / `reject` / `park` / `converge` / `export` / `show` / `list` /
20
- `learn` / `explain`. The **plan engine** (spec→plan) is its structural peer:
28
+ `confirm` / `reject` / `review` / `question` / `park` / `converge` / `export` /
29
+ `show` / `list` / `learn` / `explain`. The **plan engine** (spec→plan) is its
30
+ structural peer:
21
31
  `devague/plan.py`, `plan_convergence.py`, `plan_store.py`, `render/plan_md.py`,
22
32
  and the nested group `devague plan <move>` (`new` / `task` / `accept` / `depend`
23
33
  / `cover` / `confirm` / `reject` / `risk` / `converge` / `export` / `show` /
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devague
3
- Version: 0.5.1
3
+ Version: 0.6.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
@@ -49,6 +49,50 @@ devague --version
49
49
  Run `devague learn` (or `devague plan learn`) to learn the method, and `devague
50
50
  explain <move>` for any single move.
51
51
 
52
+ ## Human Review Loop
53
+
54
+ LLM-proposed claims and honesty conditions stay `proposed` until **you**
55
+ confirm them — that anti-fabrication rule is the point of the method. The review
56
+ loop makes that human step ergonomic at scale:
57
+
58
+ ```bash
59
+ devague review # list every proposed (unconfirmed) item, with ids
60
+ devague review --json # same, structured
61
+ devague confirm c2 h1 h3 # confirm many ids in one transactional call
62
+ devague reject c4 c5 # reject many ids in one call
63
+ devague confirm --from-review .devague/reviews/<slug>.md # apply an edited review file
64
+ ```
65
+
66
+ `review` is **not** gated on convergence and never mutates state. It writes a
67
+ durable, explicitly non-authoritative artifact you can review out of band, then
68
+ apply: each item is emitted with a `pending` marker — change it to `confirm` or
69
+ `reject` and feed the file back with `confirm --from-review`. `pending` lines are
70
+ never auto-confirmed; a batch is transactional (one bad id ⇒ nothing changes).
71
+
72
+ Open questions / pending decisions live as durable working state too:
73
+
74
+ ```bash
75
+ devague question "should batch confirm be transactional?" # record a pending decision
76
+ devague question --list # review them
77
+ devague question --resolve q1 --decision "yes, transactional"
78
+ ```
79
+
80
+ Applying a resolved decision into the frame stays an explicit move (e.g.
81
+ `devague capture --kind decision "…"` then `devague confirm`).
82
+
83
+ ### `.devague/` — what's committed vs working state
84
+
85
+ | Path | Committed? |
86
+ |------|-----------|
87
+ | `.devague/frames/`, `.devague/plans/` | yes — the converged frame/plan state |
88
+ | `.devague/reviews/<slug>.md` | no — local review working state |
89
+ | `.devague/questions/<slug>.md` | no — local pending-decision working state |
90
+ | `.devague/current`, `.devague/current_plan` | no — local pointers |
91
+
92
+ devague keeps `reviews/` and `questions/` out of git for you (it manages
93
+ `.gitignore`). Promote one into `docs/` only if you intentionally want it
94
+ committed.
95
+
52
96
  ## Driving it from an agent
53
97
 
54
98
  Inside AgentCulture, an assistant drives this CLI through two operator skills —
@@ -0,0 +1,85 @@
1
+ # devague
2
+
3
+ **`devague` is a command-line tool** that turns a vague feature idea into a
4
+ buildable **spec**, then that spec into a buildable **plan** — by working
5
+ backwards, then forwards. It is a small, deterministic Python CLI (no LLM calls
6
+ inside it, fully unit-tested) — not an agent, service, or daemon. You install it
7
+ and run `devague` from the repository you are speccing; state is plain JSON under
8
+ `.devague/`.
9
+
10
+ ```text
11
+ vague idea ──▶ buildable spec ──▶ buildable plan ──▶ build
12
+ ```
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ uv tool install devague # or: pipx install devague / pip install devague
18
+ devague --version
19
+ ```
20
+
21
+ ## Two engines, one CLI
22
+
23
+ - **Frame engine** (idea→spec) — start from the announcement ("pretend it
24
+ shipped"), capture and pressure-test claims, park open vagueness, and `export`
25
+ a spec only once the frame *converges*. Flat verbs: `devague new` /
26
+ `capture` / `interrogate` / `confirm` / `converge` / `export` / …
27
+ - **Plan engine** (spec→plan) — seed a plan from a converged frame, cover every
28
+ target with tasks that carry acceptance criteria and an acyclic dependency
29
+ order, and `export` a plan only once it *converges*. Nested group:
30
+ `devague plan new` / `task` / `cover` / `converge` / `export` / …
31
+
32
+ Run `devague learn` (or `devague plan learn`) to learn the method, and `devague
33
+ explain <move>` for any single move.
34
+
35
+ ## Human Review Loop
36
+
37
+ LLM-proposed claims and honesty conditions stay `proposed` until **you**
38
+ confirm them — that anti-fabrication rule is the point of the method. The review
39
+ loop makes that human step ergonomic at scale:
40
+
41
+ ```bash
42
+ devague review # list every proposed (unconfirmed) item, with ids
43
+ devague review --json # same, structured
44
+ devague confirm c2 h1 h3 # confirm many ids in one transactional call
45
+ devague reject c4 c5 # reject many ids in one call
46
+ devague confirm --from-review .devague/reviews/<slug>.md # apply an edited review file
47
+ ```
48
+
49
+ `review` is **not** gated on convergence and never mutates state. It writes a
50
+ durable, explicitly non-authoritative artifact you can review out of band, then
51
+ apply: each item is emitted with a `pending` marker — change it to `confirm` or
52
+ `reject` and feed the file back with `confirm --from-review`. `pending` lines are
53
+ never auto-confirmed; a batch is transactional (one bad id ⇒ nothing changes).
54
+
55
+ Open questions / pending decisions live as durable working state too:
56
+
57
+ ```bash
58
+ devague question "should batch confirm be transactional?" # record a pending decision
59
+ devague question --list # review them
60
+ devague question --resolve q1 --decision "yes, transactional"
61
+ ```
62
+
63
+ Applying a resolved decision into the frame stays an explicit move (e.g.
64
+ `devague capture --kind decision "…"` then `devague confirm`).
65
+
66
+ ### `.devague/` — what's committed vs working state
67
+
68
+ | Path | Committed? |
69
+ |------|-----------|
70
+ | `.devague/frames/`, `.devague/plans/` | yes — the converged frame/plan state |
71
+ | `.devague/reviews/<slug>.md` | no — local review working state |
72
+ | `.devague/questions/<slug>.md` | no — local pending-decision working state |
73
+ | `.devague/current`, `.devague/current_plan` | no — local pointers |
74
+
75
+ devague keeps `reviews/` and `questions/` out of git for you (it manages
76
+ `.gitignore`). Promote one into `docs/` only if you intentionally want it
77
+ committed.
78
+
79
+ ## Driving it from an agent
80
+
81
+ Inside AgentCulture, an assistant drives this CLI through two operator skills —
82
+ **`/think`** (idea→spec) and **`/spec-to-plan`** (spec→plan) — which add a
83
+ portable wrapper and a `status` next-move helper over the convergence gate. The
84
+ CLI is the deterministic affordance; the agent decides the next move. See
85
+ `CLAUDE.md` for that workflow and `docs/superpowers/specs/` for the design docs.
@@ -65,7 +65,9 @@ def _build_parser() -> argparse.ArgumentParser:
65
65
  from devague.cli._commands import new as _new_cmd
66
66
  from devague.cli._commands import park as _park_cmd
67
67
  from devague.cli._commands import plan as _plan_cmd
68
+ from devague.cli._commands import question as _question_cmd
68
69
  from devague.cli._commands import reject as _reject_cmd
70
+ from devague.cli._commands import review as _review_cmd
69
71
  from devague.cli._commands import show as _show_cmd
70
72
 
71
73
  _learn_cmd.register(sub)
@@ -75,6 +77,8 @@ def _build_parser() -> argparse.ArgumentParser:
75
77
  _interrogate_cmd.register(sub)
76
78
  _confirm_cmd.register(sub)
77
79
  _reject_cmd.register(sub)
80
+ _review_cmd.register(sub)
81
+ _question_cmd.register(sub)
78
82
  _park_cmd.register(sub)
79
83
  _converge_cmd.register(sub)
80
84
  _export_cmd.register(sub)
@@ -0,0 +1,96 @@
1
+ """``devague confirm`` — confirm claims/honesty conditions (user-only transition).
2
+
3
+ Accepts one or more ids in a single call, or a reviewed decision set via
4
+ ``--from-review <file>`` (apply the confirm/reject markers a human edited into a
5
+ ``devague review`` artifact). Either way the batch is **transactional**: every
6
+ id is validated first, and if any is unknown nothing is changed. Confirmation
7
+ stays a user-only action, and ``--from-review`` applies only what the file
8
+ explicitly marks — ``pending`` lines are never auto-confirmed. See issue #17.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ from pathlib import Path
15
+
16
+ from devague import store
17
+ from devague.cli._errors import EXIT_USER_ERROR, DevagueError
18
+ from devague.cli._frames import resolve
19
+ from devague.cli._output import emit_result
20
+ from devague.render.review_md import parse_decisions
21
+
22
+
23
+ def _exists(frame, item_id: str) -> bool:
24
+ return frame.find_claim(item_id) is not None or frame.find_honesty(item_id) is not None
25
+
26
+
27
+ def _run(args: argparse.Namespace, confirm_ids: list[str], reject_ids: list[str]) -> int:
28
+ frame = resolve(args.frame)
29
+ all_ids = confirm_ids + reject_ids
30
+ if not all_ids:
31
+ raise DevagueError(EXIT_USER_ERROR, "no ids to resolve", "pass at least one id")
32
+ unknown = [i for i in all_ids if not _exists(frame, i)]
33
+ if unknown:
34
+ raise DevagueError(
35
+ EXIT_USER_ERROR,
36
+ f"no such claim or honesty condition: {', '.join(unknown)}",
37
+ "run 'devague show'; the batch is transactional — nothing was changed",
38
+ )
39
+ for item_id in confirm_ids:
40
+ frame.set_status(item_id, "confirmed")
41
+ for item_id in reject_ids:
42
+ frame.set_status(item_id, "rejected")
43
+ store.save(frame)
44
+ if getattr(args, "json", False):
45
+ emit_result({"confirmed": confirm_ids, "rejected": reject_ids}, json_mode=True)
46
+ else:
47
+ lines = [f"{i} -> confirmed" for i in confirm_ids]
48
+ lines += [f"{i} -> rejected" for i in reject_ids]
49
+ emit_result("\n".join(lines), json_mode=False)
50
+ return 0
51
+
52
+
53
+ def _from_review(path: str) -> tuple[list[str], list[str]]:
54
+ try:
55
+ text = Path(path).read_text(encoding="utf-8")
56
+ except OSError as err:
57
+ raise DevagueError(EXIT_USER_ERROR, f"cannot read review file: {path}", str(err))
58
+ try:
59
+ decisions = parse_decisions(text)
60
+ except ValueError as err:
61
+ raise DevagueError(EXIT_USER_ERROR, str(err), "fix the conflicting decision and retry")
62
+ confirm_ids = [i for i, d in decisions.items() if d == "confirm"]
63
+ reject_ids = [i for i, d in decisions.items() if d == "reject"]
64
+ if not confirm_ids and not reject_ids:
65
+ raise DevagueError(
66
+ EXIT_USER_ERROR,
67
+ "no decisions found in review file",
68
+ "change a line's 'pending' to 'confirm' or 'reject' first",
69
+ )
70
+ return confirm_ids, reject_ids
71
+
72
+
73
+ def cmd_confirm(args: argparse.Namespace) -> int:
74
+ if args.from_review:
75
+ if args.ids:
76
+ raise DevagueError(
77
+ EXIT_USER_ERROR,
78
+ "pass ids or --from-review, not both",
79
+ "drop the positional ids when applying a review file",
80
+ )
81
+ confirm_ids, reject_ids = _from_review(args.from_review)
82
+ return _run(args, confirm_ids, reject_ids)
83
+ return _run(args, list(args.ids), [])
84
+
85
+
86
+ def cmd_reject(args: argparse.Namespace) -> int:
87
+ return _run(args, [], list(args.ids))
88
+
89
+
90
+ def register(sub: argparse._SubParsersAction) -> None:
91
+ p = sub.add_parser("confirm", help="Confirm claims/honesty conditions, or apply a review file.")
92
+ p.add_argument("ids", nargs="*", help="One or more claim ids (c*) or honesty ids (h*).")
93
+ p.add_argument("--from-review", help="Apply confirm/reject decisions from a review file.")
94
+ p.add_argument("--frame", help="Frame slug (default: current).")
95
+ p.add_argument("--json", action="store_true", help="Emit structured JSON.")
96
+ p.set_defaults(func=cmd_confirm)
@@ -0,0 +1,80 @@
1
+ """``devague question`` — record/list/resolve pending user decisions.
2
+
3
+ Durable, uncommitted working state under .devague/questions/<slug>.md. The CLI
4
+ owns the format (decision c20). Nothing here auto-resolves: ``--resolve`` only
5
+ records a decision a human made; applying it into the frame is a separate,
6
+ explicit move (see the file header). Issue #14/#17.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+
13
+ from devague import questions_io, store
14
+ from devague.cli._errors import EXIT_USER_ERROR, DevagueError
15
+ from devague.cli._frames import resolve
16
+ from devague.cli._output import emit_diagnostic, emit_result
17
+
18
+
19
+ def _load(slug: str) -> list[dict]:
20
+ path = store.questions_path(slug)
21
+ return questions_io.parse(path.read_text(encoding="utf-8")) if path.exists() else []
22
+
23
+
24
+ def _resolve_question(args: argparse.Namespace, slug: str, items: list[dict]) -> None:
25
+ target = next((i for i in items if i["id"] == args.resolve), None)
26
+ if target is None:
27
+ raise DevagueError(
28
+ EXIT_USER_ERROR,
29
+ f"no such question: {args.resolve}",
30
+ "run 'devague question --list'",
31
+ )
32
+ target["resolved"] = True
33
+ target["decision"] = args.decision
34
+ store.write_questions(slug, questions_io.render(slug, items))
35
+ if args.json:
36
+ emit_result({"id": args.resolve, "resolved": True}, json_mode=True)
37
+ else:
38
+ emit_result(f"{args.resolve} -> resolved", json_mode=False)
39
+
40
+
41
+ def _add_question(args: argparse.Namespace, slug: str, items: list[dict]) -> None:
42
+ new_id = questions_io.next_id(items)
43
+ items.append({"id": new_id, "text": args.text, "resolved": False, "decision": None})
44
+ path = store.write_questions(slug, questions_io.render(slug, items))
45
+ if args.json:
46
+ emit_result({"id": new_id, "text": args.text, "path": str(path)}, json_mode=True)
47
+ else:
48
+ emit_result(f"recorded {new_id}", json_mode=False)
49
+ emit_diagnostic(f"wrote pending decision to {path} (uncommitted working state)")
50
+
51
+
52
+ def _list_questions(args: argparse.Namespace, slug: str, items: list[dict]) -> None:
53
+ if args.json:
54
+ emit_result({"slug": slug, "questions": items}, json_mode=True)
55
+ else:
56
+ emit_result(questions_io.render(slug, items), json_mode=False)
57
+
58
+
59
+ def cmd_question(args: argparse.Namespace) -> int:
60
+ frame = resolve(args.frame) # validates the frame exists; never mutates it
61
+ slug = frame.slug
62
+ items = _load(slug)
63
+ if args.resolve:
64
+ _resolve_question(args, slug, items)
65
+ elif args.text:
66
+ _add_question(args, slug, items)
67
+ else: # default / --list: show the pending-decisions state
68
+ _list_questions(args, slug, items)
69
+ return 0
70
+
71
+
72
+ def register(sub: argparse._SubParsersAction) -> None:
73
+ p = sub.add_parser("question", help="Record / list / resolve pending user decisions.")
74
+ p.add_argument("text", nargs="?", help="Question text to record (omit to list).")
75
+ p.add_argument("--resolve", metavar="QID", help="Mark a question id resolved.")
76
+ p.add_argument("--decision", help="The decision note recorded with --resolve.")
77
+ p.add_argument("--list", action="store_true", help="List pending decisions (default).")
78
+ p.add_argument("--frame", help="Frame slug (default: current).")
79
+ p.add_argument("--json", action="store_true", help="Emit structured JSON.")
80
+ p.set_defaults(func=cmd_question)
@@ -0,0 +1,15 @@
1
+ """``devague reject`` — reject one or more claims / honesty conditions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from devague.cli._commands.confirm import cmd_reject
8
+
9
+
10
+ def register(sub: argparse._SubParsersAction) -> None:
11
+ p = sub.add_parser("reject", help="Reject one or more claims / honesty conditions.")
12
+ p.add_argument("ids", nargs="+", help="One or more claim ids (c*) or honesty ids (h*).")
13
+ p.add_argument("--frame", help="Frame slug (default: current).")
14
+ p.add_argument("--json", action="store_true", help="Emit structured JSON.")
15
+ p.set_defaults(func=cmd_reject)
@@ -0,0 +1,63 @@
1
+ """``devague review`` — surface every *proposed* (unconfirmed) item for human review.
2
+
3
+ Lists proposed claims and proposed honesty conditions with their ids. It is
4
+ deliberately NOT gated on convergence and never mutates state — it is a
5
+ read-only view of what is awaiting a user decision (issue #17). Confirm/reject
6
+ the listed ids with ``devague confirm`` / ``devague reject``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+
13
+ from devague import render, store
14
+ from devague.cli._frames import resolve
15
+ from devague.cli._output import emit_diagnostic, emit_result
16
+ from devague.render.review_md import proposed_claims, proposed_honesty
17
+
18
+
19
+ def _json_payload(frame) -> dict:
20
+ return {
21
+ "slug": frame.slug,
22
+ "proposed_claims": [
23
+ {"id": c.id, "kind": c.kind, "text": c.text} for c in proposed_claims(frame)
24
+ ],
25
+ "proposed_honesty": [
26
+ {"id": h.id, "claim_id": c.id, "claim_kind": c.kind, "text": h.text}
27
+ for c, h in proposed_honesty(frame)
28
+ ],
29
+ }
30
+
31
+
32
+ def cmd_review(args: argparse.Namespace) -> int:
33
+ frame = resolve(args.frame) # read-only: the frame is never saved/converged
34
+ artifact = render.render(frame, "review-md")
35
+ path = None
36
+ if not getattr(args, "no_write", False):
37
+ # Persist a durable, NON-authoritative artifact to uncommitted working
38
+ # state (.devague/reviews/<slug>.md) — distinct from docs/specs/.
39
+ path = store.write_review(frame.slug, artifact)
40
+ if getattr(args, "json", False):
41
+ payload = _json_payload(frame)
42
+ payload["path"] = str(path) if path else None
43
+ emit_result(payload, json_mode=True)
44
+ else:
45
+ emit_result(artifact, json_mode=False)
46
+ if path:
47
+ emit_diagnostic(f"wrote review artifact to {path} (unconfirmed, not authoritative)")
48
+ return 0
49
+
50
+
51
+ def register(sub: argparse._SubParsersAction) -> None:
52
+ p = sub.add_parser(
53
+ "review",
54
+ help="List proposed (unconfirmed) claims + honesty conditions for human review.",
55
+ )
56
+ p.add_argument("--frame", help="Frame slug (default: current).")
57
+ p.add_argument("--json", action="store_true", help="Emit structured JSON.")
58
+ p.add_argument(
59
+ "--no-write",
60
+ action="store_true",
61
+ help="Print only; do not persist .devague/reviews/<slug>.md.",
62
+ )
63
+ p.set_defaults(func=cmd_review)
@@ -0,0 +1,64 @@
1
+ """Pending-decision working state: ``.devague/questions/<slug>.md`` (CLI-owned).
2
+
3
+ A durable, **uncommitted** list of open questions / pending user decisions raised
4
+ while working a frame (issue #14/#17, decision c20). The CLI owns the markdown
5
+ format so it round-trips (``parse`` ⇄ ``render``). A resolved decision is applied
6
+ back into the frame with the normal moves (e.g. ``devague capture --kind decision
7
+ "<the decision>"`` then ``devague confirm``); resolving here only records it.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+
14
+ # Bounded whitespace only (single literal spaces, rest stripped in parse) so the
15
+ # pattern is linear — no \s+…\s+…\.* shape that trips ReDoS heuristics.
16
+ _LINE = re.compile(r"^- \[(?P<mark>[ x])\] `(?P<id>q\d+)`:(?P<rest>.*)$")
17
+ _DECIDED = " — decided: "
18
+
19
+ _BANNER = (
20
+ "> **Working state — not committed by default.** Open questions / pending "
21
+ "decisions for this frame. Apply a decision into the frame with the normal "
22
+ 'moves (e.g. `devague capture --kind decision "…"` then `devague confirm`), '
23
+ "then mark it resolved here with `devague question --resolve <id>`."
24
+ )
25
+
26
+
27
+ def parse(text: str) -> list[dict]:
28
+ items: list[dict] = []
29
+ for line in text.splitlines():
30
+ m = _LINE.match(line.strip())
31
+ if not m:
32
+ continue
33
+ rest = m.group("rest")
34
+ resolved = m.group("mark") == "x"
35
+ decision = None
36
+ if resolved and _DECIDED in rest:
37
+ # render() appends the decided tail last, so split from the right —
38
+ # otherwise a question whose *text* contains the delimiter corrupts.
39
+ rest, decision = rest.rsplit(_DECIDED, 1)
40
+ items.append(
41
+ {"id": m.group("id"), "text": rest.strip(), "resolved": resolved, "decision": decision}
42
+ )
43
+ return items
44
+
45
+
46
+ def next_id(items: list[dict]) -> str:
47
+ nums = [int(i["id"][1:]) for i in items if i["id"][1:].isdigit()]
48
+ return f"q{(max(nums) + 1) if nums else 1}"
49
+
50
+
51
+ def render(slug: str, items: list[dict]) -> str:
52
+ out = [f"# Pending decisions — {slug}", "", _BANNER, ""]
53
+ open_items = [i for i in items if not i["resolved"]]
54
+ done_items = [i for i in items if i["resolved"]]
55
+ out += ["## Open", ""]
56
+ out += [f"- [ ] `{i['id']}`: {i['text']}" for i in open_items] or ["None."]
57
+ out += [""]
58
+ if done_items:
59
+ out += ["## Resolved", ""]
60
+ for i in done_items:
61
+ tail = f"{_DECIDED}{i['decision']}" if i["decision"] else ""
62
+ out.append(f"- [x] `{i['id']}`: {i['text']}{tail}")
63
+ out += [""]
64
+ return "\n".join(out).rstrip() + "\n"
@@ -33,7 +33,9 @@ def render(frame: Frame, fmt: str) -> str:
33
33
 
34
34
  # Register the built-in renderers (import-time side effect).
35
35
  from devague.render import frame_md as _frame_md # noqa: E402
36
+ from devague.render import review_md as _review_md # noqa: E402
36
37
  from devague.render import spec_md as _spec_md # noqa: E402
37
38
 
38
39
  register("frame-md", _frame_md.render_frame)
39
40
  register("spec-md", _spec_md.render_spec)
41
+ register("review-md", _review_md.render_review)
@@ -0,0 +1,76 @@
1
+ """Renderer + parser for the human-review artifact — every *proposed* item.
2
+
3
+ Explicitly NON-authoritative and distinct from spec-md: it exists so a human can
4
+ review LLM proposals (in-terminal or out of band) before confirming any. Nothing
5
+ here is authoritative until the user runs ``devague confirm``. See issue #17.
6
+
7
+ The artifact is **round-trippable**: each proposed item is emitted with a leading
8
+ ``pending`` marker. Edit a line's marker to ``confirm`` or ``reject``, then feed
9
+ the file to ``devague confirm --from-review <file>`` to apply exactly those
10
+ decisions (``render_review`` ⇄ :func:`parse_decisions`).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+
17
+ from devague.frame import Frame
18
+
19
+ # A decision line: "- <marker> `<id>` ...". The marker is the only editable part.
20
+ _DECISIONS = ("pending", "confirm", "reject")
21
+ _LINE = re.compile(r"^- (?P<decision>pending|confirm|reject)\s+`(?P<id>[ch]\d+)`")
22
+
23
+ _BANNER = (
24
+ "> **Review artifact — nothing confirmed yet.** These are unconfirmed, "
25
+ "LLM-proposed items; they are NOT authoritative and NOT a buildable spec. "
26
+ "To apply, change a line's `pending` to `confirm` or `reject`, then run "
27
+ "`devague confirm --from-review <file>` (or `devague confirm <id> ...`)."
28
+ )
29
+
30
+
31
+ def proposed_claims(frame: Frame) -> list:
32
+ return [c for c in frame.claims if c.status == "proposed"]
33
+
34
+
35
+ def proposed_honesty(frame: Frame) -> list:
36
+ return [(c, h) for c in frame.claims for h in c.honesty_conditions if h.status == "proposed"]
37
+
38
+
39
+ def render_review(frame: Frame) -> str:
40
+ out: list[str] = [f"# Review — {frame.title}", "", _BANNER, ""]
41
+ claims = proposed_claims(frame)
42
+ pairs = proposed_honesty(frame)
43
+ if not claims and not pairs:
44
+ out += ["No proposed items — nothing awaiting review.", ""]
45
+ return "\n".join(out).rstrip() + "\n"
46
+ if claims:
47
+ out += ["## Proposed claims", ""]
48
+ out += [f"- pending `{c.id}` ({c.kind}): {c.text}" for c in claims]
49
+ out += [""]
50
+ if pairs:
51
+ out += ["## Proposed honesty conditions", ""]
52
+ out += [f"- pending `{h.id}` (on `{c.id}` {c.kind}): {h.text}" for c, h in pairs]
53
+ out += [""]
54
+ return "\n".join(out).rstrip() + "\n"
55
+
56
+
57
+ def parse_decisions(text: str) -> dict[str, str]:
58
+ """Parse a (possibly edited) review artifact into ``{id: "confirm"|"reject"}``.
59
+
60
+ ``pending`` lines carry no decision and are skipped — applying a review file
61
+ therefore touches only what the human explicitly marked (no auto-confirm).
62
+ Raises ``ValueError`` on a duplicate, conflicting decision for one id.
63
+ """
64
+ decisions: dict[str, str] = {}
65
+ for line in text.splitlines():
66
+ m = _LINE.match(line.strip())
67
+ if not m:
68
+ continue
69
+ decision, item_id = m.group("decision"), m.group("id")
70
+ if decision == "pending":
71
+ continue
72
+ if item_id in decisions and decisions[item_id] != decision:
73
+ prior = decisions[item_id]
74
+ raise ValueError(f"conflicting decisions for {item_id}: {prior} vs {decision}")
75
+ decisions[item_id] = decision
76
+ return decisions