devague 0.8.0__tar.gz → 0.9.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.8.0 → devague-0.9.0}/.claude/skills/spec-to-plan/SKILL.md +1 -0
  2. {devague-0.8.0 → devague-0.9.0}/.claude/skills/spec-to-plan/scripts/spec-to-plan.sh +1 -0
  3. {devague-0.8.0 → devague-0.9.0}/CHANGELOG.md +7 -0
  4. {devague-0.8.0 → devague-0.9.0}/CLAUDE.md +14 -3
  5. {devague-0.8.0 → devague-0.9.0}/PKG-INFO +1 -1
  6. {devague-0.8.0 → devague-0.9.0}/devague/cli/_commands/export.py +2 -1
  7. {devague-0.8.0 → devague-0.9.0}/devague/cli/_commands/plan.py +40 -4
  8. devague-0.9.0/devague/cli/_paths.py +39 -0
  9. {devague-0.8.0 → devague-0.9.0}/devague/plan.py +38 -0
  10. {devague-0.8.0 → devague-0.9.0}/devague/plan_convergence.py +11 -0
  11. {devague-0.8.0 → devague-0.9.0}/docs/spec-contract.md +11 -0
  12. {devague-0.8.0 → devague-0.9.0}/pyproject.toml +1 -1
  13. {devague-0.8.0 → devague-0.9.0}/tests/test_cli_converge_export.py +6 -2
  14. devague-0.9.0/tests/test_cli_paths.py +21 -0
  15. {devague-0.8.0 → devague-0.9.0}/tests/test_cli_plan.py +86 -2
  16. {devague-0.8.0 → devague-0.9.0}/tests/test_plan.py +76 -0
  17. {devague-0.8.0 → devague-0.9.0}/uv.lock +1 -1
  18. {devague-0.8.0 → devague-0.9.0}/.claude/skills/cicd/SKILL.md +0 -0
  19. {devague-0.8.0 → devague-0.9.0}/.claude/skills/cicd/scripts/_resolve-nick.sh +0 -0
  20. {devague-0.8.0 → devague-0.9.0}/.claude/skills/cicd/scripts/portability-lint.sh +0 -0
  21. {devague-0.8.0 → devague-0.9.0}/.claude/skills/cicd/scripts/pr-reply.sh +0 -0
  22. {devague-0.8.0 → devague-0.9.0}/.claude/skills/cicd/scripts/pr-status.sh +0 -0
  23. {devague-0.8.0 → devague-0.9.0}/.claude/skills/cicd/scripts/workflow.sh +0 -0
  24. {devague-0.8.0 → devague-0.9.0}/.claude/skills/communicate/SKILL.md +0 -0
  25. {devague-0.8.0 → devague-0.9.0}/.claude/skills/communicate/scripts/fetch-issues.sh +0 -0
  26. {devague-0.8.0 → devague-0.9.0}/.claude/skills/communicate/scripts/mesh-message.sh +0 -0
  27. {devague-0.8.0 → devague-0.9.0}/.claude/skills/communicate/scripts/post-comment.sh +0 -0
  28. {devague-0.8.0 → devague-0.9.0}/.claude/skills/communicate/scripts/post-issue.sh +0 -0
  29. {devague-0.8.0 → devague-0.9.0}/.claude/skills/communicate/scripts/templates/skill-update-brief.md +0 -0
  30. {devague-0.8.0 → devague-0.9.0}/.claude/skills/doc-test-alignment/SKILL.md +0 -0
  31. {devague-0.8.0 → devague-0.9.0}/.claude/skills/doc-test-alignment/scripts/check.sh +0 -0
  32. {devague-0.8.0 → devague-0.9.0}/.claude/skills/run-tests/SKILL.md +0 -0
  33. {devague-0.8.0 → devague-0.9.0}/.claude/skills/run-tests/scripts/test.sh +0 -0
  34. {devague-0.8.0 → devague-0.9.0}/.claude/skills/sonarclaude/SKILL.md +0 -0
  35. {devague-0.8.0 → devague-0.9.0}/.claude/skills/sonarclaude/scripts/sonar.sh +0 -0
  36. {devague-0.8.0 → devague-0.9.0}/.claude/skills/think/SKILL.md +0 -0
  37. {devague-0.8.0 → devague-0.9.0}/.claude/skills/think/scripts/think.sh +0 -0
  38. {devague-0.8.0 → devague-0.9.0}/.claude/skills/version-bump/SKILL.md +0 -0
  39. {devague-0.8.0 → devague-0.9.0}/.claude/skills/version-bump/scripts/bump.py +0 -0
  40. {devague-0.8.0 → devague-0.9.0}/.claude/skills.local.yaml.example +0 -0
  41. {devague-0.8.0 → devague-0.9.0}/.devague/current_plan +0 -0
  42. {devague-0.8.0 → devague-0.9.0}/.devague/frames/devague-0-6-0-ships-the-human-review-loop-devague.json +0 -0
  43. {devague-0.8.0 → devague-0.9.0}/.devague/frames/devague-now-ships-a-documented-spec-contract-every.json +0 -0
  44. {devague-0.8.0 → devague-0.9.0}/.devague/plans/devague-0-6-0-ships-the-human-review-loop-devague.json +0 -0
  45. {devague-0.8.0 → devague-0.9.0}/.devague/plans/devague-now-ships-a-documented-spec-contract-every.json +0 -0
  46. {devague-0.8.0 → devague-0.9.0}/.flake8 +0 -0
  47. {devague-0.8.0 → devague-0.9.0}/.github/workflows/publish.yml +0 -0
  48. {devague-0.8.0 → devague-0.9.0}/.github/workflows/security-checks.yml +0 -0
  49. {devague-0.8.0 → devague-0.9.0}/.github/workflows/tests.yml +0 -0
  50. {devague-0.8.0 → devague-0.9.0}/.gitignore +0 -0
  51. {devague-0.8.0 → devague-0.9.0}/.markdownlint-cli2.yaml +0 -0
  52. {devague-0.8.0 → devague-0.9.0}/.pre-commit-config.yaml +0 -0
  53. {devague-0.8.0 → devague-0.9.0}/LICENSE +0 -0
  54. {devague-0.8.0 → devague-0.9.0}/README.md +0 -0
  55. {devague-0.8.0 → devague-0.9.0}/culture.yaml +0 -0
  56. {devague-0.8.0 → devague-0.9.0}/devague/__init__.py +0 -0
  57. {devague-0.8.0 → devague-0.9.0}/devague/__main__.py +0 -0
  58. {devague-0.8.0 → devague-0.9.0}/devague/cli/__init__.py +0 -0
  59. {devague-0.8.0 → devague-0.9.0}/devague/cli/_commands/__init__.py +0 -0
  60. {devague-0.8.0 → devague-0.9.0}/devague/cli/_commands/capture.py +0 -0
  61. {devague-0.8.0 → devague-0.9.0}/devague/cli/_commands/confirm.py +0 -0
  62. {devague-0.8.0 → devague-0.9.0}/devague/cli/_commands/converge.py +0 -0
  63. {devague-0.8.0 → devague-0.9.0}/devague/cli/_commands/explain.py +0 -0
  64. {devague-0.8.0 → devague-0.9.0}/devague/cli/_commands/interrogate.py +0 -0
  65. {devague-0.8.0 → devague-0.9.0}/devague/cli/_commands/learn.py +0 -0
  66. {devague-0.8.0 → devague-0.9.0}/devague/cli/_commands/list_frames.py +0 -0
  67. {devague-0.8.0 → devague-0.9.0}/devague/cli/_commands/new.py +0 -0
  68. {devague-0.8.0 → devague-0.9.0}/devague/cli/_commands/park.py +0 -0
  69. {devague-0.8.0 → devague-0.9.0}/devague/cli/_commands/question.py +0 -0
  70. {devague-0.8.0 → devague-0.9.0}/devague/cli/_commands/reject.py +0 -0
  71. {devague-0.8.0 → devague-0.9.0}/devague/cli/_commands/review.py +0 -0
  72. {devague-0.8.0 → devague-0.9.0}/devague/cli/_commands/show.py +0 -0
  73. {devague-0.8.0 → devague-0.9.0}/devague/cli/_errors.py +0 -0
  74. {devague-0.8.0 → devague-0.9.0}/devague/cli/_frames.py +0 -0
  75. {devague-0.8.0 → devague-0.9.0}/devague/cli/_output.py +0 -0
  76. {devague-0.8.0 → devague-0.9.0}/devague/cli/_plans.py +0 -0
  77. {devague-0.8.0 → devague-0.9.0}/devague/convergence.py +0 -0
  78. {devague-0.8.0 → devague-0.9.0}/devague/frame.py +0 -0
  79. {devague-0.8.0 → devague-0.9.0}/devague/plan_store.py +0 -0
  80. {devague-0.8.0 → devague-0.9.0}/devague/questions_io.py +0 -0
  81. {devague-0.8.0 → devague-0.9.0}/devague/render/__init__.py +0 -0
  82. {devague-0.8.0 → devague-0.9.0}/devague/render/frame_md.py +0 -0
  83. {devague-0.8.0 → devague-0.9.0}/devague/render/plan_md.py +0 -0
  84. {devague-0.8.0 → devague-0.9.0}/devague/render/review_md.py +0 -0
  85. {devague-0.8.0 → devague-0.9.0}/devague/render/spec_md.py +0 -0
  86. {devague-0.8.0 → devague-0.9.0}/devague/store.py +0 -0
  87. {devague-0.8.0 → devague-0.9.0}/docs/examples/contract-example.json +0 -0
  88. {devague-0.8.0 → devague-0.9.0}/docs/llm-guidance.md +0 -0
  89. /devague-0.8.0/docs/plans/devague-0-6-0-ships-the-human-review-loop-devague.md → /devague-0.9.0/docs/plans/2026-05-23-devague-0-6-0-ships-the-human-review-loop-devague.md +0 -0
  90. /devague-0.8.0/docs/plans/devague-now-ships-a-documented-spec-contract-every.md → /devague-0.9.0/docs/plans/2026-05-23-devague-now-ships-a-documented-spec-contract-every.md +0 -0
  91. {devague-0.8.0 → devague-0.9.0}/docs/reviews/spec-contract-frame-review.md +0 -0
  92. {devague-0.8.0 → devague-0.9.0}/docs/skill-sources.md +0 -0
  93. /devague-0.8.0/docs/specs/devague-0-6-0-ships-the-human-review-loop-devague.md → /devague-0.9.0/docs/specs/2026-05-23-devague-0-6-0-ships-the-human-review-loop-devague.md +0 -0
  94. /devague-0.8.0/docs/specs/devague-now-ships-a-documented-spec-contract-every.md → /devague-0.9.0/docs/specs/2026-05-23-devague-now-ships-a-documented-spec-contract-every.md +0 -0
  95. {devague-0.8.0 → devague-0.9.0}/docs/superpowers/plans/2026-05-22-specifix-onboarding.md +0 -0
  96. {devague-0.8.0 → devague-0.9.0}/docs/superpowers/plans/2026-05-23-devague-rename.md +0 -0
  97. {devague-0.8.0 → devague-0.9.0}/docs/superpowers/plans/2026-05-23-devague-working-backwards-engine.md +0 -0
  98. {devague-0.8.0 → devague-0.9.0}/docs/superpowers/specs/2026-05-22-specifix-onboarding-design.md +0 -0
  99. {devague-0.8.0 → devague-0.9.0}/docs/superpowers/specs/2026-05-23-devague-spec-to-plan-design.md +0 -0
  100. {devague-0.8.0 → devague-0.9.0}/docs/superpowers/specs/2026-05-23-devague-working-backwards-design.md +0 -0
  101. {devague-0.8.0 → devague-0.9.0}/sonar-project.properties +0 -0
  102. {devague-0.8.0 → devague-0.9.0}/tests/__init__.py +0 -0
  103. {devague-0.8.0 → devague-0.9.0}/tests/test_cli_affordances.py +0 -0
  104. {devague-0.8.0 → devague-0.9.0}/tests/test_cli_chassis.py +0 -0
  105. {devague-0.8.0 → devague-0.9.0}/tests/test_cli_errors.py +0 -0
  106. {devague-0.8.0 → devague-0.9.0}/tests/test_cli_moves.py +0 -0
  107. {devague-0.8.0 → devague-0.9.0}/tests/test_cli_output.py +0 -0
  108. {devague-0.8.0 → devague-0.9.0}/tests/test_cli_question.py +0 -0
  109. {devague-0.8.0 → devague-0.9.0}/tests/test_cli_review.py +0 -0
  110. {devague-0.8.0 → devague-0.9.0}/tests/test_contract.py +0 -0
  111. {devague-0.8.0 → devague-0.9.0}/tests/test_convergence.py +0 -0
  112. {devague-0.8.0 → devague-0.9.0}/tests/test_frame.py +0 -0
  113. {devague-0.8.0 → devague-0.9.0}/tests/test_offline.py +0 -0
  114. {devague-0.8.0 → devague-0.9.0}/tests/test_package.py +0 -0
  115. {devague-0.8.0 → devague-0.9.0}/tests/test_plan_convergence.py +0 -0
  116. {devague-0.8.0 → devague-0.9.0}/tests/test_plan_store.py +0 -0
  117. {devague-0.8.0 → devague-0.9.0}/tests/test_render.py +0 -0
  118. {devague-0.8.0 → devague-0.9.0}/tests/test_render_plan.py +0 -0
  119. {devague-0.8.0 → devague-0.9.0}/tests/test_review_loop_integration.py +0 -0
  120. {devague-0.8.0 → devague-0.9.0}/tests/test_review_loop_invariants.py +0 -0
  121. {devague-0.8.0 → devague-0.9.0}/tests/test_spec_to_plan_skill.py +0 -0
  122. {devague-0.8.0 → devague-0.9.0}/tests/test_store.py +0 -0
  123. {devague-0.8.0 → devague-0.9.0}/tests/test_think_skill.py +0 -0
@@ -57,6 +57,7 @@ install hint. Every move except `status` is forwarded verbatim as `devague plan
57
57
  | `risk "<text>" --kind <kind>` | Record a first-class plan risk (`--task <tN>` to attach). |
58
58
  | `converge` | Evaluate the gate against the **live** source frame; list remaining gaps. |
59
59
  | `export` | Write the buildable plan to `docs/plans/` — only after `converge` passes. |
60
+ | `waves` | Emit deterministic dependency waves (`{plan, waves}`) — scheduling metadata only, *not* orchestration. Read-only, works on an in-progress plan; refuses a cyclic/dangling graph. Devague describes the graph; an operator decides how to run it (#20). |
60
61
  | `show` / `list` | Render a plan / list plans (`--json` for raw state). |
61
62
  | `learn` / `explain <move>` | Teach the method / explain one move. |
62
63
 
@@ -66,6 +66,7 @@ Moves (forwarded to `devague plan`; run `devague plan learn` for the method):
66
66
  risk record a first-class plan risk
67
67
  converge check whether the plan can export
68
68
  export write the buildable plan (only after converge passes)
69
+ waves emit deterministic dependency waves (scheduling metadata, not orchestration)
69
70
  show / list render a plan / list plans
70
71
  learn teach the method | explain <move> explain one move
71
72
 
@@ -5,6 +5,13 @@ 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.9.0] - 2026-05-23
9
+
10
+ ### Added
11
+
12
+ - **Plan dependency waves (#20).** New `devague plan waves [--json]` move emits the plan's task dependency graph as deterministic, machine-readable scheduling metadata (`{plan, waves}` — ordered batches of task ids where wave 0 has no unsatisfied dependency and each later wave depends only on earlier ones). Read-only and convergence-agnostic, so it works on an in-progress plan. Rejected tasks are excluded; a cycle or a dependency on a missing/rejected task is refused by reusing the plan-convergence dependency blockers (`dependency_blockers`). This is the small deterministic primitive behind #13: Devague *describes* the parallelizable graph; it does not spawn subagents, manage worktrees, mark tasks done, or pick a backend.
13
+ - **Dated export filenames (#12).** `devague export` and `devague plan export` now prefix the written file with the frame/plan creation date — `docs/specs/<YYYY-MM-DD>-<slug>.md` and `docs/plans/<YYYY-MM-DD>-<slug>.md`. The date comes from the object's `created` timestamp (not today), so re-exporting an unchanged artifact overwrites the same file rather than spawning a dated duplicate. Existing exported docs were renamed to match.
14
+
8
15
  ## [0.8.0] - 2026-05-23
9
16
 
10
17
  ### Added
@@ -30,8 +30,8 @@ gate, renderer registry, and the flat moves `new` / `capture` / `interrogate` /
30
30
  structural peer:
31
31
  `devague/plan.py`, `plan_convergence.py`, `plan_store.py`, `render/plan_md.py`,
32
32
  and the nested group `devague plan <move>` (`new` / `task` / `accept` / `depend`
33
- / `cover` / `confirm` / `reject` / `risk` / `converge` / `export` / `show` /
34
- `list` / `learn` / `explain`). The two operator skills are `/think` (idea→spec,
33
+ / `cover` / `confirm` / `reject` / `risk` / `converge` / `export` / `waves` /
34
+ `show` / `list` / `learn` / `explain`). The two operator skills are `/think` (idea→spec,
35
35
  renamed from `/devague`) and `/spec-to-plan` (spec→plan). Coverage ≥ 95 %; all
36
36
  linters pass. Run `git ls-files` to see the real surface.
37
37
 
@@ -86,7 +86,18 @@ verbs). The workflow:
86
86
  covered by a confirmed task, every confirmed task has acceptance criteria, the
87
87
  dependency graph is acyclic, and no blocking risk remains.
88
88
  5. `devague plan export` — only after `converge` passes; writes a buildable
89
- plan-md (topologically ordered) to `docs/plans/`.
89
+ plan-md (topologically ordered) to `docs/plans/<created-date>-<slug>.md`.
90
+ 6. `devague plan waves [--json]` — emit the plan's dependency graph as
91
+ deterministic **scheduling metadata** (`{plan, waves}`): ordered batches of
92
+ task ids that an external operator *could* fan out. Read-only,
93
+ convergence-agnostic (works on an in-progress plan), and explicitly **not
94
+ orchestration** — Devague describes the graph; it does not spawn subagents,
95
+ manage worktrees, mark tasks done, or pick a backend (#20). A cyclic or
96
+ dangling graph is refused via the plan-convergence dependency blockers.
97
+
98
+ Both `devague export` and `devague plan export` prefix the written file with the
99
+ frame/plan creation date (`<YYYY-MM-DD>-<slug>.md`, #12), so re-exporting an
100
+ unchanged artifact overwrites the same file rather than spawning a duplicate.
90
101
 
91
102
  Full design: `docs/superpowers/specs/2026-05-23-devague-spec-to-plan-design.md`.
92
103
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devague
3
- Version: 0.8.0
3
+ Version: 0.9.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
@@ -9,6 +9,7 @@ from devague import render, store
9
9
  from devague.cli._errors import EXIT_USER_ERROR, DevagueError
10
10
  from devague.cli._frames import resolve
11
11
  from devague.cli._output import emit_result
12
+ from devague.cli._paths import dated_name
12
13
  from devague.convergence import evaluate
13
14
 
14
15
  SPECS_DIR = Path("docs/specs")
@@ -25,7 +26,7 @@ def cmd_export(args: argparse.Namespace) -> int:
25
26
  )
26
27
  text = render.render(frame, args.format)
27
28
  SPECS_DIR.mkdir(parents=True, exist_ok=True)
28
- out_path = SPECS_DIR / f"{frame.slug}.md"
29
+ out_path = SPECS_DIR / dated_name(frame.created, frame.slug)
29
30
  out_path.write_text(text, encoding="utf-8")
30
31
  frame.status = "exported"
31
32
  store.save(frame)
@@ -19,10 +19,12 @@ from devague import plan_store, store
19
19
  from devague.cli._errors import EXIT_USER_ERROR, DevagueError
20
20
  from devague.cli._frames import resolve as resolve_frame
21
21
  from devague.cli._output import emit_result
22
+ from devague.cli._paths import dated_name
22
23
  from devague.cli._plans import resolve_plan
23
24
  from devague.convergence import evaluate as evaluate_frame
24
25
  from devague.frame import Frame
25
- from devague.plan import RISK_KINDS, Plan, targets_from_frame, to_dict
26
+ from devague.plan import RISK_KINDS, Plan, dependency_waves, targets_from_frame, to_dict
27
+ from devague.plan_convergence import dependency_blockers
26
28
  from devague.plan_convergence import evaluate as evaluate_plan
27
29
  from devague.render import plan_md
28
30
 
@@ -30,6 +32,7 @@ PLANS_OUT_DIR = Path("docs/plans")
30
32
 
31
33
  _JSON_HELP = "Emit structured JSON."
32
34
  _TASK_ID_HELP = "Task id."
35
+ _RESOLVE_HINT = "resolve: " # prefix for a "; "-joined blocker remediation hint
33
36
 
34
37
  PLAN_MOVES = {
35
38
  "new": "Start a plan from a converged frame (derives coverage targets).",
@@ -42,6 +45,7 @@ PLAN_MOVES = {
42
45
  "risk": "Record a first-class plan risk instead of papering over it.",
43
46
  "converge": "Check whether the plan can export, against the live frame.",
44
47
  "export": "Write the buildable plan — only once the plan converges.",
48
+ "waves": "Emit deterministic dependency waves (scheduling metadata, not orchestration).",
45
49
  "show": "Render the plan.",
46
50
  "list": "List plans.",
47
51
  }
@@ -102,7 +106,7 @@ def cmd_plan_new(args: argparse.Namespace) -> int:
102
106
  raise DevagueError(
103
107
  EXIT_USER_ERROR,
104
108
  f"frame '{frame.slug}' has not converged; cannot start a plan",
105
- "resolve: " + "; ".join(result.blockers),
109
+ _RESOLVE_HINT + "; ".join(result.blockers),
106
110
  )
107
111
  if plan_store.path_for(frame.slug).exists():
108
112
  raise DevagueError(
@@ -267,12 +271,12 @@ def cmd_plan_export(args: argparse.Namespace) -> int:
267
271
  raise DevagueError(
268
272
  EXIT_USER_ERROR,
269
273
  "plan has not converged; cannot export",
270
- "resolve: " + "; ".join(result.blockers),
274
+ _RESOLVE_HINT + "; ".join(result.blockers),
271
275
  )
272
276
  plan.status = "exported"
273
277
  text = plan_md.render_plan(plan, frame)
274
278
  PLANS_OUT_DIR.mkdir(parents=True, exist_ok=True)
275
- out_path = PLANS_OUT_DIR / f"{plan.slug}.md"
279
+ out_path = PLANS_OUT_DIR / dated_name(plan.created, plan.slug)
276
280
  out_path.write_text(text, encoding="utf-8")
277
281
  plan_store.save(plan)
278
282
  if getattr(args, "json", False):
@@ -282,6 +286,34 @@ def cmd_plan_export(args: argparse.Namespace) -> int:
282
286
  return 0
283
287
 
284
288
 
289
+ def cmd_plan_waves(args: argparse.Namespace) -> int:
290
+ """Emit the plan's dependency waves — deterministic scheduling metadata only.
291
+
292
+ Read-only and convergence-agnostic: waves derive purely from the task dependency
293
+ graph, so they work on an in-progress plan. Devague describes the graph; an
294
+ external operator (Culture, codexd, …) decides how to execute it — see #20. A cycle
295
+ or a dep on a missing/rejected task is refused by reusing the plan-convergence
296
+ integrity blockers.
297
+ """
298
+ plan = resolve_plan(args.plan)
299
+ blockers = dependency_blockers(plan)
300
+ if blockers:
301
+ raise DevagueError(
302
+ EXIT_USER_ERROR,
303
+ "cannot derive waves: the dependency graph is not sound",
304
+ _RESOLVE_HINT + "; ".join(blockers),
305
+ )
306
+ waves = dependency_waves(plan.tasks)
307
+ if getattr(args, "json", False):
308
+ emit_result({"plan": plan.slug, "waves": waves}, json_mode=True)
309
+ elif not waves:
310
+ emit_result("no tasks to schedule", json_mode=False)
311
+ else:
312
+ lines = [f"wave {i}: {', '.join(w)}" for i, w in enumerate(waves)]
313
+ emit_result("\n".join(lines), json_mode=False)
314
+ return 0
315
+
316
+
285
317
  def cmd_plan_show(args: argparse.Namespace) -> int:
286
318
  plan = resolve_plan(args.plan)
287
319
  if getattr(args, "json", False):
@@ -424,6 +456,10 @@ def register(sub: argparse._SubParsersAction) -> None:
424
456
  _plan_opt(pex)
425
457
  pex.set_defaults(func=cmd_plan_export)
426
458
 
459
+ pwv = psub.add_parser("waves", help="Emit deterministic dependency waves (metadata only).")
460
+ _plan_opt(pwv)
461
+ pwv.set_defaults(func=cmd_plan_waves)
462
+
427
463
  psh = psub.add_parser("show", help="Render the plan.")
428
464
  _plan_opt(psh)
429
465
  psh.set_defaults(func=cmd_plan_show)
@@ -0,0 +1,39 @@
1
+ """Filesystem naming for exported artifacts (specs and plans)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+ # Stable, date-shaped sentinel used when ``created`` is absent or malformed. It must
8
+ # not vary by run (e.g. today's date) or idempotence would break for that edge — see
9
+ # the rationale in ``dated_name``.
10
+ UNDATED_PREFIX = "0000-00-00"
11
+
12
+
13
+ def dated_name(created: str, slug: str) -> str:
14
+ """Return ``<YYYY-MM-DD>-<slug>.md`` — the export filename with a date prefix (#12).
15
+
16
+ The date is taken from ``created`` (the object's persisted ISO timestamp,
17
+ ``2026-05-23T…``) rather than today, so re-exporting an unchanged object is
18
+ idempotent — it overwrites the same file instead of spawning a dated duplicate. An
19
+ object is always saved (creation stamped) before it can converge and export, so a
20
+ real date is the normal case.
21
+
22
+ If ``created`` is somehow absent or malformed (only reachable via a hand-corrupted
23
+ store file — ``store.save`` repopulates an *empty* stamp but not an *invalid* one),
24
+ we fall back to the constant :data:`UNDATED_PREFIX` rather than today's date: a
25
+ run-varying fallback would itself break the idempotence this prefix exists to
26
+ provide.
27
+ """
28
+ date = (created or "")[:10]
29
+ if not _is_iso_date(date):
30
+ date = UNDATED_PREFIX
31
+ return f"{date}-{slug}.md"
32
+
33
+
34
+ def _is_iso_date(value: str) -> bool:
35
+ try:
36
+ time.strptime(value, "%Y-%m-%d")
37
+ return True
38
+ except ValueError:
39
+ return False
@@ -169,6 +169,44 @@ def targets_from_frame(frame: Frame) -> list[CoverageTarget]:
169
169
  return targets
170
170
 
171
171
 
172
+ def dependency_waves(tasks: list[Task]) -> list[list[str]]:
173
+ """Layer active (non-rejected) tasks into deterministic dependency waves.
174
+
175
+ Wave 0 is every active task with no unsatisfied dependency; each later wave is the
176
+ tasks whose deps are all satisfied by earlier waves — the parallel batches an
177
+ external operator *could* fan out (Devague describes the graph; it does not run it).
178
+ Within a wave ids keep stored order, so the layering is deterministic for a given
179
+ plan.
180
+
181
+ Rejected tasks are excluded entirely. A dependency on a task outside the active set
182
+ (unknown, or rejected) is treated as already satisfied here so the function stays
183
+ **total**: those dangling edges are integrity failures surfaced separately by the
184
+ plan-convergence gate (:func:`devague.plan_convergence.dependency_blockers`), not
185
+ scheduling facts. The graph is assumed acyclic; any tasks left unplaceable by a
186
+ cycle are appended as a final wave so this never loops forever.
187
+
188
+ This is the wave-grouped peer of ``render.plan_md._topo_order`` (which flattens a
189
+ *greedy* topological order for the exported plan); the two intentionally differ in
190
+ grouping, so they are kept as separate functions.
191
+ """
192
+ active = [t for t in tasks if t.status != "rejected"]
193
+ by_id = {t.id: t for t in active}
194
+ placed: set[str] = set()
195
+ remaining = list(active)
196
+ waves: list[list[str]] = []
197
+ progress = True
198
+ while remaining and progress:
199
+ ready = [t for t in remaining if all(d in placed or d not in by_id for d in t.deps)]
200
+ progress = bool(ready)
201
+ if ready:
202
+ waves.append([t.id for t in ready])
203
+ placed.update(t.id for t in ready)
204
+ remaining = [t for t in remaining if t.id not in placed]
205
+ if remaining: # cycle leftover — the caller should have blocked this
206
+ waves.append([t.id for t in remaining])
207
+ return waves
208
+
209
+
172
210
  def to_dict(plan: Plan) -> dict:
173
211
  return dataclasses.asdict(plan)
174
212
 
@@ -121,6 +121,17 @@ def _missing_dep_integrity(plan: Plan) -> list[str]:
121
121
  return missing
122
122
 
123
123
 
124
+ def dependency_blockers(plan: Plan) -> list[str]:
125
+ """Public view of just the dependency-graph integrity blockers.
126
+
127
+ The dangling-dep / rejected-dep / cycle subset of the full gate — without the
128
+ coverage, acceptance, or resolution checks. ``devague plan waves`` uses this to
129
+ refuse an unsound graph (a cycle or a dep on a missing/rejected task) while still
130
+ emitting waves for an otherwise in-progress, not-yet-converged plan.
131
+ """
132
+ return _missing_dep_integrity(plan)
133
+
134
+
124
135
  def _missing_risks(plan: Plan) -> list[str]:
125
136
  return [f"blocking risk {r.id} unresolved" for r in plan.risks if r.kind == "unknown_blocking"]
126
137
 
@@ -184,6 +184,17 @@ plus `deps`, `covers`, `acceptance_criteria`), and PlanRisks. It reuses the same
184
184
  structured convergence result, serialized under `ready_for_plan`. See
185
185
  `docs/superpowers/specs/2026-05-23-devague-spec-to-plan-design.md`.
186
186
 
187
+ `plan waves` emits the plan's dependency graph as deterministic, machine-readable
188
+ scheduling metadata — `{plan, waves}`, where `waves` is an ordered list of task-id
189
+ batches (wave 0 has no unsatisfied dependency; each later wave depends only on
190
+ earlier ones). It is **read-only**, never mutates state, and is **not** gated on
191
+ convergence, so it works on an in-progress plan. Rejected tasks are excluded; a
192
+ cycle or a dependency on a missing/rejected task is refused by reusing the
193
+ plan-convergence dependency blockers. The boundary is deliberate (issue #20):
194
+ Devague *describes* the parallelizable graph; an external operator (Culture,
195
+ codexd, …) decides how — or whether — to execute it. Devague does not spawn
196
+ subagents, manage worktrees, mark tasks done, or choose a backend.
197
+
187
198
  Plans carry the same persistence contract as frames. Every plan has an integer
188
199
  `schema_version` (currently `1`, `PLAN_SCHEMA_VERSION`), written on save and
189
200
  checked on load: `plan_store.load` **fails closed** with a clean `DevagueError`
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devague"
3
- version = "0.8.0"
3
+ version = "0.9.0"
4
4
  description = "devague — turns a vague feature idea into a buildable spec, then a buildable plan."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -2,6 +2,7 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import json
5
+ import re
5
6
  from pathlib import Path
6
7
 
7
8
  import pytest
@@ -51,10 +52,13 @@ def test_export_writes_spec_when_converged(tmp_path, monkeypatch) -> None:
51
52
  _converged(monkeypatch, tmp_path)
52
53
  rc = main(["export"])
53
54
  assert rc == 0
54
- out = Path("docs/specs") / f"{store.current_slug()}.md"
55
+ frame = store.load(store.current_slug())
56
+ # #12: exported docs carry a YYYY-MM-DD prefix from the frame's creation date.
57
+ out = Path("docs/specs") / f"{frame.created[:10]}-{frame.slug}.md"
55
58
  assert out.exists()
59
+ assert re.fullmatch(r"\d{4}-\d{2}-\d{2}-.+\.md", out.name)
56
60
  assert out.read_text(encoding="utf-8").startswith("# Specs in minutes")
57
- assert store.load(store.current_slug()).status == "exported"
61
+ assert frame.status == "exported"
58
62
 
59
63
 
60
64
  def test_converge_demotes_converged_frame_when_gate_fails(tmp_path, monkeypatch, capsys) -> None:
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from devague.cli._paths import UNDATED_PREFIX, dated_name
4
+
5
+
6
+ def test_dated_name_uses_created_date_prefix() -> None:
7
+ assert dated_name("2026-05-23T11:29:36Z", "my-slug") == "2026-05-23-my-slug.md"
8
+
9
+
10
+ def test_dated_name_falls_back_to_stable_sentinel_when_created_missing() -> None:
11
+ # A run-varying fallback (e.g. today) would break idempotence; the sentinel is
12
+ # constant, so re-exporting always lands on the same file. (Qodo #27 review.)
13
+ assert dated_name("", "my-slug") == f"{UNDATED_PREFIX}-my-slug.md"
14
+
15
+
16
+ def test_dated_name_falls_back_to_stable_sentinel_when_created_malformed() -> None:
17
+ assert dated_name("not-a-date", "my-slug") == f"{UNDATED_PREFIX}-my-slug.md"
18
+
19
+
20
+ def test_dated_name_fallback_is_idempotent_across_calls() -> None:
21
+ assert dated_name("garbage", "my-slug") == dated_name("garbage", "my-slug")
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import re
4
5
  from pathlib import Path
5
6
 
6
7
  import pytest
@@ -198,8 +199,11 @@ def test_export_writes_plan_md(tmp_path, monkeypatch, capsys) -> None:
198
199
  slug = _converged_plan(monkeypatch, tmp_path, capsys)
199
200
  rc = main(["plan", "export"])
200
201
  assert rc == 0
201
- out = Path("docs/plans") / f"{slug}.md"
202
+ plan = plan_store.load(slug)
203
+ # #12: exported docs carry a YYYY-MM-DD prefix from the plan's creation date.
204
+ out = Path("docs/plans") / f"{plan.created[:10]}-{slug}.md"
202
205
  assert out.exists()
206
+ assert re.fullmatch(r"\d{4}-\d{2}-\d{2}-.+\.md", out.name)
203
207
  text = out.read_text(encoding="utf-8")
204
208
  assert text.startswith("# Build Plan — Ship the plan engine")
205
209
  assert "status: `exported`" in text
@@ -301,13 +305,93 @@ def test_resolve_plan_rejects_malformed_value(tmp_path, monkeypatch, capsys) ->
301
305
  assert "malformed" in capsys.readouterr().err
302
306
 
303
307
 
308
+ # ── waves (#20) ───────────────────────────────────────────────────────────────
309
+ def _plan_with_deps(monkeypatch, tmp_path, capsys, deps: list[list[str]]) -> str:
310
+ """Seed an in-progress plan whose task i depends on the ids in ``deps[i-1]``."""
311
+ slug = _converged_frame(monkeypatch, tmp_path)
312
+ main(["plan", "new", "--frame", slug])
313
+ for i, d in enumerate(deps, start=1):
314
+ argv = ["plan", "task", f"task {i}"]
315
+ for dep in d:
316
+ argv += ["--dep", dep]
317
+ main(argv)
318
+ capsys.readouterr()
319
+ return slug
320
+
321
+
322
+ def test_waves_json_linear(tmp_path, monkeypatch, capsys) -> None:
323
+ slug = _plan_with_deps(monkeypatch, tmp_path, capsys, [[], ["t1"], ["t2"]])
324
+ assert main(["plan", "waves", "--json"]) == 0
325
+ payload = json.loads(capsys.readouterr().out)
326
+ assert payload["plan"] == slug
327
+ assert payload["waves"] == [["t1"], ["t2"], ["t3"]]
328
+
329
+
330
+ def test_waves_text_mode_lists_each_wave(tmp_path, monkeypatch, capsys) -> None:
331
+ _plan_with_deps(monkeypatch, tmp_path, capsys, [[], ["t1"]])
332
+ assert main(["plan", "waves"]) == 0
333
+ out = capsys.readouterr().out
334
+ assert "t1" in out and "t2" in out
335
+
336
+
337
+ def test_waves_emit_on_unconverged_plan(tmp_path, monkeypatch, capsys) -> None:
338
+ # No coverage / acceptance => the plan has not converged, but waves still emit:
339
+ # waves are scheduling metadata derived from the graph, not gated on convergence.
340
+ _plan_with_deps(monkeypatch, tmp_path, capsys, [[], ["t1"]])
341
+ assert main(["plan", "converge", "--json"]) == 0
342
+ assert json.loads(capsys.readouterr().out)["ready_for_plan"] is False
343
+ assert main(["plan", "waves", "--json"]) == 0
344
+ assert json.loads(capsys.readouterr().out)["waves"] == [["t1"], ["t2"]]
345
+
346
+
347
+ def test_waves_excludes_rejected_tasks(tmp_path, monkeypatch, capsys) -> None:
348
+ _plan_with_deps(monkeypatch, tmp_path, capsys, [[], [], ["t1"]])
349
+ main(["plan", "reject", "t2"])
350
+ capsys.readouterr()
351
+ assert main(["plan", "waves", "--json"]) == 0
352
+ flat = [tid for w in json.loads(capsys.readouterr().out)["waves"] for tid in w]
353
+ assert "t2" not in flat and {"t1", "t3"} <= set(flat)
354
+
355
+
356
+ def test_waves_does_not_mutate_plan_state(tmp_path, monkeypatch, capsys) -> None:
357
+ slug = _plan_with_deps(monkeypatch, tmp_path, capsys, [[], ["t1"]])
358
+ before = plan_store.path_for(slug).read_text(encoding="utf-8")
359
+ assert main(["plan", "waves", "--json"]) == 0
360
+ assert plan_store.path_for(slug).read_text(encoding="utf-8") == before
361
+
362
+
363
+ def test_waves_dangling_dep_errors(tmp_path, monkeypatch, capsys) -> None:
364
+ _plan_with_deps(monkeypatch, tmp_path, capsys, [["t99"]])
365
+ assert main(["plan", "waves", "--json"]) == 1
366
+ assert "unknown task" in capsys.readouterr().err
367
+
368
+
369
+ def test_waves_cycle_errors(tmp_path, monkeypatch, capsys) -> None:
370
+ _plan_with_deps(monkeypatch, tmp_path, capsys, [[], ["t1"]])
371
+ main(["plan", "depend", "t1", "--on", "t2"]) # t1 -> t2 -> t1
372
+ capsys.readouterr()
373
+ assert main(["plan", "waves"]) == 1
374
+ assert "cycle" in capsys.readouterr().err
375
+
376
+
377
+ def test_waves_dep_on_rejected_errors(tmp_path, monkeypatch, capsys) -> None:
378
+ _plan_with_deps(monkeypatch, tmp_path, capsys, [[], ["t1"]])
379
+ main(["plan", "reject", "t1"]) # t2 still depends on the now-rejected t1
380
+ capsys.readouterr()
381
+ assert main(["plan", "waves"]) == 1
382
+ assert "rejected task" in capsys.readouterr().err
383
+
384
+
304
385
  # ── learn / explain ───────────────────────────────────────────────────────────
305
386
  def test_learn_and_explain(tmp_path, monkeypatch, capsys) -> None:
306
387
  monkeypatch.chdir(tmp_path)
307
388
  assert main(["plan", "learn"]) == 0
308
389
  assert "spec into a buildable plan" in capsys.readouterr().out
309
390
  assert main(["plan", "learn", "--json"]) == 0
310
- assert "converge" in json.loads(capsys.readouterr().out)["moves"]
391
+ moves = json.loads(capsys.readouterr().out)["moves"]
392
+ assert "converge" in moves and "waves" in moves
393
+ assert main(["plan", "explain", "waves", "--json"]) == 0
394
+ assert json.loads(capsys.readouterr().out)["move"] == "waves"
311
395
  assert main(["plan", "explain", "task", "--json"]) == 0
312
396
  assert json.loads(capsys.readouterr().out)["move"] == "task"
313
397
  assert main(["plan", "explain", "bogus"]) == 1
@@ -8,6 +8,7 @@ from devague.plan import (
8
8
  Plan,
9
9
  PlanRisk,
10
10
  Task,
11
+ dependency_waves,
11
12
  from_dict,
12
13
  targets_from_frame,
13
14
  to_dict,
@@ -150,3 +151,78 @@ def test_targets_from_frame_includes_only_confirmed_spec_elements() -> None:
150
151
  assert "c3" not in by_id
151
152
  assert "c4" not in by_id
152
153
  assert "h2" not in by_id
154
+
155
+
156
+ # ── dependency waves (#20) ────────────────────────────────────────────────────
157
+ def _waves_plan(specs: list[tuple[str, list[str], str]]) -> Plan:
158
+ """Build a plan from ``(summary, deps, status)`` rows; ids are t1.. in order."""
159
+ p = Plan(slug="w", title="W", frame_slug="w")
160
+ for summary, deps, status in specs:
161
+ t = p.add_task(summary)
162
+ t.deps = list(deps)
163
+ t.status = status
164
+ return p
165
+
166
+
167
+ def test_waves_linear_chain() -> None:
168
+ p = _waves_plan(
169
+ [("a", [], "confirmed"), ("b", ["t1"], "confirmed"), ("c", ["t2"], "confirmed")]
170
+ )
171
+ assert dependency_waves(p.tasks) == [["t1"], ["t2"], ["t3"]]
172
+
173
+
174
+ def test_waves_parallel_fan_out() -> None:
175
+ p = _waves_plan(
176
+ [
177
+ ("root", [], "confirmed"),
178
+ ("a", ["t1"], "confirmed"),
179
+ ("b", ["t1"], "confirmed"),
180
+ ("c", ["t1"], "confirmed"),
181
+ ]
182
+ )
183
+ assert dependency_waves(p.tasks) == [["t1"], ["t2", "t3", "t4"]]
184
+
185
+
186
+ def test_waves_join() -> None:
187
+ p = _waves_plan(
188
+ [("a", [], "confirmed"), ("b", [], "confirmed"), ("join", ["t1", "t2"], "confirmed")]
189
+ )
190
+ assert dependency_waves(p.tasks) == [["t1", "t2"], ["t3"]]
191
+
192
+
193
+ def test_waves_independent_tasks_share_one_wave_in_stored_order() -> None:
194
+ p = _waves_plan([("a", [], "confirmed"), ("b", [], "confirmed"), ("c", [], "confirmed")])
195
+ assert dependency_waves(p.tasks) == [["t1", "t2", "t3"]]
196
+
197
+
198
+ def test_waves_exclude_rejected_tasks() -> None:
199
+ p = _waves_plan([("a", [], "confirmed"), ("dead", [], "rejected"), ("b", ["t1"], "confirmed")])
200
+ waves = dependency_waves(p.tasks)
201
+ assert waves == [["t1"], ["t3"]]
202
+ assert "t2" not in [tid for w in waves for tid in w]
203
+
204
+
205
+ def test_waves_deterministic_across_calls() -> None:
206
+ p = _waves_plan(
207
+ [("root", [], "confirmed"), ("a", ["t1"], "confirmed"), ("b", ["t1"], "confirmed")]
208
+ )
209
+ assert dependency_waves(p.tasks) == dependency_waves(p.tasks)
210
+
211
+
212
+ def test_waves_empty_plan_is_empty() -> None:
213
+ assert dependency_waves(Plan(slug="w", title="W", frame_slug="w").tasks) == []
214
+
215
+
216
+ def test_waves_dep_on_rejected_is_ignored_by_pure_layering() -> None:
217
+ # The pure function treats a dep outside the active set as satisfied (it is total);
218
+ # the *integrity error* for that dangling edge is the CLI/gate's job, not here.
219
+ p = _waves_plan([("dead", [], "rejected"), ("b", ["t1"], "confirmed")])
220
+ assert dependency_waves(p.tasks) == [["t2"]]
221
+
222
+
223
+ def test_waves_cycle_leftover_appended_without_hanging() -> None:
224
+ # Callers gate cycles via convergence blockers; the pure fn must stay total and
225
+ # surface the unplaceable tasks as a trailing wave rather than loop forever.
226
+ p = _waves_plan([("a", ["t2"], "confirmed"), ("b", ["t1"], "confirmed")])
227
+ waves = dependency_waves(p.tasks)
228
+ assert [tid for w in waves for tid in w] == ["t1", "t2"]
@@ -183,7 +183,7 @@ wheels = [
183
183
 
184
184
  [[package]]
185
185
  name = "devague"
186
- version = "0.8.0"
186
+ version = "0.9.0"
187
187
  source = { editable = "." }
188
188
 
189
189
  [package.dev-dependencies]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes