devflow-cli 2.8.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.
devflow_cli/__init__.py CHANGED
@@ -1,6 +1,6 @@
1
1
  """devflow CLI — Spec-Driven Development workflow."""
2
2
 
3
- VERSION = "2.8.0"
3
+ VERSION = "2.9.0"
4
4
  STATE_SCHEMA_VERSION = 3
5
5
 
6
6
  STEPS = [
@@ -72,7 +72,7 @@ STEP_LINEAR_ORDER = [
72
72
  "Done",
73
73
  ]
74
74
 
75
- SUPPORTED_AGENTS = ["claude-code", "cursor"]
75
+ SUPPORTED_AGENTS = ["claude-code", "codex", "cursor"]
76
76
 
77
77
  # Mapping speckit → devflow pour la migration
78
78
  SPECKIT_STEP_MAP = {
@@ -11,6 +11,7 @@ from typing import Literal
11
11
 
12
12
 
13
13
  PermissionMode = Literal["auto", "acceptEdits", "manual"]
14
+ SandboxMode = Literal["read-only", "workspace-write"]
14
15
 
15
16
 
16
17
  @dataclass(frozen=True)
@@ -34,6 +35,16 @@ class AgentAdapter(ABC):
34
35
  def build_named_agent_command(self, agent_name: str, prompt: str) -> list[str]:
35
36
  raise ValueError(f"L'adaptateur {self.name} ne supporte pas les agents nommés")
36
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
+
37
48
  def execute(
38
49
  self,
39
50
  prompt: str,
@@ -68,6 +79,43 @@ class AgentAdapter(ABC):
68
79
  except FileNotFoundError:
69
80
  return AgentExecution(127, stderr=f"Executable '{self.executable}' introuvable")
70
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
+
71
119
 
72
120
  class ClaudeCodeAdapter(AgentAdapter):
73
121
  name = "claude-code"
@@ -91,12 +139,36 @@ class CodexAdapter(AgentAdapter):
91
139
  executable = "codex"
92
140
 
93
141
  def build_command(self, prompt: str, permission_mode: PermissionMode) -> list[str]:
94
- command = [self.executable, "exec", "--sandbox", "workspace-write"]
142
+ command = [self.executable, "exec"]
95
143
  if permission_mode in {"auto", "acceptEdits"}:
96
144
  command.append("--approve-for-me")
145
+ else:
146
+ command.extend(["--sandbox", "workspace-write"])
97
147
  command.append(_portable_prompt(prompt))
98
148
  return command
99
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
+
100
172
 
101
173
  class CursorAdapter(AgentAdapter):
102
174
  name = "cursor"
devflow_cli/cli.py CHANGED
@@ -22,10 +22,12 @@ from devflow_cli.commands import hooks_cmd
22
22
  from devflow_cli.commands import linear
23
23
  from devflow_cli.commands import review
24
24
  from devflow_cli.commands import step
25
+ from devflow_cli.commands import adaptive
25
26
  from devflow_cli.commands.run import run
26
27
  from devflow_cli.commands.docs_sync import docs_sync
27
28
  from devflow_cli.commands.traceability import traceability
28
29
  from devflow_cli.commands.verify import verify
30
+ from devflow_cli.commands.assess import assess
29
31
 
30
32
  app = typer.Typer(
31
33
  name="devflow",
@@ -65,10 +67,12 @@ app.add_typer(hooks_cmd.app, name="hooks")
65
67
  app.add_typer(linear.app, name="linear")
66
68
  app.add_typer(step.app, name="step")
67
69
  app.add_typer(review.app, name="review")
70
+ app.add_typer(adaptive.app, name="adaptive")
68
71
  app.command()(run)
69
72
  app.command(name="docs-sync")(docs_sync)
70
73
  app.command()(traceability)
71
74
  app.command()(verify)
75
+ app.command()(assess)
72
76
 
73
77
 
74
78
  if __name__ == "__main__":
@@ -0,0 +1,357 @@
1
+ """Commandes de transition du workflow adaptatif."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import Literal, Optional
9
+
10
+ import typer
11
+
12
+ from devflow_cli.core.adaptive_workflow import (
13
+ complete_activity,
14
+ create_adaptive_workflow,
15
+ read_adaptive_state,
16
+ record_evidence,
17
+ workflow_lock,
18
+ write_adaptive_state,
19
+ )
20
+ from devflow_cli.core.state import read_state
21
+ from devflow_cli.core.activity_contracts import (
22
+ build_activity_contract,
23
+ render_contract_prompt,
24
+ write_activity_contract,
25
+ )
26
+ from devflow_cli.core.adaptive_runner import (
27
+ AgentActivityExecution,
28
+ run_current_activity,
29
+ )
30
+ from devflow_cli.core.worktree_snapshot import changed_paths, snapshot_worktree
31
+ from devflow_cli.core.automatic_evidence import run_automatic_verification
32
+ from devflow_cli.core.worklog import refresh_worklog, safe_refresh_worklog
33
+ from devflow_cli.core.waivers import create_validation_waiver
34
+ from devflow_cli.core.remediation import carried_changed_files, reopen_activity
35
+ from devflow_cli.adapters.agents import get_agent_adapter
36
+ from devflow_cli.utils.console import console, fail, ok
37
+ from devflow_cli.utils.paths import project_root_for_feature, resolve_feature_dir
38
+
39
+
40
+ app = typer.Typer(help="Machine d'etat des parcours adaptatifs")
41
+
42
+
43
+ def _feature(identifier: str) -> Path:
44
+ feature_dir = resolve_feature_dir(identifier)
45
+ if feature_dir is None:
46
+ fail(f"Feature '{identifier}' introuvable.")
47
+ raise typer.Exit(1)
48
+ return feature_dir
49
+
50
+
51
+ def _state(feature_dir: Path):
52
+ state = read_adaptive_state(feature_dir / "workflow-state.json")
53
+ if state is None:
54
+ fail("workflow-state.json invalide ou absent. Lancez 'devflow adaptive start'.")
55
+ raise typer.Exit(1)
56
+ return state
57
+
58
+
59
+ @app.command("start")
60
+ def start(feature: str = typer.Argument(..., help="ID ou dossier de la feature")) -> None:
61
+ """Initialise le parcours selectionne par assessment.json."""
62
+ feature_dir = _feature(feature)
63
+ state_file = feature_dir / "workflow-state.json"
64
+ with workflow_lock(feature_dir):
65
+ if state_file.exists():
66
+ fail("workflow-state.json existe deja ; le workflow ne sera pas ecrase.")
67
+ raise typer.Exit(1)
68
+ try:
69
+ assessment = json.loads(
70
+ (feature_dir / "assessment.json").read_text(encoding="utf-8")
71
+ )
72
+ state = create_adaptive_workflow(assessment)
73
+ legacy_state = read_state(feature_dir / "state.json")
74
+ if legacy_state is None or legacy_state.issueId != state.issueId:
75
+ raise ValueError("issueId incoherent entre assessment.json et state.json")
76
+ except (OSError, json.JSONDecodeError, ValueError) as exc:
77
+ fail(f"Impossible de demarrer le workflow : {exc}")
78
+ raise typer.Exit(1) from exc
79
+ write_adaptive_state(state_file, state)
80
+ safe_refresh_worklog(feature_dir)
81
+ ok(f"Workflow {state.profile} demarre. Activite courante : {state.currentActivity}")
82
+
83
+
84
+ @app.command("status")
85
+ def status(feature: str = typer.Argument(..., help="ID ou dossier de la feature")) -> None:
86
+ """Affiche la progression et les preuves restantes."""
87
+ feature_dir = _feature(feature)
88
+ with workflow_lock(feature_dir):
89
+ state = _state(feature_dir)
90
+ missing = [item for item in state.requiredEvidence if item not in state.evidence]
91
+ console.print(f"Profil : {state.profile}")
92
+ console.print(f"Statut : {state.status}")
93
+ console.print(f"Activite : {state.currentActivity}")
94
+ console.print(f"Completees: {', '.join(state.completedActivities)}")
95
+ console.print(f"Preuves manquantes: {', '.join(missing) if missing else 'aucune'}")
96
+
97
+
98
+ @app.command("complete")
99
+ def complete(
100
+ feature: str = typer.Argument(..., help="ID ou dossier de la feature"),
101
+ activity: str = typer.Argument(..., help="Activite a terminer"),
102
+ ) -> None:
103
+ """Complete l'activite courante si ses gates sont satisfaites."""
104
+ feature_dir = _feature(feature)
105
+ with workflow_lock(feature_dir):
106
+ state = _state(feature_dir)
107
+ try:
108
+ complete_activity(state, activity)
109
+ except ValueError as exc:
110
+ fail(str(exc))
111
+ raise typer.Exit(1) from exc
112
+ write_adaptive_state(feature_dir / "workflow-state.json", state)
113
+ safe_refresh_worklog(feature_dir)
114
+ if state.status == "completed":
115
+ ok(f"Workflow {state.profile} termine.")
116
+ else:
117
+ ok(f"Activite '{activity}' completee. Prochaine : {state.currentActivity}")
118
+
119
+
120
+ @app.command("evidence")
121
+ def evidence(
122
+ feature: str = typer.Argument(..., help="ID ou dossier de la feature"),
123
+ evidence_id: str = typer.Argument(..., help="Identifiant de la preuve"),
124
+ report: Optional[str] = typer.Option(None, help="Chemin ou resume de la preuve"),
125
+ ) -> None:
126
+ """Enregistre une preuve attendue par le profil."""
127
+ feature_dir = _feature(feature)
128
+ with workflow_lock(feature_dir):
129
+ state = _state(feature_dir)
130
+ try:
131
+ record_evidence(state, evidence_id, report or "")
132
+ except ValueError as exc:
133
+ fail(str(exc))
134
+ raise typer.Exit(1) from exc
135
+ write_adaptive_state(feature_dir / "workflow-state.json", state)
136
+ safe_refresh_worklog(feature_dir)
137
+ ok(f"Preuve '{evidence_id}' enregistree.")
138
+
139
+
140
+ @app.command("contract")
141
+ def contract(
142
+ feature: str = typer.Argument(..., help="ID ou dossier de la feature"),
143
+ json_output: bool = typer.Option(False, "--json", help="Affiche le contrat JSON"),
144
+ prompt: bool = typer.Option(False, "--prompt", help="Affiche le prompt portable"),
145
+ ) -> None:
146
+ """Genere le contrat portable de l'activite courante."""
147
+ if json_output and prompt:
148
+ fail("Les options --json et --prompt sont mutuellement exclusives.")
149
+ raise typer.Exit(1)
150
+ feature_dir = _feature(feature)
151
+ project_root = project_root_for_feature(feature_dir)
152
+ try:
153
+ feature_path = feature_dir.relative_to(project_root).as_posix()
154
+ except ValueError as exc:
155
+ fail(f"Feature hors du projet courant : {exc}")
156
+ raise typer.Exit(1) from exc
157
+
158
+ with workflow_lock(feature_dir):
159
+ state = _state(feature_dir)
160
+ try:
161
+ assessment = json.loads(
162
+ (feature_dir / "assessment.json").read_text(encoding="utf-8")
163
+ )
164
+ activity_contract = build_activity_contract(
165
+ state,
166
+ assessment,
167
+ feature_path,
168
+ carried_changed_files(feature_dir, state.currentActivity),
169
+ )
170
+ except (OSError, json.JSONDecodeError, ValueError) as exc:
171
+ fail(f"Impossible de generer le contrat : {exc}")
172
+ raise typer.Exit(1) from exc
173
+ output_file = feature_dir / "activity-contract.json"
174
+ write_activity_contract(output_file, activity_contract)
175
+
176
+ if json_output:
177
+ console.print_json(data=activity_contract.to_dict())
178
+ elif prompt:
179
+ console.print(render_contract_prompt(activity_contract), markup=False)
180
+ else:
181
+ ok(f"Contrat genere pour '{activity_contract.activity}'.")
182
+ console.print(f" Contrat : {output_file}")
183
+ console.print(f" ID : {activity_contract.contractId}")
184
+
185
+
186
+ @app.command("run")
187
+ def run_adaptive(
188
+ feature: str = typer.Argument(..., help="ID ou dossier de la feature"),
189
+ agent: str = typer.Option("codex", help="Backend agent structure"),
190
+ timeout: int = typer.Option(1800, min=30, help="Timeout de l'activite en secondes"),
191
+ permission_mode: Literal["auto", "acceptEdits", "manual"] = typer.Option(
192
+ "auto", help="Mode de permissions transmis a l'agent"
193
+ ),
194
+ ) -> None:
195
+ """Execute une activite agentique ou la verification locale."""
196
+ feature_dir = _feature(feature)
197
+ project_root = project_root_for_feature(feature_dir)
198
+ feature_path = feature_dir.relative_to(project_root).as_posix()
199
+ with workflow_lock(feature_dir):
200
+ current_state = _state(feature_dir)
201
+ if current_state.currentActivity == "verify":
202
+ _execute_local_verification(feature_dir, project_root)
203
+ return
204
+ if current_state.currentActivity not in {
205
+ "prepare", "risk-assessment", "spec", "research", "contracts", "plan",
206
+ "tasks", "implement", "review", "rollback-check",
207
+ }:
208
+ fail(
209
+ f"L'activite '{current_state.currentActivity}' ne necessite pas d'agent."
210
+ )
211
+ raise typer.Exit(1)
212
+ try:
213
+ adapter = get_agent_adapter(agent)
214
+ except ValueError as exc:
215
+ fail(str(exc))
216
+ raise typer.Exit(1) from exc
217
+ if adapter.name != "codex":
218
+ fail("Seul l'adaptateur codex supporte actuellement les contrats structures.")
219
+ raise typer.Exit(1)
220
+
221
+ def execute(activity_contract) -> AgentActivityExecution:
222
+ prompt_text = render_contract_prompt(activity_contract)
223
+ try:
224
+ before = snapshot_worktree(project_root)
225
+ with tempfile.TemporaryDirectory(prefix="devflow-adaptive-") as temp_dir:
226
+ temp_path = Path(temp_dir)
227
+ schema_file = temp_path / "output-schema.json"
228
+ output_file = temp_path / "last-message.json"
229
+ schema_file.write_text(
230
+ json.dumps(activity_contract.expectedOutput, ensure_ascii=False),
231
+ encoding="utf-8",
232
+ )
233
+ execution = adapter.execute_structured(
234
+ prompt_text,
235
+ project_root,
236
+ timeout,
237
+ schema_file,
238
+ output_file,
239
+ permission_mode,
240
+ "workspace-write"
241
+ if "write" in activity_contract.allowedActions
242
+ else "read-only",
243
+ )
244
+ output = (
245
+ output_file.read_text(encoding="utf-8")
246
+ if output_file.is_file()
247
+ else ""
248
+ )
249
+ after = snapshot_worktree(project_root)
250
+ observed = tuple(changed_paths(before, after))
251
+ except (OSError, ValueError) as exc:
252
+ return AgentActivityExecution(1, stderr=str(exc))
253
+ return AgentActivityExecution(
254
+ execution.returncode,
255
+ output,
256
+ execution.stderr,
257
+ observed,
258
+ execution.timed_out,
259
+ )
260
+
261
+ outcome = run_current_activity(feature_dir, feature_path, execute)
262
+ if outcome.advanced:
263
+ ok(
264
+ f"Activite '{outcome.activity}' validee. "
265
+ f"Prochaine : {outcome.nextActivity}"
266
+ )
267
+ return
268
+ fail(
269
+ f"Activite '{outcome.activity}' non validee : {outcome.reason}"
270
+ + (f" ({outcome.detail})" if outcome.detail else "")
271
+ )
272
+ raise typer.Exit(1)
273
+
274
+
275
+ @app.command("verify")
276
+ def verify_adaptive(
277
+ feature: str = typer.Argument(..., help="ID ou dossier de la feature"),
278
+ ) -> None:
279
+ """Genere localement les preuves requises par le profil actif."""
280
+ feature_dir = _feature(feature)
281
+ _execute_local_verification(feature_dir, project_root_for_feature(feature_dir))
282
+
283
+
284
+ @app.command("waive")
285
+ def waive_validation(
286
+ feature: str = typer.Argument(..., help="ID ou dossier de la feature"),
287
+ validation_id: str = typer.Argument(..., help="Identifiant du controle echoue"),
288
+ reason: str = typer.Option(..., "--reason", help="Justification obligatoire"),
289
+ author: Optional[str] = typer.Option(None, "--author", help="Auteur de la derogation"),
290
+ ) -> None:
291
+ """Rend non bloquant un controle planifie echoue, avec audit durable."""
292
+ feature_dir = _feature(feature)
293
+ with workflow_lock(feature_dir):
294
+ try:
295
+ waiver = create_validation_waiver(
296
+ feature_dir, validation_id, reason, author=author
297
+ )
298
+ except ValueError as exc:
299
+ fail(str(exc))
300
+ raise typer.Exit(1) from exc
301
+ safe_refresh_worklog(feature_dir)
302
+ ok(
303
+ f"Derogation '{waiver['id']}' enregistree pour "
304
+ f"{waiver['commandId']}."
305
+ )
306
+
307
+
308
+ @app.command("reopen")
309
+ def reopen(
310
+ feature: str = typer.Argument(..., help="ID ou dossier de la feature"),
311
+ activity: str = typer.Argument(..., help="Activite anterieure a reprendre"),
312
+ reason: str = typer.Option(..., "--reason", help="Motif obligatoire"),
313
+ author: Optional[str] = typer.Option(None, "--author", help="Auteur du retour"),
314
+ ) -> None:
315
+ """Revient a une activite anterieure sans effacer l'historique."""
316
+ feature_dir = _feature(feature)
317
+ with workflow_lock(feature_dir):
318
+ state = _state(feature_dir)
319
+ try:
320
+ entry = reopen_activity(
321
+ feature_dir, state, activity, reason, author=author
322
+ )
323
+ except (OSError, ValueError) as exc:
324
+ fail(str(exc))
325
+ raise typer.Exit(1) from exc
326
+ write_adaptive_state(feature_dir / "workflow-state.json", state)
327
+ safe_refresh_worklog(feature_dir)
328
+ ok(
329
+ f"Workflow rouvert sur '{activity}' (audit #{entry['sequence']}, "
330
+ f"archive : {entry['archivePath']})."
331
+ )
332
+
333
+
334
+ def _execute_local_verification(feature_dir: Path, project_root: Path) -> None:
335
+ outcome = run_automatic_verification(feature_dir, project_root)
336
+ if outcome.passed:
337
+ ok(f"Preuves generees et validees. Prochaine : {outcome.nextActivity}")
338
+ return
339
+ fail(
340
+ f"Verification automatique refusee : {outcome.reason}"
341
+ + (f" ({outcome.detail})" if outcome.detail else "")
342
+ )
343
+ raise typer.Exit(1)
344
+
345
+
346
+ @app.command("worklog")
347
+ def worklog(
348
+ feature: str = typer.Argument(..., help="ID ou dossier de la feature"),
349
+ ) -> None:
350
+ """Regenere le journal compact depuis les artefacts du workflow."""
351
+ feature_dir = _feature(feature)
352
+ try:
353
+ output = refresh_worklog(feature_dir)
354
+ except ValueError as exc:
355
+ fail(str(exc))
356
+ raise typer.Exit(1) from exc
357
+ ok(f"Worklog actualise : {output}")
@@ -0,0 +1,115 @@
1
+ """devflow assess — Recommande un profil sans modifier le pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tempfile
8
+ from pathlib import Path
9
+ from typing import Literal
10
+
11
+ import typer
12
+
13
+ from devflow_cli.core.assessment import AssessmentSignals, assess_feature
14
+ from devflow_cli.core.adaptive_workflow import workflow_lock
15
+ from devflow_cli.core.state import read_state
16
+ from devflow_cli.utils.console import console, fail, ok
17
+ from devflow_cli.utils.paths import resolve_feature_dir
18
+
19
+
20
+ def assess(
21
+ feature: str = typer.Argument(..., help="ID ou dossier de la feature"),
22
+ scope: Literal["local", "multiple", "transversal"] = typer.Option(
23
+ "local", help="Surface du changement"
24
+ ),
25
+ ambiguity: Literal["low", "medium", "high"] = typer.Option(
26
+ "medium", help="Niveau d'ambiguite"
27
+ ),
28
+ risk: Literal["low", "medium", "high"] = typer.Option(
29
+ "medium", help="Risque d'impact"
30
+ ),
31
+ contract_change: bool = typer.Option(
32
+ False, "--contract-change", help="Le changement modifie un contrat"
33
+ ),
34
+ data_security: bool = typer.Option(
35
+ False, "--data-security", help="Le changement touche aux donnees ou a la securite"
36
+ ),
37
+ summary: str = typer.Option("", help="Resume court de la demande"),
38
+ profile: Literal["quick", "standard", "deep", "critical"] | None = typer.Option(
39
+ None, help="Force un profil au lieu de la recommandation"
40
+ ),
41
+ json_output: bool = typer.Option(False, "--json", help="Affiche le resultat en JSON"),
42
+ ) -> None:
43
+ """Evalue une feature et ecrit assessment.json sans changer state.json."""
44
+ feature_dir = resolve_feature_dir(feature)
45
+ if feature_dir is None:
46
+ fail(f"Feature '{feature}' introuvable.")
47
+ raise typer.Exit(1)
48
+
49
+ state_file = feature_dir / "state.json"
50
+ state = read_state(state_file)
51
+ if state is None:
52
+ fail(f"state.json invalide dans {feature_dir.name}")
53
+ raise typer.Exit(1)
54
+
55
+ try:
56
+ assessment = assess_feature(
57
+ state.issueId,
58
+ AssessmentSignals(
59
+ scope=scope,
60
+ ambiguity=ambiguity,
61
+ risk=risk,
62
+ contractChange=contract_change,
63
+ dataOrSecurity=data_security,
64
+ ),
65
+ summary=summary,
66
+ profile_override=profile,
67
+ )
68
+ except ValueError as exc:
69
+ fail(str(exc))
70
+ raise typer.Exit(1) from exc
71
+ payload = assessment.to_dict()
72
+ output_file = feature_dir / "assessment.json"
73
+ with workflow_lock(feature_dir):
74
+ if (feature_dir / "workflow-state.json").exists():
75
+ fail("Un workflow adaptatif existe deja ; assessment.json ne sera pas ecrase.")
76
+ raise typer.Exit(1)
77
+ _write_json_atomic(output_file, payload)
78
+
79
+ if json_output:
80
+ console.print_json(data=payload)
81
+ return
82
+
83
+ ok(f"Profil selectionne : {assessment.selectedProfile}")
84
+ if assessment.selectedProfile != assessment.recommendedProfile:
85
+ console.print(f" Recommande : {assessment.recommendedProfile}")
86
+ console.print(f" Confiance : {assessment.confidence}")
87
+ for reason in assessment.reasons:
88
+ console.print(f" - {reason}")
89
+ console.print(f" Activites : {' -> '.join(assessment.activities)}")
90
+ console.print(f" Preuves : {', '.join(assessment.requiredEvidence)}")
91
+ console.print(f" Rapport : {output_file}")
92
+ console.print(" Pipeline : inchange")
93
+
94
+
95
+ def _write_json_atomic(target: Path, payload: dict) -> None:
96
+ """Ecrit le rapport sans laisser de JSON partiel en cas d'interruption."""
97
+ temporary: Path | None = None
98
+ try:
99
+ with tempfile.NamedTemporaryFile(
100
+ mode="w",
101
+ encoding="utf-8",
102
+ dir=target.parent,
103
+ prefix=f".{target.name}.",
104
+ suffix=".tmp",
105
+ delete=False,
106
+ ) as handle:
107
+ temporary = Path(handle.name)
108
+ handle.write(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
109
+ handle.flush()
110
+ os.fsync(handle.fileno())
111
+ os.replace(temporary, target)
112
+ temporary = None
113
+ finally:
114
+ if temporary is not None:
115
+ temporary.unlink(missing_ok=True)
@@ -23,12 +23,20 @@ def _count_files(directory: Path, pattern: str) -> int:
23
23
  return len(list(directory.glob(pattern)))
24
24
 
25
25
 
26
- def check() -> None:
26
+ def check(
27
+ ai: str = typer.Option(
28
+ "claude-code", "--ai", help="Agent a verifier : claude-code ou codex"
29
+ ),
30
+ ) -> None:
27
31
  """Verifie les prerequis (outils, fichiers, config)."""
28
32
  ok_count = 0
29
33
  warn_count = 0
30
34
  fail_count = 0
31
35
 
36
+ if ai not in {"claude-code", "codex"}:
37
+ fail("Agent non supporte pour check : utilisez claude-code ou codex")
38
+ raise typer.Exit(1)
39
+
32
40
  claude_dir = get_claude_dir()
33
41
 
34
42
  console.print("Verification des prerequis devflow...")
@@ -47,6 +55,41 @@ def check() -> None:
47
55
  fail("git -- non installe")
48
56
  fail_count += 1
49
57
 
58
+ if ai == "codex":
59
+ codex_path = _cmd_exists("codex")
60
+ if codex_path:
61
+ result = subprocess.run(
62
+ ["codex", "--version"], capture_output=True, text=True
63
+ )
64
+ version = (result.stdout or result.stderr).strip()
65
+ if result.returncode == 0:
66
+ ok(f"codex CLI -- {codex_path} ({version})")
67
+ ok_count += 1
68
+ else:
69
+ fail(f"codex CLI -- execution impossible ({version})")
70
+ fail_count += 1
71
+ else:
72
+ fail("codex CLI -- non installee")
73
+ fail_count += 1
74
+
75
+ specs_dir = Path(".specify/specs")
76
+ if specs_dir.is_dir():
77
+ ok(".specify/specs/ -- present dans le projet courant")
78
+ ok_count += 1
79
+ else:
80
+ warn(".specify/specs/ -- absent; lancez devflow init --ai codex")
81
+ warn_count += 1
82
+
83
+ console.print()
84
+ total = ok_count + warn_count + fail_count
85
+ console.print(
86
+ f"Resume : {ok_count} OK, {warn_count} WARN, "
87
+ f"{fail_count} FAIL ({total} checks)"
88
+ )
89
+ if fail_count > 0:
90
+ raise typer.Exit(1)
91
+ return
92
+
50
93
  # claude CLI
51
94
  claude_path = _cmd_exists("claude")
52
95
  if claude_path: