devflow-cli 2.3.0__py3-none-any.whl → 2.9.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.
Files changed (47) hide show
  1. devflow_cli/__init__.py +27 -2
  2. devflow_cli/adapters/__init__.py +19 -0
  3. devflow_cli/adapters/agents.py +220 -0
  4. devflow_cli/adapters/linear.py +79 -0
  5. devflow_cli/cli.py +20 -0
  6. devflow_cli/commands/adaptive.py +357 -0
  7. devflow_cli/commands/assess.py +115 -0
  8. devflow_cli/commands/check.py +44 -1
  9. devflow_cli/commands/docs_sync.py +33 -0
  10. devflow_cli/commands/extension.py +3 -1
  11. devflow_cli/commands/feature.py +4 -4
  12. devflow_cli/commands/hooks_cmd.py +193 -0
  13. devflow_cli/commands/init_cmd.py +16 -10
  14. devflow_cli/commands/linear.py +30 -0
  15. devflow_cli/commands/migrate_speckit.py +139 -22
  16. devflow_cli/commands/review.py +54 -0
  17. devflow_cli/commands/run.py +123 -0
  18. devflow_cli/commands/status.py +24 -29
  19. devflow_cli/commands/step.py +118 -0
  20. devflow_cli/commands/traceability.py +47 -0
  21. devflow_cli/commands/verify.py +78 -0
  22. devflow_cli/core/activity_contracts.py +735 -0
  23. devflow_cli/core/adaptive_runner.py +332 -0
  24. devflow_cli/core/adaptive_workflow.py +303 -0
  25. devflow_cli/core/assessment.py +161 -0
  26. devflow_cli/core/automatic_evidence.py +717 -0
  27. devflow_cli/core/docgen.py +93 -0
  28. devflow_cli/core/hooks.py +143 -2
  29. devflow_cli/core/orchestrator.py +170 -0
  30. devflow_cli/core/remediation.py +155 -0
  31. devflow_cli/core/state.py +207 -6
  32. devflow_cli/core/state_store.py +45 -0
  33. devflow_cli/core/traceability.py +81 -0
  34. devflow_cli/core/validators.py +49 -5
  35. devflow_cli/core/verification.py +339 -0
  36. devflow_cli/core/waivers.py +285 -0
  37. devflow_cli/core/worklog.py +304 -0
  38. devflow_cli/core/worktree_snapshot.py +91 -0
  39. devflow_cli/schemas/state.schema.json +39 -0
  40. devflow_cli/utils/paths.py +78 -8
  41. devflow_cli-2.9.0.dist-info/METADATA +223 -0
  42. devflow_cli-2.9.0.dist-info/RECORD +63 -0
  43. {devflow_cli-2.3.0.dist-info → devflow_cli-2.9.0.dist-info}/WHEEL +1 -1
  44. devflow_cli-2.3.0.dist-info/METADATA +0 -141
  45. devflow_cli-2.3.0.dist-info/RECORD +0 -35
  46. {devflow_cli-2.3.0.dist-info → devflow_cli-2.9.0.dist-info}/entry_points.txt +0 -0
  47. {devflow_cli-2.3.0.dist-info → devflow_cli-2.9.0.dist-info}/licenses/LICENSE +0 -0
devflow_cli/__init__.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """devflow CLI — Spec-Driven Development workflow."""
2
2
 
3
- VERSION = "2.3.0"
3
+ VERSION = "2.9.0"
4
+ STATE_SCHEMA_VERSION = 3
4
5
 
5
6
  STEPS = [
6
7
  "constitution", # Pré-requis projet (1/projet). Auto-complétée si .specify/memory/constitution.md existe.
@@ -20,6 +21,12 @@ STEPS = [
20
21
 
21
22
  OPTIONAL_STEPS = {"clarify", "research", "contracts", "docs"}
22
23
 
24
+ # Le mode fast conserve les gates et les bornes du pipeline. Il compte donc
25
+ # 9 etapes effectives, dont 4 etapes productives principales.
26
+ FAST_PRODUCTIVE_STEPS = ("spec", "plan", "tasks", "implement")
27
+ FAST_STEP_COUNT = len(STEPS) - len(OPTIONAL_STEPS)
28
+ FULL_STEP_COUNT = len(STEPS)
29
+
23
30
  REVIEW_GATES = {"review-spec", "review-tasks", "review-impl"}
24
31
 
25
32
  STEP_ARTIFACTS = {
@@ -51,7 +58,21 @@ STEP_LINEAR_MAPPING = {
51
58
  "done": "Done",
52
59
  }
53
60
 
54
- SUPPORTED_AGENTS = ["claude-code", "cursor"]
61
+ # Ordre de progression des statuts Linear (pour non-régression lors de la sync).
62
+ # Un statut ne doit jamais régresser : si l'index courant >= index cible, on ne met pas à jour.
63
+ STEP_LINEAR_ORDER = [
64
+ "Backlog",
65
+ "Spec",
66
+ "Research",
67
+ "Plan",
68
+ "Tasks",
69
+ "Review",
70
+ "In Progress",
71
+ "Doc",
72
+ "Done",
73
+ ]
74
+
75
+ SUPPORTED_AGENTS = ["claude-code", "codex", "cursor"]
55
76
 
56
77
  # Mapping speckit → devflow pour la migration
57
78
  SPECKIT_STEP_MAP = {
@@ -85,4 +106,8 @@ assert OPTIONAL_STEPS <= _steps_set, f"OPTIONAL_STEPS not subset of STEPS: {OPTI
85
106
  assert REVIEW_GATES <= _steps_set, f"REVIEW_GATES not subset of STEPS: {REVIEW_GATES - _steps_set}"
86
107
  assert set(STEP_ARTIFACTS.keys()) <= _steps_set, f"STEP_ARTIFACTS has unknown keys: {set(STEP_ARTIFACTS.keys()) - _steps_set}"
87
108
  assert set(STEP_LINEAR_MAPPING.keys()) <= _steps_set, f"STEP_LINEAR_MAPPING has unknown keys: {set(STEP_LINEAR_MAPPING.keys()) - _steps_set}"
109
+ assert set(STEP_LINEAR_MAPPING.values()) <= set(STEP_LINEAR_ORDER), (
110
+ f"STEP_LINEAR_MAPPING values not in STEP_LINEAR_ORDER: "
111
+ f"{set(STEP_LINEAR_MAPPING.values()) - set(STEP_LINEAR_ORDER)}"
112
+ )
88
113
  assert set(SPECKIT_STEP_MAP.values()) <= _steps_set, f"SPECKIT_STEP_MAP maps to unknown steps: {set(SPECKIT_STEP_MAP.values()) - _steps_set}"
@@ -0,0 +1,19 @@
1
+ """Adaptateurs des services externes utilisés par devflow."""
2
+
3
+ from devflow_cli.adapters.agents import (
4
+ AgentAdapter,
5
+ AgentExecution,
6
+ ClaudeCodeAdapter,
7
+ CodexAdapter,
8
+ CursorAdapter,
9
+ get_agent_adapter,
10
+ )
11
+
12
+ __all__ = [
13
+ "AgentAdapter",
14
+ "AgentExecution",
15
+ "ClaudeCodeAdapter",
16
+ "CodexAdapter",
17
+ "CursorAdapter",
18
+ "get_agent_adapter",
19
+ ]
@@ -0,0 +1,220 @@
1
+ """Adaptateurs d'exécution pour les agents IA en mode non interactif."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import re
7
+ from abc import ABC, abstractmethod
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Literal
11
+
12
+
13
+ PermissionMode = Literal["auto", "acceptEdits", "manual"]
14
+ SandboxMode = Literal["read-only", "workspace-write"]
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class AgentExecution:
19
+ returncode: int
20
+ stdout: str = ""
21
+ stderr: str = ""
22
+ timed_out: bool = False
23
+
24
+
25
+ class AgentAdapter(ABC):
26
+ """Contrat minimal d'un agent pilotable par le CLI."""
27
+
28
+ name: str
29
+ executable: str
30
+
31
+ @abstractmethod
32
+ def build_command(self, prompt: str, permission_mode: PermissionMode) -> list[str]:
33
+ """Construit une commande sans passer par un shell."""
34
+
35
+ def build_named_agent_command(self, agent_name: str, prompt: str) -> list[str]:
36
+ raise ValueError(f"L'adaptateur {self.name} ne supporte pas les agents nommés")
37
+
38
+ def build_structured_command(
39
+ self,
40
+ prompt: str,
41
+ permission_mode: PermissionMode,
42
+ schema_file: Path,
43
+ output_file: Path,
44
+ sandbox_mode: SandboxMode = "workspace-write",
45
+ ) -> list[str]:
46
+ raise ValueError(f"L'adaptateur {self.name} ne supporte pas les sorties structurees")
47
+
48
+ def execute(
49
+ self,
50
+ prompt: str,
51
+ cwd: Path,
52
+ timeout: int,
53
+ permission_mode: PermissionMode = "auto",
54
+ *,
55
+ capture_output: bool = False,
56
+ ) -> AgentExecution:
57
+ command = self.build_command(prompt, permission_mode)
58
+ try:
59
+ result = subprocess.run(
60
+ command,
61
+ cwd=cwd,
62
+ timeout=timeout,
63
+ text=True,
64
+ capture_output=capture_output,
65
+ shell=False,
66
+ )
67
+ return AgentExecution(
68
+ result.returncode,
69
+ result.stdout or "",
70
+ result.stderr or "",
71
+ )
72
+ except subprocess.TimeoutExpired as exc:
73
+ return AgentExecution(
74
+ 124,
75
+ _text(exc.stdout),
76
+ _text(exc.stderr),
77
+ timed_out=True,
78
+ )
79
+ except FileNotFoundError:
80
+ return AgentExecution(127, stderr=f"Executable '{self.executable}' introuvable")
81
+
82
+ def execute_structured(
83
+ self,
84
+ prompt: str,
85
+ cwd: Path,
86
+ timeout: int,
87
+ schema_file: Path,
88
+ output_file: Path,
89
+ permission_mode: PermissionMode = "auto",
90
+ sandbox_mode: SandboxMode = "workspace-write",
91
+ ) -> AgentExecution:
92
+ command = self.build_structured_command(
93
+ prompt, permission_mode, schema_file, output_file, sandbox_mode
94
+ )
95
+ try:
96
+ result = subprocess.run(
97
+ command,
98
+ cwd=cwd,
99
+ timeout=timeout,
100
+ text=True,
101
+ capture_output=True,
102
+ shell=False,
103
+ )
104
+ return AgentExecution(
105
+ result.returncode,
106
+ result.stdout or "",
107
+ result.stderr or "",
108
+ )
109
+ except subprocess.TimeoutExpired as exc:
110
+ return AgentExecution(
111
+ 124,
112
+ _text(exc.stdout),
113
+ _text(exc.stderr),
114
+ timed_out=True,
115
+ )
116
+ except FileNotFoundError:
117
+ return AgentExecution(127, stderr=f"Executable '{self.executable}' introuvable")
118
+
119
+
120
+ class ClaudeCodeAdapter(AgentAdapter):
121
+ name = "claude-code"
122
+ executable = "claude"
123
+
124
+ def build_command(self, prompt: str, permission_mode: PermissionMode) -> list[str]:
125
+ return [
126
+ self.executable,
127
+ "--print",
128
+ "--permission-mode",
129
+ permission_mode,
130
+ prompt,
131
+ ]
132
+
133
+ def build_named_agent_command(self, agent_name: str, prompt: str) -> list[str]:
134
+ return [self.executable, "--print", "--agent", agent_name, prompt]
135
+
136
+
137
+ class CodexAdapter(AgentAdapter):
138
+ name = "codex"
139
+ executable = "codex"
140
+
141
+ def build_command(self, prompt: str, permission_mode: PermissionMode) -> list[str]:
142
+ command = [self.executable, "exec"]
143
+ if permission_mode in {"auto", "acceptEdits"}:
144
+ command.append("--approve-for-me")
145
+ else:
146
+ command.extend(["--sandbox", "workspace-write"])
147
+ command.append(_portable_prompt(prompt))
148
+ return command
149
+
150
+ def build_structured_command(
151
+ self,
152
+ prompt: str,
153
+ permission_mode: PermissionMode,
154
+ schema_file: Path,
155
+ output_file: Path,
156
+ sandbox_mode: SandboxMode = "workspace-write",
157
+ ) -> list[str]:
158
+ command = [self.executable, "exec"]
159
+ if permission_mode in {"auto", "acceptEdits"} and sandbox_mode == "workspace-write":
160
+ command.append("--approve-for-me")
161
+ else:
162
+ command.extend(["--sandbox", sandbox_mode])
163
+ command.extend([
164
+ "--output-schema",
165
+ str(schema_file),
166
+ "--output-last-message",
167
+ str(output_file),
168
+ ])
169
+ command.append(prompt)
170
+ return command
171
+
172
+
173
+ class CursorAdapter(AgentAdapter):
174
+ name = "cursor"
175
+ executable = "cursor-agent"
176
+
177
+ def build_command(self, prompt: str, permission_mode: PermissionMode) -> list[str]:
178
+ command = [self.executable, "--print", "--output-format", "text"]
179
+ if permission_mode in {"auto", "acceptEdits"}:
180
+ command.append("--force")
181
+ command.append(_portable_prompt(prompt))
182
+ return command
183
+
184
+
185
+ _AGENT_ADAPTERS = {
186
+ "claude": ClaudeCodeAdapter,
187
+ "claude-code": ClaudeCodeAdapter,
188
+ "codex": CodexAdapter,
189
+ "cursor": CursorAdapter,
190
+ "cursor-agent": CursorAdapter,
191
+ }
192
+
193
+
194
+ def get_agent_adapter(name: str) -> AgentAdapter:
195
+ try:
196
+ adapter_type = _AGENT_ADAPTERS[name.casefold()]
197
+ except KeyError as exc:
198
+ supported = ", ".join(sorted({kind().name for kind in _AGENT_ADAPTERS.values()}))
199
+ raise ValueError(f"Agent inconnu '{name}'. Agents supportés: {supported}") from exc
200
+ return adapter_type()
201
+
202
+
203
+ def _portable_prompt(prompt: str) -> str:
204
+ """Traduit une slash-command Claude en instruction portable pour les autres agents."""
205
+ match = re.fullmatch(r"/devflow\.([a-z0-9-]+)(?:\s+(.*))?", prompt.strip())
206
+ if not match:
207
+ return prompt
208
+ command_name, arguments = match.groups()
209
+ arguments = arguments or ""
210
+ return (
211
+ f"Exécute strictement le workflow décrit dans "
212
+ f"commands/devflow.{command_name}.md avec $ARGUMENTS = {arguments!r}. "
213
+ "Travaille dans le dépôt courant et respecte toutes les validations de ce fichier."
214
+ )
215
+
216
+
217
+ def _text(value: str | bytes | None) -> str:
218
+ if value is None:
219
+ return ""
220
+ return value.decode(errors="replace") if isinstance(value, bytes) else value
@@ -0,0 +1,79 @@
1
+ """Politique de synchronisation Linear, indépendante du transport MCP/API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Literal, Protocol
7
+
8
+ from devflow_cli import STEP_LINEAR_MAPPING, STEP_LINEAR_ORDER
9
+
10
+
11
+ LinearAction = Literal["update", "keep", "skip"]
12
+
13
+
14
+ class LinearAdapter(Protocol):
15
+ """Transport minimal requis pour synchroniser le statut d'une issue."""
16
+
17
+ def get_status(self, issue_id: str) -> str | None: ...
18
+
19
+ def update_status(self, issue_id: str, status: str) -> None: ...
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class LinearDecision:
24
+ action: LinearAction
25
+ step: str
26
+ current_status: str | None
27
+ target_status: str | None
28
+ reason: str
29
+
30
+ @property
31
+ def should_update(self) -> bool:
32
+ return self.action == "update"
33
+
34
+ def to_dict(self) -> dict[str, str | bool | None]:
35
+ return {
36
+ "action": self.action,
37
+ "step": self.step,
38
+ "currentStatus": self.current_status,
39
+ "targetStatus": self.target_status,
40
+ "shouldUpdate": self.should_update,
41
+ "reason": self.reason,
42
+ }
43
+
44
+
45
+ def decide_linear_transition(step: str, current_status: str | None) -> LinearDecision:
46
+ """Applique le mapping et la règle de non-régression canonique."""
47
+ target = STEP_LINEAR_MAPPING.get(step)
48
+ if target is None:
49
+ return LinearDecision("skip", step, current_status, None, "Étape sans statut Linear")
50
+ if current_status not in STEP_LINEAR_ORDER:
51
+ return LinearDecision(
52
+ "skip",
53
+ step,
54
+ current_status,
55
+ target,
56
+ "Statut Linear courant absent de l'ordre canonique",
57
+ )
58
+ current_index = STEP_LINEAR_ORDER.index(current_status)
59
+ target_index = STEP_LINEAR_ORDER.index(target)
60
+ if current_index >= target_index:
61
+ return LinearDecision(
62
+ "keep",
63
+ step,
64
+ current_status,
65
+ target,
66
+ "Statut courant identique ou plus avancé",
67
+ )
68
+ return LinearDecision("update", step, current_status, target, "Progression autorisée")
69
+
70
+
71
+ def sync_linear_status(
72
+ adapter: LinearAdapter, issue_id: str, step: str
73
+ ) -> LinearDecision:
74
+ """Synchronise via un transport injecté lorsque la politique l'autorise."""
75
+ current_status = adapter.get_status(issue_id)
76
+ decision = decide_linear_transition(step, current_status)
77
+ if decision.should_update and decision.target_status is not None:
78
+ adapter.update_status(issue_id, decision.target_status)
79
+ return decision
devflow_cli/cli.py CHANGED
@@ -18,6 +18,16 @@ from devflow_cli.commands.upgrade_cmd import upgrade
18
18
  from devflow_cli.commands.rollback import rollback
19
19
  from devflow_cli.commands.migrate_speckit import migrate_speckit
20
20
  from devflow_cli.commands import extension
21
+ from devflow_cli.commands import hooks_cmd
22
+ from devflow_cli.commands import linear
23
+ from devflow_cli.commands import review
24
+ from devflow_cli.commands import step
25
+ from devflow_cli.commands import adaptive
26
+ from devflow_cli.commands.run import run
27
+ from devflow_cli.commands.docs_sync import docs_sync
28
+ from devflow_cli.commands.traceability import traceability
29
+ from devflow_cli.commands.verify import verify
30
+ from devflow_cli.commands.assess import assess
21
31
 
22
32
  app = typer.Typer(
23
33
  name="devflow",
@@ -53,6 +63,16 @@ app.command()(upgrade)
53
63
  app.command()(rollback)
54
64
  app.command(name="migrate-speckit")(migrate_speckit)
55
65
  app.add_typer(extension.app, name="extension")
66
+ app.add_typer(hooks_cmd.app, name="hooks")
67
+ app.add_typer(linear.app, name="linear")
68
+ app.add_typer(step.app, name="step")
69
+ app.add_typer(review.app, name="review")
70
+ app.add_typer(adaptive.app, name="adaptive")
71
+ app.command()(run)
72
+ app.command(name="docs-sync")(docs_sync)
73
+ app.command()(traceability)
74
+ app.command()(verify)
75
+ app.command()(assess)
56
76
 
57
77
 
58
78
  if __name__ == "__main__":