devflow-cli 8.0.0__py3-none-any.whl → 8.2.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 — workflow de développement adaptatif."""
2
2
 
3
- VERSION = "8.0.0"
3
+ VERSION = "8.2.0"
4
4
 
5
5
  SUPPORTED_AGENTS = ["claude-code", "codex", "cursor"]
6
6
 
@@ -22,6 +22,24 @@ from devflow_cli.adapters.linear import environment_without_linear_key
22
22
  PermissionMode = Literal["auto", "acceptEdits", "manual"]
23
23
  SandboxMode = Literal["read-only", "workspace-write"]
24
24
 
25
+ # L'agent a termine sans erreur (code 0) mais sa sortie structuree manque ou n'est
26
+ # pas le JSON attendu. Claude porte le diagnostic dedie ; Codex et Cursor gardent
27
+ # leurs valeurs detaillees, traitees comme la meme famille par la reprise guidee.
28
+ STRUCTURED_OUTPUT_MISSING = "structured-output-missing"
29
+ MISSING_STRUCTURED_OUTPUT_DIAGNOSTICS = frozenset({
30
+ STRUCTURED_OUTPUT_MISSING,
31
+ "final-output-empty",
32
+ "final-output-absent",
33
+ "final-output-invalid-json",
34
+ })
35
+ STRUCTURED_OUTPUT_MISSING_MESSAGE = (
36
+ "l'agent a termine sans erreur mais sa sortie structuree manque "
37
+ "ou n'est pas le JSON attendu"
38
+ )
39
+ # Impose le premier plan aux sous-agents de l'agent Claude d'une activite ; desactive
40
+ # aussi ses commandes shell en arriere-plan.
41
+ CLAUDE_FOREGROUND_ENV = "CLAUDE_CODE_DISABLE_BACKGROUND_TASKS"
42
+
25
43
  _SAFE_DIAGNOSTIC_MESSAGES = {
26
44
  "final-output-empty": "--output-last-message est vide",
27
45
  "final-output-absent": "--output-last-message est absent",
@@ -165,7 +183,7 @@ class AgentAdapter(ABC):
165
183
  capture_output=True,
166
184
  stdin=subprocess.DEVNULL,
167
185
  shell=False,
168
- env=environment_without_linear_key(),
186
+ env=self.structured_environment(),
169
187
  )
170
188
  execution = AgentExecution(
171
189
  result.returncode,
@@ -223,7 +241,9 @@ class AgentAdapter(ABC):
223
241
  "Sortie structuree invalide "
224
242
  f"({self.name}) [{diagnostic}]"
225
243
  + (
226
- f" : {_SAFE_DIAGNOSTIC_MESSAGES[diagnostic]}"
244
+ f" : {self.missing_output_message(execution.stdout)}"
245
+ if diagnostic == STRUCTURED_OUTPUT_MISSING
246
+ else f" : {_SAFE_DIAGNOSTIC_MESSAGES[diagnostic]}"
227
247
  if diagnostic in _SAFE_DIAGNOSTIC_MESSAGES
228
248
  else ""
229
249
  ),
@@ -251,6 +271,27 @@ class AgentAdapter(ABC):
251
271
  except FileNotFoundError:
252
272
  return AgentExecution(127, stderr=f"Executable '{self.executable}' introuvable", duration_seconds=time.monotonic() - started)
253
273
 
274
+ def structured_environment(self) -> dict[str, str]:
275
+ """Environnement de l'agent d'une activite : jamais la cle Linear."""
276
+ return environment_without_linear_key()
277
+
278
+ def subagent_counts(self, stdout: str) -> tuple[int | None, int | None]:
279
+ """Sous-agents lances et lances en arriere-plan ; None si l'agent ne le dit pas."""
280
+ return None, None
281
+
282
+ def missing_output_message(self, stdout: str) -> str:
283
+ """Cause d'une sortie structuree absente : message fixe et entiers seulement."""
284
+ try:
285
+ spawned, background = self.subagent_counts(stdout)
286
+ except Exception: # noqa: BLE001 - format externe non contractuel
287
+ spawned, background = None, None
288
+ message = STRUCTURED_OUTPUT_MISSING_MESSAGE
289
+ if spawned is not None:
290
+ message += f" ; sous-agents lances : {spawned}"
291
+ if background is not None:
292
+ message += f", dont {background} en arriere-plan"
293
+ return message
294
+
254
295
  def session_id(self, stdout: str, stderr: str) -> str | None:
255
296
  """Extrait un identifiant opaque de session, sans conserver la sortie."""
256
297
  return _session_id(stdout, stderr)
@@ -326,8 +367,23 @@ class ClaudeCodeAdapter(AgentAdapter):
326
367
  return envelope["structured_output"]
327
368
  result = envelope.get("result")
328
369
  if isinstance(result, str) and result.strip():
329
- return json.loads(_strip_json_fence(result))
330
- raise ValueError("champ structured_output absent")
370
+ try:
371
+ return json.loads(_strip_json_fence(result))
372
+ except json.JSONDecodeError:
373
+ pass
374
+ raise StructuredOutputError(
375
+ STRUCTURED_OUTPUT_MISSING, "champ structured_output absent"
376
+ )
377
+
378
+ def structured_environment(self) -> dict[str, str]:
379
+ return {**environment_without_linear_key(), CLAUDE_FOREGROUND_ENV: "1"}
380
+
381
+ def subagent_counts(self, stdout: str) -> tuple[int | None, int | None]:
382
+ envelope = json.loads(stdout)
383
+ stats = envelope.get("subagent_stats") if isinstance(envelope, dict) else None
384
+ if not isinstance(stats, dict):
385
+ return None, None
386
+ return _count(stats.get("spawned")), _count(stats.get("started_in_background"))
331
387
 
332
388
  def usage(self, stdout: str, session_id: str | None) -> AgentUsage | None:
333
389
  """Lit l'enveloppe --output-format json : usage cumule de tous les tours."""
@@ -515,6 +571,14 @@ def get_agent_adapter(name: str) -> AgentAdapter:
515
571
  return adapter_type()
516
572
 
517
573
 
574
+ def describe_missing_output(agent: str, stdout: str) -> str:
575
+ """Cause d'une sortie structuree absente, pour la reprise guidee."""
576
+ try:
577
+ return get_agent_adapter(agent).missing_output_message(stdout)
578
+ except ValueError:
579
+ return STRUCTURED_OUTPUT_MISSING_MESSAGE
580
+
581
+
518
582
  def _portable_prompt(prompt: str) -> str:
519
583
  """Traduit une slash-command Claude en instruction portable pour les autres agents."""
520
584
  match = re.fullmatch(r"/devflow\.([a-z0-9-]+)(?:\s+(.*))?", prompt.strip())
@@ -610,6 +674,10 @@ def _int(value: object) -> int:
610
674
  return value if isinstance(value, int) and not isinstance(value, bool) else 0
611
675
 
612
676
 
677
+ def _count(value: object) -> int | None:
678
+ return value if isinstance(value, int) and not isinstance(value, bool) else None
679
+
680
+
613
681
  def _codex_home() -> Path:
614
682
  return Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex")
615
683