devflow-cli 6.0.0__py3-none-any.whl → 6.1.1__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 = "6.0.0"
3
+ VERSION = "6.1.1"
4
4
 
5
5
  SUPPORTED_AGENTS = ["claude-code", "codex", "cursor"]
6
6
 
@@ -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
 
@@ -25,6 +27,38 @@ _SAFE_DIAGNOSTIC_MESSAGES = {
25
27
  }
26
28
 
27
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
+
28
62
  @dataclass(frozen=True)
29
63
  class AgentExecution:
30
64
  returncode: int
@@ -36,6 +70,7 @@ class AgentExecution:
36
70
  final_output_bytes: int = 0
37
71
  final_output_diagnostic: str = "not-evaluated"
38
72
  session_id: str | None = None
73
+ usage: AgentUsage | None = None
39
74
 
40
75
 
41
76
  class StructuredOutputError(ValueError):
@@ -138,6 +173,9 @@ class AgentAdapter(ABC):
138
173
  final_output_diagnostic="process-failed" if result.returncode else "validating",
139
174
  session_id=self.session_id(result.stdout or "", result.stderr or ""),
140
175
  )
176
+ execution = replace(
177
+ execution, usage=self.safe_usage(execution.stdout, execution.session_id)
178
+ )
141
179
  if execution.returncode != 0:
142
180
  return execution
143
181
  try:
@@ -169,6 +207,7 @@ class AgentAdapter(ABC):
169
207
  _output_size(output_file),
170
208
  "validated",
171
209
  execution.session_id,
210
+ execution.usage,
172
211
  )
173
212
 
174
213
  status = _output_status(output_file)
@@ -189,16 +228,21 @@ class AgentAdapter(ABC):
189
228
  final_output_bytes=size,
190
229
  final_output_diagnostic=diagnostic,
191
230
  session_id=execution.session_id,
231
+ usage=execution.usage,
192
232
  )
193
233
  except subprocess.TimeoutExpired as exc:
234
+ stdout, stderr = _text(exc.stdout), _text(exc.stderr)
235
+ session_id = self.session_id(stdout, stderr)
194
236
  return AgentExecution(
195
237
  124,
196
- _text(exc.stdout),
197
- _text(exc.stderr),
238
+ stdout,
239
+ stderr,
198
240
  timed_out=True,
199
241
  duration_seconds=time.monotonic() - started,
200
242
  final_output_status=_output_status(output_file),
201
243
  final_output_bytes=_output_size(output_file),
244
+ session_id=session_id,
245
+ usage=self.safe_usage(stdout, session_id),
202
246
  )
203
247
  except FileNotFoundError:
204
248
  return AgentExecution(127, stderr=f"Executable '{self.executable}' introuvable", duration_seconds=time.monotonic() - started)
@@ -207,6 +251,17 @@ class AgentAdapter(ABC):
207
251
  """Extrait un identifiant opaque de session, sans conserver la sortie."""
208
252
  return _session_id(stdout, stderr)
209
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
+
210
265
  def recover_structured_payload(self, raw_output: str) -> object:
211
266
  """Extrait la reponse structuree d'une trace brute conservee apres un echec."""
212
267
  return self._structured_payload(raw_output, None)
@@ -270,6 +325,33 @@ class ClaudeCodeAdapter(AgentAdapter):
270
325
  return json.loads(_strip_json_fence(result))
271
326
  raise ValueError("champ structured_output absent")
272
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
+ )
354
+
273
355
 
274
356
  class CodexAdapter(AgentAdapter):
275
357
  name = "codex"
@@ -338,6 +420,32 @@ class CodexAdapter(AgentAdapter):
338
420
  return json.loads(_strip_json_fence(final_message))
339
421
  raise StructuredOutputError(diagnostic, message)
340
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),
446
+ )
447
+ return replace(usage, **session) if session else usage
448
+
341
449
 
342
450
  class CursorAdapter(AgentAdapter):
343
451
  name = "cursor"
@@ -492,3 +600,92 @@ def _validate_structured_payload(payload: object, schema_file: Path) -> None:
492
600
  validator_type = validator_for(schema)
493
601
  validator_type.check_schema(schema)
494
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
@@ -22,6 +22,7 @@ from devflow_cli.commands.docs_sync import docs_sync
22
22
  from devflow_cli.commands.traceability import traceability
23
23
  from devflow_cli.commands.verify import verify
24
24
  from devflow_cli.commands.assess import assess
25
+ from devflow_cli.commands.usage import usage
25
26
 
26
27
  app = typer.Typer(
27
28
  name="devflow",
@@ -61,6 +62,7 @@ app.command(name="docs-sync")(docs_sync)
61
62
  app.command()(traceability)
62
63
  app.command()(verify)
63
64
  app.command()(assess)
65
+ app.command()(usage)
64
66
 
65
67
 
66
68
  if __name__ == "__main__":
@@ -6,6 +6,8 @@ import json
6
6
  import os
7
7
  import shlex
8
8
  import tempfile
9
+ import uuid
10
+ from datetime import datetime, timezone
9
11
  from pathlib import Path
10
12
  from typing import Literal, Optional
11
13
 
@@ -44,6 +46,7 @@ from devflow_cli.core.automatic_evidence import (
44
46
  stale_review_evidence,
45
47
  )
46
48
  from devflow_cli.core.worklog import refresh_worklog, safe_refresh_worklog
49
+ from devflow_cli.core.telemetry import append_record, telemetry_enabled, usage_payload
47
50
  from devflow_cli.core.waivers import create_validation_waiver
48
51
  from devflow_cli.core.remediation import (
49
52
  carried_changed_files,
@@ -366,10 +369,15 @@ def run_adaptive(
366
369
  else:
367
370
  fail(str(exc))
368
371
  raise typer.Exit(int(ExitCode.INVALID_CONTRACT)) from exc
372
+ run_id = str(uuid.uuid4())
373
+ attempt: dict = {}
374
+
369
375
  def execute(activity_contract) -> AgentActivityExecution:
370
376
  prompt_text = render_contract_prompt(activity_contract)
377
+ attempt["contract"] = activity_contract
378
+ attempt["startedAt"] = _utc_now()
371
379
  try:
372
- before = snapshot_worktree(project_root)
380
+ before = snapshot_worktree(project_root, feature_dir)
373
381
  with tempfile.TemporaryDirectory(prefix="devflow-adaptive-") as temp_dir:
374
382
  temp_path = Path(temp_dir)
375
383
  schema_file = temp_path / "output-schema.json"
@@ -389,15 +397,18 @@ def run_adaptive(
389
397
  if "write" in activity_contract.allowedActions
390
398
  else "read-only",
391
399
  )
400
+ attempt["execution"] = execution
392
401
  output = (
393
402
  output_file.read_text(encoding="utf-8")
394
403
  if output_file.is_file()
395
404
  else execution.stdout
396
405
  )
397
- after = snapshot_worktree(project_root)
406
+ after = snapshot_worktree(project_root, feature_dir)
398
407
  observed = tuple(changed_paths(before, after))
408
+ attempt["changedFileCount"] = len(observed)
399
409
  audit_payload = {
400
410
  "schemaVersion": 1,
411
+ "runId": run_id,
401
412
  "agent": agent,
402
413
  "activity": activity_contract.activity,
403
414
  "returnCode": execution.returncode,
@@ -423,6 +434,10 @@ def run_adaptive(
423
434
  )
424
435
 
425
436
  outcome = run_current_activity(feature_dir, feature_path, execute)
437
+ _record_telemetry(
438
+ run_id, attempt, agent, project_root, outcome.reason,
439
+ json_output=json_output,
440
+ )
426
441
  if outcome.advanced:
427
442
  if json_output:
428
443
  _print_execution_json(
@@ -827,6 +842,52 @@ def _print_execution_json(
827
842
  }, ensure_ascii=False))
828
843
 
829
844
 
845
+ def _utc_now() -> str:
846
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
847
+
848
+
849
+ def _record_telemetry(
850
+ run_id: str,
851
+ attempt: dict,
852
+ agent: str,
853
+ project_root: Path,
854
+ reason: str,
855
+ *,
856
+ json_output: bool,
857
+ ) -> None:
858
+ """Consigne la consommation d'une tentative ; ne bloque jamais l'activite."""
859
+ execution = attempt.get("execution")
860
+ contract = attempt.get("contract")
861
+ if execution is None or contract is None or not telemetry_enabled():
862
+ return
863
+ record = {
864
+ "schemaVersion": 1,
865
+ "runId": run_id,
866
+ "recordedAt": _utc_now(),
867
+ "startedAt": attempt.get("startedAt"),
868
+ "project": str(project_root),
869
+ "issueId": contract.issueId,
870
+ "profile": contract.profile,
871
+ "activity": contract.activity,
872
+ "contractId": contract.contractId,
873
+ "agent": agent,
874
+ "sessionId": execution.session_id,
875
+ "outcome": reason,
876
+ "advanced": reason == "advanced",
877
+ "returnCode": execution.returncode,
878
+ "timedOut": execution.timed_out,
879
+ "durationSeconds": round(execution.duration_seconds, 3),
880
+ "finalOutputDiagnostic": execution.final_output_diagnostic,
881
+ "worktreeChangedFileCount": attempt.get("changedFileCount"),
882
+ "usage": usage_payload(execution.usage),
883
+ }
884
+ try:
885
+ append_record(record)
886
+ except OSError as exc:
887
+ if not json_output:
888
+ warn(f"Telemetrie non enregistree : {exc}")
889
+
890
+
830
891
  def _write_json_atomic(path: Path, payload: dict) -> None:
831
892
  temporary: Path | None = None
832
893
  try:
@@ -0,0 +1,92 @@
1
+ """devflow usage — Resume de la consommation enregistree des activites agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Optional
7
+
8
+ import typer
9
+ from rich.table import Table
10
+
11
+ from devflow_cli.core.telemetry import (
12
+ GROUP_KEYS,
13
+ filter_records,
14
+ parse_since,
15
+ read_records,
16
+ summarize,
17
+ telemetry_enabled,
18
+ telemetry_file,
19
+ )
20
+ from devflow_cli.utils.console import console, fail, warn
21
+
22
+
23
+ def usage(
24
+ since: Optional[str] = typer.Option(
25
+ None, "--since", help="Periode : 7d, 12h ou une date ISO"
26
+ ),
27
+ by: str = typer.Option(
28
+ "activity", "--by", help=f"Regroupement : {', '.join(GROUP_KEYS)}"
29
+ ),
30
+ project: Optional[str] = typer.Option(
31
+ None, "--project", help="Ne garder que les projets dont le chemin contient ce texte"
32
+ ),
33
+ json_output: bool = typer.Option(False, "--json", help="Sortie JSON"),
34
+ ) -> None:
35
+ """Resume la consommation des tentatives agent (~/.devflow/telemetry.jsonl)."""
36
+ try:
37
+ start = parse_since(since) if since else None
38
+ rows = summarize(
39
+ filter_records(read_records(), since=start, project=project), by
40
+ )
41
+ except ValueError as exc:
42
+ fail(str(exc))
43
+ raise typer.Exit(2) from exc
44
+
45
+ if json_output:
46
+ console.print_json(json.dumps({"source": str(telemetry_file()), "by": by, "rows": rows}))
47
+ return
48
+ if not telemetry_enabled():
49
+ warn("Collecte desactivee (DEVFLOW_TELEMETRY=0) : aucune nouvelle tentative n'est enregistree.")
50
+ if not rows:
51
+ console.print(f"Aucune tentative enregistree dans {telemetry_file()}.")
52
+ return
53
+
54
+ table = Table(title=f"Consommation par {by}")
55
+ for column in (
56
+ by, "essais", "ok", "entree", "cache lu", "sortie", "USD", "tours", "q.5h", "q.sem",
57
+ ):
58
+ table.add_column(column, no_wrap=True, justify="left" if column == by else "right")
59
+ for row in rows:
60
+ table.add_row(
61
+ row[by],
62
+ str(row["attempts"]),
63
+ str(row["advanced"]),
64
+ _tokens(row["inputTokens"]),
65
+ _tokens(row["cacheReadTokens"]),
66
+ _tokens(row["outputTokens"]),
67
+ _optional(row["costUsd"], "{:.2f}"),
68
+ _optional(row["meanTurns"], "{:g}"),
69
+ _optional(row["quota5hPoints"], "{:g}"),
70
+ _optional(row["quotaWeeklyPoints"], "{:g}"),
71
+ )
72
+ console.print(table)
73
+ console.print(
74
+ "essais = tentatives agent ; ok = tentatives avancees ; q. = points de quota Codex ; "
75
+ "detail complet (cache ecrit, contexte au 1er tour) avec --json. "
76
+ "Les tokens de Claude et de Codex ne sont pas comparables entre eux ; "
77
+ "le cout USD est l'equivalent API declare par Claude, les quotas sont "
78
+ "releves dans les journaux Codex."
79
+ )
80
+
81
+
82
+ def _tokens(value: int | None) -> str:
83
+ if value is None:
84
+ return "-"
85
+ for suffix, divisor in (("M", 1_000_000), ("k", 1_000)):
86
+ if value >= divisor:
87
+ return f"{value / divisor:.1f}{suffix}"
88
+ return str(value)
89
+
90
+
91
+ def _optional(value, pattern: str) -> str:
92
+ return "-" if value is None else pattern.format(value)
@@ -296,7 +296,7 @@ def reconcile_current_activity(
296
296
  raise ValueError(
297
297
  "la tentative ne correspond pas au contrat de l'activite courante"
298
298
  )
299
- snapshot = snapshot_worktree(project_root_for_feature(feature_dir))
299
+ snapshot = snapshot_worktree(project_root_for_feature(feature_dir), feature_dir)
300
300
  if snapshot.head != attempt.get("head") or snapshot.branch != attempt.get("branch"):
301
301
  raise ValueError("HEAD ou la branche a change depuis la tentative")
302
302
  drifted = sorted(
@@ -784,7 +784,7 @@ def _record_failed_attempt(
784
784
  ) -> None:
785
785
  """Consigne les faits d'une tentative refusee pour une reprise controlee."""
786
786
  try:
787
- snapshot = snapshot_worktree(project_root_for_feature(feature_dir))
787
+ snapshot = snapshot_worktree(project_root_for_feature(feature_dir), feature_dir)
788
788
  except (OSError, ValueError):
789
789
  return
790
790
  payload = {
@@ -208,7 +208,7 @@ def _active_attempt_files(feature_dir: Path, activity: str) -> list[str]:
208
208
  or not all(isinstance(path, str) for path in carried)
209
209
  ):
210
210
  raise StaleAttemptError(f"fiche de tentative {activity} invalide")
211
- snapshot = snapshot_worktree(project_root_for_feature(feature_dir))
211
+ snapshot = snapshot_worktree(project_root_for_feature(feature_dir), feature_dir)
212
212
  if snapshot.head != payload["head"] or snapshot.branch != payload["branch"]:
213
213
  raise StaleAttemptError(
214
214
  f"HEAD ou la branche a change depuis la tentative {activity}"
@@ -243,7 +243,7 @@ def _still_modified(feature_dir: Path, files: list[str]) -> list[str]:
243
243
  if not files:
244
244
  return []
245
245
  try:
246
- snapshot = snapshot_worktree(project_root_for_feature(feature_dir))
246
+ snapshot = snapshot_worktree(project_root_for_feature(feature_dir), feature_dir)
247
247
  except (OSError, ValueError):
248
248
  return files
249
249
  return [path for path in files if path in snapshot.files]
@@ -0,0 +1,168 @@
1
+ """Telemetrie locale de consommation des activites agent.
2
+
3
+ Chaque tentative `adaptive run` confiee a un agent ajoute une ligne JSON a
4
+ `~/.devflow/telemetry.jsonl`. Le fichier est commun a tous les projets, car les
5
+ quotas des agents sont partages par le compte. Il ne contient ni prompt, ni
6
+ reponse, ni contenu de fichier. `DEVFLOW_TELEMETRY=0` desactive la collecte ;
7
+ `DEVFLOW_HOME` deplace le repertoire.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import statistics
15
+ from collections import defaultdict
16
+ from dataclasses import asdict
17
+ from datetime import datetime, timedelta, timezone
18
+ from pathlib import Path
19
+ from typing import Any, Iterable
20
+
21
+ from devflow_cli.adapters.agents import AgentUsage
22
+
23
+ TELEMETRY_SCHEMA_VERSION = 1
24
+ TELEMETRY_ENV = "DEVFLOW_TELEMETRY"
25
+ DEVFLOW_HOME_ENV = "DEVFLOW_HOME"
26
+ _DISABLED_VALUES = {"0", "false", "no", "off"}
27
+ GROUP_KEYS = ("activity", "profile", "agent", "model", "project")
28
+
29
+
30
+ def telemetry_enabled() -> bool:
31
+ return os.environ.get(TELEMETRY_ENV, "1").strip().casefold() not in _DISABLED_VALUES
32
+
33
+
34
+ def telemetry_file() -> Path:
35
+ home = os.environ.get(DEVFLOW_HOME_ENV)
36
+ return (Path(home) if home else Path.home() / ".devflow") / "telemetry.jsonl"
37
+
38
+
39
+ def usage_payload(usage: AgentUsage | None) -> dict[str, Any] | None:
40
+ if usage is None:
41
+ return None
42
+ payload = asdict(usage)
43
+ payload["models"] = list(usage.models)
44
+ payload["quota"] = [asdict(window) for window in usage.quota]
45
+ return payload
46
+
47
+
48
+ def append_record(record: dict[str, Any], path: Path | None = None) -> Path:
49
+ """Ajoute une ligne en une seule ecriture O_APPEND ; leve OSError en cas d'echec."""
50
+ target = path or telemetry_file()
51
+ target.parent.mkdir(parents=True, exist_ok=True)
52
+ line = (json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n").encode("utf-8")
53
+ descriptor = os.open(target, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
54
+ try:
55
+ os.write(descriptor, line)
56
+ finally:
57
+ os.close(descriptor)
58
+ return target
59
+
60
+
61
+ def read_records(path: Path | None = None) -> list[dict[str, Any]]:
62
+ """Lit les tentatives enregistrees en ignorant les lignes illisibles."""
63
+ target = path or telemetry_file()
64
+ if not target.is_file():
65
+ return []
66
+ records: list[dict[str, Any]] = []
67
+ for line in target.read_text(encoding="utf-8", errors="replace").splitlines():
68
+ try:
69
+ record = json.loads(line)
70
+ except json.JSONDecodeError:
71
+ continue
72
+ if isinstance(record, dict) and record.get("schemaVersion") == TELEMETRY_SCHEMA_VERSION:
73
+ records.append(record)
74
+ return records
75
+
76
+
77
+ def parse_since(value: str, now: datetime | None = None) -> datetime:
78
+ """Accepte une duree (`7d`, `12h`) ou une date ISO."""
79
+ reference = now or datetime.now(timezone.utc)
80
+ text = value.strip()
81
+ if len(text) > 1 and text[-1] in {"d", "h"} and text[:-1].isdigit():
82
+ amount = int(text[:-1])
83
+ return reference - (timedelta(days=amount) if text[-1] == "d" else timedelta(hours=amount))
84
+ try:
85
+ parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
86
+ except ValueError as exc:
87
+ raise ValueError(f"--since invalide : {value} (attendu 7d, 12h ou une date ISO)") from exc
88
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
89
+
90
+
91
+ def filter_records(
92
+ records: Iterable[dict[str, Any]],
93
+ *,
94
+ since: datetime | None = None,
95
+ project: str | None = None,
96
+ ) -> list[dict[str, Any]]:
97
+ selected = []
98
+ for record in records:
99
+ if project and project not in str(record.get("project", "")):
100
+ continue
101
+ if since is not None:
102
+ try:
103
+ recorded = datetime.fromisoformat(str(record.get("recordedAt", "")).replace("Z", "+00:00"))
104
+ except ValueError:
105
+ continue
106
+ if recorded < since:
107
+ continue
108
+ selected.append(record)
109
+ return selected
110
+
111
+
112
+ def _group_value(record: dict[str, Any], key: str) -> str:
113
+ if key == "model":
114
+ usage = record.get("usage") or {}
115
+ models = ",".join(usage.get("models") or []) or "?"
116
+ effort = usage.get("effort")
117
+ return f"{models}/{effort}" if effort else models
118
+ if key == "project":
119
+ return Path(str(record.get("project") or "?")).name
120
+ return str(record.get(key) or "?")
121
+
122
+
123
+ def _quota_delta(record: dict[str, Any], minutes: int) -> float | None:
124
+ usage = record.get("usage") or {}
125
+ for window in usage.get("quota") or []:
126
+ if window.get("windowMinutes") == minutes and window.get("deltaPoints") is not None:
127
+ return float(window["deltaPoints"])
128
+ return None
129
+
130
+
131
+ def summarize(records: list[dict[str, Any]], by: str) -> list[dict[str, Any]]:
132
+ """Agrege les tentatives par cle, sans melanger les unites des agents."""
133
+ if by not in GROUP_KEYS:
134
+ raise ValueError(f"--by invalide : {by} (valeurs : {', '.join(GROUP_KEYS)})")
135
+ groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
136
+ for record in records:
137
+ groups[_group_value(record, by)].append(record)
138
+ rows = []
139
+ for name, items in groups.items():
140
+ usages = [record.get("usage") or {} for record in items]
141
+ measured = [usage for usage in usages if usage]
142
+ costs = [usage["costUsd"] for usage in measured if usage.get("costUsd") is not None]
143
+ turns = [usage["turns"] for usage in measured if usage.get("turns") is not None]
144
+ contexts = [
145
+ usage["firstContextTokens"]
146
+ for usage in measured
147
+ if usage.get("firstContextTokens") is not None
148
+ ]
149
+ five_hours = [d for d in (_quota_delta(r, 300) for r in items) if d is not None]
150
+ weekly = [d for d in (_quota_delta(r, 10080) for r in items) if d is not None]
151
+ rows.append({
152
+ by: name,
153
+ "attempts": len(items),
154
+ "advanced": sum(1 for record in items if record.get("advanced")),
155
+ "contracts": len({record.get("contractId") for record in items}),
156
+ "measured": len(measured),
157
+ "durationSeconds": round(sum(float(r.get("durationSeconds") or 0) for r in items), 1),
158
+ "inputTokens": sum(int(u.get("inputTokens") or 0) for u in measured),
159
+ "cacheReadTokens": sum(int(u.get("cacheReadTokens") or 0) for u in measured),
160
+ "cacheWriteTokens": sum(int(u.get("cacheWriteTokens") or 0) for u in measured),
161
+ "outputTokens": sum(int(u.get("outputTokens") or 0) for u in measured),
162
+ "costUsd": round(sum(costs), 4) if costs else None,
163
+ "meanTurns": round(statistics.mean(turns), 1) if turns else None,
164
+ "medianFirstContextTokens": int(statistics.median(contexts)) if contexts else None,
165
+ "quota5hPoints": sum(five_hours) if five_hours else None,
166
+ "quotaWeeklyPoints": sum(weekly) if weekly else None,
167
+ })
168
+ return sorted(rows, key=lambda row: (-row["attempts"], row[by]))
@@ -17,8 +17,19 @@ class WorktreeSnapshot:
17
17
  files: dict[str, str]
18
18
 
19
19
 
20
- def snapshot_worktree(project_root: Path) -> WorktreeSnapshot:
21
- """Capture HEAD, branche et contenu des fichiers modifies/non suivis."""
20
+ # Traces brutes d'agent ecrites par devflow ; ignorees par le .gitignore de la feature.
21
+ TRACE_SUFFIX = ".raw.txt"
22
+
23
+
24
+ def snapshot_worktree(
25
+ project_root: Path, feature_dir: Path | None = None
26
+ ) -> WorktreeSnapshot:
27
+ """Capture HEAD, branche et contenu des fichiers modifies/non suivis.
28
+
29
+ Avec ``feature_dir``, les fichiers ignores par git dans ce seul dossier
30
+ sont aussi captures, hors traces brutes : un projet qui ignore ``.specify``
31
+ doit tout de meme voir les artefacts de sa feature.
32
+ """
22
33
  head = _git_text(project_root, ["rev-parse", "--verify", "HEAD"], allow_failure=True)
23
34
  branch = _git_text(project_root, ["branch", "--show-current"], allow_failure=True)
24
35
  tracked = _git_paths(
@@ -30,7 +41,15 @@ def snapshot_worktree(project_root: Path) -> WorktreeSnapshot:
30
41
  project_root,
31
42
  ["ls-files", "--others", "--exclude-standard", "-z"],
32
43
  )
33
- paths = sorted(set(tracked) | set(untracked))
44
+ paths = set(tracked) | set(untracked)
45
+ subtree = _subtree(project_root, feature_dir)
46
+ if subtree is not None:
47
+ ignored = _git_paths(
48
+ project_root,
49
+ ["ls-files", "--others", "--ignored", "--exclude-standard", "-z", "--", subtree],
50
+ )
51
+ paths |= {path for path in ignored if not path.endswith(TRACE_SUFFIX)}
52
+ paths = sorted(paths)
34
53
  return WorktreeSnapshot(
35
54
  head=head,
36
55
  branch=branch,
@@ -64,6 +83,15 @@ def changed_paths(before: WorktreeSnapshot, after: WorktreeSnapshot) -> list[str
64
83
  return sorted(path for path in paths if before.files.get(path) != after.files.get(path))
65
84
 
66
85
 
86
+ def _subtree(project_root: Path, feature_dir: Path | None) -> str | None:
87
+ if feature_dir is None:
88
+ return None
89
+ try:
90
+ return feature_dir.resolve().relative_to(project_root.resolve()).as_posix()
91
+ except ValueError:
92
+ return None
93
+
94
+
67
95
  def _git_text(project_root: Path, args: list[str], *, allow_failure: bool = False) -> str:
68
96
  result = subprocess.run(
69
97
  ["git", *args],
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: devflow-cli
3
- Version: 6.0.0
3
+ Version: 6.1.1
4
4
  Summary: Spec-Driven Development workflow CLI
5
5
  Project-URL: Homepage, https://github.com/sopequenoteck/devflow
6
6
  Project-URL: Repository, https://github.com/sopequenoteck/devflow
@@ -1,10 +1,10 @@
1
- devflow_cli/__init__.py,sha256=PJFBeX5myYT2_LgT3pqJ-g0tbYk0MwB9P2QxYrlNScs,1002
2
- devflow_cli/cli.py,sha256=llRYI7JxM7UJLw5I3eqyYx8zMRN7Wxwj7QcHdGe9HkQ,2063
1
+ devflow_cli/__init__.py,sha256=VmCwbQc6bNPKKQvcsirS32ZlbjHbOt8DbCAfd3EcBfA,1002
2
+ devflow_cli/cli.py,sha256=nst8kP2QneJZJg6K5bBf9uO8Nj9lYmoHrEhcxA7nsIM,2129
3
3
  devflow_cli/adapters/__init__.py,sha256=RkjHPU2Lp4hSVIq2iOmyTrv2zYU9NuiIF9wEMWzLdMI,377
4
- devflow_cli/adapters/agents.py,sha256=ccOxRl6F_PgB_4o-VMSbmP16m8IDlI7HQcadhh6C1Jc,18139
4
+ devflow_cli/adapters/agents.py,sha256=8_y70AIhmtuR8kyPN3Wpj0-i76k9wd_uaycVKTqC96g,26503
5
5
  devflow_cli/adapters/linear.py,sha256=MyqI2tHx7NNHY1KRfeYZ_Zjc-6_xhZPWD4oS-j7wnNY,2659
6
6
  devflow_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
- devflow_cli/commands/adaptive.py,sha256=0c2CFotDapCrk_T0Z6_o0lSiXJBZc2bfZvxC_kqwhtY,43751
7
+ devflow_cli/commands/adaptive.py,sha256=_CICxGw27XOSnXteudn75IriY12zz5jhcZoH__zNfns,45797
8
8
  devflow_cli/commands/assess.py,sha256=GrWGGZjvBAgHWzSlUdtS-IfYocacA6VYxWduDvBunzg,4395
9
9
  devflow_cli/commands/check.py,sha256=M_sbcH1L3FEa4DF9gm46LEADlJndRKlaEDoysIBHv8k,6668
10
10
  devflow_cli/commands/context.py,sha256=hwTitOHxIpJMx1X8R1jHHy9CpE4HVZpJKkyeOujxAio,6246
@@ -19,10 +19,11 @@ devflow_cli/commands/stale.py,sha256=UiaUb48lrXmPyjfvbFkChfLE-rs58KhgKu-zgL_NBIo
19
19
  devflow_cli/commands/status.py,sha256=b7r8lFcUOuph57BPSQzSVGr4039sIzrsSV1gW77KM54,7244
20
20
  devflow_cli/commands/traceability.py,sha256=DhUOBzSmKVk17j1Qz0yxD5zD2-iNWaR9F133VNPjy70,1767
21
21
  devflow_cli/commands/upgrade_cmd.py,sha256=d5uBFUPzPsCJDLxhQBFaeHWJv3GWWLmPpkYHRVd1gwM,5664
22
+ devflow_cli/commands/usage.py,sha256=ehH1AH5J-XfkebFx6qLBwpKN-MOoX2uUbq4LejBpNsk,3148
22
23
  devflow_cli/commands/verify.py,sha256=sKmoHfA-PRWoDbgLC0nW2nvd89Zdktlkep0sxF7q3jQ,3968
23
24
  devflow_cli/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
24
25
  devflow_cli/core/activity_contracts.py,sha256=U9AknLNFIusjuh4MonY_8i3__uDeo_NF2z3zfGJynA8,42631
25
- devflow_cli/core/adaptive_runner.py,sha256=_JmnglYkqN3HhV7UZdfSkCiR_O141rHIoEl1XEuIOFw,33687
26
+ devflow_cli/core/adaptive_runner.py,sha256=o9HHPNZJyA9u_zui9pNZrzMkH_jzszbI-GMweoRjqB8,33713
26
27
  devflow_cli/core/adaptive_workflow.py,sha256=qsjkahK9yklf6LjyQn69PVl9v0AoX2YPQiG2_umpb_U,13065
27
28
  devflow_cli/core/agent_failure.py,sha256=iX4q249lAHeVdD_ZF1aObmU-FX6sjfYP9dfLSnWGAD0,3771
28
29
  devflow_cli/core/assessment.py,sha256=l5QRntLJyBx3MdN-kepmMAsruHdJsG4TDgSdgOSHkqU,10062
@@ -37,16 +38,17 @@ devflow_cli/core/manifest.py,sha256=964iYthqhzf3qQgxN4hZ33BvS2r7BKDZoKdiOWRybag,
37
38
  devflow_cli/core/omissions.py,sha256=-9dqaQLRIy-1t6it7gecKn_0KpqbEjPIT0ec86KvXCk,7449
38
39
  devflow_cli/core/operational.py,sha256=ZEWh6DKJfHYLpV9r3vZwaDFREQwlJF0wTtgB1VleXog,1204
39
40
  devflow_cli/core/redaction.py,sha256=o_1MDtCyvRLrk_dWlV6_7E6_mPVB1B5a0cCfTBj9j0Q,1973
40
- devflow_cli/core/remediation.py,sha256=D7spH-xO1KezSY3YVuAl4ZdFCKhXKGSYVWgnLHC4Dp4,13549
41
+ devflow_cli/core/remediation.py,sha256=FknpUBR9WGZkmeDf6F2HINKKm3rG4AB5C7NTgrPhPVc,13575
41
42
  devflow_cli/core/scanner.py,sha256=bcoC91Nc0Hw2P7aHpL-9ITxynLZhLTEKpOXo553a-18,4395
42
43
  devflow_cli/core/staleness.py,sha256=Bn4jN8r601Ifn0AU8BHZy-Z-5NLPjj_JTK-V0KU9TUM,5256
44
+ devflow_cli/core/telemetry.py,sha256=emcYo6Qikc86TYKT5Mc_9-3Thkt3nztkPZrpTy9VlvA,6952
43
45
  devflow_cli/core/traceability.py,sha256=jfNE9MZzGkmaPer9Wa6Y18yA9Y3sqdeR-nzH9h_TB-8,2968
44
46
  devflow_cli/core/validation_approvals.py,sha256=BMjwDbkkY9K-uqk19duKWIbh7-Dk5ceprNPgQHx3lrg,8421
45
47
  devflow_cli/core/validators.py,sha256=c2T6NAe80sPh2uS8gCQfyiCkLT3eBPLizCbDsE0PgNo,9934
46
48
  devflow_cli/core/verification.py,sha256=a2-pAnMQqBhGnV87LwhMc03zFRjDECB7sWYGxQu21lo,16879
47
49
  devflow_cli/core/waivers.py,sha256=w3bza1OicSxAViYU2hvbrylQTNVgmN7Rd9y2L6Wm2OE,11302
48
50
  devflow_cli/core/worklog.py,sha256=w_R_K-xI7gx9lYQhcjJbI_nBQqztMHsOPWj1tQCXjjA,12668
49
- devflow_cli/core/worktree_snapshot.py,sha256=8YDAQwJ1lBbChck2IQUM2iEAAMv27tHedtfELS10nnU,3701
51
+ devflow_cli/core/worktree_snapshot.py,sha256=5ycGW5sYSeoht-ofUqoNhrscZuhTJlynfObzisJal90,4669
50
52
  devflow_cli/schemas/activity-contract-v3.schema.json,sha256=_R6oKY2K3LxNCIATQunDM7e41J6w4Dg15TrN3B-NL0c,1977
51
53
  devflow_cli/schemas/activity-result-v1.schema.json,sha256=UybTDGJcPZ_CJoD0oAlgBWJOEsQTp06KRnogoa1pFFY,3140
52
54
  devflow_cli/schemas/assessment-v3.schema.json,sha256=aCybk-TaE5aJDKA2dhadSq58a-t2hFy6A-hCDlIl2Yo,1798
@@ -83,8 +85,8 @@ devflow_cli/resources/templates/contracts/api-template.md,sha256=rC5v7JBz71PznCn
83
85
  devflow_cli/resources/templates/contracts/components-template.md,sha256=XIWfEXfC72sNmL5OA3V-7xIywkNDU2DBPLfyiagOWcs,484
84
86
  devflow_cli/resources/templates/contracts/interfaces-template.md,sha256=kf8B7b6HsmN7jyRlLNU7RnZl0rTMlI5s97yVRyI-6yc,336
85
87
  devflow_cli/resources/templates/contracts/services-template.md,sha256=cFafKCyLmq_a7aR7G0LXAaMO2z4Fuflq6iKdxfVg0vg,477
86
- devflow_cli-6.0.0.dist-info/METADATA,sha256=yXyFrd52FZgip0slqbh31DWipOfDBVyrcAacMLRSK5s,4061
87
- devflow_cli-6.0.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
88
- devflow_cli-6.0.0.dist-info/entry_points.txt,sha256=GVJ6bMvFpqyO5589iC4nFKeGTpGIW8aKn_QPPpSZ6kU,48
89
- devflow_cli-6.0.0.dist-info/licenses/LICENSE,sha256=v79PvnmqOdKhQiOa_QcWxng4q8GuGscLOwi7ygUpwpo,1070
90
- devflow_cli-6.0.0.dist-info/RECORD,,
88
+ devflow_cli-6.1.1.dist-info/METADATA,sha256=KHMl28FYPV7eUHT9Hn6GH4VwneeUhK_H8Zb9NaD5Kqs,4061
89
+ devflow_cli-6.1.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
90
+ devflow_cli-6.1.1.dist-info/entry_points.txt,sha256=GVJ6bMvFpqyO5589iC4nFKeGTpGIW8aKn_QPPpSZ6kU,48
91
+ devflow_cli-6.1.1.dist-info/licenses/LICENSE,sha256=v79PvnmqOdKhQiOa_QcWxng4q8GuGscLOwi7ygUpwpo,1070
92
+ devflow_cli-6.1.1.dist-info/RECORD,,