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