speckit-specops 0.1.0__py3-none-any.whl
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.
- speckit_specops-0.1.0.dist-info/METADATA +233 -0
- speckit_specops-0.1.0.dist-info/RECORD +22 -0
- speckit_specops-0.1.0.dist-info/WHEEL +4 -0
- speckit_specops-0.1.0.dist-info/entry_points.txt +2 -0
- speckit_specops-0.1.0.dist-info/licenses/LICENSE +21 -0
- specops/__init__.py +6 -0
- specops/cli.py +200 -0
- specops/config.py +77 -0
- specops/consistency.py +112 -0
- specops/errors.py +18 -0
- specops/gitops.py +64 -0
- specops/initializer.py +262 -0
- specops/reconcile.py +78 -0
- specops/speckit.py +276 -0
- specops/status.py +495 -0
- specops/templates/directives/implement.md +60 -0
- specops/templates/directives/plan.md +38 -0
- specops/templates/directives/specify.md +19 -0
- specops/templates/directives/tasks.md +33 -0
- specops/templates/review.md +68 -0
- specops/templates/specops.json +5 -0
- specops/templates/status.yaml +16 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: speckit-specops
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Spec-Driven Development (SDD) process enforcement CLI extending Speckit
|
|
5
|
+
Author-email: Paulo Segundo <paulosegundo@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Requires-Dist: gitpython>=3.1.40
|
|
13
|
+
Requires-Dist: pyyaml>=6.0
|
|
14
|
+
Requires-Dist: typer>=0.9.0
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: mypy>=1.10.0; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
19
|
+
Requires-Dist: ruff>=0.4.0; extra == 'dev'
|
|
20
|
+
Requires-Dist: types-pyyaml>=6.0; extra == 'dev'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# SpecOps CLI
|
|
24
|
+
|
|
25
|
+
[](https://github.com/paulo2nd/specops/actions/workflows/ci.yml)
|
|
26
|
+
[](https://pypi.org/project/speckit-specops/)
|
|
27
|
+
[](https://pypi.org/project/speckit-specops/)
|
|
28
|
+
[](LICENSE)
|
|
29
|
+
|
|
30
|
+
**SpecOps turns [GitHub Speckit](https://github.com/vgrecov/speckit)'s
|
|
31
|
+
spec-driven workflow into an enforced, auditable process.** It layers an
|
|
32
|
+
agent-guided *atomic development* methodology on top of any Speckit repository —
|
|
33
|
+
a physical state ledger, machine-collected evidence, and token-optimized review —
|
|
34
|
+
**without replacing or forking a single Speckit file.**
|
|
35
|
+
|
|
36
|
+
> Speckit gives your agents great artifacts (spec → plan → tasks → implement).
|
|
37
|
+
> SpecOps makes sure they actually follow them: state is on disk and
|
|
38
|
+
> Git-verifiable, evidence is collected by tooling instead of claimed by the
|
|
39
|
+
> agent, and review rejects as cheaply as possible.
|
|
40
|
+
|
|
41
|
+
## Why SpecOps?
|
|
42
|
+
|
|
43
|
+
Spec-driven development with AI agents has three recurring failure modes.
|
|
44
|
+
SpecOps addresses each one:
|
|
45
|
+
|
|
46
|
+
| Problem | Without SpecOps | With SpecOps |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| **Agents hallucinate progress** | "Done ✅" with no proof | Every task is closed with machine-collected evidence (test output, commit hashes, diffs) recorded in the ledger |
|
|
49
|
+
| **State lives in the chat** | Lost on context reset; not auditable | State is a physical `status.yaml` ledger, Git-verifiable and recovery-safe |
|
|
50
|
+
| **Reviews are slow and expensive** | Agent reads the whole repo | `/specops-review` rejects cheapest-first (reconcile → lint/test → out-of-plan files) before reading any code |
|
|
51
|
+
|
|
52
|
+
## What it adds to Speckit
|
|
53
|
+
|
|
54
|
+
- **📒 Physical state ledger (Repo-as-State).** A structured `status.yaml`
|
|
55
|
+
tracks phase, tasks, evidence, and review cycles. Mutated only through
|
|
56
|
+
`specops` commands — never hand-edited, never held in agent memory.
|
|
57
|
+
- **🔬 Automated evidence collection.** `complete-task --auto` runs your test
|
|
58
|
+
command, harvests commits and diffs, and records them as typed evidence. A
|
|
59
|
+
task cannot be `DONE` without proof.
|
|
60
|
+
- **🔁 A phase state machine wired into the prompts.** `specops init` injects
|
|
61
|
+
directives into the specify, plan, tasks, and implement prompts so the ledger
|
|
62
|
+
is created and phases advance automatically — the human never runs the
|
|
63
|
+
bookkeeping by hand.
|
|
64
|
+
- **✂️ Token-optimized surgical review.** The installed `/specops-review`
|
|
65
|
+
command reviews only in-scope files and stops at the first cheap rejection.
|
|
66
|
+
- **📐 Empirical verification & gates.** `specops consistency` and
|
|
67
|
+
`specops reconcile` are exit-code gates you can drop into CI or agent prompts.
|
|
68
|
+
- **➕ Additive and reversible.** Everything is delivered through
|
|
69
|
+
marker-delimited blocks. Uninstalling restores your Speckit files
|
|
70
|
+
byte-for-byte.
|
|
71
|
+
|
|
72
|
+
## Install
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
pip install speckit-specops
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Installs the `specops` command. Requires Python ≥ 3.10 and Git ≥ 2.30. No
|
|
79
|
+
network I/O after install.
|
|
80
|
+
|
|
81
|
+
## Quick Start
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
# In a Speckit-initialized repository:
|
|
85
|
+
specops init # inject directives, install /specops-review, create specops.json
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
That's it. From here you drive Speckit as usual (`/speckit.specify`,
|
|
89
|
+
`/speckit.plan`, `/speckit.tasks`, `/speckit.implement`) and the injected
|
|
90
|
+
directives take care of the ledger and phase transitions. Check state anytime:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
specops status show
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## How it works
|
|
97
|
+
|
|
98
|
+
SpecOps rides the Speckit lifecycle. Once `specops init` has run, the injected
|
|
99
|
+
directives drive the ledger at each stage seam:
|
|
100
|
+
|
|
101
|
+
| Speckit stage | What SpecOps does |
|
|
102
|
+
|---|---|
|
|
103
|
+
| **specify** | Marks the repo as SpecOps-managed (informational; no ledger yet) |
|
|
104
|
+
| **plan** | Enforces empirical path verification and the `consistency` gate |
|
|
105
|
+
| **tasks** | Creates the ledger (`status init-spec`), advances the phase to `TASKS`, and requires `[SC-xxx]` coverage tags on every task |
|
|
106
|
+
| **implement** | Opens `IMPLEMENT`, runs the evidence-backed ledger loop, then opens `REVIEW` |
|
|
107
|
+
| **review** | `/specops-review` validates the diff and records `APPROVED` / `REJECTED` |
|
|
108
|
+
|
|
109
|
+
The phase machine is `SPECIFY → PLAN → TASKS → IMPLEMENT → REVIEW → DONE`.
|
|
110
|
+
If SpecOps is not installed, the Speckit prompts still work standalone — the
|
|
111
|
+
directives degrade to no-ops.
|
|
112
|
+
|
|
113
|
+
## Command reference
|
|
114
|
+
|
|
115
|
+
### `specops init [--non-interactive]`
|
|
116
|
+
|
|
117
|
+
Prepares a Speckit repository in one run: validates (or offers to create) a Git
|
|
118
|
+
repo, detects Speckit, resolves prompt targets from Speckit's integration
|
|
119
|
+
manifests (works with any recorded agent layout — Claude skills, GitHub
|
|
120
|
+
Copilot, etc.), creates/merge-preserves `specops.json`, installs
|
|
121
|
+
`/specops-review`, and injects the directive blocks into the specify, plan,
|
|
122
|
+
tasks, and implement prompts (additive, idempotent, byte-identical restore on
|
|
123
|
+
removal). `--non-interactive` declines all prompts (CI-safe).
|
|
124
|
+
|
|
125
|
+
> **Speckit upgrade note**: a Speckit upgrade may rewrite prompt files and
|
|
126
|
+
> remove the injected blocks. Just re-run `specops init` to re-inject.
|
|
127
|
+
|
|
128
|
+
### `specops status show`
|
|
129
|
+
|
|
130
|
+
Read-only. Prints ledger state: feature, branch, phase, active task, task counts
|
|
131
|
+
(pending / in progress / done / orphaned), and the review-cycle history.
|
|
132
|
+
|
|
133
|
+
### `specops status init-spec [<name>]`
|
|
134
|
+
|
|
135
|
+
Creates `<feature_dir>/status.yaml` from the packaged scaffold, syncing task IDs
|
|
136
|
+
from `tasks.md`. Usually run for you by the tasks directive.
|
|
137
|
+
|
|
138
|
+
### `specops status start-task <task-id>`
|
|
139
|
+
|
|
140
|
+
Marks the task `IN_PROGRESS` and records `started_commit = HEAD`. Enforces the
|
|
141
|
+
single-active-task rule.
|
|
142
|
+
|
|
143
|
+
### `specops status complete-task <task-id> [--auto | --evidence "CLASS:summary"]`
|
|
144
|
+
|
|
145
|
+
Marks the task `DONE` with exactly one evidence source:
|
|
146
|
+
|
|
147
|
+
- `--auto`: runs `test_command`; on success, harvests `started_commit..HEAD`
|
|
148
|
+
commits + diff as `TEST_REPORT`/`CODE_DIFF` evidence.
|
|
149
|
+
- `--evidence "CLASS:summary"`: caller-supplied, with `CLASS` in
|
|
150
|
+
`CLI_LOG | TEST_REPORT | SCREENSHOT_PATH | CODE_DIFF`.
|
|
151
|
+
|
|
152
|
+
### `specops status transition-phase <phase> [-r APPROVED|REJECTED]`
|
|
153
|
+
|
|
154
|
+
Advances the phase one step forward. Two transitions require `-r`:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
specops status transition-phase DONE -r APPROVED # approved → close the feature
|
|
158
|
+
specops status transition-phase IMPLEMENT -r REJECTED # rejected → send back for rework
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Entering `DONE` requires the latest review cycle to be `APPROVED`.
|
|
162
|
+
|
|
163
|
+
### `specops reconcile`
|
|
164
|
+
|
|
165
|
+
Read-only gate. Verifies every ledger commit hash is reachable from `HEAD` and
|
|
166
|
+
every `DONE` task has commits and evidence. Exit 1 on any divergence.
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
specops reconcile || exit 1 # preflight before review
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### `specops consistency`
|
|
173
|
+
|
|
174
|
+
Read-only gate. Verifies every `SC-\d+` in the spec has ≥ 1 task with a matching
|
|
175
|
+
`[SC-xxx]` tag, and every `plan.md` path declaration carries a valid action
|
|
176
|
+
suffix (`(create)`/`(modify)`/`(remove)`). Exit 1 on violation.
|
|
177
|
+
|
|
178
|
+
### `specops --version`
|
|
179
|
+
|
|
180
|
+
Prints the version and exits. Works anywhere.
|
|
181
|
+
|
|
182
|
+
## Configuration — `specops.json`
|
|
183
|
+
|
|
184
|
+
| Key | Purpose | Default |
|
|
185
|
+
|---|---|---|
|
|
186
|
+
| `test_command` | Command run by `complete-task --auto` | `pytest` |
|
|
187
|
+
| `lint_command` | Command referenced by the review prompt | `""` |
|
|
188
|
+
| `skills_dir` | Directory the review prompt loads skills from | `.specify/skills` |
|
|
189
|
+
|
|
190
|
+
Unknown keys are preserved on re-init.
|
|
191
|
+
|
|
192
|
+
## The `/specops-review` command
|
|
193
|
+
|
|
194
|
+
Installed by `specops init` (the name follows the layout's separator, e.g.
|
|
195
|
+
`/specops-review` for Claude skills). Not a CLI command — a packaged prompt that
|
|
196
|
+
drives the review agent cheapest-rejection-first:
|
|
197
|
+
|
|
198
|
+
1. Load skills from `skills_dir`.
|
|
199
|
+
2. `specops reconcile` — abort immediately on failure.
|
|
200
|
+
3. `lint_command` + `test_command` — reject on failure.
|
|
201
|
+
4. `git status --porcelain` — reject any out-of-plan file without reading code.
|
|
202
|
+
5. Surgical diff review of in-scope files only.
|
|
203
|
+
6. Write `revisions/revision-X.md` and record the `APPROVED`/`REJECTED` outcome.
|
|
204
|
+
|
|
205
|
+
## Language policy
|
|
206
|
+
|
|
207
|
+
All SpecOps operational output (CLI messages, injected assets) is in English.
|
|
208
|
+
Your prose (`spec.md`, `plan.md`, task descriptions) may be in **any language** —
|
|
209
|
+
SpecOps parses only structural tokens (`SC-\d+`, `T\d+`, action suffixes), never
|
|
210
|
+
content.
|
|
211
|
+
|
|
212
|
+
## Supported Speckit layouts
|
|
213
|
+
|
|
214
|
+
SpecOps resolves prompt targets at runtime from
|
|
215
|
+
`.specify/integrations/<agent>.manifest.json`. Any Speckit integration with a
|
|
216
|
+
recorded manifest is supported; unknown layouts fail closed. Tested with
|
|
217
|
+
Speckit ≥ 0.12 (Claude skills mode, separator `-`).
|
|
218
|
+
|
|
219
|
+
## Uninstall
|
|
220
|
+
|
|
221
|
+
Remove the appended block from each prompt file, then delete `specops.json` and
|
|
222
|
+
the installed review command. No other files are written; the restore is
|
|
223
|
+
byte-identical.
|
|
224
|
+
|
|
225
|
+
## Contributing
|
|
226
|
+
|
|
227
|
+
Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for dev setup,
|
|
228
|
+
the quality gates, and project principles. SpecOps is at `0.x`; the CLI surface
|
|
229
|
+
and ledger format may still change before `1.0` (see [CHANGELOG.md](CHANGELOG.md)).
|
|
230
|
+
|
|
231
|
+
## License
|
|
232
|
+
|
|
233
|
+
[MIT](LICENSE) © Paulo Segundo
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
specops/__init__.py,sha256=norFtsf5zFQJHWTkdIYOBGlM-Bgb0wTngCzE0iehTtQ,171
|
|
2
|
+
specops/cli.py,sha256=aUnZa0LeApQuaBnFWsbSQQtwAayY2dgldQ1Q_Jwo9AQ,5888
|
|
3
|
+
specops/config.py,sha256=CZ1hvmEi2dkkFSVk5tiNAeh671dN-RSRF-KB983_mbU,2274
|
|
4
|
+
specops/consistency.py,sha256=91UDnkyUFAkkSjXC9BEblaaJz0DaKKXY2mdFelQFnAc,4411
|
|
5
|
+
specops/errors.py,sha256=GaqWtIE-Yk7R3xTU4wRve2h6sRxbfAhM8eoGtO0Khn8,483
|
|
6
|
+
specops/gitops.py,sha256=Q_jfT5UcdlztWBjP37gB90-0011E4a__Oaw9d4otXoM,2071
|
|
7
|
+
specops/initializer.py,sha256=HqJ_ft3nYKNqoT-paY-lGM5eNzSKvwvqI5KdGuENO9s,10059
|
|
8
|
+
specops/reconcile.py,sha256=aCXkfGfOpKPr36gxwtxeGC8s3UKh3PW_dQiEiLTSdwk,2613
|
|
9
|
+
specops/speckit.py,sha256=jSd1P1IjJP9nKLiYUm3g295czdn8Tsx-hFdgspj6WY8,9886
|
|
10
|
+
specops/status.py,sha256=0h7Cbws7gY7kz2F5W8bgyFZ2Kgn873JJKASXnhLRq9Q,16867
|
|
11
|
+
specops/templates/review.md,sha256=F3pY1Wb-Ml25kywafsCGBXH_wgOvbpDfAU0amg3eK-k,2405
|
|
12
|
+
specops/templates/specops.json,sha256=_BKhOLVrVTMai54YiOVMT7N7AEtBZQdb5hlNrgU2ltI,88
|
|
13
|
+
specops/templates/status.yaml,sha256=map7cZsJdkkPlQnaQsd3P9Gvxt3_JYj8kcTgA0GgbMA,257
|
|
14
|
+
specops/templates/directives/implement.md,sha256=5PfidS8HK9GfbZvbcnJk4lF5mfhS5P-YYh034jes6wE,2416
|
|
15
|
+
specops/templates/directives/plan.md,sha256=Ph-RTKYYaZu-v3MOBbp2Es_uIzYsweNl8Bsh3Xkk8n0,1518
|
|
16
|
+
specops/templates/directives/specify.md,sha256=TLaTmdUf_J02wnTMKPOrAlauHClaKe84kR0qJtP65lU,646
|
|
17
|
+
specops/templates/directives/tasks.md,sha256=Hi4yeZS6tYC2S0yg0V3UpT6o-T9qTa5X-M5zVbs0yPo,1251
|
|
18
|
+
speckit_specops-0.1.0.dist-info/METADATA,sha256=9AznotYcZXfDd-iBxTRcRH3_27z5RCAl24C3iuAgW8k,9775
|
|
19
|
+
speckit_specops-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
20
|
+
speckit_specops-0.1.0.dist-info/entry_points.txt,sha256=1jtzdTyyNwLisxJ8zCBh6wIZMLAoRsOyYQ-hfjh-NfE,44
|
|
21
|
+
speckit_specops-0.1.0.dist-info/licenses/LICENSE,sha256=BBBg5rGxqq0hAymn1JhjBzJyutAL83eM8bC8eetuOWQ,1070
|
|
22
|
+
speckit_specops-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Paulo Segundo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
specops/__init__.py
ADDED
specops/cli.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""SpecOps CLI entrypoint (Typer). All output is in English (FR-014)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import functools
|
|
5
|
+
import importlib.metadata
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, TypeVar
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from specops import gitops
|
|
13
|
+
from specops.errors import SpecopsError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _version_callback(value: bool) -> None:
|
|
17
|
+
if value:
|
|
18
|
+
try:
|
|
19
|
+
version = importlib.metadata.version("specops-cli")
|
|
20
|
+
except importlib.metadata.PackageNotFoundError:
|
|
21
|
+
version = "0.0.0.dev0"
|
|
22
|
+
typer.echo(f"specops {version}")
|
|
23
|
+
raise typer.Exit(0)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
app = typer.Typer(
|
|
27
|
+
name="specops",
|
|
28
|
+
help="SpecOps CLI — Speckit companion for agent-guided atomic development.",
|
|
29
|
+
no_args_is_help=True,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.callback()
|
|
34
|
+
def _root_callback(
|
|
35
|
+
version: bool | None = typer.Option(
|
|
36
|
+
None,
|
|
37
|
+
"--version",
|
|
38
|
+
callback=_version_callback,
|
|
39
|
+
is_eager=True,
|
|
40
|
+
help="Show the version and exit.",
|
|
41
|
+
),
|
|
42
|
+
) -> None:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
status_app = typer.Typer(
|
|
46
|
+
name="status",
|
|
47
|
+
help="Ledger management: init-spec, start-task, complete-task, transition-phase.",
|
|
48
|
+
no_args_is_help=True,
|
|
49
|
+
)
|
|
50
|
+
app.add_typer(status_app, name="status")
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Error boundary: single exit-code mapper (contracts/errors.md)
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
_F = TypeVar("_F", bound=Callable[..., Any])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _handle_errors(fn: _F) -> _F:
|
|
60
|
+
"""Catch SpecopsError, echo message to stderr, exit with the mapped code."""
|
|
61
|
+
@functools.wraps(fn)
|
|
62
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
63
|
+
try:
|
|
64
|
+
return fn(*args, **kwargs)
|
|
65
|
+
except SpecopsError as exc:
|
|
66
|
+
typer.echo(exc.message, err=True)
|
|
67
|
+
raise typer.Exit(exc.exit_code) from None
|
|
68
|
+
return wrapper # type: ignore[return-value]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _require_git(root: Path = Path(".")) -> None:
|
|
72
|
+
"""Fail with exit 1 within <1 s when not inside a Git repo (FR-002, SC-008)."""
|
|
73
|
+
if not gitops.is_git_repo(root):
|
|
74
|
+
typer.echo("Not a Git repository. Run 'git init' or 'specops init' first.", err=True)
|
|
75
|
+
raise typer.Exit(1)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# Commands
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
@app.command("init")
|
|
83
|
+
@_handle_errors
|
|
84
|
+
def init(
|
|
85
|
+
non_interactive: bool = typer.Option(
|
|
86
|
+
False, "--non-interactive", help="Decline all interactive prompts.",
|
|
87
|
+
),
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Prepare a Speckit repository for SpecOps."""
|
|
90
|
+
from specops import initializer
|
|
91
|
+
root = Path(".")
|
|
92
|
+
initializer.run(root, non_interactive=non_interactive)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command("reconcile")
|
|
96
|
+
@_handle_errors
|
|
97
|
+
def reconcile() -> None:
|
|
98
|
+
"""Validate ledger commit hashes against Git history."""
|
|
99
|
+
root = Path(".")
|
|
100
|
+
_require_git(root)
|
|
101
|
+
from specops import reconcile as rec_mod
|
|
102
|
+
warnings, violations = rec_mod.run(root)
|
|
103
|
+
for w in warnings:
|
|
104
|
+
typer.echo(w)
|
|
105
|
+
if violations:
|
|
106
|
+
for v in violations:
|
|
107
|
+
typer.echo(v, err=True)
|
|
108
|
+
raise typer.Exit(1)
|
|
109
|
+
typer.echo("reconcile: ok")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@app.command("consistency")
|
|
113
|
+
@_handle_errors
|
|
114
|
+
def consistency() -> None:
|
|
115
|
+
"""Validate SC-ID coverage and plan path-suffix declarations."""
|
|
116
|
+
root = Path(".")
|
|
117
|
+
_require_git(root)
|
|
118
|
+
from specops import consistency as con_mod
|
|
119
|
+
warnings, violations = con_mod.run(root)
|
|
120
|
+
for w in warnings:
|
|
121
|
+
typer.echo(w)
|
|
122
|
+
if violations:
|
|
123
|
+
for v in violations:
|
|
124
|
+
typer.echo(v, err=True)
|
|
125
|
+
raise typer.Exit(1)
|
|
126
|
+
typer.echo("consistency: ok")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
# status subcommands
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
@status_app.command("init-spec")
|
|
134
|
+
@_handle_errors
|
|
135
|
+
def status_init_spec(
|
|
136
|
+
name: str = typer.Argument(None, help="Feature directory name (optional)."),
|
|
137
|
+
) -> None:
|
|
138
|
+
"""Create status.yaml for the active feature."""
|
|
139
|
+
root = Path(".")
|
|
140
|
+
_require_git(root)
|
|
141
|
+
from specops import status
|
|
142
|
+
typer.echo(status.cmd_init_spec(root, name))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@status_app.command("start-task")
|
|
146
|
+
@_handle_errors
|
|
147
|
+
def status_start_task(
|
|
148
|
+
task_id: str = typer.Argument(..., help="Task identifier, e.g. T001."),
|
|
149
|
+
) -> None:
|
|
150
|
+
"""Mark a task IN_PROGRESS and record the start commit."""
|
|
151
|
+
root = Path(".")
|
|
152
|
+
_require_git(root)
|
|
153
|
+
from specops import status
|
|
154
|
+
typer.echo(status.cmd_start_task(root, task_id))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@status_app.command("complete-task")
|
|
158
|
+
@_handle_errors
|
|
159
|
+
def status_complete_task(
|
|
160
|
+
task_id: str = typer.Argument(..., help="Task identifier, e.g. T001."),
|
|
161
|
+
auto: bool = typer.Option(
|
|
162
|
+
False, "--auto", help="Run test_command and harvest evidence automatically."
|
|
163
|
+
),
|
|
164
|
+
evidence: str = typer.Option(
|
|
165
|
+
None, "--evidence", help='Caller-supplied evidence string, e.g. "CLI_LOG:checked ok".'
|
|
166
|
+
),
|
|
167
|
+
) -> None:
|
|
168
|
+
"""Mark a task DONE with evidence."""
|
|
169
|
+
root = Path(".")
|
|
170
|
+
_require_git(root)
|
|
171
|
+
from specops import status
|
|
172
|
+
typer.echo(status.cmd_complete_task(root, task_id, auto=auto, evidence=evidence))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@status_app.command("show")
|
|
176
|
+
@_handle_errors
|
|
177
|
+
def status_show() -> None:
|
|
178
|
+
"""Show ledger state (read-only)."""
|
|
179
|
+
root = Path(".")
|
|
180
|
+
from specops import status
|
|
181
|
+
typer.echo(status.cmd_show(root))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@status_app.command("transition-phase")
|
|
185
|
+
@_handle_errors
|
|
186
|
+
def status_transition_phase(
|
|
187
|
+
phase: str = typer.Argument(..., help="Target phase, e.g. PLAN."),
|
|
188
|
+
result: str = typer.Option(
|
|
189
|
+
None, "-r", "--result", help="Transition result (APPROVED|REJECTED)."
|
|
190
|
+
),
|
|
191
|
+
) -> None:
|
|
192
|
+
"""Advance the feature phase state machine."""
|
|
193
|
+
root = Path(".")
|
|
194
|
+
_require_git(root)
|
|
195
|
+
from specops import status
|
|
196
|
+
typer.echo(status.cmd_transition_phase(root, phase, result=result))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
if __name__ == "__main__":
|
|
200
|
+
app()
|
specops/config.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""specops.json load/validate/merge helpers (R10)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from specops.errors import SpecopsError
|
|
9
|
+
|
|
10
|
+
CONFIG_FILENAME = "specops.json"
|
|
11
|
+
|
|
12
|
+
_DEFAULTS: dict[str, Any] = {
|
|
13
|
+
"test_command": "pytest",
|
|
14
|
+
"lint_command": "",
|
|
15
|
+
"skills_dir": ".specify/skills",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ConfigError(SpecopsError):
|
|
20
|
+
"""Raised on missing or unreadable specops.json."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def config_path(root: Path) -> Path:
|
|
24
|
+
return root / CONFIG_FILENAME
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load(root: Path) -> dict[str, Any]:
|
|
28
|
+
"""
|
|
29
|
+
Load specops.json from *root*.
|
|
30
|
+
|
|
31
|
+
Raises ConfigError when the file is absent or not valid JSON.
|
|
32
|
+
Unknown keys are preserved (R10).
|
|
33
|
+
"""
|
|
34
|
+
path = config_path(root)
|
|
35
|
+
if not path.is_file():
|
|
36
|
+
raise ConfigError(
|
|
37
|
+
f"{CONFIG_FILENAME} not found in {root}. Run 'specops init' first."
|
|
38
|
+
)
|
|
39
|
+
try:
|
|
40
|
+
return json.loads(path.read_text())
|
|
41
|
+
except json.JSONDecodeError as exc:
|
|
42
|
+
raise ConfigError(f"Cannot parse {path}: {exc}") from exc
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def merge_preserve(existing: dict[str, Any], template: dict[str, Any]) -> dict[str, Any]:
|
|
46
|
+
"""
|
|
47
|
+
Merge *template* into *existing*, keeping existing values and unknown keys.
|
|
48
|
+
|
|
49
|
+
New keys from *template* that are absent in *existing* are added with
|
|
50
|
+
their template values. Existing keys (including unknown ones) are untouched.
|
|
51
|
+
"""
|
|
52
|
+
result = dict(existing)
|
|
53
|
+
for key, value in template.items():
|
|
54
|
+
if key not in result:
|
|
55
|
+
result[key] = value
|
|
56
|
+
return result
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def create_or_merge(root: Path) -> tuple[dict[str, Any], bool]:
|
|
60
|
+
"""
|
|
61
|
+
Create specops.json from defaults, or merge-preserve an existing one.
|
|
62
|
+
|
|
63
|
+
Returns (config_dict, created) where *created* is True when the file
|
|
64
|
+
was newly created, False when an existing file was updated.
|
|
65
|
+
"""
|
|
66
|
+
path = config_path(root)
|
|
67
|
+
if path.is_file():
|
|
68
|
+
try:
|
|
69
|
+
existing = json.loads(path.read_text())
|
|
70
|
+
except (json.JSONDecodeError, OSError):
|
|
71
|
+
existing = {}
|
|
72
|
+
merged = merge_preserve(existing, _DEFAULTS)
|
|
73
|
+
path.write_text(json.dumps(merged, indent=2) + "\n")
|
|
74
|
+
return merged, False
|
|
75
|
+
else:
|
|
76
|
+
path.write_text(json.dumps(_DEFAULTS, indent=2) + "\n")
|
|
77
|
+
return dict(_DEFAULTS), True
|
specops/consistency.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""specops consistency: SC-ID coverage + plan path-suffix validation (US4, FR-012)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from specops import gitops, speckit
|
|
7
|
+
from specops.errors import SpecopsError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def run(root: Path) -> tuple[list[str], list[str]]:
|
|
11
|
+
"""
|
|
12
|
+
Validate SC-ID coverage traceability and plan path-suffix declarations.
|
|
13
|
+
|
|
14
|
+
Returns (warnings, violations). Violations map to exit 1.
|
|
15
|
+
Raises SpecopsError on blocking preconditions.
|
|
16
|
+
"""
|
|
17
|
+
feature_dir = speckit.resolve_feature_dir(root)
|
|
18
|
+
if feature_dir is None:
|
|
19
|
+
raise SpecopsError("Cannot resolve active feature directory.")
|
|
20
|
+
|
|
21
|
+
spec_path = feature_dir / "spec.md"
|
|
22
|
+
tasks_path = feature_dir / "tasks.md"
|
|
23
|
+
plan_path = feature_dir / "plan.md"
|
|
24
|
+
|
|
25
|
+
violations: list[str] = []
|
|
26
|
+
warnings: list[str] = []
|
|
27
|
+
|
|
28
|
+
# ------------------------------------------------------------------
|
|
29
|
+
# SC-ID coverage check (R6, FR-012)
|
|
30
|
+
# ------------------------------------------------------------------
|
|
31
|
+
if spec_path.is_file() and tasks_path.is_file():
|
|
32
|
+
spec_text = spec_path.read_text(encoding="utf-8")
|
|
33
|
+
tasks_text = tasks_path.read_text(encoding="utf-8")
|
|
34
|
+
|
|
35
|
+
spec_scs = set(speckit.extract_sc_ids(spec_text))
|
|
36
|
+
spec_lines = spec_text.splitlines()
|
|
37
|
+
|
|
38
|
+
def _sc_line(sc_id: str) -> int:
|
|
39
|
+
for i, line in enumerate(spec_lines, start=1):
|
|
40
|
+
if f"**{sc_id}**:" in line:
|
|
41
|
+
return i
|
|
42
|
+
return 0
|
|
43
|
+
|
|
44
|
+
covered: dict[str, list[str]] = {sc: [] for sc in spec_scs}
|
|
45
|
+
unknown_refs: list[tuple[str, int, str]] = []
|
|
46
|
+
|
|
47
|
+
for lineno, line in enumerate(tasks_text.splitlines(), start=1):
|
|
48
|
+
tags = speckit.extract_coverage_tags(line)
|
|
49
|
+
task_ids = speckit.extract_task_ids(line)
|
|
50
|
+
task_id = task_ids[0] if task_ids else "?"
|
|
51
|
+
for tag in tags:
|
|
52
|
+
if tag in spec_scs:
|
|
53
|
+
covered[tag].append(task_id)
|
|
54
|
+
else:
|
|
55
|
+
unknown_refs.append((str(tasks_path), lineno, tag))
|
|
56
|
+
|
|
57
|
+
for sc, covering_tasks in covered.items():
|
|
58
|
+
if not covering_tasks:
|
|
59
|
+
line_num = _sc_line(sc)
|
|
60
|
+
violations.append(
|
|
61
|
+
f"consistency: {spec_path.name}:{line_num} - SC '{sc}' has no covering task"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
for _file, lineno, tag in unknown_refs:
|
|
65
|
+
violations.append(
|
|
66
|
+
f"consistency: {tasks_path.name}:{lineno}"
|
|
67
|
+
f" - coverage tag '{tag}' references unknown SC"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# ------------------------------------------------------------------
|
|
71
|
+
# Path-suffix validation (FR-012)
|
|
72
|
+
# ------------------------------------------------------------------
|
|
73
|
+
if plan_path.is_file():
|
|
74
|
+
plan_text = plan_path.read_text(encoding="utf-8")
|
|
75
|
+
repo = gitops.find_repo(root)
|
|
76
|
+
|
|
77
|
+
for lineno, line in enumerate(plan_text.splitlines(), start=1):
|
|
78
|
+
parsed = speckit.parse_plan_path_action(line)
|
|
79
|
+
if not parsed:
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
raw_path, action = parsed
|
|
83
|
+
candidate = root / raw_path if not raw_path.startswith("/") else Path(raw_path)
|
|
84
|
+
|
|
85
|
+
if action == "modify":
|
|
86
|
+
if not candidate.is_file():
|
|
87
|
+
violations.append(
|
|
88
|
+
f"consistency: {plan_path.name}:{lineno} - "
|
|
89
|
+
f"(modify) path '{raw_path}' does not exist in worktree"
|
|
90
|
+
)
|
|
91
|
+
elif action == "create":
|
|
92
|
+
if not candidate.parent.is_dir():
|
|
93
|
+
violations.append(
|
|
94
|
+
f"consistency: {plan_path.name}:{lineno} - "
|
|
95
|
+
f"(create) parent of '{raw_path}' does not exist"
|
|
96
|
+
)
|
|
97
|
+
elif action == "remove":
|
|
98
|
+
in_worktree = candidate.exists()
|
|
99
|
+
in_history = False
|
|
100
|
+
if repo is not None:
|
|
101
|
+
try:
|
|
102
|
+
repo.git.ls_files("--error-unmatch", raw_path)
|
|
103
|
+
in_history = True
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
106
|
+
if not in_worktree and not in_history:
|
|
107
|
+
violations.append(
|
|
108
|
+
f"consistency: {plan_path.name}:{lineno} - "
|
|
109
|
+
f"(remove) path '{raw_path}' not in worktree or Git history"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return warnings, violations
|