devflow-cli 2.3.0__py3-none-any.whl → 2.8.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.
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.8.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,6 +58,20 @@ STEP_LINEAR_MAPPING = {
51
58
  "done": "Done",
52
59
  }
53
60
 
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
+
54
75
  SUPPORTED_AGENTS = ["claude-code", "cursor"]
55
76
 
56
77
  # Mapping speckit → devflow pour la migration
@@ -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,148 @@
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
+
15
+
16
+ @dataclass(frozen=True)
17
+ class AgentExecution:
18
+ returncode: int
19
+ stdout: str = ""
20
+ stderr: str = ""
21
+ timed_out: bool = False
22
+
23
+
24
+ class AgentAdapter(ABC):
25
+ """Contrat minimal d'un agent pilotable par le CLI."""
26
+
27
+ name: str
28
+ executable: str
29
+
30
+ @abstractmethod
31
+ def build_command(self, prompt: str, permission_mode: PermissionMode) -> list[str]:
32
+ """Construit une commande sans passer par un shell."""
33
+
34
+ def build_named_agent_command(self, agent_name: str, prompt: str) -> list[str]:
35
+ raise ValueError(f"L'adaptateur {self.name} ne supporte pas les agents nommés")
36
+
37
+ def execute(
38
+ self,
39
+ prompt: str,
40
+ cwd: Path,
41
+ timeout: int,
42
+ permission_mode: PermissionMode = "auto",
43
+ *,
44
+ capture_output: bool = False,
45
+ ) -> AgentExecution:
46
+ command = self.build_command(prompt, permission_mode)
47
+ try:
48
+ result = subprocess.run(
49
+ command,
50
+ cwd=cwd,
51
+ timeout=timeout,
52
+ text=True,
53
+ capture_output=capture_output,
54
+ shell=False,
55
+ )
56
+ return AgentExecution(
57
+ result.returncode,
58
+ result.stdout or "",
59
+ result.stderr or "",
60
+ )
61
+ except subprocess.TimeoutExpired as exc:
62
+ return AgentExecution(
63
+ 124,
64
+ _text(exc.stdout),
65
+ _text(exc.stderr),
66
+ timed_out=True,
67
+ )
68
+ except FileNotFoundError:
69
+ return AgentExecution(127, stderr=f"Executable '{self.executable}' introuvable")
70
+
71
+
72
+ class ClaudeCodeAdapter(AgentAdapter):
73
+ name = "claude-code"
74
+ executable = "claude"
75
+
76
+ def build_command(self, prompt: str, permission_mode: PermissionMode) -> list[str]:
77
+ return [
78
+ self.executable,
79
+ "--print",
80
+ "--permission-mode",
81
+ permission_mode,
82
+ prompt,
83
+ ]
84
+
85
+ def build_named_agent_command(self, agent_name: str, prompt: str) -> list[str]:
86
+ return [self.executable, "--print", "--agent", agent_name, prompt]
87
+
88
+
89
+ class CodexAdapter(AgentAdapter):
90
+ name = "codex"
91
+ executable = "codex"
92
+
93
+ def build_command(self, prompt: str, permission_mode: PermissionMode) -> list[str]:
94
+ command = [self.executable, "exec", "--sandbox", "workspace-write"]
95
+ if permission_mode in {"auto", "acceptEdits"}:
96
+ command.append("--approve-for-me")
97
+ command.append(_portable_prompt(prompt))
98
+ return command
99
+
100
+
101
+ class CursorAdapter(AgentAdapter):
102
+ name = "cursor"
103
+ executable = "cursor-agent"
104
+
105
+ def build_command(self, prompt: str, permission_mode: PermissionMode) -> list[str]:
106
+ command = [self.executable, "--print", "--output-format", "text"]
107
+ if permission_mode in {"auto", "acceptEdits"}:
108
+ command.append("--force")
109
+ command.append(_portable_prompt(prompt))
110
+ return command
111
+
112
+
113
+ _AGENT_ADAPTERS = {
114
+ "claude": ClaudeCodeAdapter,
115
+ "claude-code": ClaudeCodeAdapter,
116
+ "codex": CodexAdapter,
117
+ "cursor": CursorAdapter,
118
+ "cursor-agent": CursorAdapter,
119
+ }
120
+
121
+
122
+ def get_agent_adapter(name: str) -> AgentAdapter:
123
+ try:
124
+ adapter_type = _AGENT_ADAPTERS[name.casefold()]
125
+ except KeyError as exc:
126
+ supported = ", ".join(sorted({kind().name for kind in _AGENT_ADAPTERS.values()}))
127
+ raise ValueError(f"Agent inconnu '{name}'. Agents supportés: {supported}") from exc
128
+ return adapter_type()
129
+
130
+
131
+ def _portable_prompt(prompt: str) -> str:
132
+ """Traduit une slash-command Claude en instruction portable pour les autres agents."""
133
+ match = re.fullmatch(r"/devflow\.([a-z0-9-]+)(?:\s+(.*))?", prompt.strip())
134
+ if not match:
135
+ return prompt
136
+ command_name, arguments = match.groups()
137
+ arguments = arguments or ""
138
+ return (
139
+ f"Exécute strictement le workflow décrit dans "
140
+ f"commands/devflow.{command_name}.md avec $ARGUMENTS = {arguments!r}. "
141
+ "Travaille dans le dépôt courant et respecte toutes les validations de ce fichier."
142
+ )
143
+
144
+
145
+ def _text(value: str | bytes | None) -> str:
146
+ if value is None:
147
+ return ""
148
+ 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,14 @@ 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.run import run
26
+ from devflow_cli.commands.docs_sync import docs_sync
27
+ from devflow_cli.commands.traceability import traceability
28
+ from devflow_cli.commands.verify import verify
21
29
 
22
30
  app = typer.Typer(
23
31
  name="devflow",
@@ -53,6 +61,14 @@ app.command()(upgrade)
53
61
  app.command()(rollback)
54
62
  app.command(name="migrate-speckit")(migrate_speckit)
55
63
  app.add_typer(extension.app, name="extension")
64
+ app.add_typer(hooks_cmd.app, name="hooks")
65
+ app.add_typer(linear.app, name="linear")
66
+ app.add_typer(step.app, name="step")
67
+ app.add_typer(review.app, name="review")
68
+ app.command()(run)
69
+ app.command(name="docs-sync")(docs_sync)
70
+ app.command()(traceability)
71
+ app.command()(verify)
56
72
 
57
73
 
58
74
  if __name__ == "__main__":
@@ -0,0 +1,33 @@
1
+ """devflow docs-sync — Synchronise la documentation dérivée des constantes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from devflow_cli.core.docgen import sync_pipeline_docs
10
+ from devflow_cli.utils.console import fail, ok
11
+
12
+
13
+ def docs_sync(
14
+ path: Path = typer.Argument(Path("."), help="Racine du depot devflow"),
15
+ check: bool = typer.Option(False, "--check", help="Vérifie sans modifier"),
16
+ ) -> None:
17
+ """Génère les références du pipeline depuis les constantes Python."""
18
+ root = path.resolve()
19
+ try:
20
+ stale = sync_pipeline_docs(root, check=check)
21
+ except (OSError, ValueError) as exc:
22
+ fail(str(exc))
23
+ raise typer.Exit(1) from exc
24
+
25
+ if check and stale:
26
+ for path in stale:
27
+ fail(f"Documentation obsolète: {path.relative_to(root)}")
28
+ raise typer.Exit(1)
29
+ if stale:
30
+ for path in stale:
31
+ ok(f"Synchronisé: {path.relative_to(root)}")
32
+ else:
33
+ ok("Documentation déjà synchronisée.")
@@ -76,7 +76,9 @@ def add(name: str = typer.Argument(..., help="Nom de l'extension a installer"))
76
76
  console.print("Utilisez 'devflow extension catalog' pour voir les extensions disponibles.")
77
77
  raise typer.Exit(1)
78
78
 
79
- source_label = f" [{ext.source}]" if ext.source != "principal" else ""
79
+ # Des crochets nus sont interpretes comme du markup Rich et rendent le
80
+ # libelle invisible (ex: "[communaute]").
81
+ source_label = f" ({ext.source})" if ext.source != "principal" else ""
80
82
  console.print(f"Installation de '{name}'{source_label}...")
81
83
  console.print()
82
84
 
@@ -65,7 +65,7 @@ def _init_feature_files(
65
65
  def feature(
66
66
  issue_id: str = typer.Argument(..., help="Identifiant de l'issue (ex: KS-123)"),
67
67
  short_name: Optional[str] = typer.Option(None, "--short-name", help="Nom court pour le repertoire (ex: user-auth)"),
68
- fast: bool = typer.Option(False, "--fast", help="Mode minimal (4 etapes : spec, plan, tasks, implement)"),
68
+ fast: bool = typer.Option(False, "--fast", help="Mode accelere (9 etapes, dont 4 productives)"),
69
69
  ) -> None:
70
70
  """Initialise une feature : branche git + state.json + review-log.md."""
71
71
  _validate_issue_id(issue_id)
@@ -102,9 +102,9 @@ def feature(
102
102
  console.print(f"Feature [bold]{issue_id}[/bold] initialisee :")
103
103
  console.print(f" Branche : {branch}")
104
104
  console.print(f" Dossier : {feature_dir}/")
105
- from devflow_cli import STEPS, OPTIONAL_STEPS
106
- total = len(STEPS)
107
- fast_count = total - len(OPTIONAL_STEPS)
105
+ from devflow_cli import FAST_STEP_COUNT, FULL_STEP_COUNT
106
+ total = FULL_STEP_COUNT
107
+ fast_count = FAST_STEP_COUNT
108
108
  console.print(f" Mode : {mode} ({fast_count} etapes)" if fast else f" Mode : {mode} ({total} etapes)")
109
109
  console.print(f" Etat : spec (etape initiale)")
110
110
  console.print()
@@ -0,0 +1,193 @@
1
+ """devflow hooks — Gestion et exécution contrôlée des hooks devflow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import stat
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ import typer
10
+
11
+ from devflow_cli.core.git import is_git_repo
12
+ from devflow_cli.core.hooks import (
13
+ DEFAULT_HOOK_TIMEOUT,
14
+ MAX_HOOK_TIMEOUT,
15
+ VALID_HOOK_POINTS,
16
+ run_hook_point,
17
+ )
18
+ from devflow_cli.utils.console import console, ok, fail, warn
19
+
20
+ app = typer.Typer(help="Gestion et execution des hooks devflow")
21
+
22
+ HOOKS_DIR_NAME = ".githooks"
23
+ HOOK_FILES = ("post-checkout", "post-merge")
24
+
25
+
26
+ def _configure_hooks_path(repo: Path) -> None:
27
+ subprocess.run(
28
+ ["git", "-C", str(repo), "config", "core.hooksPath", HOOKS_DIR_NAME],
29
+ check=True,
30
+ capture_output=True,
31
+ )
32
+
33
+
34
+ def _current_hooks_path(repo: Path) -> str | None:
35
+ result = subprocess.run(
36
+ ["git", "-C", str(repo), "config", "--get", "core.hooksPath"],
37
+ capture_output=True,
38
+ text=True,
39
+ )
40
+ if result.returncode != 0:
41
+ return None
42
+ return result.stdout.strip() or None
43
+
44
+
45
+ def _unset_hooks_path(repo: Path) -> None:
46
+ subprocess.run(
47
+ ["git", "-C", str(repo), "config", "--unset", "core.hooksPath"],
48
+ capture_output=True,
49
+ )
50
+
51
+
52
+ @app.command("install")
53
+ def install() -> None:
54
+ """Active les git hooks devflow (core.hooksPath + chmod +x)."""
55
+ repo = Path.cwd().resolve()
56
+
57
+ if not is_git_repo(repo):
58
+ fail(f"{repo} n'est pas un depot git.")
59
+ raise typer.Exit(1)
60
+
61
+ hooks_dir = repo / HOOKS_DIR_NAME
62
+ if not hooks_dir.is_dir():
63
+ fail(f"Dossier {HOOKS_DIR_NAME}/ introuvable a la racine du repo.")
64
+ raise typer.Exit(1)
65
+
66
+ missing = [h for h in HOOK_FILES if not (hooks_dir / h).is_file()]
67
+ if missing:
68
+ fail(f"Hooks manquants dans {HOOKS_DIR_NAME}/ : {', '.join(missing)}")
69
+ raise typer.Exit(1)
70
+
71
+ for name in HOOK_FILES:
72
+ path = hooks_dir / name
73
+ mode = path.stat().st_mode
74
+ path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
75
+ ok(f"chmod +x {HOOKS_DIR_NAME}/{name}")
76
+
77
+ _configure_hooks_path(repo)
78
+ ok(f"git config core.hooksPath {HOOKS_DIR_NAME}")
79
+
80
+ console.print()
81
+ console.print("[green]Hooks devflow actifs.[/green] Un checkout ou merge sur develop lancera `devflow init --here --force`.")
82
+
83
+
84
+ @app.command("uninstall")
85
+ def uninstall() -> None:
86
+ """Desactive les git hooks devflow (supprime core.hooksPath)."""
87
+ repo = Path.cwd().resolve()
88
+
89
+ if not is_git_repo(repo):
90
+ fail(f"{repo} n'est pas un depot git.")
91
+ raise typer.Exit(1)
92
+
93
+ current = _current_hooks_path(repo)
94
+ if current is None:
95
+ warn("core.hooksPath n'est pas configure — rien a desactiver.")
96
+ return
97
+
98
+ if current != HOOKS_DIR_NAME:
99
+ warn(f"core.hooksPath pointe sur '{current}' (pas sur '{HOOKS_DIR_NAME}'). Abandon.")
100
+ raise typer.Exit(1)
101
+
102
+ _unset_hooks_path(repo)
103
+ ok(f"git config core.hooksPath supprime (etait: {current})")
104
+
105
+
106
+ @app.command("status")
107
+ def status() -> None:
108
+ """Affiche l'etat des hooks devflow pour ce repo."""
109
+ repo = Path.cwd().resolve()
110
+
111
+ if not is_git_repo(repo):
112
+ fail(f"{repo} n'est pas un depot git.")
113
+ raise typer.Exit(1)
114
+
115
+ hooks_path = _current_hooks_path(repo)
116
+ hooks_dir = repo / HOOKS_DIR_NAME
117
+
118
+ console.print(f"Repo : {repo}")
119
+ console.print(f"hooksPath : {hooks_path or '[dim](non configure)[/dim]'}")
120
+ console.print(f"{HOOKS_DIR_NAME}/ : {'present' if hooks_dir.is_dir() else '[dim]absent[/dim]'}")
121
+
122
+ if hooks_dir.is_dir():
123
+ for name in HOOK_FILES:
124
+ p = hooks_dir / name
125
+ if not p.is_file():
126
+ console.print(f" {name:<15} : [dim]absent[/dim]")
127
+ continue
128
+ executable = bool(p.stat().st_mode & stat.S_IXUSR)
129
+ console.print(f" {name:<15} : present ({'exec' if executable else 'non-exec'})")
130
+
131
+ active = hooks_path == HOOKS_DIR_NAME and hooks_dir.is_dir()
132
+ console.print()
133
+ console.print(f"Statut : {'[green]actif[/green]' if active else '[yellow]inactif[/yellow]'}")
134
+
135
+
136
+ @app.command("run")
137
+ def run(
138
+ hook_point: str = typer.Argument(..., help="Point de hook devflow"),
139
+ issue_id: str = typer.Argument(..., help="ID de l'issue courante"),
140
+ allow_shell: bool = typer.Option(
141
+ False,
142
+ "--allow-shell",
143
+ help="Autorise explicitement les hooks de type shell",
144
+ ),
145
+ timeout: int = typer.Option(
146
+ DEFAULT_HOOK_TIMEOUT,
147
+ min=1,
148
+ max=MAX_HOOK_TIMEOUT,
149
+ help="Timeout par defaut par hook",
150
+ ),
151
+ ) -> None:
152
+ """Execute un point de hook depuis devflow-hooks.yml."""
153
+ if hook_point not in VALID_HOOK_POINTS:
154
+ fail(f"Hook point inconnu: {hook_point}")
155
+ raise typer.Exit(1)
156
+
157
+ repo = Path.cwd().resolve()
158
+ hooks_file = repo / "devflow-hooks.yml"
159
+ if not hooks_file.is_file():
160
+ console.print("Aucun devflow-hooks.yml — rien a executer.")
161
+ return
162
+
163
+ try:
164
+ executions = run_hook_point(
165
+ hooks_file,
166
+ hook_point,
167
+ issue_id,
168
+ repo,
169
+ allow_shell=allow_shell,
170
+ default_timeout=timeout,
171
+ )
172
+ except (ValueError, PermissionError) as exc:
173
+ fail(str(exc))
174
+ raise typer.Exit(1) from exc
175
+
176
+ if not executions:
177
+ console.print(f"Aucun hook configure pour {hook_point}.")
178
+ return
179
+
180
+ failed = False
181
+ for execution in executions:
182
+ label = " ".join(execution.command)
183
+ if execution.succeeded:
184
+ ok(f"{execution.hook_type}: {label}")
185
+ else:
186
+ failed = True
187
+ reason = "timeout" if execution.timed_out else f"code {execution.returncode}"
188
+ fail(f"{execution.hook_type}: {label} ({reason})")
189
+ if execution.stderr.strip():
190
+ console.print(f" {execution.stderr.strip()}")
191
+
192
+ if failed:
193
+ raise typer.Exit(1)
@@ -0,0 +1,30 @@
1
+ """Commandes de politique Linear indépendantes du transport."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import typer
8
+
9
+ from devflow_cli.adapters.linear import decide_linear_transition
10
+ from devflow_cli.utils.console import console
11
+
12
+
13
+ app = typer.Typer(help="Évalue la politique de synchronisation Linear.")
14
+
15
+
16
+ @app.command()
17
+ def decision(
18
+ step: str = typer.Argument(..., help="Étape devflow"),
19
+ current_status: str = typer.Argument(..., help="Statut Linear courant"),
20
+ json_output: bool = typer.Option(False, "--json", help="Sortie JSON"),
21
+ ) -> None:
22
+ """Décide si le statut Linear peut progresser sans régression."""
23
+ result = decide_linear_transition(step, current_status)
24
+ if json_output:
25
+ console.print_json(json.dumps(result.to_dict(), ensure_ascii=False))
26
+ return
27
+ console.print(
28
+ f"{result.action}: {current_status} → {result.target_status or '—'} "
29
+ f"({result.reason})"
30
+ )