devflow-cli 3.0.1__py3-none-any.whl → 6.0.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 (41) hide show
  1. devflow_cli/__init__.py +4 -2
  2. devflow_cli/adapters/agents.py +177 -28
  3. devflow_cli/cli.py +0 -2
  4. devflow_cli/commands/adaptive.py +569 -75
  5. devflow_cli/commands/check.py +41 -0
  6. devflow_cli/commands/feature.py +25 -3
  7. devflow_cli/commands/status.py +3 -3
  8. devflow_cli/commands/upgrade_cmd.py +14 -8
  9. devflow_cli/commands/verify.py +30 -0
  10. devflow_cli/core/activity_contracts.py +277 -29
  11. devflow_cli/core/adaptive_runner.py +572 -40
  12. devflow_cli/core/adaptive_workflow.py +90 -34
  13. devflow_cli/core/agent_failure.py +113 -0
  14. devflow_cli/core/assessment.py +15 -4
  15. devflow_cli/core/automatic_evidence.py +249 -41
  16. devflow_cli/core/contract_schemas.py +21 -4
  17. devflow_cli/core/docgen.py +4 -3
  18. devflow_cli/core/manifest.py +9 -1
  19. devflow_cli/core/omissions.py +168 -0
  20. devflow_cli/core/operational.py +6 -2
  21. devflow_cli/core/remediation.py +203 -25
  22. devflow_cli/core/validation_approvals.py +220 -0
  23. devflow_cli/core/validators.py +59 -9
  24. devflow_cli/core/verification.py +95 -8
  25. devflow_cli/core/worklog.py +32 -3
  26. devflow_cli/core/worktree_snapshot.py +17 -0
  27. devflow_cli/resources/commands/devflow.md +8 -4
  28. devflow_cli/resources/templates/plan-template.md +4 -4
  29. devflow_cli/schemas/activity-contract-v3.schema.json +32 -0
  30. devflow_cli/schemas/activity-result-v1.schema.json +13 -1
  31. devflow_cli/schemas/{assessment-v1.schema.json → assessment-v3.schema.json} +8 -32
  32. devflow_cli/schemas/workflow-state-v4.schema.json +55 -0
  33. devflow_cli/utils/paths.py +17 -2
  34. {devflow_cli-3.0.1.dist-info → devflow_cli-6.0.0.dist-info}/METADATA +11 -9
  35. {devflow_cli-3.0.1.dist-info → devflow_cli-6.0.0.dist-info}/RECORD +38 -36
  36. {devflow_cli-3.0.1.dist-info → devflow_cli-6.0.0.dist-info}/WHEEL +1 -1
  37. devflow_cli/commands/migrate_cmd.py +0 -200
  38. devflow_cli/schemas/activity-contract-v1.schema.json +0 -76
  39. devflow_cli/schemas/workflow-state-v2.schema.json +0 -66
  40. {devflow_cli-3.0.1.dist-info → devflow_cli-6.0.0.dist-info}/entry_points.txt +0 -0
  41. {devflow_cli-3.0.1.dist-info → devflow_cli-6.0.0.dist-info}/licenses/LICENSE +0 -0
devflow_cli/__init__.py CHANGED
@@ -1,6 +1,6 @@
1
1
  """devflow CLI — workflow de développement adaptatif."""
2
2
 
3
- VERSION = "3.0.1"
3
+ VERSION = "6.0.0"
4
4
 
5
5
  SUPPORTED_AGENTS = ["claude-code", "codex", "cursor"]
6
6
 
@@ -13,6 +13,7 @@ ACTIVITY_LINEAR_MAPPING = {
13
13
  "contracts": "Plan",
14
14
  "plan": "Plan",
15
15
  "tasks": "Tasks",
16
+ "preflight-evidence": "Plan",
16
17
  "implement": "In Progress",
17
18
  "verify": "Review",
18
19
  "review": "Review",
@@ -38,9 +39,10 @@ ARTIFACTS = [
38
39
  "contracts.md",
39
40
  "plan.md",
40
41
  "tasks.md",
42
+ "preflight-evidence.md",
41
43
  "worklog.md",
42
44
  "activity-contract.json",
43
- "activity-result.json",
45
+ "implement-result.json",
44
46
  "verification.json",
45
47
  ]
46
48
 
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  import subprocess
7
7
  import re
8
+ import time
8
9
  from abc import ABC, abstractmethod
9
10
  from dataclasses import dataclass
10
11
  from pathlib import Path
@@ -17,6 +18,12 @@ from jsonschema.validators import validator_for
17
18
  PermissionMode = Literal["auto", "acceptEdits", "manual"]
18
19
  SandboxMode = Literal["read-only", "workspace-write"]
19
20
 
21
+ _SAFE_DIAGNOSTIC_MESSAGES = {
22
+ "final-output-empty": "--output-last-message est vide",
23
+ "final-output-absent": "--output-last-message est absent",
24
+ "agent-turn-failed": "le tour agent a echoue avant la reponse finale",
25
+ }
26
+
20
27
 
21
28
  @dataclass(frozen=True)
22
29
  class AgentExecution:
@@ -24,6 +31,19 @@ class AgentExecution:
24
31
  stdout: str = ""
25
32
  stderr: str = ""
26
33
  timed_out: bool = False
34
+ duration_seconds: float = 0.0
35
+ final_output_status: str = "unknown"
36
+ final_output_bytes: int = 0
37
+ final_output_diagnostic: str = "not-evaluated"
38
+ session_id: str | None = None
39
+
40
+
41
+ class StructuredOutputError(ValueError):
42
+ """Erreur de sortie agent dont le diagnostic peut etre expose sans contenu."""
43
+
44
+ def __init__(self, diagnostic: str, message: str) -> None:
45
+ super().__init__(message)
46
+ self.diagnostic = diagnostic
27
47
 
28
48
 
29
49
  class AgentAdapter(ABC):
@@ -66,6 +86,7 @@ class AgentAdapter(ABC):
66
86
  timeout=timeout,
67
87
  text=True,
68
88
  capture_output=capture_output,
89
+ stdin=subprocess.DEVNULL,
69
90
  shell=False,
70
91
  )
71
92
  return AgentExecution(
@@ -96,6 +117,7 @@ class AgentAdapter(ABC):
96
117
  command = self.build_structured_command(
97
118
  prompt, permission_mode, schema_file, output_file, sandbox_mode
98
119
  )
120
+ started = time.monotonic()
99
121
  try:
100
122
  result = subprocess.run(
101
123
  command,
@@ -103,12 +125,18 @@ class AgentAdapter(ABC):
103
125
  timeout=timeout,
104
126
  text=True,
105
127
  capture_output=True,
128
+ stdin=subprocess.DEVNULL,
106
129
  shell=False,
107
130
  )
108
131
  execution = AgentExecution(
109
132
  result.returncode,
110
133
  result.stdout or "",
111
134
  result.stderr or "",
135
+ duration_seconds=time.monotonic() - started,
136
+ final_output_status=_output_status(output_file),
137
+ final_output_bytes=_output_size(output_file),
138
+ final_output_diagnostic="process-failed" if result.returncode else "validating",
139
+ session_id=self.session_id(result.stdout or "", result.stderr or ""),
112
140
  )
113
141
  if execution.returncode != 0:
114
142
  return execution
@@ -118,28 +146,73 @@ class AgentAdapter(ABC):
118
146
  output_file.write_text(
119
147
  json.dumps(payload, ensure_ascii=False), encoding="utf-8"
120
148
  )
121
- except (
122
- OSError, ValueError, json.JSONDecodeError, SchemaError, ValidationError
123
- ) as exc:
124
- output_file.unlink(missing_ok=True)
149
+ except StructuredOutputError as exc:
150
+ diagnostic = exc.diagnostic
151
+ except json.JSONDecodeError:
152
+ diagnostic = "final-output-invalid-json"
153
+ except ValidationError:
154
+ diagnostic = "final-output-schema-rejected"
155
+ except SchemaError:
156
+ diagnostic = "output-schema-invalid"
157
+ except OSError:
158
+ diagnostic = "final-output-unreadable"
159
+ except ValueError:
160
+ diagnostic = "final-output-invalid"
161
+ else:
125
162
  return AgentExecution(
126
- 1,
163
+ execution.returncode,
127
164
  execution.stdout,
128
- f"Sortie structuree invalide ({self.name}) : {exc}",
165
+ execution.stderr,
166
+ execution.timed_out,
167
+ execution.duration_seconds,
168
+ _output_status(output_file),
169
+ _output_size(output_file),
170
+ "validated",
171
+ execution.session_id,
129
172
  )
130
- return execution
173
+
174
+ status = _output_status(output_file)
175
+ size = _output_size(output_file)
176
+ output_file.unlink(missing_ok=True)
177
+ return AgentExecution(
178
+ 1,
179
+ execution.stdout,
180
+ "Sortie structuree invalide "
181
+ f"({self.name}) [{diagnostic}]"
182
+ + (
183
+ f" : {_SAFE_DIAGNOSTIC_MESSAGES[diagnostic]}"
184
+ if diagnostic in _SAFE_DIAGNOSTIC_MESSAGES
185
+ else ""
186
+ ),
187
+ duration_seconds=execution.duration_seconds,
188
+ final_output_status=status,
189
+ final_output_bytes=size,
190
+ final_output_diagnostic=diagnostic,
191
+ session_id=execution.session_id,
192
+ )
131
193
  except subprocess.TimeoutExpired as exc:
132
194
  return AgentExecution(
133
195
  124,
134
196
  _text(exc.stdout),
135
197
  _text(exc.stderr),
136
198
  timed_out=True,
199
+ duration_seconds=time.monotonic() - started,
200
+ final_output_status=_output_status(output_file),
201
+ final_output_bytes=_output_size(output_file),
137
202
  )
138
203
  except FileNotFoundError:
139
- return AgentExecution(127, stderr=f"Executable '{self.executable}' introuvable")
204
+ return AgentExecution(127, stderr=f"Executable '{self.executable}' introuvable", duration_seconds=time.monotonic() - started)
205
+
206
+ def session_id(self, stdout: str, stderr: str) -> str | None:
207
+ """Extrait un identifiant opaque de session, sans conserver la sortie."""
208
+ return _session_id(stdout, stderr)
209
+
210
+ def recover_structured_payload(self, raw_output: str) -> object:
211
+ """Extrait la reponse structuree d'une trace brute conservee apres un echec."""
212
+ return self._structured_payload(raw_output, None)
140
213
 
141
- def _structured_payload(self, stdout: str, output_file: Path) -> object:
142
- if output_file.is_file():
214
+ def _structured_payload(self, stdout: str, output_file: Path | None) -> object:
215
+ if output_file is not None and output_file.is_file():
143
216
  return json.loads(output_file.read_text(encoding="utf-8"))
144
217
  return json.loads(stdout)
145
218
 
@@ -169,7 +242,7 @@ class ClaudeCodeAdapter(AgentAdapter):
169
242
  sandbox_mode: SandboxMode = "workspace-write",
170
243
  ) -> list[str]:
171
244
  del output_file
172
- schema = schema_file.read_text(encoding="utf-8")
245
+ schema = _claude_compatible_schema(schema_file)
173
246
  effective_mode = "plan" if sandbox_mode == "read-only" else permission_mode
174
247
  return [
175
248
  self.executable,
@@ -183,16 +256,19 @@ class ClaudeCodeAdapter(AgentAdapter):
183
256
  prompt,
184
257
  ]
185
258
 
186
- def _structured_payload(self, stdout: str, output_file: Path) -> object:
259
+ def _structured_payload(self, stdout: str, output_file: Path | None) -> object:
187
260
  del output_file
188
261
  envelope = json.loads(stdout)
189
262
  if not isinstance(envelope, dict):
190
263
  raise ValueError("enveloppe JSON Claude absente")
191
264
  if envelope.get("subtype") not in {None, "success"}:
192
265
  raise ValueError(f"execution Claude terminee avec {envelope.get('subtype')}")
193
- if "structured_output" not in envelope:
194
- raise ValueError("champ structured_output absent")
195
- return envelope["structured_output"]
266
+ if "structured_output" in envelope:
267
+ return envelope["structured_output"]
268
+ result = envelope.get("result")
269
+ if isinstance(result, str) and result.strip():
270
+ return json.loads(_strip_json_fence(result))
271
+ raise ValueError("champ structured_output absent")
196
272
 
197
273
 
198
274
  class CodexAdapter(AgentAdapter):
@@ -222,6 +298,7 @@ class CodexAdapter(AgentAdapter):
222
298
  else:
223
299
  command.extend(["--sandbox", sandbox_mode])
224
300
  command.extend([
301
+ "--json",
225
302
  "--output-schema",
226
303
  str(schema_file),
227
304
  "--output-last-message",
@@ -230,22 +307,36 @@ class CodexAdapter(AgentAdapter):
230
307
  command.append(prompt)
231
308
  return command
232
309
 
233
- def _structured_payload(self, stdout: str, output_file: Path) -> object:
234
- """Lit la reponse finale Codex et distingue son absence d'un JSON invalide."""
235
- if output_file.is_file():
310
+ def session_id(self, stdout: str, stderr: str) -> str | None:
311
+ """Prefere l'identifiant de fil annonce par le flux d'evenements."""
312
+ for event in json_line_events(stdout):
313
+ if event.get("type") == "thread.started" and isinstance(
314
+ event.get("thread_id"), str
315
+ ):
316
+ return event["thread_id"]
317
+ return _session_id(stdout, stderr)
318
+
319
+ def _structured_payload(self, stdout: str, output_file: Path | None) -> object:
320
+ """Lit la reponse finale Codex, sinon la recupere depuis le flux d'evenements."""
321
+ if output_file is not None and output_file.is_file():
236
322
  payload = output_file.read_text(encoding="utf-8")
237
323
  if payload.strip():
238
324
  return json.loads(payload)
239
- raise ValueError(
240
- "Codex a termine sans reponse finale structuree "
241
- "(--output-last-message est vide)"
325
+ diagnostic = "final-output-empty"
326
+ message = "Codex a termine sans reponse finale structuree"
327
+ else:
328
+ diagnostic = "final-output-absent"
329
+ message = "Codex n'a pas produit le fichier de reponse finale structuree"
330
+ events = json_line_events(stdout)
331
+ if any(event.get("type") == "turn.failed" for event in events):
332
+ raise StructuredOutputError(
333
+ "agent-turn-failed",
334
+ "Codex a interrompu son tour avant la reponse finale",
242
335
  )
243
- if stdout.strip():
244
- return json.loads(stdout)
245
- raise ValueError(
246
- "Codex n'a pas produit de reponse finale structuree "
247
- "ni de sortie standard exploitable"
248
- )
336
+ final_message = _codex_final_message(events)
337
+ if final_message is not None:
338
+ return json.loads(_strip_json_fence(final_message))
339
+ raise StructuredOutputError(diagnostic, message)
249
340
 
250
341
 
251
342
  class CursorAdapter(AgentAdapter):
@@ -281,7 +372,7 @@ class CursorAdapter(AgentAdapter):
281
372
  command.append(structured_prompt)
282
373
  return command
283
374
 
284
- def _structured_payload(self, stdout: str, output_file: Path) -> object:
375
+ def _structured_payload(self, stdout: str, output_file: Path | None) -> object:
285
376
  del output_file
286
377
  envelope = json.loads(stdout)
287
378
  if not isinstance(envelope, dict):
@@ -326,12 +417,70 @@ def _portable_prompt(prompt: str) -> str:
326
417
  )
327
418
 
328
419
 
420
+ def _claude_compatible_schema(schema_file: Path) -> str:
421
+ """Adapte l'en-tete Draft 2020-12 au format accepte par Claude Code."""
422
+ raw_schema = schema_file.read_text(encoding="utf-8")
423
+ schema = json.loads(raw_schema)
424
+ if not isinstance(schema, dict):
425
+ raise ValueError("schema JSON Claude invalide")
426
+ if schema.get("$schema") != "https://json-schema.org/draft/2020-12/schema":
427
+ return raw_schema
428
+ schema["$schema"] = "http://json-schema.org/draft-07/schema#"
429
+ return json.dumps(schema, ensure_ascii=False)
430
+
431
+
329
432
  def _text(value: str | bytes | None) -> str:
330
433
  if value is None:
331
434
  return ""
332
435
  return value.decode(errors="replace") if isinstance(value, bytes) else value
333
436
 
334
437
 
438
+ def _output_status(path: Path) -> str:
439
+ if not path.is_file():
440
+ return "absent"
441
+ return "empty" if _output_size(path) == 0 else "present"
442
+
443
+
444
+ def _output_size(path: Path) -> int:
445
+ try:
446
+ return path.stat().st_size
447
+ except OSError:
448
+ return 0
449
+
450
+
451
+ def _session_id(stdout: str, stderr: str) -> str | None:
452
+ # Only retain an opaque identifier, never the surrounding agent output.
453
+ match = re.search(r'(?i)(?:session[_ -]?id|session)\s*[=:]\s*["\']?([a-z0-9-]{8,})', stdout + "\n" + stderr)
454
+ return match.group(1) if match else None
455
+
456
+
457
+ def json_line_events(stdout: str) -> list[dict]:
458
+ """Decode un flux JSONL d'agent en ignorant les lignes non JSON ou non objet."""
459
+ events: list[dict] = []
460
+ for line in stdout.splitlines():
461
+ line = line.strip()
462
+ if not line.startswith("{"):
463
+ continue
464
+ try:
465
+ event = json.loads(line)
466
+ except json.JSONDecodeError:
467
+ continue
468
+ if isinstance(event, dict):
469
+ events.append(event)
470
+ return events
471
+
472
+
473
+ def _codex_final_message(events: list[dict]) -> str | None:
474
+ """Retourne le dernier message d'agent complet du flux d'evenements."""
475
+ for event in reversed(events):
476
+ item = event.get("item")
477
+ if event.get("type") != "item.completed" or not isinstance(item, dict):
478
+ continue
479
+ if item.get("type") == "agent_message" and isinstance(item.get("text"), str):
480
+ return item["text"]
481
+ return None
482
+
483
+
335
484
  def _strip_json_fence(value: str) -> str:
336
485
  text = value.strip()
337
486
  match = re.fullmatch(r"```(?:json)?\s*([\s\S]*?)\s*```", text, re.IGNORECASE)
devflow_cli/cli.py CHANGED
@@ -13,7 +13,6 @@ from devflow_cli.commands.feature import feature
13
13
  from devflow_cli.commands.context import context
14
14
  from devflow_cli.commands.export_cmd import export
15
15
  from devflow_cli.commands.stale import stale
16
- from devflow_cli.commands.migrate_cmd import migrate
17
16
  from devflow_cli.commands.upgrade_cmd import upgrade
18
17
  from devflow_cli.commands import extension
19
18
  from devflow_cli.commands import hooks_cmd
@@ -53,7 +52,6 @@ app.command()(feature)
53
52
  app.command()(context)
54
53
  app.command(name="export")(export)
55
54
  app.command()(stale)
56
- app.command()(migrate)
57
55
  app.command()(upgrade)
58
56
  app.add_typer(extension.app, name="extension")
59
57
  app.add_typer(hooks_cmd.app, name="hooks")