deepmodel-dmx 0.3.3__tar.gz → 0.3.4__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 (89) hide show
  1. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/CHANGELOG.md +11 -0
  2. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/PKG-INFO +107 -4
  3. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/README.md +106 -3
  4. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/pyproject.toml +1 -1
  5. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/loop_tools.py +135 -2
  6. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/skills/workflow/1-triage/dmx-create-ticket.md +34 -12
  7. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/skills/workflow/5-ship/dmx-close-ticket.md +6 -2
  8. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/skills/workflow/6-release/dmx-draft-release-note.md +32 -1
  9. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/validators/check_spec_complete.py +1 -1
  10. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/tests/test_loop_tools.py +208 -1
  11. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/tests/test_validators.py +1 -1
  12. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/uv.lock +1 -1
  13. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/.editorconfig +0 -0
  14. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  15. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/.github/dependabot.yml +0 -0
  16. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/.github/workflows/publish.yml +0 -0
  17. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/.github/workflows/test.yml +0 -0
  18. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/.gitignore +0 -0
  19. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/.pre-commit-config.yaml +0 -0
  20. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/CLA.md +0 -0
  21. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/CONTRIBUTING.md +0 -0
  22. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/LICENSE +0 -0
  23. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/SECURITY.md +0 -0
  24. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/__init__.py +0 -0
  25. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/_workflow_version.py +0 -0
  26. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/catalog.py +0 -0
  27. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/cli.py +0 -0
  28. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/exceptions.py +0 -0
  29. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/http_auth.py +0 -0
  30. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/ide/__init__.py +0 -0
  31. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/ide/detect.py +0 -0
  32. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/ide/emitters.py +0 -0
  33. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/loop_memory.py +0 -0
  34. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/loop_schema.py +0 -0
  35. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/loop_state.py +0 -0
  36. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/loops/__init__.py +0 -0
  37. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/loops/dev.yaml +0 -0
  38. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/loops/plan.yaml +0 -0
  39. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/loops/release.yaml +0 -0
  40. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/loops/spec.yaml +0 -0
  41. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/loops/validate.yaml +0 -0
  42. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/repeat_until.py +0 -0
  43. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/rules/system-prompt.md +0 -0
  44. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/server.py +0 -0
  45. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/skills/loop/dmx-loop-continue.md +0 -0
  46. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/skills/loop/dmx-run-loop.md +0 -0
  47. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/skills/specialist/dmx-docs.md +0 -0
  48. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/skills/specialist/dmx-review.md +0 -0
  49. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/skills/specialist/dmx-secure.md +0 -0
  50. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/skills/specialist/dmx-test.md +0 -0
  51. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/skills/utility/dmx-commit.md +0 -0
  52. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/skills/utility/dmx-create-branch.md +0 -0
  53. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/skills/utility/dmx-draft-pr-description.md +0 -0
  54. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/skills/utility/dmx-status.md +0 -0
  55. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/skills/utility/dmx-sync-branch.md +0 -0
  56. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/skills/utility/dmx-update-memory.md +0 -0
  57. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/skills/workflow/0-init/dmx-init.md +0 -0
  58. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/skills/workflow/1-triage/dmx-derive-ticket.md +0 -0
  59. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/skills/workflow/1-triage/dmx-hotfix.md +0 -0
  60. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/skills/workflow/2-plan/dmx-plan.md +0 -0
  61. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/skills/workflow/3-build/dmx-implement-next-phase.md +0 -0
  62. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/skills/workflow/3-build/dmx-implement-next-task.md +0 -0
  63. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/skills/workflow/4-validate/dmx-validate.md +0 -0
  64. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/skills/workflow/5-ship/dmx-create-pr.md +0 -0
  65. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/skills/workflow/6-release/dmx-create-release.md +0 -0
  66. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/skills/workflow/6-release/dmx-release-merge.md +0 -0
  67. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/tools.py +0 -0
  68. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/validator_runner.py +0 -0
  69. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/validators/__init__.py +0 -0
  70. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/validators/check_plan_complete.py +0 -0
  71. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/validators/check_pr_ready.py +0 -0
  72. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/validators/run_tests.py +0 -0
  73. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/validators/spec_adherence.py +0 -0
  74. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/src/dmx/workspace.py +0 -0
  75. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/tests/__init__.py +0 -0
  76. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/tests/__snapshots__/test_ide.ambr +0 -0
  77. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/tests/conftest.py +0 -0
  78. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/tests/test_catalog.py +0 -0
  79. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/tests/test_cli.py +0 -0
  80. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/tests/test_ide.py +0 -0
  81. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/tests/test_loop_integration.py +0 -0
  82. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/tests/test_loop_memory.py +0 -0
  83. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/tests/test_loop_schema.py +0 -0
  84. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/tests/test_loop_state.py +0 -0
  85. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/tests/test_repeat_until.py +0 -0
  86. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/tests/test_server.py +0 -0
  87. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/tests/test_tools.py +0 -0
  88. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/tests/test_validator_runner.py +0 -0
  89. {deepmodel_dmx-0.3.3 → deepmodel_dmx-0.3.4}/tests/test_workspace.py +0 -0
@@ -7,6 +7,17 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
  ---
9
9
 
10
+ ## [Unreleased]
11
+
12
+ ## [0.3.4] — 2026-09-08
13
+
14
+ ### Fixed
15
+
16
+ - Loop run state (`.dmx/jobs/{job_id}/*.json`) and the session-note breadcrumb `_finish_loop` writes to `activeContext.md` are now committed once a loop run genuinely finishes (not `paused`/`iterating`), instead of being left as an uncommitted, local-only change — matching the README's documented "committed with the PR" contract for `jobs/`. This mattered most for the bundled `release` loop, which has no `on_complete` chain target: nothing downstream ran to commit its final state, and `close-ticket` explicitly makes no `.dmx/` changes before force-deleting the branch, so the loop's own "complete" outcome was silently and permanently lost every time. Silent no-op outside a git repo or when there's nothing to commit; if a commit is attempted but fails (e.g. rejected by a pre-commit hook), the loop's own response now surfaces a warning instead of only logging it server-side, and every git call is timeout-bounded so a hung `git commit` (e.g. waiting on a GPG passphrase) can't hang the whole MCP tool call. (GH-23)
17
+ - `close-ticket` no longer transitions the ticket to Done/Complete (or adds the "PR merged" comment) unless Step 4 actually found a merged PR for the branch — previously the transition step ran unconditionally, so running `/dmx/close-ticket` while a PR was still open could mark the ticket Done before the code had actually merged. Also hardened the transition itself to treat an already-terminal ticket/issue as a no-op success rather than an error (idempotent for `github-issues`' auto-close-via-`Closes #N`, and for Jira setups where a GitHub↔Jira integration already auto-transitioned the ticket). (GH-21)
18
+ - `draft-release-note` now commits and pushes `.dmx/releases/{version}.md` after writing it, instead of leaving it as an uncommitted local file. `release-merge` opens its PR straight from `{branch_base}`'s pushed state, so the release notes file was previously silently absent from that PR's diff whenever it hadn't been committed by hand first. Re-running it against unchanged content (nothing new merged) is a no-op rather than failing on "nothing to commit". (GH-22)
19
+ - Starting the `spec` loop, or running `/dmx/create-ticket` manually, on a repository with zero commits now surfaces a clear, actionable error instead of a confusing one. Previously, `current_branch()` (which shells out to `git rev-parse --abbrev-ref HEAD`) silently returned `None` on a freshly `git init`'d repo's unborn `HEAD`, causing the `spec` loop's branch guard to fail with a generic "could not determine the current git branch" message — even though the branch name itself was perfectly resolvable. `_branch_guard_error` now distinguishes this specific case and tells the user to commit and push before retrying. `dmx-create-ticket.md` gained the same check as its new Step 2, so manual/foreground usage fails fast before creating a ticket or attempting to branch, rather than failing later when GitHub's `create_branch` API rejects branching from a ref-less remote. (GH-19)
20
+
10
21
  ## [0.3.3] — 2026-09-02
11
22
 
12
23
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: deepmodel-dmx
3
- Version: 0.3.3
3
+ Version: 0.3.4
4
4
  Summary: AI SDLC MCP server — skills and rules for any AI IDE.
5
5
  License: AGPL-3.0
6
6
  License-File: LICENSE
@@ -90,7 +90,7 @@ Bundled loops for the SDLC pipeline:
90
90
  | `plan` | plan | `dev` |
91
91
  | `dev` | implement-next-phase, commit | `validate` |
92
92
  | `validate` | validate | `release` |
93
- | `release` | create-pr, update-memory | — |
93
+ | `release` | create-pr | — |
94
94
 
95
95
  Each loop config defines a goal state, optional `repeat_until` condition, validators, human gate policy, and `on_complete` chaining. Run state is written to `.dmx/jobs/{job_id}/{loop_name}-{task_id}.json` — there's no separate active-run pointer; the active run is derived by scanning a job's state files for the one non-terminal (`pending`/`running`/`paused`/`iterating`) entry, keyed off the current branch/ticket. This keeps loop state isolated per branch: pausing work on one branch and running a loop on another can't corrupt or lose either one's state.
96
96
 
@@ -121,12 +121,111 @@ Loop-level memory hooks: before the first skill runs, the runtime surfaces `acti
121
121
  }
122
122
  ```
123
123
 
124
- Add to your IDE config ([Claude Code, Copilot, Antigravity ↓](#step-1--add-the-mcp-server)). Run `/dmx/init` once per project. Then:
124
+ Add to your IDE config ([Claude Code, Copilot, Antigravity ↓](#step-1--add-the-mcp-server)). Then follow [Your first project](#your-first-project) to initialize and run the full loop on a new repo.
125
+
126
+ ## Your first project
127
+
128
+ A brand-new repo, from `/dmx/init` through the first merged PR. You review at every gate; `/dmx/loop-continue` is how you move forward.
129
+
130
+ ### Before you start
131
+
132
+ - A GitHub repo cloned locally with an `origin` remote. The spec loop creates the feature branch on GitHub from `origin/{branch_base}` (usually `main` or `master`), even if you track work in Jira or use no ticketing system.
133
+ - The dmx MCP server added to your IDE ([install guide](#step-1--add-the-mcp-server)).
134
+ - The GitHub MCP server (`user-github`) authenticated. `/dmx/init` probes it before writing any files.
135
+ - The Atlassian MCP server only if you will choose Jira at init.
136
+
137
+ ### 1. Initialize
138
+
139
+ On the integration branch (`main` or `master`):
125
140
 
126
141
  ```
127
- /dmx/create-ticket
142
+ /dmx/init
128
143
  ```
129
144
 
145
+ Choose a workflow — **sdlc** (this walkthrough) or **freestyle** (no process enforced) — and a ticketing system: `none`, `github-issues`, or `jira`. Re-run `/dmx/init` at any time to switch workflow or ticketing; existing memory bank files with content are left intact.
146
+
147
+ Init writes `.dmx/config.md` and the memory bank (`projectbrief.md`, `productContext.md`, `systemPatterns.md`, `techContext.md`, `activeContext.md`). Open a **new chat** so the IDE rules take effect.
148
+
149
+ If you already have a `docs/` folder, init reads it when populating the memory bank. Writing the requirement first is slightly better; either order works.
150
+
151
+ ### 2. Write the product requirement
152
+
153
+ Create `docs/requirements.md` at the repo root and put the product or feature description there. You can paste the requirement into chat later instead, but a file is easier to review and reuse.
154
+
155
+ `docs/requirements.md` is a convention, not a dmx artifact. The spec loop uses whatever you point it at.
156
+
157
+ ### 3. Commit and push
158
+
159
+ The spec loop must start from the configured integration branch, and it creates the remote feature branch from whatever is already on origin. Commit `.dmx/`, `docs/`, and any files init wrote, then push:
160
+
161
+ ```
162
+ git add .
163
+ git commit -m "chore: initialize dmx and capture product requirements"
164
+ git push -u origin HEAD
165
+ ```
166
+
167
+ Stay on `main` or `master`.
168
+
169
+ ### 4. Start the spec loop
170
+
171
+ In the same message as the command, point at the requirement file or paste it:
172
+
173
+ ```
174
+ /dmx/run-loop spec
175
+
176
+ See docs/requirements.md
177
+ ```
178
+
179
+ This creates a GitHub issue or Jira ticket (if you configured one), cuts a feature branch from `origin/{branch_base}`, checks it out, and writes `.dmx/spec.md`. Then it pauses.
180
+
181
+ ### 5. Answer the spec
182
+
183
+ Open `.dmx/spec.md`. Fill in Technical Approach if it is incomplete, and answer every question. Empty answers or placeholders (`TBD`, `TODO`) fail the spec validator.
184
+
185
+ ```
186
+ /dmx/loop-continue
187
+ ```
188
+
189
+ On success the runtime auto-chains to the `plan` loop.
190
+
191
+ ### 6. Review the plan
192
+
193
+ `plan` writes `.dmx/tasks.md` with phases and tasks, then pauses. Edit freely — this is the implementation contract.
194
+
195
+ ```
196
+ /dmx/loop-continue
197
+ ```
198
+
199
+ That starts the `dev` loop.
200
+
201
+ ### 7. Build phase by phase
202
+
203
+ Each `dev` cycle:
204
+
205
+ 1. Implements the next unchecked phase in `tasks.md`, then pauses.
206
+ 2. Review the diff.
207
+ 3. `/dmx/loop-continue` runs **commit only**. It does not push.
208
+ 4. Review the commit.
209
+ 5. `/dmx/loop-continue` again — validators run, then either the next phase starts or the loop chains to `validate`.
210
+
211
+ Repeat until every phase is checked off.
212
+
213
+ ### 8. Validate and open the PR
214
+
215
+ After the last phase, `validate` runs the quality gate and pauses. Review the report, then `/dmx/loop-continue`. On success the `release` loop runs `create-pr`: it pushes the feature branch and opens the pull request.
216
+
217
+ ### 9. Merge and close
218
+
219
+ Review the PR and merge it. Then:
220
+
221
+ ```
222
+ /dmx/close-ticket
223
+ ```
224
+
225
+ That closes the ticket, comments the PR link, and deletes the feature branch locally and on origin. `/dmx/loop-continue` after merge is not a close path — the release loop ends when the PR exists.
226
+
227
+ You can run the same steps by hand (`/dmx/create-ticket`, `/dmx/plan`, `/dmx/implement-next-phase`, …) instead of loops. See the skill catalog below.
228
+
130
229
  ## Learn more
131
230
 
132
231
  - [When Is a Loop Ready to Run Without You?](https://himakara.hashnode.dev/when-is-a-loop-ready-to-run-without-you) — the thinking behind dmx
@@ -208,6 +307,10 @@ Safe to re-run. Updates config without overwriting memory bank files that alread
208
307
 
209
308
  ### Step 3 — Start your first ticket
210
309
 
310
+ On a new repo, follow [Your first project](#your-first-project) (`/dmx/run-loop spec` through `/dmx/close-ticket`).
311
+
312
+ To start a ticket by hand instead:
313
+
211
314
  ```
212
315
  /dmx/create-ticket
213
316
  ```
@@ -55,7 +55,7 @@ Bundled loops for the SDLC pipeline:
55
55
  | `plan` | plan | `dev` |
56
56
  | `dev` | implement-next-phase, commit | `validate` |
57
57
  | `validate` | validate | `release` |
58
- | `release` | create-pr, update-memory | — |
58
+ | `release` | create-pr | — |
59
59
 
60
60
  Each loop config defines a goal state, optional `repeat_until` condition, validators, human gate policy, and `on_complete` chaining. Run state is written to `.dmx/jobs/{job_id}/{loop_name}-{task_id}.json` — there's no separate active-run pointer; the active run is derived by scanning a job's state files for the one non-terminal (`pending`/`running`/`paused`/`iterating`) entry, keyed off the current branch/ticket. This keeps loop state isolated per branch: pausing work on one branch and running a loop on another can't corrupt or lose either one's state.
61
61
 
@@ -86,12 +86,111 @@ Loop-level memory hooks: before the first skill runs, the runtime surfaces `acti
86
86
  }
87
87
  ```
88
88
 
89
- Add to your IDE config ([Claude Code, Copilot, Antigravity ↓](#step-1--add-the-mcp-server)). Run `/dmx/init` once per project. Then:
89
+ Add to your IDE config ([Claude Code, Copilot, Antigravity ↓](#step-1--add-the-mcp-server)). Then follow [Your first project](#your-first-project) to initialize and run the full loop on a new repo.
90
+
91
+ ## Your first project
92
+
93
+ A brand-new repo, from `/dmx/init` through the first merged PR. You review at every gate; `/dmx/loop-continue` is how you move forward.
94
+
95
+ ### Before you start
96
+
97
+ - A GitHub repo cloned locally with an `origin` remote. The spec loop creates the feature branch on GitHub from `origin/{branch_base}` (usually `main` or `master`), even if you track work in Jira or use no ticketing system.
98
+ - The dmx MCP server added to your IDE ([install guide](#step-1--add-the-mcp-server)).
99
+ - The GitHub MCP server (`user-github`) authenticated. `/dmx/init` probes it before writing any files.
100
+ - The Atlassian MCP server only if you will choose Jira at init.
101
+
102
+ ### 1. Initialize
103
+
104
+ On the integration branch (`main` or `master`):
90
105
 
91
106
  ```
92
- /dmx/create-ticket
107
+ /dmx/init
93
108
  ```
94
109
 
110
+ Choose a workflow — **sdlc** (this walkthrough) or **freestyle** (no process enforced) — and a ticketing system: `none`, `github-issues`, or `jira`. Re-run `/dmx/init` at any time to switch workflow or ticketing; existing memory bank files with content are left intact.
111
+
112
+ Init writes `.dmx/config.md` and the memory bank (`projectbrief.md`, `productContext.md`, `systemPatterns.md`, `techContext.md`, `activeContext.md`). Open a **new chat** so the IDE rules take effect.
113
+
114
+ If you already have a `docs/` folder, init reads it when populating the memory bank. Writing the requirement first is slightly better; either order works.
115
+
116
+ ### 2. Write the product requirement
117
+
118
+ Create `docs/requirements.md` at the repo root and put the product or feature description there. You can paste the requirement into chat later instead, but a file is easier to review and reuse.
119
+
120
+ `docs/requirements.md` is a convention, not a dmx artifact. The spec loop uses whatever you point it at.
121
+
122
+ ### 3. Commit and push
123
+
124
+ The spec loop must start from the configured integration branch, and it creates the remote feature branch from whatever is already on origin. Commit `.dmx/`, `docs/`, and any files init wrote, then push:
125
+
126
+ ```
127
+ git add .
128
+ git commit -m "chore: initialize dmx and capture product requirements"
129
+ git push -u origin HEAD
130
+ ```
131
+
132
+ Stay on `main` or `master`.
133
+
134
+ ### 4. Start the spec loop
135
+
136
+ In the same message as the command, point at the requirement file or paste it:
137
+
138
+ ```
139
+ /dmx/run-loop spec
140
+
141
+ See docs/requirements.md
142
+ ```
143
+
144
+ This creates a GitHub issue or Jira ticket (if you configured one), cuts a feature branch from `origin/{branch_base}`, checks it out, and writes `.dmx/spec.md`. Then it pauses.
145
+
146
+ ### 5. Answer the spec
147
+
148
+ Open `.dmx/spec.md`. Fill in Technical Approach if it is incomplete, and answer every question. Empty answers or placeholders (`TBD`, `TODO`) fail the spec validator.
149
+
150
+ ```
151
+ /dmx/loop-continue
152
+ ```
153
+
154
+ On success the runtime auto-chains to the `plan` loop.
155
+
156
+ ### 6. Review the plan
157
+
158
+ `plan` writes `.dmx/tasks.md` with phases and tasks, then pauses. Edit freely — this is the implementation contract.
159
+
160
+ ```
161
+ /dmx/loop-continue
162
+ ```
163
+
164
+ That starts the `dev` loop.
165
+
166
+ ### 7. Build phase by phase
167
+
168
+ Each `dev` cycle:
169
+
170
+ 1. Implements the next unchecked phase in `tasks.md`, then pauses.
171
+ 2. Review the diff.
172
+ 3. `/dmx/loop-continue` runs **commit only**. It does not push.
173
+ 4. Review the commit.
174
+ 5. `/dmx/loop-continue` again — validators run, then either the next phase starts or the loop chains to `validate`.
175
+
176
+ Repeat until every phase is checked off.
177
+
178
+ ### 8. Validate and open the PR
179
+
180
+ After the last phase, `validate` runs the quality gate and pauses. Review the report, then `/dmx/loop-continue`. On success the `release` loop runs `create-pr`: it pushes the feature branch and opens the pull request.
181
+
182
+ ### 9. Merge and close
183
+
184
+ Review the PR and merge it. Then:
185
+
186
+ ```
187
+ /dmx/close-ticket
188
+ ```
189
+
190
+ That closes the ticket, comments the PR link, and deletes the feature branch locally and on origin. `/dmx/loop-continue` after merge is not a close path — the release loop ends when the PR exists.
191
+
192
+ You can run the same steps by hand (`/dmx/create-ticket`, `/dmx/plan`, `/dmx/implement-next-phase`, …) instead of loops. See the skill catalog below.
193
+
95
194
  ## Learn more
96
195
 
97
196
  - [When Is a Loop Ready to Run Without You?](https://himakara.hashnode.dev/when-is-a-loop-ready-to-run-without-you) — the thinking behind dmx
@@ -173,6 +272,10 @@ Safe to re-run. Updates config without overwriting memory bank files that alread
173
272
 
174
273
  ### Step 3 — Start your first ticket
175
274
 
275
+ On a new repo, follow [Your first project](#your-first-project) (`/dmx/run-loop spec` through `/dmx/close-ticket`).
276
+
277
+ To start a ticket by hand instead:
278
+
176
279
  ```
177
280
  /dmx/create-ticket
178
281
  ```
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "deepmodel-dmx"
7
- version = "0.3.3"
7
+ version = "0.3.4"
8
8
  description = "AI SDLC MCP server — skills and rules for any AI IDE."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -40,6 +40,7 @@ from __future__ import annotations
40
40
  import importlib.resources as pkg
41
41
  import logging
42
42
  import re
43
+ import subprocess
43
44
  from pathlib import Path
44
45
 
45
46
  from fastmcp import (
@@ -150,6 +151,41 @@ def _read_branch_base(workspace_root: Path) -> str | None:
150
151
  return value if value and value != "{REQUIRED}" else None
151
152
 
152
153
 
154
+ def _repo_has_no_commits(root: Path) -> bool:
155
+ """True if *root* is a git repo with an unborn HEAD (zero commits).
156
+
157
+ ``git rev-parse --abbrev-ref HEAD`` — what :func:`current_branch` uses —
158
+ fails on a freshly ``git init``'d repo before its first commit, even
159
+ though the branch name (e.g. ``main``/``master``) is perfectly
160
+ resolvable via ``git symbolic-ref``. This distinguishes that specific,
161
+ fixable case (make a commit) from other reasons ``current_branch``
162
+ might return ``None`` (not a git repo at all, detached HEAD, etc.),
163
+ which don't have an actionable one-line fix.
164
+ """
165
+ try:
166
+ result = subprocess.run(
167
+ ["git", "rev-parse", "--is-inside-work-tree"],
168
+ capture_output=True,
169
+ text=True,
170
+ cwd=root,
171
+ )
172
+ except Exception: # noqa: BLE001
173
+ return False
174
+ if result.returncode != 0 or result.stdout.strip() != "true":
175
+ return False # not a git repo at all — a different problem
176
+
177
+ try:
178
+ verify = subprocess.run(
179
+ ["git", "rev-parse", "--verify", "--quiet", "HEAD"],
180
+ capture_output=True,
181
+ text=True,
182
+ cwd=root,
183
+ )
184
+ except Exception: # noqa: BLE001
185
+ return False
186
+ return verify.returncode != 0
187
+
188
+
153
189
  def _branch_guard_error(root: Path, config: LoopConfig) -> str | None:
154
190
  """Return an error message if *config* declares ``require_branch`` and
155
191
  the current branch doesn't satisfy it, else None."""
@@ -166,6 +202,13 @@ def _branch_guard_error(root: Path, config: LoopConfig) -> str | None:
166
202
 
167
203
  branch = current_branch(root)
168
204
  if branch is None:
205
+ if _repo_has_no_commits(root):
206
+ return (
207
+ f"Cannot start the `{config.name}` loop: this repository has no commits yet. "
208
+ f"`{config.name}` creates a new branch from `{branch_base}` on GitHub, which "
209
+ "requires at least one commit to exist first. Commit something (e.g. the "
210
+ "`.dmx/` files `/dmx/init` just wrote) and push to origin, then try again."
211
+ )
169
212
  return (
170
213
  f"Cannot start the `{config.name}` loop: could not determine the current git "
171
214
  f"branch. Make sure you're in a git repository checked out to `{branch_base}`."
@@ -451,6 +494,79 @@ def _start_loop(root: Path, name: str, description: str | None = None) -> str:
451
494
  # ---------------------------------------------------------------------------
452
495
 
453
496
 
497
+ _COMMIT_DMX_STATE_TIMEOUT_SECONDS = 15
498
+
499
+
500
+ def _commit_dmx_state(root: Path, message: str) -> str | None:
501
+ """Best-effort commit of any uncommitted ``.dmx/`` changes.
502
+
503
+ Called once a loop run has genuinely finished (a terminal outcome —
504
+ not ``paused``/``iterating``, which will write state again on the next
505
+ call) so its final state — the job's state JSON and the session-note
506
+ breadcrumb ``_finish_loop`` just wrote to ``activeContext.md`` — isn't
507
+ left as an uncommitted, local-only change. This matters most for a
508
+ loop with no ``on_complete`` chain target (e.g. the bundled ``release``
509
+ loop): nothing downstream runs to commit on its behalf, and
510
+ ``close-ticket`` explicitly makes no ``.dmx/`` changes before
511
+ force-deleting the branch, so an uncommitted final state here would be
512
+ silently and permanently lost — see GH-23.
513
+
514
+ Silently does nothing if *root* isn't a git repo or there's nothing to
515
+ commit — this is a best-effort durability improvement (matching the
516
+ README's "committed with the PR" claim for ``.dmx/jobs/``), not
517
+ something that should ever break a loop response.
518
+
519
+ Every subprocess call is timeout-bounded so a hung ``git`` invocation
520
+ (e.g. a pre-commit hook prompting for input, or GPG signing waiting on
521
+ a passphrase) can't hang the whole MCP tool call indefinitely.
522
+
523
+ Returns:
524
+ ``None`` if there was nothing to commit or the commit succeeded.
525
+ A short, user-facing warning string if a commit was *attempted*
526
+ but failed (e.g. a pre-commit hook rejected it) — silently
527
+ swallowing that would defeat the entire point of this call, so
528
+ callers should surface it in the loop's response rather than only
529
+ logging it server-side.
530
+ """
531
+ try:
532
+ status = subprocess.run(
533
+ ["git", "status", "--short", "--", ".dmx/"],
534
+ capture_output=True,
535
+ text=True,
536
+ cwd=root,
537
+ timeout=_COMMIT_DMX_STATE_TIMEOUT_SECONDS,
538
+ )
539
+ except Exception: # noqa: BLE001
540
+ return None
541
+ if status.returncode != 0 or not status.stdout.strip():
542
+ return None # not a git repo, or nothing under .dmx/ to commit
543
+
544
+ try:
545
+ subprocess.run(
546
+ ["git", "add", ".dmx/"],
547
+ capture_output=True,
548
+ text=True,
549
+ cwd=root,
550
+ check=True,
551
+ timeout=_COMMIT_DMX_STATE_TIMEOUT_SECONDS,
552
+ )
553
+ subprocess.run(
554
+ ["git", "commit", "-m", message],
555
+ capture_output=True,
556
+ text=True,
557
+ cwd=root,
558
+ check=True,
559
+ timeout=_COMMIT_DMX_STATE_TIMEOUT_SECONDS,
560
+ )
561
+ except Exception as exc: # noqa: BLE001
562
+ logger.warning("could not auto-commit .dmx/ state (%s): %s", message, exc)
563
+ return (
564
+ "⚠️ Could not auto-commit the loop's final `.dmx/` state (see server logs for "
565
+ "details) — run `git status .dmx/` and commit manually if needed."
566
+ )
567
+ return None
568
+
569
+
454
570
  def _finish_loop(
455
571
  root: Path,
456
572
  job_id: str,
@@ -571,25 +687,42 @@ def _finish_loop(
571
687
  f"{loop_name} loop completed (outcome: {outcome}) — chaining to "
572
688
  f"{next_loop} was configured but blocked: {chain_guard_error}",
573
689
  )
574
- return (
690
+ commit_warning = _commit_dmx_state(
691
+ root, f"chore: sync loop state for {loop_name} (job {job_id})"
692
+ )
693
+ message = (
575
694
  f"{_complete_message(loop_name, job_id, outcome)}\n\n"
576
695
  f"Configured to chain to **{next_loop}**, but it couldn't start: "
577
696
  f"{chain_guard_error}"
578
697
  )
698
+ if commit_warning:
699
+ message += f"\n\n{commit_warning}"
700
+ return message
579
701
 
580
702
  append_session_note(
581
703
  root,
582
704
  f"{loop_name} loop completed (outcome: {outcome}) — "
583
705
  f"chained to {next_loop} (job `{job_id}`).",
584
706
  )
707
+ commit_warning = _commit_dmx_state(
708
+ root, f"chore: sync loop state for {loop_name} (job {job_id})"
709
+ )
585
710
  chain_header = (
586
711
  f"**{loop_name} loop — complete** (outcome: `{outcome}`)\n\n"
587
712
  f"Chaining automatically to **{next_loop}** loop.\n\n"
588
713
  )
714
+ if commit_warning:
715
+ chain_header += f"{commit_warning}\n\n"
589
716
  return chain_header + _start_loop(root, next_loop)
590
717
 
591
718
  append_session_note(root, f"{loop_name} loop completed (outcome: {outcome}) (job `{job_id}`).")
592
- return _complete_message(loop_name, job_id, outcome)
719
+ commit_warning = _commit_dmx_state(
720
+ root, f"chore: sync loop state for {loop_name} (job {job_id})"
721
+ )
722
+ message = _complete_message(loop_name, job_id, outcome)
723
+ if commit_warning:
724
+ message += f"\n\n{commit_warning}"
725
+ return message
593
726
 
594
727
 
595
728
  # ---------------------------------------------------------------------------
@@ -28,7 +28,29 @@ The project configuration is injected into your context as a rule. Extract:
28
28
 
29
29
  If configuration is not available in context, fall back to reading `.dmx/config.md`. If neither is found, stop: "Project configuration not found. Run /dmx/init to set up this project."
30
30
 
31
- ## Step 2 — Read project context
31
+ ## Step 2 — Verify the repo has at least one commit
32
+
33
+ Run:
34
+ ```
35
+ git rev-parse --verify --quiet HEAD
36
+ ```
37
+
38
+ If this fails (non-zero exit, no output — an unborn `HEAD`, meaning zero commits exist yet), stop before creating anything:
39
+ ```
40
+ This repository has no commits yet. Creating a ticket branches from `{config.branch_base}`
41
+ on GitHub, which requires at least one commit to exist first.
42
+
43
+ Commit something first, e.g. the files /dmx/init just wrote:
44
+ git add .
45
+ git commit -m "chore: initialize project"
46
+ git push -u origin HEAD
47
+
48
+ Then run /dmx/create-ticket again.
49
+ ```
50
+
51
+ Do not proceed to Step 3 in this case, even if `{{task}}` was provided.
52
+
53
+ ## Step 3 — Read project context
32
54
 
33
55
  Read the following memory bank files:
34
56
  - `.dmx/projectbrief.md` — project goals and scope
@@ -39,7 +61,7 @@ If `.dmx/` does not exist, stop: "Memory bank not found. Run /dmx/init to set up
39
61
 
40
62
  Store this context. It will inform the ticket content, technical approach, and spec questions.
41
63
 
42
- ## Step 3 — Infer type
64
+ ## Step 4 — Infer type
43
65
 
44
66
  If `{{type}}` was provided, use it. Accepted values: `feature`, `bug`, `chore`.
45
67
 
@@ -53,9 +75,9 @@ Store as `branch_type` (`feature` | `bug` | `chore`).
53
75
  For Jira `issueTypeName` mapping: `feature` → `Story`, `bug` → `Bug`, `chore` → `Task`.
54
76
  For GitHub Issues label: use `branch_type` as-is.
55
77
 
56
- ## Step 4 — Draft ticket content
78
+ ## Step 5 — Draft ticket content
57
79
 
58
- Using `{{task}}` and the project context from Step 2, generate a `summary` and `description`.
80
+ Using `{{task}}` and the project context from Step 3, generate a `summary` and `description`.
59
81
 
60
82
  ### Summary
61
83
  Single sentence, max 80 characters, imperative present tense, capital first letter, no trailing period. Specific enough that scope is clear without reading the description.
@@ -86,7 +108,7 @@ Examples:
86
108
 
87
109
  Rules: write for the implementer. For bugs, lead with observed vs expected behaviour. Acceptance criteria must be independently verifiable.
88
110
 
89
- ## Step 5 — Create the ticket
111
+ ## Step 6 — Create the ticket
90
112
 
91
113
  **If `ticketing` is `jira`:**
92
114
 
@@ -96,7 +118,7 @@ Call `createJiraIssue` on `user-atlassian`:
96
118
  ```
97
119
  cloudId: {config.cloud_id}
98
120
  projectKey: {config.project_key}
99
- issueTypeName: {Jira type from Step 3}
121
+ issueTypeName: {Jira type from Step 4}
100
122
  summary: {summary}
101
123
  description: {description}
102
124
  assignee_account_id: {account_id}
@@ -129,7 +151,7 @@ Store `ticket_url` = `{html_url}`.
129
151
 
130
152
  No ticket is created. `ticket_ref` = `none`. `ticket_url` = none.
131
153
 
132
- ## Step 6 — Construct the branch name
154
+ ## Step 7 — Construct the branch name
133
155
 
134
156
  Slugify the summary:
135
157
  - Lowercase, hyphens for spaces, remove special characters, collapse consecutive hyphens, truncate to 60 chars
@@ -140,7 +162,7 @@ Slugify the summary:
140
162
  | `github-issues` | `{branch_type}-gh-{number}-{slug}` | `feature-gh-123-add-rate-limiting` |
141
163
  | `none` | `{branch_type}-{slug}` | `feature-add-rate-limiting` |
142
164
 
143
- ## Step 7 — Create the remote branch and check out locally
165
+ ## Step 8 — Create the remote branch and check out locally
144
166
 
145
167
  Call `create_branch` on `user-github`:
146
168
  ```
@@ -158,7 +180,7 @@ git fetch origin
158
180
  git checkout {branch_name}
159
181
  ```
160
182
 
161
- ## Step 8 — Scaffold spec.md
183
+ ## Step 9 — Scaffold spec.md
162
184
 
163
185
  Write `.dmx/spec.md`:
164
186
 
@@ -179,7 +201,7 @@ ticketing: {ticketing}
179
201
  ---
180
202
 
181
203
  ## Context
182
- {From ticket description Step 4 — why this work is needed in this project}
204
+ {From ticket description Step 5 — why this work is needed in this project}
183
205
 
184
206
  ## Scope
185
207
  {Bullet list of what is included. Use project patterns to name specific layers, services, or files.}
@@ -221,7 +243,7 @@ ticketing: {ticketing}
221
243
 
222
244
  When `ticketing` is `none`, omit `ticket` from frontmatter.
223
245
 
224
- ## Step 9 — Transition to In Progress
246
+ ## Step 10 — Transition to In Progress
225
247
 
226
248
  **If `ticketing` is `jira`:**
227
249
  Call `getTransitionsForJiraIssue` on `user-atlassian`. Find `In Progress`. Call `transitionJiraIssue`.
@@ -237,7 +259,7 @@ labels: ["in-progress"]
237
259
 
238
260
  **If `ticketing` is `none`:** Skip.
239
261
 
240
- ## Step 10 — Return the result
262
+ ## Step 11 — Return the result
241
263
 
242
264
  ```
243
265
  {if ticketing ≠ none} Ticket: {ticket_ref} — {summary}
@@ -67,13 +67,15 @@ head: {config.owner}:{close_branch}
67
67
 
68
68
  Filter to PRs where `merged_at` is not null. Store `html_url` and `base.ref`.
69
69
 
70
- If no merged PR is found, warn: "No merged PR found for `{close_branch}`. Proceeding with branch deletion only — ticket will not be updated."
70
+ If no merged PR is found, warn: "No merged PR found for `{close_branch}`. Proceeding with branch deletion only — ticket will not be updated." Then **skip Steps 5 and 6 entirely** — do not transition or comment on a ticket whose work hasn't actually merged yet.
71
71
 
72
72
  ## Step 5 — Transition ticket to Done
73
73
 
74
+ Only run this step if Step 4 found a merged PR. Otherwise skip to Step 7.
75
+
74
76
  **If `ticketing` is `jira`:**
75
77
  1. Call `getTransitionsForJiraIssue` on `user-atlassian` with `{config.cloud_id}` and ticket ID.
76
- 2. Find transition named `Done`. Call `transitionJiraIssue`.
78
+ 2. If no transition named `Done` is available (e.g. the ticket is already in a terminal state — possibly auto-transitioned by a GitHub↔Jira integration when the PR merged), treat this as already-done and continue; do not fail. Otherwise call `transitionJiraIssue`.
77
79
 
78
80
  **If `ticketing` is `github-issues`:**
79
81
  Call `update_issue` on `user-github`:
@@ -83,6 +85,7 @@ repo: {config.repo}
83
85
  issue_number: {ticket number}
84
86
  state: closed
85
87
  ```
88
+ This is idempotent — if the issue was already auto-closed by a `Closes #N` reference in the PR body, this call succeeds as a no-op. Do not treat an already-closed issue as an error.
86
89
 
87
90
  **If `ticketing` is `none`:** Skip.
88
91
 
@@ -167,4 +170,5 @@ Next:
167
170
 
168
171
  - Never close a protected branch.
169
172
  - If the PR is not confirmed merged, warn before deleting the branch. Do not assume the work is done.
173
+ - Never transition or comment on a ticket unless Step 4 found a merged PR — a ticket must not jump to Done while its PR is still open.
170
174
  - Make no changes to any `.dmx/` files — this skill is external-cleanup only. Memory learnings were already synced by `/dmx/create-pr`.
@@ -205,13 +205,44 @@ Create `.dmx/releases/` if it does not exist.
205
205
 
206
206
  Write the assembled release notes to `.dmx/releases/{{version}}.md`.
207
207
 
208
- ## Step 11 — Return the result
208
+ ## Step 11 — Commit and push the release notes
209
+
210
+ `/dmx/release-merge` opens a real GitHub pull request from `{config.branch_base}` — which only
211
+ reflects what's actually pushed to origin. Leaving this file uncommitted means it silently
212
+ never makes it into that PR's diff.
213
+
214
+ Run:
215
+ ```
216
+ git status --short .dmx/releases/{{version}}.md
217
+ ```
218
+
219
+ If this reports a change:
220
+ ```
221
+ git add .dmx/releases/{{version}}.md
222
+ git commit -m "docs: draft release notes for {{version}}"
223
+ git push
224
+ ```
225
+
226
+ If it reports nothing (e.g. re-running against identical content — a bare `git commit` here
227
+ would fail with "nothing to commit"), skip straight to Step 12; the file is already committed
228
+ and pushed from a previous run.
229
+
230
+ This skill must never leave `.dmx/releases/{{version}}.md` uncommitted when it was actually
231
+ changed — do not skip the commit above just because you expect a later command to commit it.
232
+
233
+ ## Step 12 — Return the result
209
234
 
210
235
  Output:
211
236
  ```
212
237
  Release notes drafted: .dmx/releases/{{version}}.md
213
238
  {N} changes across {categories list}.
239
+ {if committed in Step 11} Committed and pushed to {config.branch_base}.
240
+ {else} Already committed from a previous run — nothing new to push.
214
241
 
215
242
  Review and edit the file, then:
216
243
  - Run /dmx/release-merge version:{{version}} to open the {config.branch_base} → {config.production_branch} PR.
217
244
  ```
245
+
246
+ ## Guards
247
+
248
+ - Never leave `.dmx/releases/{{version}}.md` uncommitted — Step 11 is mandatory whenever the file was created or changed.
@@ -65,7 +65,7 @@ def _qa_answered(content: str) -> tuple[bool, str]:
65
65
  Structural rather than label-based, to tolerate wording drift between
66
66
  this validator and whatever skill scaffolds spec.md: a question is
67
67
  recognized as either a numbered list item (``N. {question}`` — the
68
- format ``dmx-create-ticket.md`` Step 8 actually writes) or a classic
68
+ format ``dmx-create-ticket.md`` Step 9 actually writes) or a classic
69
69
  ``Q:`` line. Everything following it, up to the next question or
70
70
  heading, is its answer — whether labeled ``Answer:``/``A:`` or not. A
71
71
  blank body or an explicit TBD/N/A placeholder counts as unanswered.
@@ -6,6 +6,7 @@ can be exercised directly without mocking the MCP server plumbing.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import subprocess
9
10
  from typing import TYPE_CHECKING
10
11
 
11
12
  from dmx.loop_schema import LoopConfig
@@ -16,7 +17,13 @@ from dmx.loop_state import (
16
17
  read_state,
17
18
  write_initial_state,
18
19
  )
19
- from dmx.loop_tools import _find_active, _finish_loop, _maybe_promote_pending_job, _start_loop
20
+ from dmx.loop_tools import (
21
+ _commit_dmx_state,
22
+ _find_active,
23
+ _finish_loop,
24
+ _maybe_promote_pending_job,
25
+ _start_loop,
26
+ )
20
27
 
21
28
  if TYPE_CHECKING:
22
29
  from pathlib import Path
@@ -455,6 +462,52 @@ class TestBranchGuard:
455
462
  message = _start_loop(tmp_path, "spec")
456
463
 
457
464
  assert "cannot start" in message.lower()
465
+ assert "no commits yet" not in message.lower()
466
+
467
+ def test_blocks_start_with_specific_message_on_a_zero_commit_repo(self, tmp_path: Path) -> None:
468
+ """A freshly `git init`'d repo (no commits yet) makes
469
+ `git rev-parse --abbrev-ref HEAD` fail (unborn HEAD), even though the
470
+ branch name is perfectly real and resolvable via `git symbolic-ref`.
471
+ This must surface a specific, actionable message rather than the
472
+ generic "could not determine branch" one."""
473
+ (tmp_path / ".dmx").mkdir(exist_ok=True)
474
+ (tmp_path / ".dmx" / "config.md").write_text("branch_base: main\n", encoding="utf-8")
475
+ subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True)
476
+
477
+ message = _start_loop(tmp_path, "spec")
478
+
479
+ assert "cannot start" in message.lower()
480
+ assert "no commits yet" in message.lower()
481
+ assert "commit something" in message.lower()
482
+ assert "get_skill_definition" not in message
483
+
484
+ def test_zero_commit_guard_does_not_fire_outside_a_git_repo(self, tmp_path: Path) -> None:
485
+ """A plain non-git directory must still get the generic message, not
486
+ be misreported as a zero-commit git repo."""
487
+ (tmp_path / ".dmx").mkdir(exist_ok=True)
488
+ (tmp_path / ".dmx" / "config.md").write_text("branch_base: main\n", encoding="utf-8")
489
+
490
+ message = _start_loop(tmp_path, "spec")
491
+
492
+ assert "cannot start" in message.lower()
493
+ assert "no commits yet" not in message.lower()
494
+
495
+ def test_zero_commit_guard_does_not_fire_once_a_commit_exists(
496
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
497
+ ) -> None:
498
+ subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True)
499
+ subprocess.run(
500
+ ["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True
501
+ )
502
+ subprocess.run(["git", "config", "user.name", "Test"], cwd=tmp_path, check=True)
503
+ (tmp_path / "README.md").write_text("hi\n", encoding="utf-8")
504
+ subprocess.run(["git", "add", "."], cwd=tmp_path, check=True)
505
+ subprocess.run(["git", "commit", "-q", "-m", "initial"], cwd=tmp_path, check=True)
506
+ _allow_spec_loop_start(tmp_path, monkeypatch, branch="main")
507
+
508
+ message = _start_loop(tmp_path, "spec")
509
+
510
+ assert "get_skill_definition" in message
458
511
 
459
512
  def test_allows_start_from_configured_base_branch(
460
513
  self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
@@ -574,3 +627,157 @@ class TestFindActiveAndPendingPromotion:
574
627
  def test_promote_is_noop_for_non_pending_job_id(self, tmp_path: Path) -> None:
575
628
  (tmp_path / ".dmx").mkdir(exist_ok=True)
576
629
  assert _maybe_promote_pending_job(tmp_path, "GH-9") == "GH-9"
630
+
631
+
632
+ def _init_real_git_repo(root: Path) -> None:
633
+ subprocess.run(["git", "init", "-q"], cwd=root, check=True)
634
+ subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=root, check=True)
635
+ subprocess.run(["git", "config", "user.name", "Test"], cwd=root, check=True)
636
+ (root / "README.md").write_text("hi\n", encoding="utf-8")
637
+ subprocess.run(["git", "add", "."], cwd=root, check=True)
638
+ subprocess.run(["git", "commit", "-q", "-m", "initial"], cwd=root, check=True)
639
+
640
+
641
+ def _dmx_dirty(root: Path) -> str:
642
+ result = subprocess.run(
643
+ ["git", "status", "--short", "--", ".dmx/"],
644
+ cwd=root,
645
+ capture_output=True,
646
+ text=True,
647
+ check=True,
648
+ )
649
+ return result.stdout
650
+
651
+
652
+ def _install_rejecting_pre_commit_hook(root: Path) -> None:
653
+ """Install a pre-commit hook that always rejects the commit, so
654
+ `git commit` fails deterministically regardless of the host's git
655
+ identity configuration."""
656
+ hooks_dir = root / ".git" / "hooks"
657
+ hooks_dir.mkdir(parents=True, exist_ok=True)
658
+ hook_path = hooks_dir / "pre-commit"
659
+ hook_path.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8")
660
+ hook_path.chmod(0o755)
661
+
662
+
663
+ def _commit_count(root: Path) -> int:
664
+ result = subprocess.run(
665
+ ["git", "rev-list", "--count", "HEAD"],
666
+ cwd=root,
667
+ capture_output=True,
668
+ text=True,
669
+ check=True,
670
+ )
671
+ return int(result.stdout.strip())
672
+
673
+
674
+ class TestCommitDmxState:
675
+ """GH-23: .dmx/jobs/*.json (and other .dmx/ writes made by _finish_loop,
676
+ e.g. the activeContext.md session note) must not be left as an
677
+ uncommitted, local-only change once a loop run has genuinely finished —
678
+ otherwise a terminal loop like `release`, with nothing downstream to
679
+ commit on its behalf, permanently loses that final state the moment
680
+ close-ticket force-deletes the branch."""
681
+
682
+ def test_commits_dirty_dmx_changes(self, tmp_path: Path) -> None:
683
+ _init_real_git_repo(tmp_path)
684
+ (tmp_path / ".dmx").mkdir(exist_ok=True)
685
+ (tmp_path / ".dmx" / "activeContext.md").write_text("note\n", encoding="utf-8")
686
+ before = _commit_count(tmp_path)
687
+
688
+ result = _commit_dmx_state(tmp_path, "chore: sync loop state")
689
+
690
+ assert result is None
691
+ assert _dmx_dirty(tmp_path) == ""
692
+ assert _commit_count(tmp_path) == before + 1
693
+
694
+ def test_noop_when_nothing_dirty(self, tmp_path: Path) -> None:
695
+ _init_real_git_repo(tmp_path)
696
+ before = _commit_count(tmp_path)
697
+
698
+ result = _commit_dmx_state(tmp_path, "chore: sync loop state")
699
+
700
+ assert result is None
701
+ assert _commit_count(tmp_path) == before
702
+
703
+ def test_noop_outside_a_git_repo(self, tmp_path: Path) -> None:
704
+ (tmp_path / ".dmx").mkdir(exist_ok=True)
705
+ (tmp_path / ".dmx" / "activeContext.md").write_text("note\n", encoding="utf-8")
706
+
707
+ result = _commit_dmx_state(tmp_path, "chore: sync loop state") # must not raise
708
+
709
+ assert result is None
710
+ assert not (tmp_path / ".git").exists()
711
+
712
+ def test_returns_a_warning_string_when_commit_is_attempted_but_fails(
713
+ self, tmp_path: Path
714
+ ) -> None:
715
+ """A commit can be attempted and still fail (rejected by a
716
+ pre-commit hook, GPG signing failure, disk full, etc.) — that must
717
+ never be swallowed silently, since silent failure here defeats the
718
+ entire point of this function (durability of the loop's final
719
+ state). Forced here via an always-failing pre-commit hook, which
720
+ reliably fails `git commit` regardless of the host's git identity
721
+ configuration (unlike unsetting user.name/email, which a global
722
+ config can silently paper over)."""
723
+ _init_real_git_repo(tmp_path)
724
+ _install_rejecting_pre_commit_hook(tmp_path)
725
+ (tmp_path / ".dmx").mkdir(exist_ok=True)
726
+ (tmp_path / ".dmx" / "activeContext.md").write_text("note\n", encoding="utf-8")
727
+
728
+ result = _commit_dmx_state(tmp_path, "chore: sync loop state")
729
+
730
+ assert result is not None
731
+ assert "could not auto-commit" in result.lower()
732
+ # The failed commit must leave the change sitting uncommitted, not
733
+ # silently discarded — an operator following the warning's advice
734
+ # to inspect/commit manually still has something to find.
735
+ assert "activeContext.md" in _dmx_dirty(tmp_path)
736
+
737
+ def test_release_style_loop_with_no_chain_target_commits_final_state(
738
+ self, tmp_path: Path
739
+ ) -> None:
740
+ """End-to-end: a terminal loop (on_complete -> null, matching the
741
+ bundled `release` loop) leaves .dmx/ clean after _finish_loop
742
+ returns, instead of stranding the job state JSON + session note as
743
+ an uncommitted change nothing downstream will ever pick up."""
744
+ _init_real_git_repo(tmp_path)
745
+ config = LoopConfig.model_validate(
746
+ {
747
+ "name": "release",
748
+ "skills": ["create-pr"],
749
+ "validators": [{"tool": "v", "checks": [{"name": "check_a", "required": True}]}],
750
+ }
751
+ )
752
+ _write_validator(tmp_path, "v", PASSING_VALIDATOR)
753
+ _setup(tmp_path, config, job_id="GH-1", task_id="T")
754
+ before = _commit_count(tmp_path)
755
+
756
+ message = _finish_loop(tmp_path, "GH-1", "release", "T", config, {})
757
+
758
+ assert "complete" in message.lower()
759
+ assert _dmx_dirty(tmp_path) == ""
760
+ assert _commit_count(tmp_path) == before + 1
761
+
762
+ def test_release_style_loop_surfaces_a_warning_when_the_commit_attempt_fails(
763
+ self, tmp_path: Path
764
+ ) -> None:
765
+ """A failed auto-commit must be visible in the loop's own response,
766
+ not just a server-side log line an operator has no reason to go
767
+ looking for."""
768
+ _init_real_git_repo(tmp_path)
769
+ _install_rejecting_pre_commit_hook(tmp_path)
770
+ config = LoopConfig.model_validate(
771
+ {
772
+ "name": "release",
773
+ "skills": ["create-pr"],
774
+ "validators": [{"tool": "v", "checks": [{"name": "check_a", "required": True}]}],
775
+ }
776
+ )
777
+ _write_validator(tmp_path, "v", PASSING_VALIDATOR)
778
+ _setup(tmp_path, config, job_id="GH-1", task_id="T")
779
+
780
+ message = _finish_loop(tmp_path, "GH-1", "release", "T", config, {})
781
+
782
+ assert "complete" in message.lower()
783
+ assert "could not auto-commit" in message.lower()
@@ -63,7 +63,7 @@ class TestCheckSpecComplete:
63
63
  assert qa_check["pass"] is False
64
64
 
65
65
  def test_dmx_create_ticket_numbered_answer_template_passes(self, tmp_path: Path) -> None:
66
- """The actual template dmx-create-ticket.md Step 8 writes — numbered
66
+ """The actual template dmx-create-ticket.md Step 9 writes — numbered
67
67
  questions with an indented 'Answer:' line, not 'Q:'/'A:'."""
68
68
  self._write_spec(
69
69
  tmp_path,
@@ -443,7 +443,7 @@ wheels = [
443
443
 
444
444
  [[package]]
445
445
  name = "deepmodel-dmx"
446
- version = "0.3.3"
446
+ version = "0.3.4"
447
447
  source = { editable = "." }
448
448
  dependencies = [
449
449
  { name = "click" },
File without changes
File without changes
File without changes
File without changes