devflow-cli 4.0.2__py3-none-any.whl → 6.1.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 (46) hide show
  1. devflow_cli/__init__.py +2 -2
  2. devflow_cli/adapters/agents.py +274 -19
  3. devflow_cli/cli.py +2 -2
  4. devflow_cli/commands/adaptive.py +584 -110
  5. devflow_cli/commands/assess.py +0 -1
  6. devflow_cli/commands/check.py +41 -0
  7. devflow_cli/commands/feature.py +25 -3
  8. devflow_cli/commands/status.py +3 -3
  9. devflow_cli/commands/upgrade_cmd.py +14 -8
  10. devflow_cli/commands/usage.py +92 -0
  11. devflow_cli/commands/verify.py +30 -0
  12. devflow_cli/core/activity_contracts.py +218 -23
  13. devflow_cli/core/adaptive_runner.py +559 -41
  14. devflow_cli/core/adaptive_workflow.py +90 -50
  15. devflow_cli/core/agent_failure.py +113 -0
  16. devflow_cli/core/assessment.py +17 -19
  17. devflow_cli/core/automatic_evidence.py +252 -71
  18. devflow_cli/core/contract_schemas.py +21 -31
  19. devflow_cli/core/docgen.py +6 -5
  20. devflow_cli/core/manifest.py +9 -1
  21. devflow_cli/core/omissions.py +168 -0
  22. devflow_cli/core/operational.py +5 -1
  23. devflow_cli/core/remediation.py +202 -25
  24. devflow_cli/core/telemetry.py +168 -0
  25. devflow_cli/core/validation_approvals.py +220 -0
  26. devflow_cli/core/validators.py +59 -9
  27. devflow_cli/core/verification.py +95 -8
  28. devflow_cli/core/worklog.py +14 -3
  29. devflow_cli/core/worktree_snapshot.py +17 -0
  30. devflow_cli/resources/commands/devflow.md +8 -4
  31. devflow_cli/resources/templates/plan-template.md +4 -4
  32. devflow_cli/schemas/{activity-contract-v2.schema.json → activity-contract-v3.schema.json} +3 -3
  33. devflow_cli/schemas/activity-result-v1.schema.json +13 -1
  34. devflow_cli/schemas/{assessment-v2.schema.json → assessment-v3.schema.json} +3 -3
  35. devflow_cli/schemas/{workflow-state-v3.schema.json → workflow-state-v4.schema.json} +23 -4
  36. devflow_cli/utils/paths.py +17 -2
  37. {devflow_cli-4.0.2.dist-info → devflow_cli-6.1.0.dist-info}/METADATA +8 -23
  38. {devflow_cli-4.0.2.dist-info → devflow_cli-6.1.0.dist-info}/RECORD +41 -41
  39. {devflow_cli-4.0.2.dist-info → devflow_cli-6.1.0.dist-info}/WHEEL +1 -1
  40. devflow_cli/commands/migrate_cmd.py +0 -200
  41. devflow_cli/core/v4_migration.py +0 -134
  42. devflow_cli/schemas/activity-contract-v1.schema.json +0 -76
  43. devflow_cli/schemas/assessment-v1.schema.json +0 -60
  44. devflow_cli/schemas/workflow-state-v2.schema.json +0 -66
  45. {devflow_cli-4.0.2.dist-info → devflow_cli-6.1.0.dist-info}/entry_points.txt +0 -0
  46. {devflow_cli-4.0.2.dist-info → devflow_cli-6.1.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 = "4.0.2"
3
+ VERSION = "6.1.0"
4
4
 
5
5
  SUPPORTED_AGENTS = ["claude-code", "codex", "cursor"]
6
6
 
@@ -42,7 +42,7 @@ ARTIFACTS = [
42
42
  "preflight-evidence.md",
43
43
  "worklog.md",
44
44
  "activity-contract.json",
45
- "activity-result.json",
45
+ "implement-result.json",
46
46
  "verification.json",
47
47
  ]
48
48
 
@@ -3,11 +3,13 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import os
6
7
  import subprocess
7
8
  import re
8
9
  import time
9
10
  from abc import ABC, abstractmethod
10
- from dataclasses import dataclass
11
+ from dataclasses import dataclass, replace
12
+ from datetime import datetime, timedelta
11
13
  from pathlib import Path
12
14
  from typing import Literal
13
15
 
@@ -21,9 +23,42 @@ SandboxMode = Literal["read-only", "workspace-write"]
21
23
  _SAFE_DIAGNOSTIC_MESSAGES = {
22
24
  "final-output-empty": "--output-last-message est vide",
23
25
  "final-output-absent": "--output-last-message est absent",
26
+ "agent-turn-failed": "le tour agent a echoue avant la reponse finale",
24
27
  }
25
28
 
26
29
 
30
+ @dataclass(frozen=True)
31
+ class QuotaWindow:
32
+ """Evolution d'une fenetre de quota observee pendant une session agent."""
33
+
34
+ windowMinutes: int | None
35
+ usedPercentStart: float
36
+ usedPercentEnd: float
37
+ # None si la fenetre a ete reinitialisee entre les deux releves.
38
+ deltaPoints: float | None
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class AgentUsage:
43
+ """Compteurs de consommation declares par l'agent, sans aucun contenu.
44
+
45
+ inputTokens exclut toujours les tokens lus depuis le cache.
46
+ """
47
+
48
+ inputTokens: int = 0
49
+ cacheReadTokens: int = 0
50
+ cacheWriteTokens: int = 0
51
+ outputTokens: int = 0
52
+ reasoningTokens: int = 0
53
+ models: tuple[str, ...] = ()
54
+ effort: str | None = None
55
+ turns: int | None = None
56
+ costUsd: float | None = None
57
+ subagents: int | None = None
58
+ firstContextTokens: int | None = None
59
+ quota: tuple[QuotaWindow, ...] = ()
60
+
61
+
27
62
  @dataclass(frozen=True)
28
63
  class AgentExecution:
29
64
  returncode: int
@@ -35,6 +70,7 @@ class AgentExecution:
35
70
  final_output_bytes: int = 0
36
71
  final_output_diagnostic: str = "not-evaluated"
37
72
  session_id: str | None = None
73
+ usage: AgentUsage | None = None
38
74
 
39
75
 
40
76
  class StructuredOutputError(ValueError):
@@ -85,6 +121,7 @@ class AgentAdapter(ABC):
85
121
  timeout=timeout,
86
122
  text=True,
87
123
  capture_output=capture_output,
124
+ stdin=subprocess.DEVNULL,
88
125
  shell=False,
89
126
  )
90
127
  return AgentExecution(
@@ -123,6 +160,7 @@ class AgentAdapter(ABC):
123
160
  timeout=timeout,
124
161
  text=True,
125
162
  capture_output=True,
163
+ stdin=subprocess.DEVNULL,
126
164
  shell=False,
127
165
  )
128
166
  execution = AgentExecution(
@@ -133,7 +171,10 @@ class AgentAdapter(ABC):
133
171
  final_output_status=_output_status(output_file),
134
172
  final_output_bytes=_output_size(output_file),
135
173
  final_output_diagnostic="process-failed" if result.returncode else "validating",
136
- session_id=_session_id(result.stdout or "", result.stderr or ""),
174
+ session_id=self.session_id(result.stdout or "", result.stderr or ""),
175
+ )
176
+ execution = replace(
177
+ execution, usage=self.safe_usage(execution.stdout, execution.session_id)
137
178
  )
138
179
  if execution.returncode != 0:
139
180
  return execution
@@ -166,6 +207,7 @@ class AgentAdapter(ABC):
166
207
  _output_size(output_file),
167
208
  "validated",
168
209
  execution.session_id,
210
+ execution.usage,
169
211
  )
170
212
 
171
213
  status = _output_status(output_file)
@@ -186,22 +228,46 @@ class AgentAdapter(ABC):
186
228
  final_output_bytes=size,
187
229
  final_output_diagnostic=diagnostic,
188
230
  session_id=execution.session_id,
231
+ usage=execution.usage,
189
232
  )
190
233
  except subprocess.TimeoutExpired as exc:
234
+ stdout, stderr = _text(exc.stdout), _text(exc.stderr)
235
+ session_id = self.session_id(stdout, stderr)
191
236
  return AgentExecution(
192
237
  124,
193
- _text(exc.stdout),
194
- _text(exc.stderr),
238
+ stdout,
239
+ stderr,
195
240
  timed_out=True,
196
241
  duration_seconds=time.monotonic() - started,
197
242
  final_output_status=_output_status(output_file),
198
243
  final_output_bytes=_output_size(output_file),
244
+ session_id=session_id,
245
+ usage=self.safe_usage(stdout, session_id),
199
246
  )
200
247
  except FileNotFoundError:
201
248
  return AgentExecution(127, stderr=f"Executable '{self.executable}' introuvable", duration_seconds=time.monotonic() - started)
202
249
 
203
- def _structured_payload(self, stdout: str, output_file: Path) -> object:
204
- if output_file.is_file():
250
+ def session_id(self, stdout: str, stderr: str) -> str | None:
251
+ """Extrait un identifiant opaque de session, sans conserver la sortie."""
252
+ return _session_id(stdout, stderr)
253
+
254
+ def usage(self, stdout: str, session_id: str | None) -> AgentUsage | None:
255
+ """Extrait les compteurs de consommation ; None si l'agent n'en fournit pas."""
256
+ return None
257
+
258
+ def safe_usage(self, stdout: str, session_id: str | None) -> AgentUsage | None:
259
+ """La mesure de consommation ne doit jamais faire echouer une activite."""
260
+ try:
261
+ return self.usage(stdout, session_id)
262
+ except Exception: # noqa: BLE001 - format externe non contractuel
263
+ return None
264
+
265
+ def recover_structured_payload(self, raw_output: str) -> object:
266
+ """Extrait la reponse structuree d'une trace brute conservee apres un echec."""
267
+ return self._structured_payload(raw_output, None)
268
+
269
+ def _structured_payload(self, stdout: str, output_file: Path | None) -> object:
270
+ if output_file is not None and output_file.is_file():
205
271
  return json.loads(output_file.read_text(encoding="utf-8"))
206
272
  return json.loads(stdout)
207
273
 
@@ -245,16 +311,46 @@ class ClaudeCodeAdapter(AgentAdapter):
245
311
  prompt,
246
312
  ]
247
313
 
248
- def _structured_payload(self, stdout: str, output_file: Path) -> object:
314
+ def _structured_payload(self, stdout: str, output_file: Path | None) -> object:
249
315
  del output_file
250
316
  envelope = json.loads(stdout)
251
317
  if not isinstance(envelope, dict):
252
318
  raise ValueError("enveloppe JSON Claude absente")
253
319
  if envelope.get("subtype") not in {None, "success"}:
254
320
  raise ValueError(f"execution Claude terminee avec {envelope.get('subtype')}")
255
- if "structured_output" not in envelope:
256
- raise ValueError("champ structured_output absent")
257
- return envelope["structured_output"]
321
+ if "structured_output" in envelope:
322
+ return envelope["structured_output"]
323
+ result = envelope.get("result")
324
+ if isinstance(result, str) and result.strip():
325
+ return json.loads(_strip_json_fence(result))
326
+ raise ValueError("champ structured_output absent")
327
+
328
+ def usage(self, stdout: str, session_id: str | None) -> AgentUsage | None:
329
+ """Lit l'enveloppe --output-format json : usage cumule de tous les tours."""
330
+ envelope = json.loads(stdout)
331
+ if not isinstance(envelope, dict) or not isinstance(envelope.get("usage"), dict):
332
+ return None
333
+ usage = envelope["usage"]
334
+ details = usage.get("output_tokens_details")
335
+ subagents = envelope.get("subagent_stats")
336
+ model_usage = envelope.get("modelUsage")
337
+ cost = envelope.get("total_cost_usd")
338
+ turns = envelope.get("num_turns")
339
+ return AgentUsage(
340
+ inputTokens=_int(usage.get("input_tokens")),
341
+ cacheReadTokens=_int(usage.get("cache_read_input_tokens")),
342
+ cacheWriteTokens=_int(usage.get("cache_creation_input_tokens")),
343
+ outputTokens=_int(usage.get("output_tokens")),
344
+ reasoningTokens=_int(details.get("thinking_tokens")) if isinstance(details, dict) else 0,
345
+ models=tuple(model_usage) if isinstance(model_usage, dict) else (),
346
+ turns=turns if isinstance(turns, int) else None,
347
+ costUsd=float(cost) if isinstance(cost, (int, float)) else None,
348
+ subagents=(
349
+ subagents.get("spawned")
350
+ if isinstance(subagents, dict) and isinstance(subagents.get("spawned"), int)
351
+ else None
352
+ ),
353
+ )
258
354
 
259
355
 
260
356
  class CodexAdapter(AgentAdapter):
@@ -284,6 +380,7 @@ class CodexAdapter(AgentAdapter):
284
380
  else:
285
381
  command.extend(["--sandbox", sandbox_mode])
286
382
  command.extend([
383
+ "--json",
287
384
  "--output-schema",
288
385
  str(schema_file),
289
386
  "--output-last-message",
@@ -292,20 +389,62 @@ class CodexAdapter(AgentAdapter):
292
389
  command.append(prompt)
293
390
  return command
294
391
 
295
- def _structured_payload(self, stdout: str, output_file: Path) -> object:
296
- """Lit la reponse finale Codex et distingue son absence d'un JSON invalide."""
297
- if output_file.is_file():
392
+ def session_id(self, stdout: str, stderr: str) -> str | None:
393
+ """Prefere l'identifiant de fil annonce par le flux d'evenements."""
394
+ for event in json_line_events(stdout):
395
+ if event.get("type") == "thread.started" and isinstance(
396
+ event.get("thread_id"), str
397
+ ):
398
+ return event["thread_id"]
399
+ return _session_id(stdout, stderr)
400
+
401
+ def _structured_payload(self, stdout: str, output_file: Path | None) -> object:
402
+ """Lit la reponse finale Codex, sinon la recupere depuis le flux d'evenements."""
403
+ if output_file is not None and output_file.is_file():
298
404
  payload = output_file.read_text(encoding="utf-8")
299
405
  if payload.strip():
300
406
  return json.loads(payload)
407
+ diagnostic = "final-output-empty"
408
+ message = "Codex a termine sans reponse finale structuree"
409
+ else:
410
+ diagnostic = "final-output-absent"
411
+ message = "Codex n'a pas produit le fichier de reponse finale structuree"
412
+ events = json_line_events(stdout)
413
+ if any(event.get("type") == "turn.failed" for event in events):
301
414
  raise StructuredOutputError(
302
- "final-output-empty",
303
- "Codex a termine sans reponse finale structuree",
415
+ "agent-turn-failed",
416
+ "Codex a interrompu son tour avant la reponse finale",
304
417
  )
305
- raise StructuredOutputError(
306
- "final-output-absent",
307
- "Codex n'a pas produit le fichier de reponse finale structuree",
418
+ final_message = _codex_final_message(events)
419
+ if final_message is not None:
420
+ return json.loads(_strip_json_fence(final_message))
421
+ raise StructuredOutputError(diagnostic, message)
422
+
423
+ def usage(self, stdout: str, session_id: str | None) -> AgentUsage | None:
424
+ """Combine le flux --json et, au mieux, le journal de session Codex.
425
+
426
+ Le flux ne donne que les tokens ; le modele, l'effort, le nombre de
427
+ requetes et les quotas ne figurent que dans le journal de session, dont
428
+ le format est interne a Codex.
429
+ """
430
+ totals = [
431
+ event["usage"]
432
+ for event in json_line_events(stdout)
433
+ if event.get("type") == "turn.completed" and isinstance(event.get("usage"), dict)
434
+ ]
435
+ session = _codex_session_facts(session_id)
436
+ if not totals and session is None:
437
+ return None
438
+ raw_input = sum(_int(item.get("input_tokens")) for item in totals)
439
+ cached = sum(_int(item.get("cached_input_tokens")) for item in totals)
440
+ usage = AgentUsage(
441
+ inputTokens=max(raw_input - cached, 0),
442
+ cacheReadTokens=cached,
443
+ cacheWriteTokens=sum(_int(item.get("cache_write_input_tokens")) for item in totals),
444
+ outputTokens=sum(_int(item.get("output_tokens")) for item in totals),
445
+ reasoningTokens=sum(_int(item.get("reasoning_output_tokens")) for item in totals),
308
446
  )
447
+ return replace(usage, **session) if session else usage
309
448
 
310
449
 
311
450
  class CursorAdapter(AgentAdapter):
@@ -341,7 +480,7 @@ class CursorAdapter(AgentAdapter):
341
480
  command.append(structured_prompt)
342
481
  return command
343
482
 
344
- def _structured_payload(self, stdout: str, output_file: Path) -> object:
483
+ def _structured_payload(self, stdout: str, output_file: Path | None) -> object:
345
484
  del output_file
346
485
  envelope = json.loads(stdout)
347
486
  if not isinstance(envelope, dict):
@@ -423,6 +562,33 @@ def _session_id(stdout: str, stderr: str) -> str | None:
423
562
  return match.group(1) if match else None
424
563
 
425
564
 
565
+ def json_line_events(stdout: str) -> list[dict]:
566
+ """Decode un flux JSONL d'agent en ignorant les lignes non JSON ou non objet."""
567
+ events: list[dict] = []
568
+ for line in stdout.splitlines():
569
+ line = line.strip()
570
+ if not line.startswith("{"):
571
+ continue
572
+ try:
573
+ event = json.loads(line)
574
+ except json.JSONDecodeError:
575
+ continue
576
+ if isinstance(event, dict):
577
+ events.append(event)
578
+ return events
579
+
580
+
581
+ def _codex_final_message(events: list[dict]) -> str | None:
582
+ """Retourne le dernier message d'agent complet du flux d'evenements."""
583
+ for event in reversed(events):
584
+ item = event.get("item")
585
+ if event.get("type") != "item.completed" or not isinstance(item, dict):
586
+ continue
587
+ if item.get("type") == "agent_message" and isinstance(item.get("text"), str):
588
+ return item["text"]
589
+ return None
590
+
591
+
426
592
  def _strip_json_fence(value: str) -> str:
427
593
  text = value.strip()
428
594
  match = re.fullmatch(r"```(?:json)?\s*([\s\S]*?)\s*```", text, re.IGNORECASE)
@@ -434,3 +600,92 @@ def _validate_structured_payload(payload: object, schema_file: Path) -> None:
434
600
  validator_type = validator_for(schema)
435
601
  validator_type.check_schema(schema)
436
602
  validator_type(schema).validate(payload)
603
+
604
+
605
+ def _int(value: object) -> int:
606
+ return value if isinstance(value, int) and not isinstance(value, bool) else 0
607
+
608
+
609
+ def _codex_home() -> Path:
610
+ return Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex")
611
+
612
+
613
+ def _codex_session_file(session_id: str | None) -> Path | None:
614
+ """Retrouve le journal d'une session Codex recente a partir de son identifiant."""
615
+ if not session_id or not re.fullmatch(r"[A-Za-z0-9-]{8,}", session_id):
616
+ return None
617
+ sessions = _codex_home() / "sessions"
618
+ today = datetime.now()
619
+ for offset in range(3):
620
+ day = today - timedelta(days=offset)
621
+ directory = sessions / f"{day:%Y}" / f"{day:%m}" / f"{day:%d}"
622
+ matches = sorted(directory.glob(f"rollout-*{session_id}.jsonl"))
623
+ if matches:
624
+ return matches[-1]
625
+ return None
626
+
627
+
628
+ def _codex_session_facts(session_id: str | None) -> dict | None:
629
+ """Extrait modele, effort, requetes et quotas du journal de session Codex."""
630
+ path = _codex_session_file(session_id)
631
+ if path is None:
632
+ return None
633
+ models: list[str] = []
634
+ effort: str | None = None
635
+ totals: set[int] = set()
636
+ first_context: int | None = None
637
+ limits: list[dict] = []
638
+ with path.open(encoding="utf-8", errors="replace") as handle:
639
+ for event in json_line_events(handle.read()):
640
+ payload = event.get("payload")
641
+ if not isinstance(payload, dict):
642
+ continue
643
+ if event.get("type") == "turn_context":
644
+ model = payload.get("model")
645
+ if isinstance(model, str) and model not in models:
646
+ models.append(model)
647
+ if isinstance(payload.get("effort"), str):
648
+ effort = payload["effort"]
649
+ elif event.get("type") == "event_msg" and payload.get("type") == "token_count":
650
+ info = payload.get("info")
651
+ if isinstance(info, dict) and isinstance(info.get("total_token_usage"), dict):
652
+ marker = _int(info["total_token_usage"].get("total_tokens"))
653
+ if marker not in totals:
654
+ totals.add(marker)
655
+ last = info.get("last_token_usage")
656
+ if first_context is None and isinstance(last, dict):
657
+ first_context = _int(last.get("input_tokens")) or None
658
+ if isinstance(payload.get("rate_limits"), dict):
659
+ limits.append(payload["rate_limits"])
660
+ return {
661
+ "models": tuple(models),
662
+ "effort": effort,
663
+ "turns": len(totals) or None,
664
+ "firstContextTokens": first_context,
665
+ "quota": _quota_windows(limits),
666
+ }
667
+
668
+
669
+ def _quota_windows(limits: list[dict]) -> tuple[QuotaWindow, ...]:
670
+ """Compare le premier et le dernier releve de chaque fenetre de quota."""
671
+ windows: list[QuotaWindow] = []
672
+ if not limits:
673
+ return ()
674
+ for key in ("primary", "secondary"):
675
+ first, last = limits[0].get(key), limits[-1].get(key)
676
+ if not isinstance(first, dict) or not isinstance(last, dict):
677
+ continue
678
+ start, end = first.get("used_percent"), last.get("used_percent")
679
+ if not isinstance(start, (int, float)) or not isinstance(end, (int, float)):
680
+ continue
681
+ minutes = last.get("window_minutes")
682
+ same_window = first.get("resets_at") == last.get("resets_at")
683
+ windows.append(
684
+ QuotaWindow(
685
+ windowMinutes=minutes if isinstance(minutes, int) else None,
686
+ usedPercentStart=float(start),
687
+ usedPercentEnd=float(end),
688
+ deltaPoints=float(end) - float(start) if same_window else None,
689
+ )
690
+ )
691
+ return tuple(windows)
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
@@ -23,6 +22,7 @@ from devflow_cli.commands.docs_sync import docs_sync
23
22
  from devflow_cli.commands.traceability import traceability
24
23
  from devflow_cli.commands.verify import verify
25
24
  from devflow_cli.commands.assess import assess
25
+ from devflow_cli.commands.usage import usage
26
26
 
27
27
  app = typer.Typer(
28
28
  name="devflow",
@@ -53,7 +53,6 @@ app.command()(feature)
53
53
  app.command()(context)
54
54
  app.command(name="export")(export)
55
55
  app.command()(stale)
56
- app.command()(migrate)
57
56
  app.command()(upgrade)
58
57
  app.add_typer(extension.app, name="extension")
59
58
  app.add_typer(hooks_cmd.app, name="hooks")
@@ -63,6 +62,7 @@ app.command(name="docs-sync")(docs_sync)
63
62
  app.command()(traceability)
64
63
  app.command()(verify)
65
64
  app.command()(assess)
65
+ app.command()(usage)
66
66
 
67
67
 
68
68
  if __name__ == "__main__":