devflow-cli 10.1.1__py3-none-any.whl → 10.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 = "10.1.1"
3
+ VERSION = "10.2.0"
4
4
 
5
5
  ACTIVITY_LINEAR_MAPPING = {
6
6
  "assess": "Backlog",
devflow_cli/cli.py CHANGED
@@ -16,6 +16,7 @@ from devflow_cli.commands.feature import feature
16
16
  from devflow_cli.commands.upgrade_cmd import upgrade
17
17
  from devflow_cli.commands import adaptive
18
18
  from devflow_cli.commands import feedback
19
+ from devflow_cli.commands.report import report
19
20
  from devflow_cli.commands.docs_sync import docs_sync
20
21
  from devflow_cli.commands.assess import assess
21
22
 
@@ -32,7 +33,7 @@ class DevflowGroup(TyperGroup):
32
33
 
33
34
  app = typer.Typer(
34
35
  name="devflow",
35
- help="Spec-Driven Development workflow CLI",
36
+ help="Workflow adaptatif pour Claude Code : profils, preuves et contrats d'activité",
36
37
  no_args_is_help=True,
37
38
  cls=DevflowGroup,
38
39
  )
@@ -49,7 +50,7 @@ def main(
49
50
  version: bool = typer.Option(False, "--version", callback=version_callback, is_eager=True, help="Affiche la version"),
50
51
  verbose: bool = typer.Option(False, "--verbose", "-v", help="Active les logs dans stderr"),
51
52
  ) -> None:
52
- """devflow — Spec-Driven Development workflow CLI."""
53
+ """devflow — Workflow adaptatif pour Claude Code : profils, preuves et contrats d'activité."""
53
54
  setup_logging(verbose=verbose)
54
55
 
55
56
 
@@ -60,6 +61,7 @@ app.command()(feature)
60
61
  app.command()(upgrade)
61
62
  app.add_typer(adaptive.app, name="adaptive")
62
63
  app.add_typer(feedback.app, name="feedback")
64
+ app.command()(report)
63
65
  app.command(name="docs-sync")(docs_sync)
64
66
  app.command()(assess)
65
67
 
@@ -58,6 +58,7 @@ from devflow_cli.core.worklog import (
58
58
  safe_refresh_worklog,
59
59
  )
60
60
  from devflow_cli.core.telemetry import append_record, telemetry_enabled, usage_payload
61
+ from devflow_cli.core.decisions import bind_state, decision, note_refusal, note_run_id
61
62
  from devflow_cli.core.remediation import (
62
63
  discard_attempt,
63
64
  reopen_activity,
@@ -239,9 +240,10 @@ def complete(
239
240
  ) -> None:
240
241
  """Complete l'activite courante si ses gates sont satisfaites."""
241
242
  feature_dir = _feature(feature)
242
- with workflow_lock(feature_dir):
243
+ with decision("complete", feature_dir), workflow_lock(feature_dir):
243
244
  refuse_if_agent_running(feature_dir)
244
245
  state = _state(feature_dir)
246
+ bind_state(state)
245
247
  activity = activity or state.currentActivity
246
248
  try:
247
249
  if activity != state.currentActivity:
@@ -299,12 +301,25 @@ def run_adaptive(
299
301
  if until is not None:
300
302
  _run_until(feature_dir, timeout, permission_mode, until)
301
303
  return
304
+ with decision("run", feature_dir, json_output=json_output):
305
+ _run_activity(feature, feature_dir, timeout, permission_mode, json_output)
306
+
307
+
308
+ def _run_activity(
309
+ feature: str,
310
+ feature_dir: Path,
311
+ timeout: int,
312
+ permission_mode: Literal["auto", "acceptEdits", "manual"],
313
+ json_output: bool,
314
+ ) -> None:
315
+ """Une activite de `adaptive run`, par agent ou par verification locale."""
302
316
  project_root = project_root_for_feature(feature_dir)
303
317
  feature_path = feature_dir.relative_to(project_root).as_posix()
304
318
  with workflow_lock(feature_dir):
305
319
  current_state = _state(
306
320
  feature_dir, json_output=json_output, issue_id=feature
307
321
  )
322
+ bind_state(current_state)
308
323
  if current_state.currentActivity == "verify":
309
324
  _execute_local_verification(
310
325
  feature_dir, project_root, current_state.issueId, json_output=json_output
@@ -315,6 +330,7 @@ def run_adaptive(
315
330
  "tasks", "preflight-evidence", "implement", "review", "rollback-check",
316
331
  }:
317
332
  detail = f"L'activite '{current_state.currentActivity}' ne necessite pas d'agent."
333
+ note_refusal("unsupported-activity")
318
334
  if json_output:
319
335
  _print_execution_json(
320
336
  current_state.issueId, current_state.currentActivity, False,
@@ -407,6 +423,8 @@ def run_adaptive(
407
423
  )
408
424
 
409
425
  outcome = run_current_activity(feature_dir, feature_path, execute, agent=agent)
426
+ if attempt.get("execution") is not None:
427
+ note_run_id(run_id)
410
428
  _record_telemetry(
411
429
  run_id, attempt, agent, project_root, outcome.reason,
412
430
  json_output=json_output,
@@ -429,6 +447,7 @@ def run_adaptive(
429
447
  return
430
448
  if outcome.reason != "agent-running": # worklog.md : fichier d'execution (B4)
431
449
  safe_refresh_worklog(feature_dir)
450
+ note_refusal(outcome.reason)
432
451
  exit_code = exit_code_for_run_reason(outcome.reason)
433
452
  execution = attempt.get("execution")
434
453
  recovery = (
@@ -568,6 +587,21 @@ def reconcile_adaptive(
568
587
  fail("--reason ne peut pas etre vide")
569
588
  raise typer.Exit(int(ExitCode.USAGE))
570
589
 
590
+ with decision("reconcile", feature_dir, state):
591
+ _reconcile_activity(
592
+ feature_dir, feature_path, state, result, reason, corrected_payload
593
+ )
594
+
595
+
596
+ def _reconcile_activity(
597
+ feature_dir: Path,
598
+ feature_path: str,
599
+ state: AdaptiveWorkflowState,
600
+ result: Path | None,
601
+ reason: str | None,
602
+ corrected_payload: object | None,
603
+ ) -> None:
604
+ """Reconciliation de l'activite courante, une fois les options validees."""
571
605
  missing_output: dict[str, str] = {}
572
606
 
573
607
  def recover(agent: str, raw_output: str) -> object:
@@ -597,6 +631,7 @@ def reconcile_adaptive(
597
631
  _print_pending_approvals(feature_dir, state.issueId, outcome.activity)
598
632
  remove_result_template(state.issueId, outcome.activity)
599
633
  return
634
+ note_refusal(outcome.reason)
600
635
  if outcome.reason != "agent-running": # worklog.md : fichier d'execution (B4)
601
636
  safe_refresh_worklog(feature_dir)
602
637
  if (
@@ -665,28 +700,30 @@ def verify_adaptive(
665
700
  """Genere localement les preuves requises par le profil actif."""
666
701
  feature_dir = _feature(feature, json_output=json_output)
667
702
  state = _state(feature_dir, json_output=json_output, issue_id=feature)
668
- with workflow_lock(feature_dir):
669
- refuse_if_agent_running(
670
- feature_dir, json_issue_id=state.issueId if json_output else None
671
- )
672
- if not refresh and state.currentActivity != "verify":
673
- detail = (
674
- "Activite courante attendue pour verify : verify "
675
- f"(actuelle : {state.currentActivity})."
676
- )
677
- if json_output:
678
- _print_execution_json(
679
- state.issueId, state.currentActivity, False, "wrong-activity",
680
- detail, state.currentActivity, ExitCode.INVALID_CONTRACT,
703
+ with decision("verify", feature_dir, state, json_output=json_output):
704
+ with workflow_lock(feature_dir):
705
+ refuse_if_agent_running(
706
+ feature_dir, json_issue_id=state.issueId if json_output else None
681
707
  )
682
- else:
683
- fail(detail)
684
- console.print(f" Suite : {_next_command(state, feature_dir)}")
685
- raise typer.Exit(int(ExitCode.INVALID_CONTRACT))
686
- _execute_local_verification(
687
- feature_dir, project_root_for_feature(feature_dir), state.issueId,
688
- refresh=refresh, json_output=json_output,
689
- )
708
+ if not refresh and state.currentActivity != "verify":
709
+ detail = (
710
+ "Activite courante attendue pour verify : verify "
711
+ f"(actuelle : {state.currentActivity})."
712
+ )
713
+ note_refusal("wrong-activity")
714
+ if json_output:
715
+ _print_execution_json(
716
+ state.issueId, state.currentActivity, False, "wrong-activity",
717
+ detail, state.currentActivity, ExitCode.INVALID_CONTRACT,
718
+ )
719
+ else:
720
+ fail(detail)
721
+ console.print(f" Suite : {_next_command(state, feature_dir)}")
722
+ raise typer.Exit(int(ExitCode.INVALID_CONTRACT))
723
+ _execute_local_verification(
724
+ feature_dir, project_root_for_feature(feature_dir), state.issueId,
725
+ refresh=refresh, json_output=json_output,
726
+ )
690
727
 
691
728
 
692
729
  @app.command("approve-command")
@@ -741,9 +778,10 @@ def reopen(
741
778
  ) -> None:
742
779
  """Revient a une activite anterieure sans effacer l'historique."""
743
780
  feature_dir = _feature(feature)
744
- with workflow_lock(feature_dir):
781
+ with decision("reopen", feature_dir), workflow_lock(feature_dir):
745
782
  refuse_if_agent_running(feature_dir)
746
783
  state = _state(feature_dir)
784
+ bind_state(state)
747
785
  try:
748
786
  entry = reopen_activity(
749
787
  feature_dir, state, activity, reason, author=author
@@ -771,9 +809,10 @@ def discard_attempt_command(
771
809
  ) -> None:
772
810
  """Abandonne la tentative de l'activite courante, avec archive et audit."""
773
811
  feature_dir = _feature(feature)
774
- with workflow_lock(feature_dir):
812
+ with decision("discard-attempt", feature_dir), workflow_lock(feature_dir):
775
813
  refuse_if_agent_running(feature_dir)
776
814
  state = _state(feature_dir)
815
+ bind_state(state)
777
816
  try:
778
817
  entry = discard_attempt(feature_dir, state, reason, author=author)
779
818
  except (OSError, ValueError) as exc:
@@ -857,6 +896,8 @@ def _execute_local_verification(
857
896
  if not refresh:
858
897
  sync_activity_status(issue_id, "verify", stream)
859
898
  outcome = run_automatic_verification(feature_dir, project_root, refresh=refresh)
899
+ if not outcome.passed:
900
+ note_refusal(outcome.reason)
860
901
  recovery = (
861
902
  None if outcome.passed
862
903
  else _verify_recovery(feature_dir, issue_id, outcome, refresh=refresh)
@@ -1028,6 +1069,7 @@ def refuse_if_agent_running(feature_dir: Path, *, json_issue_id: str | None = No
1028
1069
  try:
1029
1070
  warning = ensure_no_agent_running(feature_dir)
1030
1071
  except AgentRunningError as exc:
1072
+ note_refusal("agent-running")
1031
1073
  if json_issue_id is not None:
1032
1074
  _print_execution_json(
1033
1075
  json_issue_id, None, False, "agent-running", str(exc), None,
@@ -1067,6 +1109,8 @@ def _print_execution_json(
1067
1109
  detail: str, next_activity: str | None, exit_code: ExitCode,
1068
1110
  *, warnings: tuple[str, ...] = (), recovery: dict | None = None,
1069
1111
  ) -> None:
1112
+ if not passed:
1113
+ note_refusal(reason)
1070
1114
  payload = {
1071
1115
  "schemaVersion": 1,
1072
1116
  "issueId": issue_id,
@@ -0,0 +1,324 @@
1
+ """devflow report — Restitue coûts, refus et reprises des parcours.
2
+
3
+ Lecture seule de `telemetry.jsonl`, `feedback.jsonl` et `decisions.jsonl` ; les
4
+ dossiers de feature ne sont jamais lus.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import statistics
11
+ from datetime import datetime
12
+ from typing import Any, Optional
13
+
14
+ import typer
15
+ from rich.markup import escape
16
+
17
+ from devflow_cli.core.decisions import read_decisions
18
+ from devflow_cli.core.feedback import MIN_PREFIX, read_feedback, resolutions, strip_controls
19
+ from devflow_cli.core.telemetry import read_records
20
+ from devflow_cli.utils.console import console, fail, make_table
21
+
22
+ NO_ACTIVITY = "sans activité"
23
+
24
+
25
+ def _plain(text: object) -> str:
26
+ """Texte issu des fichiers : sans controle ni balisage rich."""
27
+ return escape(strip_controls(str(text)))
28
+
29
+
30
+ def _amount(value: float) -> float:
31
+ return round(value, 2)
32
+
33
+
34
+ def _median(values: list[float]) -> float | None:
35
+ return _amount(statistics.median(values)) if values else None
36
+
37
+
38
+ def _keep(
39
+ record: dict[str, Any], since: str | None, project: str | None, issue: str | None
40
+ ) -> bool:
41
+ if since is not None and str(record.get("recordedAt") or "")[:10] < since:
42
+ return False
43
+ if project and project not in str(record.get("project") or ""):
44
+ return False
45
+ if issue is not None and record.get("issueId") != issue:
46
+ return False
47
+ return True
48
+
49
+
50
+ def _path(record: dict[str, Any]) -> tuple[str, str]:
51
+ return str(record.get("project") or ""), str(record.get("issueId") or "")
52
+
53
+
54
+ def _cost(record: dict[str, Any]) -> float | None:
55
+ cost = (record.get("usage") or {}).get("costUsd")
56
+ return float(cost) if isinstance(cost, (int, float)) else None
57
+
58
+
59
+ def _sum_costs(records: list[dict[str, Any]]) -> float:
60
+ return sum(cost for cost in map(_cost, records) if cost is not None)
61
+
62
+
63
+ def _by_path(records: list[dict[str, Any]]) -> dict[tuple[str, str], list[dict[str, Any]]]:
64
+ paths: dict[tuple[str, str], list[dict[str, Any]]] = {}
65
+ for record in sorted(records, key=lambda item: str(item.get("recordedAt") or "")):
66
+ paths.setdefault(_path(record), []).append(record)
67
+ return paths
68
+
69
+
70
+ def profiles_section(telemetry: list[dict[str, Any]]) -> list[dict[str, Any]]:
71
+ """Par profil : parcours, cout median et maximal, minutes agent medianes."""
72
+ grouped: dict[str, list[list[dict[str, Any]]]] = {}
73
+ for records in _by_path(telemetry).values():
74
+ profile = str(records[-1].get("profile") or "?")
75
+ grouped.setdefault(profile, []).append(records)
76
+ rows = []
77
+ for profile in sorted(grouped):
78
+ paths = grouped[profile]
79
+ costs = [
80
+ _sum_costs(records) for records in paths
81
+ if any(_cost(record) is not None for record in records)
82
+ ]
83
+ minutes = [
84
+ sum(float(record.get("durationSeconds") or 0) for record in records) / 60
85
+ for records in paths
86
+ ]
87
+ rows.append({
88
+ "profile": profile,
89
+ "paths": len(paths),
90
+ "medianCostUsd": _median(costs),
91
+ "maxCostUsd": _amount(max(costs)) if costs else None,
92
+ "medianAgentMinutes": _median(minutes),
93
+ })
94
+ return rows
95
+
96
+
97
+ def activities_section(telemetry: list[dict[str, Any]]) -> list[dict[str, Any]]:
98
+ """Par activite : tentatives, refus, taux de refus, cout des tentatives refusees."""
99
+ grouped: dict[str, list[dict[str, Any]]] = {}
100
+ for record in telemetry:
101
+ grouped.setdefault(str(record.get("activity") or "?"), []).append(record)
102
+ rows = []
103
+ for activity in sorted(grouped):
104
+ records = grouped[activity]
105
+ refused = [record for record in records if record.get("advanced") is not True]
106
+ rows.append({
107
+ "activity": activity,
108
+ "attempts": len(records),
109
+ "refused": len(refused),
110
+ "refusalRatePercent": _amount(100 * len(refused) / len(records)),
111
+ "refusedCostUsd": _amount(_sum_costs(refused)),
112
+ })
113
+ return rows
114
+
115
+
116
+ def retries_section(telemetry: list[dict[str, Any]]) -> list[dict[str, Any]]:
117
+ """Parcours ayant rejoue une activite : tentatives supplementaires et leur cout."""
118
+ rows = []
119
+ for (project, issue), records in sorted(_by_path(telemetry).items()):
120
+ by_activity: dict[str, list[dict[str, Any]]] = {}
121
+ for record in records:
122
+ by_activity.setdefault(str(record.get("activity") or "?"), []).append(record)
123
+ replayed = {name: items for name, items in by_activity.items() if len(items) >= 2}
124
+ if not replayed:
125
+ continue
126
+ extra = [record for items in replayed.values() for record in items[1:]]
127
+ rows.append({
128
+ "project": project,
129
+ "issueId": issue,
130
+ "activities": sorted(replayed),
131
+ "extraAttempts": len(extra),
132
+ "extraCostUsd": _amount(_sum_costs(extra)),
133
+ })
134
+ return rows
135
+
136
+
137
+ def decisions_section(
138
+ decisions: list[dict[str, Any]], telemetry: list[dict[str, Any]]
139
+ ) -> dict[str, Any]:
140
+ """Refus par code (avec ou sans agent), decisions par parcours, delai median."""
141
+ run_ids = {record.get("runId") for record in telemetry if record.get("runId")}
142
+ by_reason: dict[str, dict[str, int]] = {}
143
+ for record in decisions:
144
+ if record.get("outcome") != "refused":
145
+ continue
146
+ counts = by_reason.setdefault(
147
+ str(record.get("reason") or "?"), {"refused": 0, "withAgent": 0}
148
+ )
149
+ counts["refused"] += 1
150
+ if record.get("runId") in run_ids:
151
+ counts["withAgent"] += 1
152
+ reasons = [
153
+ {
154
+ "reason": reason,
155
+ "refused": counts["refused"],
156
+ "withAgent": counts["withAgent"],
157
+ "withoutAgent": counts["refused"] - counts["withAgent"],
158
+ }
159
+ for reason, counts in sorted(by_reason.items(), key=lambda item: (-item[1]["refused"], item[0]))
160
+ ]
161
+ paths = []
162
+ gaps: list[float] = []
163
+ for (project, issue), records in sorted(_by_path(decisions).items()):
164
+ paths.append({
165
+ "project": project,
166
+ "issueId": issue,
167
+ "decisions": len(records),
168
+ "refused": sum(1 for record in records if record.get("outcome") == "refused"),
169
+ })
170
+ moments = [_parse_time(record.get("recordedAt")) for record in records]
171
+ known = [moment for moment in moments if moment is not None]
172
+ gaps.extend(
173
+ (later - earlier).total_seconds() / 60
174
+ for earlier, later in zip(known, known[1:])
175
+ )
176
+ return {
177
+ "byReason": reasons,
178
+ "byPath": paths,
179
+ "medianGapMinutes": _median(gaps),
180
+ }
181
+
182
+
183
+ def frictions_section(feedback: list[dict[str, Any]], since, project, issue) -> list[dict[str, Any]]:
184
+ """Frictions ouvertes, groupees par activite ; « sans activite » a defaut."""
185
+ treated = resolutions(feedback)
186
+ groups: dict[str, list[dict[str, Any]]] = {}
187
+ for entry in feedback:
188
+ if entry.get("kind") != "entry" or entry.get("type") != "friction":
189
+ continue
190
+ if entry["id"] in treated or not _keep(entry, since, project, issue):
191
+ continue
192
+ activity = entry.get("activity")
193
+ key = activity if isinstance(activity, str) and activity else NO_ACTIVITY
194
+ groups.setdefault(key, []).append({
195
+ "id": entry["id"][:MIN_PREFIX],
196
+ "issueId": entry.get("issueId"),
197
+ "severity": entry.get("severity"),
198
+ "what": strip_controls(str(entry.get("what") or "")),
199
+ })
200
+ ordered = sorted(groups, key=lambda key: (key == NO_ACTIVITY, key))
201
+ return [
202
+ {"activity": key, "count": len(groups[key]), "entries": groups[key]}
203
+ for key in ordered
204
+ ]
205
+
206
+
207
+ def _parse_time(value: object) -> datetime | None:
208
+ try:
209
+ return datetime.strptime(str(value), "%Y-%m-%dT%H:%M:%SZ")
210
+ except ValueError:
211
+ return None
212
+
213
+
214
+ def build_report(
215
+ since: str | None = None, project: str | None = None, issue: str | None = None
216
+ ) -> dict[str, Any]:
217
+ telemetry = [r for r in read_records() if _keep(r, since, project, issue)]
218
+ decisions = [r for r in read_decisions() if _keep(r, since, project, issue)]
219
+ return {
220
+ "profiles": profiles_section(telemetry),
221
+ "activities": activities_section(telemetry),
222
+ "retries": retries_section(telemetry),
223
+ "decisions": decisions_section(decisions, telemetry),
224
+ "frictions": frictions_section(read_feedback(), since, project, issue),
225
+ }
226
+
227
+
228
+ def _money(value: float | None) -> str:
229
+ return "-" if value is None else f"{value:.2f} $"
230
+
231
+
232
+ def _number(value: float | None) -> str:
233
+ return "-" if value is None else f"{value:.2f}"
234
+
235
+
236
+ def _print_table(title: str, columns: tuple[str, ...], rows: list[list[str]]) -> None:
237
+ if not rows:
238
+ console.print(f"{title} : aucune donnée.", highlight=False)
239
+ return
240
+ table = make_table(*columns, title=title)
241
+ for row in rows:
242
+ table.add_row(*(_plain(cell) for cell in row))
243
+ console.print(table)
244
+
245
+
246
+ def _print_report(report: dict[str, Any]) -> None:
247
+ _print_table(
248
+ "Profils", ("Profil", "Parcours", "Coût médian", "Coût max", "Minutes agent"),
249
+ [
250
+ [row["profile"], str(row["paths"]), _money(row["medianCostUsd"]),
251
+ _money(row["maxCostUsd"]), _number(row["medianAgentMinutes"])]
252
+ for row in report["profiles"]
253
+ ],
254
+ )
255
+ _print_table(
256
+ "Activités", ("Activité", "Tentatives", "Refus", "Taux", "Coût refusé"),
257
+ [
258
+ [row["activity"], str(row["attempts"]), str(row["refused"]),
259
+ f"{row['refusalRatePercent']:.2f} %", _money(row["refusedCostUsd"])]
260
+ for row in report["activities"]
261
+ ],
262
+ )
263
+ _print_table(
264
+ "Reprises", ("Projet", "Issue", "Activités", "Tentatives +", "Coût +"),
265
+ [
266
+ [row["project"], row["issueId"], ", ".join(row["activities"]),
267
+ str(row["extraAttempts"]), _money(row["extraCostUsd"])]
268
+ for row in report["retries"]
269
+ ],
270
+ )
271
+ decisions = report["decisions"]
272
+ _print_table(
273
+ "Décisions : refus par code", ("Code", "Refus", "Avec agent", "Sans agent"),
274
+ [
275
+ [row["reason"], str(row["refused"]), str(row["withAgent"]),
276
+ str(row["withoutAgent"])]
277
+ for row in decisions["byReason"]
278
+ ],
279
+ )
280
+ _print_table(
281
+ "Décisions par parcours", ("Projet", "Issue", "Décisions", "Refus"),
282
+ [
283
+ [row["project"], row["issueId"], str(row["decisions"]), str(row["refused"])]
284
+ for row in decisions["byPath"]
285
+ ],
286
+ )
287
+ console.print(
288
+ "Délai médian entre deux décisions (min) : "
289
+ f"{_number(decisions['medianGapMinutes'])}",
290
+ highlight=False,
291
+ )
292
+ _print_table(
293
+ "Frictions ouvertes", ("Activité", "Id", "Issue", "Gravité", "Constat"),
294
+ [
295
+ [group["activity"], entry["id"], entry["issueId"] or "-",
296
+ entry["severity"] or "-", entry["what"]]
297
+ for group in report["frictions"]
298
+ for entry in group["entries"]
299
+ ],
300
+ )
301
+
302
+
303
+ def report(
304
+ since: Optional[str] = typer.Option(
305
+ None, "--since", help="Date UTC incluse (AAAA-MM-JJ), sur recordedAt"
306
+ ),
307
+ project: Optional[str] = typer.Option(
308
+ None, "--project", help="Ne garder que les projets dont le chemin contient ce texte"
309
+ ),
310
+ issue: Optional[str] = typer.Option(None, "--issue", help="Issue du parcours"),
311
+ json_output: bool = typer.Option(False, "--json", help="Sortie JSON"),
312
+ ) -> None:
313
+ """Restitue coûts, refus et reprises des parcours (télémétrie, retours, décisions)."""
314
+ if since is not None:
315
+ try:
316
+ since = datetime.strptime(since, "%Y-%m-%d").date().isoformat()
317
+ except ValueError as exc:
318
+ fail(f"--since invalide : {_plain(since)} (format attendu AAAA-MM-JJ)")
319
+ raise typer.Exit(2) from exc
320
+ data = build_report(since, project, issue)
321
+ if json_output:
322
+ typer.echo(json.dumps(data, ensure_ascii=False))
323
+ return
324
+ _print_report(data)
@@ -0,0 +1,192 @@
1
+ """Journal des decisions des commandes adaptatives (`decisions.jsonl`).
2
+
3
+ Chaque terminaison de `adaptive run`, `reconcile`, `reopen`, `verify`, `complete`
4
+ et `discard-attempt`, qu'elle avance ou refuse, ajoute une ligne a
5
+ `decisions.jsonl`, a cote de `telemetry.jsonl` (meme `DEVFLOW_HOME`, meme
6
+ interrupteur `DEVFLOW_TELEMETRY=0`). Une ligne ne porte aucun texte libre : le code
7
+ de refus, l'activite et le `runId` de la tentative agent, qui la relie a la
8
+ telemetrie. Une erreur d'ecriture produit un avertissement et ne change jamais le
9
+ code de sortie de la commande.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from contextlib import contextmanager
16
+ from contextvars import ContextVar
17
+ from dataclasses import dataclass
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+ from typing import Any, Iterator
21
+
22
+ import typer
23
+
24
+ from devflow_cli import VERSION
25
+ from devflow_cli.core.adaptive_workflow import read_adaptive_state
26
+ from devflow_cli.core.operational import ExitCode
27
+ from devflow_cli.core.telemetry import append_record, telemetry_enabled, telemetry_file
28
+ from devflow_cli.utils.console import warn
29
+ from devflow_cli.utils.paths import project_root_for_feature
30
+
31
+ DECISIONS_SCHEMA_VERSION = 1
32
+ DECISION_COMMANDS = (
33
+ "run", "reconcile", "reopen", "verify", "complete", "discard-attempt",
34
+ )
35
+ DECISION_OUTCOMES = ("advanced", "refused")
36
+ DECISION_FIELDS = (
37
+ "schemaVersion", "recordedAt", "cliVersion", "project", "issueId", "command",
38
+ "activity", "outcome", "reason", "runId",
39
+ )
40
+
41
+
42
+ def decisions_file() -> Path:
43
+ return telemetry_file().parent / "decisions.jsonl"
44
+
45
+
46
+ def build_decision(
47
+ *,
48
+ project: str,
49
+ issue_id: str,
50
+ command: str,
51
+ activity: str | None,
52
+ outcome: str,
53
+ reason: str | None,
54
+ run_id: str | None,
55
+ ) -> dict[str, Any]:
56
+ """Ligne du journal ; `reason` est nul pour une decision qui avance."""
57
+ return {
58
+ "schemaVersion": DECISIONS_SCHEMA_VERSION,
59
+ "recordedAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
60
+ "cliVersion": VERSION,
61
+ "project": project,
62
+ "issueId": issue_id,
63
+ "command": command,
64
+ "activity": activity,
65
+ "outcome": outcome,
66
+ "reason": reason if outcome == "refused" else None,
67
+ "runId": run_id,
68
+ }
69
+
70
+
71
+ def read_decisions(path: Path | None = None) -> list[dict[str, Any]]:
72
+ """Lit les decisions en ignorant les lignes illisibles ou d'un autre schema."""
73
+ target = path or decisions_file()
74
+ if not target.is_file():
75
+ return []
76
+ records: list[dict[str, Any]] = []
77
+ for line in target.read_text(encoding="utf-8", errors="replace").splitlines():
78
+ try:
79
+ record = json.loads(line)
80
+ except ValueError:
81
+ continue
82
+ if (
83
+ isinstance(record, dict)
84
+ and record.get("schemaVersion") == DECISIONS_SCHEMA_VERSION
85
+ and all(field in record for field in DECISION_FIELDS)
86
+ ):
87
+ records.append(record)
88
+ return records
89
+
90
+
91
+ @dataclass
92
+ class _Scope:
93
+ command: str
94
+ feature_dir: Path
95
+ json_output: bool
96
+ issue_id: str | None = None
97
+ activity: str | None = None
98
+ reason: str | None = None
99
+ run_id: str | None = None
100
+
101
+
102
+ _CURRENT: ContextVar[_Scope | None] = ContextVar("devflow_decision_scope", default=None)
103
+
104
+
105
+ def note_refusal(reason: str) -> None:
106
+ """Retient le code du refus en cours ; sans effet hors d'une decision."""
107
+ scope = _CURRENT.get()
108
+ if scope is not None and reason:
109
+ scope.reason = reason
110
+
111
+
112
+ def note_run_id(run_id: str) -> None:
113
+ """Retient l'identifiant de la tentative agent, celui de la ligne de telemetrie."""
114
+ scope = _CURRENT.get()
115
+ if scope is not None:
116
+ scope.run_id = run_id
117
+
118
+
119
+ def _fallback_reason(exit_code: int) -> str:
120
+ """Refus sans code emis : nom du code de sortie, en kebab-case (`blocked`)."""
121
+ try:
122
+ return ExitCode(exit_code).name.lower().replace("_", "-")
123
+ except ValueError:
124
+ return f"exit-{exit_code}"
125
+
126
+
127
+ @contextmanager
128
+ def decision(
129
+ command: str, feature_dir: Path, state=None, *, json_output: bool = False
130
+ ) -> Iterator[None]:
131
+ """Consigne la terminaison de la commande, qu'elle avance ou refuse.
132
+
133
+ A armer apres les controles d'usage : tout ce qui sort du bloc est une decision.
134
+ Un `typer.Exit` de code 0 ou un retour normal avance ; un autre code refuse, avec
135
+ le code retenu par `note_refusal`, a defaut celui derive du code de sortie. Sans
136
+ etat lisible (etat invalide ou d'un format anterieur), rien n'est consigne.
137
+ """
138
+ scope = _Scope(command, feature_dir, json_output)
139
+ if state is not None:
140
+ scope.issue_id, scope.activity = state.issueId, state.currentActivity
141
+ token = _CURRENT.set(scope)
142
+ try:
143
+ yield
144
+ except typer.Exit as exc:
145
+ code = int(exc.exit_code or 0)
146
+ if code == 0:
147
+ _record(scope, "advanced", None)
148
+ else:
149
+ _record(scope, "refused", scope.reason or _fallback_reason(code))
150
+ raise
151
+ else:
152
+ _record(scope, "advanced", None)
153
+ finally:
154
+ _CURRENT.reset(token)
155
+
156
+
157
+ def bind_state(state) -> None:
158
+ """Fixe l'issue et l'activite courante une fois l'etat lu."""
159
+ scope = _CURRENT.get()
160
+ if scope is not None:
161
+ scope.issue_id, scope.activity = state.issueId, state.currentActivity
162
+
163
+
164
+ def _record(scope: _Scope, outcome: str, reason: str | None) -> None:
165
+ if not telemetry_enabled():
166
+ return
167
+ if scope.issue_id is None:
168
+ try:
169
+ state = read_adaptive_state(scope.feature_dir / "workflow-state.json")
170
+ except Exception: # noqa: BLE001 - etat anterieur ou illisible : pas une decision
171
+ return
172
+ if state is None:
173
+ return
174
+ scope.issue_id, scope.activity = state.issueId, state.currentActivity
175
+ try:
176
+ project = str(project_root_for_feature(scope.feature_dir))
177
+ except ValueError:
178
+ return
179
+ record = build_decision(
180
+ project=project,
181
+ issue_id=scope.issue_id,
182
+ command=scope.command,
183
+ activity=scope.activity,
184
+ outcome=outcome,
185
+ reason=reason,
186
+ run_id=scope.run_id,
187
+ )
188
+ try:
189
+ append_record(record, decisions_file())
190
+ except OSError as exc:
191
+ if not scope.json_output:
192
+ warn(f"Decision non enregistree : {exc}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: devflow-cli
3
- Version: 10.1.1
3
+ Version: 10.2.0
4
4
  Summary: Workflow adaptatif pour Claude Code : profils, preuves et contrats d'activité
5
5
  Project-URL: Homepage, https://github.com/sopequenoteck/devflow
6
6
  Project-URL: Repository, https://github.com/sopequenoteck/devflow
@@ -122,6 +122,10 @@ Chaque refus affiche son motif et la commande de reprise recommandée.
122
122
  - En fin de parcours, terminé ou abandonné : un bilan avec
123
123
  `devflow feedback add --type bilan`.
124
124
 
125
+ `devflow report` restitue, tous parcours confondus, les coûts par profil et
126
+ par activité, les refus de la CLI avec ou sans agent, les reprises et les
127
+ frictions ouvertes.
128
+
125
129
  ## Profils et garde-fous
126
130
 
127
131
  Le profil fixe la route documentaire :
@@ -1,16 +1,17 @@
1
- devflow_cli/__init__.py,sha256=NAGGk9OBe8YXw8PKPqglUP-Ky2xGiIPf0Ine3TMUXl0,871
2
- devflow_cli/cli.py,sha256=DVLx3wr1Hulke2qxhgHsEvvprRuO7SVETui_VGPNQv4,1983
1
+ devflow_cli/__init__.py,sha256=cjEP4qJtRxR_uhuY3EcJ5DiGTgPfDzvMh7foXSZR6DU,871
2
+ devflow_cli/cli.py,sha256=QQktLkHK1cEAj0ZlEuGKYumm3xJUHM-aeC-E2RgfcEY,2136
3
3
  devflow_cli/adapters/__init__.py,sha256=z1J7_YJHcgak2a6dTZpOUKKwf-K7UwRqGzS8XY9duC0,299
4
4
  devflow_cli/adapters/agents.py,sha256=-9zmNlFmQfP2pOGxA3_SSKij8ScR76J4-sgv4GMBWEI,18530
5
5
  devflow_cli/adapters/linear.py,sha256=xHKjMuL3fFJEDBF65pPJ-yWHpXom999UAU2A0C7_QeI,8362
6
6
  devflow_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
- devflow_cli/commands/adaptive.py,sha256=jogZ_NE0EnuGOzU-w42HM7IxUiF2AYARZWS40HKMkLU,63295
7
+ devflow_cli/commands/adaptive.py,sha256=PO_JW1KwNrb-Jozib-NPB21HCIeG5i0iPgYX5WBUuS4,64999
8
8
  devflow_cli/commands/assess.py,sha256=V-E3ft41ljCrqp2WLjR1Dqw-JtswcqsK2nD1-euNbNE,5655
9
9
  devflow_cli/commands/check.py,sha256=nJOHVFvgT3Nq70g-4QGrSDZVwCSetu6g7snZcfLFf9I,4421
10
10
  devflow_cli/commands/docs_sync.py,sha256=Xc0mulpNTjDyeUDumKL7VNG0eumAf3016wEM_dqXpSU,1021
11
11
  devflow_cli/commands/feature.py,sha256=IkXKdKTNK6QTTzIG3zFu5a13_MMBEsTc1xjvUjdOLVo,5006
12
12
  devflow_cli/commands/feedback.py,sha256=lP-q-gcSuf1--DMLfgEchwmxllpmjs8-Te06p7l0RQU,9314
13
13
  devflow_cli/commands/init_cmd.py,sha256=n1vanuDTt3LtxsYFyeJDetlFWPWbTQLQZ4Vwdx33oV0,3743
14
+ devflow_cli/commands/report.py,sha256=rLzb2CqMqCU5U6Ssw1ab6NdtpaUGdOsY66hZ51H3DVA,12091
14
15
  devflow_cli/commands/status.py,sha256=Votdh_sZkRuC_iui1krrdXE9cZ5dpY2OO2au4PVa7IQ,8647
15
16
  devflow_cli/commands/upgrade_cmd.py,sha256=7GYe1T7SWzkDSYW9x1G-Dznw9qwKT2uI1gJUcWr9_2M,6093
16
17
  devflow_cli/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -23,6 +24,7 @@ devflow_cli/core/agent_run_marker.py,sha256=QW3ScA2ZpqfQdUo5ANTbenDVyY2iHpB_wTaN
23
24
  devflow_cli/core/assessment.py,sha256=ACtJbXq1qZjVfeERjy8Qg6-ioLfSMUBVQm2l-mtGwgI,11522
24
25
  devflow_cli/core/automatic_evidence.py,sha256=pqSnNXe6RHthII6fgoHlCNkGiFTKQ4yyRtErGUwbvas,42559
25
26
  devflow_cli/core/contract_schemas.py,sha256=M0Twbt_SVVfuPa7udC-EJuR3pTl9pWrxQUCohIsg1h0,8477
27
+ devflow_cli/core/decisions.py,sha256=tFYKNDaztMvnhLfVGJKIux5zaSjEPx2nwXs5nb2qv4U,6308
26
28
  devflow_cli/core/docgen.py,sha256=47JoVDWetkcctgHwJZC67AVebOKgl10VB9hZ3Xr63mo,3908
27
29
  devflow_cli/core/feedback.py,sha256=07asmhX1cG4SXkoLlnSjA_UXgYnkFYoxl4u3JmOhpm0,13382
28
30
  devflow_cli/core/git.py,sha256=SsHL0OeJ8Bzc5cJC0moeBuyuwnvrJjtwnaI58TnzUSU,2536
@@ -56,8 +58,8 @@ devflow_cli/utils/strings.py,sha256=bfFXCwBDnFxshgZOYfy6mtysnjiaWO1wFnqBf7HLg4o,
56
58
  devflow_cli/resources/commands/devflow.md,sha256=5MjahTVYXAT_zrjEQmM25KH9s6DbRmyI1TdJCxYlrKk,3464
57
59
  devflow_cli/resources/commands/devflow.status.md,sha256=tqjQ1IboU99ztG6C4nunBHGJCvMn3qDqx3AoF4SPjCk,338
58
60
  devflow_cli/resources/templates/constitution-template.md,sha256=uSzNuwF-xIRx8cHt9gloynRjNgDGmnN5z2Xck6fCOsI,2102
59
- devflow_cli-10.1.1.dist-info/METADATA,sha256=2J_RNk3Db7cbmZbRZxe_NfLeXFOnXtLde9bIRsOec4I,6501
60
- devflow_cli-10.1.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
61
- devflow_cli-10.1.1.dist-info/entry_points.txt,sha256=GVJ6bMvFpqyO5589iC4nFKeGTpGIW8aKn_QPPpSZ6kU,48
62
- devflow_cli-10.1.1.dist-info/licenses/LICENSE,sha256=v79PvnmqOdKhQiOa_QcWxng4q8GuGscLOwi7ygUpwpo,1070
63
- devflow_cli-10.1.1.dist-info/RECORD,,
61
+ devflow_cli-10.2.0.dist-info/METADATA,sha256=aCyvi0qzuPnbCz05ltRmvDrX2GSqjRaqWQ_S8BPLnBo,6674
62
+ devflow_cli-10.2.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
63
+ devflow_cli-10.2.0.dist-info/entry_points.txt,sha256=GVJ6bMvFpqyO5589iC4nFKeGTpGIW8aKn_QPPpSZ6kU,48
64
+ devflow_cli-10.2.0.dist-info/licenses/LICENSE,sha256=v79PvnmqOdKhQiOa_QcWxng4q8GuGscLOwi7ygUpwpo,1070
65
+ devflow_cli-10.2.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.32.4
2
+ Generator: hatchling 1.33.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any