devflow-cli 6.2.0__py3-none-any.whl → 6.3.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 = "6.2.0"
3
+ VERSION = "6.3.0"
4
4
 
5
5
  SUPPORTED_AGENTS = ["claude-code", "codex", "cursor"]
6
6
 
devflow_cli/cli.py CHANGED
@@ -18,6 +18,7 @@ from devflow_cli.commands import extension
18
18
  from devflow_cli.commands import hooks_cmd
19
19
  from devflow_cli.commands import linear
20
20
  from devflow_cli.commands import adaptive
21
+ from devflow_cli.commands import feedback
21
22
  from devflow_cli.commands.docs_sync import docs_sync
22
23
  from devflow_cli.commands.traceability import traceability
23
24
  from devflow_cli.commands.verify import verify
@@ -58,6 +59,7 @@ app.add_typer(extension.app, name="extension")
58
59
  app.add_typer(hooks_cmd.app, name="hooks")
59
60
  app.add_typer(linear.app, name="linear")
60
61
  app.add_typer(adaptive.app, name="adaptive")
62
+ app.add_typer(feedback.app, name="feedback")
61
63
  app.command(name="docs-sync")(docs_sync)
62
64
  app.command()(traceability)
63
65
  app.command()(verify)
@@ -13,6 +13,7 @@ from typing import Literal, Optional
13
13
 
14
14
  import typer
15
15
 
16
+ from devflow_cli import VERSION
16
17
  from devflow_cli.core.adaptive_workflow import (
17
18
  AdaptiveWorkflowState,
18
19
  complete_activity,
@@ -862,6 +863,7 @@ def _record_telemetry(
862
863
  return
863
864
  record = {
864
865
  "schemaVersion": 1,
866
+ "cliVersion": VERSION,
865
867
  "runId": run_id,
866
868
  "recordedAt": _utc_now(),
867
869
  "startedAt": attempt.get("startedAt"),
@@ -0,0 +1,282 @@
1
+ """devflow feedback — Consigne et exploite les retours d'usage de Devflow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any, Optional
8
+
9
+ import typer
10
+ from rich.markup import escape
11
+
12
+ from devflow_cli.core.feedback import (
13
+ FEEDBACK_SCHEMA_VERSION,
14
+ FEEDBACK_TYPES,
15
+ MIN_PREFIX,
16
+ SEVERITIES,
17
+ STATUSES,
18
+ append_feedback,
19
+ build_entry,
20
+ build_resolution,
21
+ feedback_file,
22
+ find_entry,
23
+ group_entries,
24
+ read_feedback,
25
+ render_export,
26
+ resolutions,
27
+ sanitize_text,
28
+ select_entries,
29
+ strip_controls,
30
+ )
31
+ from devflow_cli.utils.console import console, fail, ok
32
+
33
+ app = typer.Typer(
34
+ help="Consigne, trie et exporte les retours d'usage de Devflow.",
35
+ no_args_is_help=True,
36
+ )
37
+
38
+
39
+ def _plain(text: object) -> str:
40
+ """Texte issu du fichier : sans controle ni balisage rich."""
41
+ return escape(strip_controls(str(text)))
42
+
43
+
44
+ def _refuse(message: str) -> typer.Exit:
45
+ fail(_plain(message))
46
+ return typer.Exit(2)
47
+
48
+
49
+ def _values(values: tuple[str, ...]) -> str:
50
+ return ", ".join(values)
51
+
52
+
53
+ def _check_choice(option: str, value: str | None, allowed: tuple[str, ...]) -> None:
54
+ if value is not None and value not in allowed:
55
+ raise _refuse(f"{option} invalide : {value!r} (valeurs admises : {_values(allowed)})")
56
+
57
+
58
+ def _write(record: dict[str, Any]) -> Path:
59
+ try:
60
+ return append_feedback(record)
61
+ except OSError as exc:
62
+ fail(f"Ecriture impossible dans {_plain(feedback_file())} : {_plain(exc)}")
63
+ raise typer.Exit(1) from exc
64
+
65
+
66
+ @app.command()
67
+ def add(
68
+ type_: Optional[str] = typer.Option(
69
+ None, "--type", help=f"Type de retour : {_values(FEEDBACK_TYPES)}"
70
+ ),
71
+ severity: Optional[str] = typer.Option(
72
+ None, "--severity", help=f"Gravite (friction seulement) : {_values(SEVERITIES)}"
73
+ ),
74
+ issue: Optional[str] = typer.Option(
75
+ None, "--issue", help="Issue du parcours (obligatoire pour un bilan)"
76
+ ),
77
+ what: Optional[str] = typer.Option(
78
+ None, "--what", help="Ce qui s'est passe (deroule du parcours pour un bilan)"
79
+ ),
80
+ impact: Optional[str] = typer.Option(
81
+ None, "--impact", help="Pourquoi ca coute (obligatoire pour une friction)"
82
+ ),
83
+ suggestion: Optional[str] = typer.Option(
84
+ None, "--suggestion", help="Piste (obligatoire pour une friction)"
85
+ ),
86
+ weighed: Optional[str] = typer.Option(
87
+ None, "--weighed", help="Activites qui ont pese (bilan seulement)"
88
+ ),
89
+ went_well: Optional[str] = typer.Option(
90
+ None, "--went-well", help="Ce qui a bien marche (bilan seulement)"
91
+ ),
92
+ json_output: bool = typer.Option(False, "--json", help="Sortie JSON"),
93
+ ) -> None:
94
+ """Consigne un retour d'usage dans ~/.devflow/feedback.jsonl.
95
+
96
+ Seuls les secrets libellés (token=, password: …) sont masqués : ne jamais coller de jeton nu, de contenu de fichier ni de sortie d'environnement.
97
+ """
98
+ if type_ is None:
99
+ raise _refuse(f"--type est requis (valeurs admises : {_values(FEEDBACK_TYPES)})")
100
+ _check_choice("--type", type_, FEEDBACK_TYPES)
101
+ _check_choice("--severity", severity, SEVERITIES)
102
+ is_friction = type_ == "friction"
103
+
104
+ if is_friction and severity is None:
105
+ raise _refuse(
106
+ f"--severity est requis pour une friction (valeurs admises : {_values(SEVERITIES)})"
107
+ )
108
+ if not is_friction:
109
+ if severity is not None:
110
+ raise _refuse("--severity est refuse pour un bilan")
111
+ else:
112
+ for option, value in (("--weighed", weighed), ("--went-well", went_well)):
113
+ if value is not None:
114
+ raise _refuse(f"{option} est refuse pour une friction")
115
+
116
+ clean = {
117
+ name: sanitize_text(value) if value is not None else None
118
+ for name, value in (
119
+ ("--issue", issue),
120
+ ("--what", what),
121
+ ("--impact", impact),
122
+ ("--suggestion", suggestion),
123
+ ("--weighed", weighed),
124
+ ("--went-well", went_well),
125
+ )
126
+ }
127
+ required = ["--what", "--impact", "--suggestion"] if is_friction else [
128
+ "--issue", "--what", "--weighed", "--went-well",
129
+ ]
130
+ for option in required:
131
+ if not clean[option]:
132
+ raise _refuse(f"{option} est requis pour un {type_} et ne peut pas etre vide")
133
+
134
+ entry = build_entry(
135
+ type_,
136
+ severity=severity,
137
+ issue=clean["--issue"],
138
+ what=clean["--what"] or "",
139
+ impact=clean["--impact"],
140
+ suggestion=clean["--suggestion"],
141
+ weighed=clean["--weighed"],
142
+ went_well=clean["--went-well"],
143
+ cwd=Path.cwd(),
144
+ )
145
+ path = _write(entry)
146
+ if json_output:
147
+ typer.echo(json.dumps(entry, ensure_ascii=False, sort_keys=True))
148
+ return
149
+ ok(f"Retour {entry['id'][:MIN_PREFIX]} consigné dans {_plain(path)}")
150
+
151
+
152
+ def _validate_filters(severity: str | None, status: str | None) -> None:
153
+ _check_choice("--severity", severity, SEVERITIES)
154
+ _check_choice("--status", status, STATUSES)
155
+
156
+
157
+ @app.command(name="list")
158
+ def list_feedback(
159
+ cli_version: Optional[str] = typer.Option(
160
+ None, "--cli-version", help="Version de la CLI de l'entree (egalite exacte)"
161
+ ),
162
+ project: Optional[str] = typer.Option(
163
+ None, "--project", help="Ne garder que les projets dont le chemin contient ce texte"
164
+ ),
165
+ severity: Optional[str] = typer.Option(
166
+ None, "--severity", help=f"Gravite : {_values(SEVERITIES)} (exclut les bilans)"
167
+ ),
168
+ status: Optional[str] = typer.Option(
169
+ None, "--status", help=f"Statut : {_values(STATUSES)}"
170
+ ),
171
+ json_output: bool = typer.Option(False, "--json", help="Sortie JSON"),
172
+ ) -> None:
173
+ """Liste les retours consignes, regroupes par parcours (projet + issue)."""
174
+ _validate_filters(severity, status)
175
+ records = read_feedback()
176
+ treated = resolutions(records)
177
+ groups = group_entries(select_entries(records, cli_version, project, severity, status))
178
+ source = feedback_file()
179
+
180
+ if json_output:
181
+ payload_groups = []
182
+ for group in groups:
183
+ entries = []
184
+ for entry in group["entries"]:
185
+ event = treated.get(entry["id"])
186
+ resolution = (
187
+ {k: v for k, v in event.items()
188
+ if k not in ("schemaVersion", "kind", "id", "entryId")}
189
+ if event else None
190
+ )
191
+ entries.append({
192
+ **entry,
193
+ "status": "traité" if event else "ouvert",
194
+ "resolution": resolution,
195
+ })
196
+ payload_groups.append({**group, "entries": entries})
197
+ typer.echo(json.dumps(
198
+ {"schemaVersion": FEEDBACK_SCHEMA_VERSION, "source": str(source),
199
+ "groups": payload_groups},
200
+ ensure_ascii=False,
201
+ ))
202
+ return
203
+
204
+ if not groups:
205
+ console.print(f"Aucun retour consigné dans {_plain(source)}.", soft_wrap=True)
206
+ return
207
+ for group in groups:
208
+ label = group["issueId"] or "sans issue"
209
+ console.print(f"{_plain(group['project'])} · {_plain(label)}", style="bold", soft_wrap=True, highlight=False)
210
+ for entry in group["entries"]:
211
+ event = treated.get(entry["id"])
212
+ cells = [
213
+ entry["id"][:MIN_PREFIX],
214
+ entry["type"],
215
+ entry.get("severity") or "-",
216
+ "traité" if event else "ouvert",
217
+ entry.get("cliVersion") or "-",
218
+ entry.get("activity") or "-",
219
+ ]
220
+ console.print(" " + _plain(" ".join(str(c) for c in cells)), soft_wrap=True, highlight=False)
221
+ console.print(" " + _plain(entry.get("what", "")), soft_wrap=True, highlight=False)
222
+ if event:
223
+ trace = f"traité par {event.get('resolvedBy', 'inconnu')} le {event.get('resolvedAt', '?')}"
224
+ if event.get("ref"):
225
+ trace += f" (réf. {event['ref']})"
226
+ console.print(" " + _plain(trace), soft_wrap=True, highlight=False)
227
+
228
+
229
+ @app.command()
230
+ def resolve(
231
+ entry_id: str = typer.Argument(
232
+ ..., help=f"Identifiant complet ou prefixe d'au moins {MIN_PREFIX} caracteres"
233
+ ),
234
+ ref: Optional[str] = typer.Option(
235
+ None, "--ref", help="Reference du traitement (issue, commit)"
236
+ ),
237
+ by: Optional[str] = typer.Option(
238
+ None, "--by", help="Auteur du traitement (defaut : utilisateur systeme)"
239
+ ),
240
+ json_output: bool = typer.Option(False, "--json", help="Sortie JSON"),
241
+ ) -> None:
242
+ """Marque un retour comme traite en ajoutant un evenement (rien n'est reecrit)."""
243
+ records = read_feedback()
244
+ try:
245
+ entry = find_entry(records, entry_id)
246
+ except ValueError as exc:
247
+ raise _refuse(str(exc)) from exc
248
+ if entry["id"] in resolutions(records):
249
+ raise _refuse(f"Le retour {entry['id'][:MIN_PREFIX]} est deja traite")
250
+ clean_by = None
251
+ if by is not None:
252
+ clean_by = sanitize_text(by)
253
+ if not clean_by:
254
+ raise _refuse("--by ne peut pas etre vide")
255
+ event = build_resolution(entry["id"], clean_by, ref)
256
+ _write(event)
257
+ if json_output:
258
+ typer.echo(json.dumps(event, ensure_ascii=False, sort_keys=True))
259
+ return
260
+ ok(f"Retour {entry['id'][:MIN_PREFIX]} marqué traité")
261
+
262
+
263
+ @app.command()
264
+ def export(
265
+ cli_version: Optional[str] = typer.Option(
266
+ None, "--cli-version", help="Version de la CLI de l'entree (egalite exacte)"
267
+ ),
268
+ project: Optional[str] = typer.Option(
269
+ None, "--project", help="Ne garder que les projets dont le chemin contient ce texte"
270
+ ),
271
+ severity: Optional[str] = typer.Option(
272
+ None, "--severity", help=f"Gravite : {_values(SEVERITIES)} (exclut les bilans)"
273
+ ),
274
+ status: Optional[str] = typer.Option(
275
+ None, "--status", help=f"Statut : {_values(STATUSES)} (defaut : ouvert)"
276
+ ),
277
+ ) -> None:
278
+ """Exporte les retours en Markdown sur la sortie standard (jamais dans un fichier)."""
279
+ _validate_filters(severity, status)
280
+ records = read_feedback()
281
+ entries = select_entries(records, cli_version, project, severity, status or "ouvert")
282
+ typer.echo(render_export(entries, resolutions(records), Path.home().resolve()))
@@ -0,0 +1,495 @@
1
+ """Retours d'usage de Devflow (`devflow feedback`).
2
+
3
+ Les retours sont ajoutes a `feedback.jsonl`, a cote de `telemetry.jsonl`
4
+ (`DEVFLOW_HOME`, a defaut `~/.devflow`), en append-only. Le fichier ne contient
5
+ que du texte saisi et passe par `redact_text()` : seuls les secrets libelles
6
+ sont masques. `DEVFLOW_TELEMETRY` n'a aucun effet sur l'ecriture d'un retour.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import getpass
12
+ import json
13
+ import math
14
+ import os
15
+ import re
16
+ import subprocess
17
+ import unicodedata
18
+ import uuid
19
+ from datetime import datetime, timezone
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from devflow_cli import VERSION
24
+ from devflow_cli.core.redaction import redact_text
25
+ from devflow_cli.core.telemetry import (
26
+ _quota_delta,
27
+ append_record,
28
+ read_records,
29
+ telemetry_enabled,
30
+ telemetry_file,
31
+ )
32
+ from devflow_cli.utils.paths import project_root_for_feature, resolve_feature_dir
33
+
34
+ FEEDBACK_SCHEMA_VERSION = 1
35
+ FEEDBACK_TYPES = ("friction", "bilan")
36
+ SEVERITIES = ("bloquant", "majeur", "mineur")
37
+ STATUSES = ("ouvert", "traité")
38
+ MIN_PREFIX = 8
39
+
40
+ _KEPT_CONTROLS = {"\n", "\t"}
41
+ _DROPPED_CATEGORIES = {"Cc", "Zl", "Zp"}
42
+ _PATH_END = r"""(?=$|/|\s|[,;:!?)\]}>"'`]|\.(?:$|\s))"""
43
+ _FENCES = ("```", "~~~")
44
+ _SEPARATOR_RE = re.compile(r"([-*_=])\1{2,}")
45
+ _ID_RE = re.compile(r"[0-9a-f]{8,32}")
46
+
47
+
48
+ def feedback_file() -> Path:
49
+ return telemetry_file().parent / "feedback.jsonl"
50
+
51
+
52
+ def strip_controls(value: str) -> str:
53
+ """Supprime les controles (Cc) sauf saut de ligne et tabulation, et U+2028/U+2029."""
54
+ return "".join(
55
+ char for char in value
56
+ if char in _KEPT_CONTROLS or unicodedata.category(char) not in _DROPPED_CATEGORIES
57
+ )
58
+
59
+
60
+ def sanitize_text(value: str) -> str:
61
+ """Controle supprimes puis secrets libelles masques, puis blancs de bord retires."""
62
+ return redact_text(strip_controls(value)).strip()
63
+
64
+
65
+ def append_feedback(record: dict[str, Any], path: Path | None = None) -> Path:
66
+ """Ajoute une ligne en une seule ecriture O_APPEND ; leve OSError en cas d'echec.
67
+
68
+ Si le fichier ne finit pas par un saut de ligne (ecriture interrompue, edition
69
+ a la main), un saut de ligne est d'abord ajoute pour ne pas coller les lignes.
70
+ """
71
+ target = path or feedback_file()
72
+ if target.is_file() and target.stat().st_size > 0:
73
+ with target.open("rb") as handle:
74
+ handle.seek(-1, 2)
75
+ last = handle.read(1)
76
+ if last != b"\n":
77
+ descriptor = os.open(target, os.O_WRONLY | os.O_APPEND)
78
+ try:
79
+ os.write(descriptor, b"\n")
80
+ finally:
81
+ os.close(descriptor)
82
+ return append_record(record, target)
83
+
84
+
85
+ def _utc_now() -> str:
86
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
87
+
88
+
89
+ def _is_valid(record: Any) -> bool:
90
+ if not isinstance(record, dict):
91
+ return False
92
+ if record.get("schemaVersion") != FEEDBACK_SCHEMA_VERSION:
93
+ return False
94
+ if not isinstance(record.get("id"), str):
95
+ return False
96
+ kind = record.get("kind")
97
+ if kind == "entry":
98
+ return record.get("type") in FEEDBACK_TYPES and isinstance(record.get("project"), str)
99
+ if kind == "resolution":
100
+ return isinstance(record.get("entryId"), str)
101
+ return False
102
+
103
+
104
+ def read_feedback(path: Path | None = None) -> list[dict[str, Any]]:
105
+ """Lit les entrees et evenements valides ; ignore toute ligne illisible."""
106
+ target = path or feedback_file()
107
+ if not target.is_file():
108
+ return []
109
+ records: list[dict[str, Any]] = []
110
+ for line in target.read_text(encoding="utf-8", errors="replace").split("\n"):
111
+ try:
112
+ record = json.loads(line)
113
+ except json.JSONDecodeError:
114
+ continue
115
+ if _is_valid(record):
116
+ records.append(record)
117
+ return records
118
+
119
+
120
+ def _entries(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
121
+ return [record for record in records if record["kind"] == "entry"]
122
+
123
+
124
+ def resolutions(records: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
125
+ """Premier evenement de traitement de chaque entree lisible."""
126
+ known = {entry["id"] for entry in _entries(records)}
127
+ found: dict[str, dict[str, Any]] = {}
128
+ for record in records:
129
+ if record["kind"] == "resolution" and record["entryId"] in known:
130
+ found.setdefault(record["entryId"], record)
131
+ return found
132
+
133
+
134
+ # --- Contexte d'une entree ---------------------------------------------------
135
+
136
+
137
+ def _read_state(feature_dir: Path) -> tuple[str | None, str | None]:
138
+ try:
139
+ state = json.loads((feature_dir / "workflow-state.json").read_text(encoding="utf-8"))
140
+ except (OSError, ValueError):
141
+ return None, None
142
+ if not isinstance(state, dict):
143
+ return None, None
144
+ activity = state.get("currentActivity")
145
+ profile = state.get("profile")
146
+ return (
147
+ activity if isinstance(activity, str) else None,
148
+ profile if isinstance(profile, str) else None,
149
+ )
150
+
151
+
152
+ def _git_root(cwd: Path) -> str:
153
+ try:
154
+ result = subprocess.run(
155
+ ["git", "rev-parse", "--show-toplevel"],
156
+ cwd=cwd,
157
+ capture_output=True,
158
+ text=True,
159
+ timeout=5,
160
+ check=False,
161
+ )
162
+ except (OSError, subprocess.SubprocessError):
163
+ return str(cwd.resolve())
164
+ top = result.stdout.strip()
165
+ if result.returncode != 0 or not top:
166
+ return str(cwd.resolve())
167
+ return str(Path(top).resolve())
168
+
169
+
170
+ def resolve_context(issue: str | None, cwd: Path) -> tuple[str, str | None, str | None]:
171
+ """Projet, activite et profil d'une entree (C4.1)."""
172
+ if issue:
173
+ feature_dir = resolve_feature_dir(issue, cwd)
174
+ if feature_dir is not None:
175
+ try:
176
+ project = str(project_root_for_feature(feature_dir))
177
+ except ValueError:
178
+ project = None
179
+ if project is not None:
180
+ activity, profile = _read_state(feature_dir)
181
+ return project, activity, profile
182
+ return _git_root(cwd), None, None
183
+
184
+
185
+ def matching_telemetry(project: str, issue: str | None) -> list[dict[str, Any]]:
186
+ """Tentatives de meme projet et meme issue ; vide si issue absente ou collecte coupee."""
187
+ if not issue or not telemetry_enabled():
188
+ return []
189
+ return [
190
+ record for record in read_records()
191
+ if record.get("project") == project and record.get("issueId") == issue
192
+ ]
193
+
194
+
195
+ def latest_run_id(records: list[dict[str, Any]]) -> str | None:
196
+ best: dict[str, Any] | None = None
197
+ for record in records:
198
+ if best is None or str(record.get("recordedAt", "")) >= str(best.get("recordedAt", "")):
199
+ best = record
200
+ run_id = best.get("runId") if best else None
201
+ return run_id if isinstance(run_id, str) else None
202
+
203
+
204
+ def path_cost(records: list[dict[str, Any]]) -> dict[str, Any] | None:
205
+ """Cout d'un parcours ; USD et points de quota ne sont jamais additionnes."""
206
+ if not records:
207
+ return None
208
+ usages = [record.get("usage") or {} for record in records]
209
+ measured = [usage for usage in usages if usage]
210
+ costs = [usage["costUsd"] for usage in measured if usage.get("costUsd") is not None]
211
+ five_hours = [d for d in (_quota_delta(r, 300) for r in records) if d is not None]
212
+ weekly = [d for d in (_quota_delta(r, 10080) for r in records) if d is not None]
213
+ cost: dict[str, Any] = {"attempts": len(records), "measured": len(measured)}
214
+ if costs:
215
+ cost["costUsd"] = round(sum(costs), 4)
216
+ if five_hours:
217
+ cost["quota5hPoints"] = sum(five_hours)
218
+ if weekly:
219
+ cost["quotaWeeklyPoints"] = sum(weekly)
220
+ cost["partial"] = len(measured) < len(records)
221
+ return cost
222
+
223
+
224
+ def friction_ids(records: list[dict[str, Any]], project: str, issue: str | None) -> list[str]:
225
+ return [
226
+ entry["id"] for entry in _entries(records)
227
+ if entry["type"] == "friction"
228
+ and entry["project"] == project
229
+ and entry.get("issueId") == issue
230
+ ]
231
+
232
+
233
+ def build_entry(
234
+ entry_type: str,
235
+ *,
236
+ severity: str | None,
237
+ issue: str | None,
238
+ what: str,
239
+ impact: str | None,
240
+ suggestion: str | None,
241
+ weighed: str | None,
242
+ went_well: str | None,
243
+ cwd: Path,
244
+ ) -> dict[str, Any]:
245
+ """Construit une entree ; les champs facultatifs vides sont omis (jamais null)."""
246
+ issue = sanitize_text(issue or "") or None
247
+ project, activity, profile = resolve_context(issue, cwd)
248
+ telemetry = matching_telemetry(project, issue)
249
+ entry: dict[str, Any] = {
250
+ "schemaVersion": FEEDBACK_SCHEMA_VERSION,
251
+ "kind": "entry",
252
+ "id": uuid.uuid4().hex,
253
+ "type": entry_type,
254
+ "cliVersion": VERSION,
255
+ "project": project,
256
+ "recordedAt": _utc_now(),
257
+ "what": sanitize_text(what),
258
+ }
259
+ optional = {
260
+ "severity": severity if entry_type == "friction" else None,
261
+ "issueId": issue,
262
+ "activity": activity,
263
+ "profile": profile,
264
+ "runId": latest_run_id(telemetry) if telemetry else None,
265
+ "impact": sanitize_text(impact or "") or None,
266
+ "suggestion": sanitize_text(suggestion or "") or None,
267
+ "weighed": sanitize_text(weighed or "") or None if entry_type == "bilan" else None,
268
+ "wentWell": sanitize_text(went_well or "") or None if entry_type == "bilan" else None,
269
+ }
270
+ entry.update({key: value for key, value in optional.items() if value is not None})
271
+ if entry_type == "bilan":
272
+ entry["frictionIds"] = friction_ids(read_feedback(), project, issue)
273
+ cost = path_cost(telemetry)
274
+ if cost is not None:
275
+ entry["cost"] = cost
276
+ return entry
277
+
278
+
279
+ def build_resolution(entry_id: str, by: str | None, ref: str | None) -> dict[str, Any]:
280
+ """Evenement de traitement ; `by` deja assaini, a defaut l'utilisateur systeme."""
281
+ if by is None:
282
+ try:
283
+ by = sanitize_text(getpass.getuser()) or "inconnu"
284
+ except Exception: # noqa: BLE001 - getuser peut lever plusieurs types
285
+ by = "inconnu"
286
+ event: dict[str, Any] = {
287
+ "schemaVersion": FEEDBACK_SCHEMA_VERSION,
288
+ "kind": "resolution",
289
+ "id": uuid.uuid4().hex,
290
+ "entryId": entry_id,
291
+ "resolvedBy": by,
292
+ "resolvedAt": _utc_now(),
293
+ "cliVersion": VERSION,
294
+ }
295
+ clean_ref = sanitize_text(ref or "")
296
+ if clean_ref:
297
+ event["ref"] = clean_ref
298
+ return event
299
+
300
+
301
+ # --- Statut, selection, regroupement -----------------------------------------
302
+
303
+
304
+ def find_entry(records: list[dict[str, Any]], entry_id: str) -> dict[str, Any]:
305
+ """Entree designee par un identifiant complet ou un prefixe unique de 8 caracteres."""
306
+ prefix = entry_id.strip().lower()
307
+ if not _ID_RE.fullmatch(prefix):
308
+ raise ValueError(
309
+ f"Identifiant invalide : {entry_id!r} "
310
+ f"(attendu {MIN_PREFIX} a 32 caracteres hexadecimaux)"
311
+ )
312
+ matches = [entry for entry in _entries(records) if entry["id"].startswith(prefix)]
313
+ if not matches:
314
+ raise ValueError(f"Aucun retour ne correspond a l'identifiant {prefix}")
315
+ if len(matches) > 1:
316
+ raise ValueError(f"Identifiant ambigu : {prefix} designe {len(matches)} retours")
317
+ return matches[0]
318
+
319
+
320
+ def select_entries(
321
+ records: list[dict[str, Any]],
322
+ cli_version: str | None = None,
323
+ project: str | None = None,
324
+ severity: str | None = None,
325
+ status: str | None = None,
326
+ ) -> list[dict[str, Any]]:
327
+ """Entrees retenues par les filtres (ET logique), dans l'ordre du fichier."""
328
+ treated = resolutions(records)
329
+ selected = []
330
+ for entry in _entries(records):
331
+ if cli_version is not None and entry.get("cliVersion") != cli_version:
332
+ continue
333
+ if project and project not in entry["project"]:
334
+ continue
335
+ if severity is not None and entry.get("severity") != severity:
336
+ continue
337
+ if status is not None and ("traité" if entry["id"] in treated else "ouvert") != status:
338
+ continue
339
+ selected.append(entry)
340
+ return selected
341
+
342
+
343
+ def group_entries(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
344
+ """Groupes (projet, issue) ; parcours sans issue en dernier dans chaque projet."""
345
+ groups: dict[tuple[str, str | None], list[dict[str, Any]]] = {}
346
+ for entry in entries:
347
+ issue = entry.get("issueId")
348
+ key = (entry["project"], issue if isinstance(issue, str) else None)
349
+ groups.setdefault(key, []).append(entry)
350
+ ordered = sorted(groups, key=lambda key: (key[0], key[1] is None, key[1] or ""))
351
+ return [
352
+ {"project": project, "issueId": issue, "entries": groups[(project, issue)]}
353
+ for project, issue in ordered
354
+ ]
355
+
356
+
357
+ # --- Export ------------------------------------------------------------------
358
+
359
+
360
+ def normalize_paths(text: str, project: str, home: Path) -> str:
361
+ """Racine du projet -> `<projet>`, puis home -> `~`, sans toucher aux prefixes partiels."""
362
+ if project and project != "/":
363
+ text = re.sub(re.escape(project) + _PATH_END, "<projet>", text)
364
+ forms = {str(home), str(home.resolve())}
365
+ forms.discard("/")
366
+ forms.discard("")
367
+ for form in sorted(forms, key=len, reverse=True):
368
+ text = re.sub(re.escape(form) + _PATH_END, "~", text)
369
+ return text
370
+
371
+
372
+ def neutralize_markdown(text: str) -> str:
373
+ """Prefixe d'une barre oblique inverse les lignes qui casseraient la structure."""
374
+ lines = []
375
+ for line in text.split("\n"):
376
+ stripped = line.lstrip()
377
+ compact = "".join(stripped.split())
378
+ if (
379
+ stripped.startswith("#")
380
+ or stripped.startswith(_FENCES)
381
+ or _SEPARATOR_RE.fullmatch(compact)
382
+ ):
383
+ line = line[: len(line) - len(stripped)] + "\\" + stripped
384
+ lines.append(line)
385
+ return "\n".join(lines)
386
+
387
+
388
+ def _inline(value: Any) -> str:
389
+ return " ".join(str(value).split())
390
+
391
+
392
+ def _number(value: Any) -> float | None:
393
+ """Nombre fini, sinon None (un champ de type inattendu est ignore au rendu)."""
394
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
395
+ return None
396
+ return float(value) if math.isfinite(value) else None
397
+
398
+
399
+ def _cost_line(cost: dict[str, Any]) -> str:
400
+ attempts = _number(cost.get("attempts")) or 0
401
+ measured = _number(cost.get("measured")) or 0
402
+ head = f"{attempts:g} tentatives, {measured:g} mesurées"
403
+ if cost.get("partial") is True:
404
+ head += ", partiel"
405
+ amounts = []
406
+ usd = _number(cost.get("costUsd"))
407
+ five_hours = _number(cost.get("quota5hPoints"))
408
+ weekly = _number(cost.get("quotaWeeklyPoints"))
409
+ if usd is not None:
410
+ amounts.append(f"{usd:.2f} USD")
411
+ if five_hours is not None:
412
+ amounts.append(f"{five_hours:g} points de quota 5 h")
413
+ if weekly is not None:
414
+ amounts.append(f"{weekly:g} points de quota hebdomadaires")
415
+ return head + (" : " + " ; ".join(amounts) if amounts else "") + "."
416
+
417
+
418
+ def render_export(
419
+ entries: list[dict[str, Any]],
420
+ treated: dict[str, dict[str, Any]],
421
+ home: Path,
422
+ ) -> str:
423
+ """Markdown au format de FEEDBACK.md ; chemins normalises, structure neutralisee."""
424
+ out = ["# Retours d'usage devflow — export", ""]
425
+ if not entries:
426
+ out += ["Aucun retour.", ""]
427
+ return "\n".join(out)
428
+
429
+ ordered = [entry for group in group_entries(entries) for entry in group["entries"]]
430
+ sections = [(severity.capitalize(), severity) for severity in SEVERITIES]
431
+ sections.append(("Bilans de parcours", None))
432
+ for title, severity in sections:
433
+ chosen = [
434
+ entry for entry in ordered
435
+ if (entry.get("severity") == severity if severity else entry["type"] == "bilan")
436
+ ]
437
+ if not chosen:
438
+ continue
439
+ out += [f"## {title}", ""]
440
+ for entry in chosen:
441
+ out += _render_entry(entry, treated.get(entry["id"]), home)
442
+ return "\n".join(out)
443
+
444
+
445
+ def _render_entry(
446
+ entry: dict[str, Any], resolution: dict[str, Any] | None, home: Path
447
+ ) -> list[str]:
448
+ project = entry["project"]
449
+
450
+ def text(value: Any) -> str:
451
+ return neutralize_markdown(normalize_paths(str(value), project, home))
452
+
453
+ def inline(value: Any) -> str:
454
+ return text(_inline(value))
455
+
456
+ issue = entry.get("issueId")
457
+ title = [entry["type"], inline(issue) if issue else "sans issue"]
458
+ if entry.get("activity"):
459
+ title.append(inline(entry["activity"]))
460
+ title.append(entry["id"][:MIN_PREFIX])
461
+ context = [f"Projet {inline(Path(project).name)}"]
462
+ for label, key in (("issue", "issueId"), ("activité", "activity"), ("profil", "profile")):
463
+ if entry.get(key):
464
+ context.append(f"{label} {inline(entry[key])}")
465
+ if entry.get("cliVersion"):
466
+ context.append(f"CLI {inline(entry['cliVersion'])}")
467
+ if entry.get("recordedAt"):
468
+ context.append(inline(str(entry["recordedAt"])[:10]))
469
+
470
+ lines = [f"### {' · '.join(title)}", "", f"**Contexte.** {', '.join(context)}.", ""]
471
+
472
+ def block(label: str, key: str) -> None:
473
+ if entry.get(key):
474
+ lines.extend([f"**{label}.**", text(entry[key]), ""])
475
+
476
+ block("Ce qui s'est passé", "what")
477
+ block("Pourquoi ça coûte", "impact")
478
+ block("Piste", "suggestion")
479
+ if entry["type"] == "bilan":
480
+ if isinstance(entry.get("cost"), dict):
481
+ lines.extend([f"**Coût du parcours.** {_cost_line(entry['cost'])}", ""])
482
+ block("Activités qui ont pesé", "weighed")
483
+ block("Ce qui a bien marché", "wentWell")
484
+ ids = entry.get("frictionIds")
485
+ ids = [i for i in ids if isinstance(i, str)] if isinstance(ids, list) else []
486
+ shown = ", ".join(f"`{inline(str(i)[:MIN_PREFIX])}`" for i in ids) if ids else "aucune"
487
+ lines.extend([f"**Frictions référencées.** {shown}" + ".", ""])
488
+ if resolution is not None:
489
+ trace = f"Par {inline(resolution.get('resolvedBy', 'inconnu'))}"
490
+ if resolution.get("resolvedAt"):
491
+ trace += f" le {inline(str(resolution['resolvedAt'])[:10])}"
492
+ if resolution.get("ref"):
493
+ trace += f", réf. {inline(resolution['ref'])}"
494
+ lines.extend([f"**Traité.** {trace}.", ""])
495
+ return lines
@@ -25,5 +25,25 @@ interpréter de `state.json`.
25
25
  tentative refusée et afficher sa commande de reprise.
26
26
  7. Afficher ensuite `devflow adaptive status ISSUE_ID`.
27
27
 
28
+ ## Retours d'usage
29
+
30
+ Consigner les retours au fil du parcours avec `devflow feedback add`, sans
31
+ interaction.
32
+
33
+ - Une `friction` dès qu'un comportement de Devflow est en cause : refus à tort,
34
+ message trompeur, reprise inadaptée, prérequis d'environnement non documenté.
35
+ Ne jamais en consigner pour un refus justifié du travail de l'agent.
36
+ - Un `bilan` en fin de parcours, terminé ou abandonné.
37
+ - Ne jamais coller de secret, de contenu de fichier ni de sortie brute
38
+ d'environnement : seuls les secrets libellés (`token=`, `password:` …) sont
39
+ masqués.
40
+
41
+ ```bash
42
+ devflow feedback add --type friction --severity majeur --issue <ISSUE> \
43
+ --what "…" --impact "…" --suggestion "…"
44
+ devflow feedback add --type bilan --issue <ISSUE> --what "…" \
45
+ --weighed "…" --went-well "…"
46
+ ```
47
+
28
48
  Le contrat produit par Devflow est la seule autorité sur les actions,
29
49
  artefacts, validations et preuves de l'activité.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: devflow-cli
3
- Version: 6.2.0
3
+ Version: 6.3.0
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=pRas3ja9tJb0F6YHAqQMncoMsOxuNSjH3HDXEbiSejc,1002
2
- devflow_cli/cli.py,sha256=nst8kP2QneJZJg6K5bBf9uO8Nj9lYmoHrEhcxA7nsIM,2129
1
+ devflow_cli/__init__.py,sha256=EaJQ7YnyA-lEJmnyXiOSovWHa-5JC1C3VWMJ_7Wol4M,1002
2
+ devflow_cli/cli.py,sha256=SY0nIY0R1XjoVxGzSPn7Qcojsh6bMHHmiSKigZBndWE,2216
3
3
  devflow_cli/adapters/__init__.py,sha256=RkjHPU2Lp4hSVIq2iOmyTrv2zYU9NuiIF9wEMWzLdMI,377
4
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=_CICxGw27XOSnXteudn75IriY12zz5jhcZoH__zNfns,45797
7
+ devflow_cli/commands/adaptive.py,sha256=mtc7CzQFQ4C1j-XmGgAAsuf386Y6FhWv36E_FpvSnPc,45860
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
@@ -12,6 +12,7 @@ devflow_cli/commands/docs_sync.py,sha256=Xc0mulpNTjDyeUDumKL7VNG0eumAf3016wEM_dq
12
12
  devflow_cli/commands/export_cmd.py,sha256=_DVgQru8z7UM9br-ntzMkNPcDL9OpY1G6S3cMPJ4Gqo,3259
13
13
  devflow_cli/commands/extension.py,sha256=W2bc1xntJmDUrvLt8GFgg3lgTcJSwmGGTDPUkrxonBo,4494
14
14
  devflow_cli/commands/feature.py,sha256=gomtMUUJl-0YfZZIx0PHNHBX-Rq5GhmMZE-XkP55SwE,4200
15
+ devflow_cli/commands/feedback.py,sha256=pKG4jCsyVcfTA7Df_qe8tYF1VzveLJ-WfR9BcV9ZtMM,10268
15
16
  devflow_cli/commands/hooks_cmd.py,sha256=440K9AmXt6nF99JV2v6ActvG7-Pp1u6rYScEHRNoibI,5924
16
17
  devflow_cli/commands/init_cmd.py,sha256=VXR52ocqkO23J18pgij0Mhc_BTBl2PolxArjoCOuNow,5383
17
18
  devflow_cli/commands/linear.py,sha256=i066d5ryyBzZVfrSudbD0VWg9fSLivnB8yNe4vAMMW0,972
@@ -31,6 +32,7 @@ devflow_cli/core/automatic_evidence.py,sha256=DAd74AWeLF1L8ySlTgQ3tVJMgbVKfB1Zqx
31
32
  devflow_cli/core/catalog.py,sha256=nLPc78JkbISaSxJnHlWiMi_lvepaumbfwcjgLuVy7Zc,3102
32
33
  devflow_cli/core/contract_schemas.py,sha256=ugHrC4vcRpf3Dn_4JGHdBIkVr_TjnKu5dvvy3b0yKec,4480
33
34
  devflow_cli/core/docgen.py,sha256=EPDP0EKIToGYN1inQRj3CSYzADNtLTI_YCFYU4YWRuM,3075
35
+ devflow_cli/core/feedback.py,sha256=ZdlEnM8mQuFpGHQ_fn1pG3L3XvLrNwkBNSw_MozJkbo,18110
34
36
  devflow_cli/core/git.py,sha256=o7Vk_n8b3-cAQa3CR1FblTZUvMDez7Ik4D9lrYI0njY,2561
35
37
  devflow_cli/core/hooks.py,sha256=p_NC0mR0Qa0a-LvfWhDQqqcAwxC5qRRk-lPHKUHGpiA,7942
36
38
  devflow_cli/core/installer.py,sha256=MfcGZKoOcQc2qviNnGShoD0P_MqOgRuVHX7YwCrG2vw,6259
@@ -61,7 +63,7 @@ devflow_cli/utils/strings.py,sha256=bfFXCwBDnFxshgZOYfy6mtysnjiaWO1wFnqBf7HLg4o,
61
63
  devflow_cli/resources/commands/devflow.context.md,sha256=SdvZ286cJKkgQIKYwnjd30v5zDeEXTFBhpEEsEOInoc,2048
62
64
  devflow_cli/resources/commands/devflow.extension.md,sha256=w7hoFfxRS0y-3rsvUmQDx1fvZhtE9KhANmRgFDwYVaA,2373
63
65
  devflow_cli/resources/commands/devflow.init.md,sha256=wplv3IiJrNkUoPXGZtOw9k6m3dzXBQDYAcPPN8d3EF8,2011
64
- devflow_cli/resources/commands/devflow.md,sha256=X_xRpkOIuCL19ExG1egh7pJpaesO_GCqbjW_8wHmejQ,1275
66
+ devflow_cli/resources/commands/devflow.md,sha256=_8CQfwiT-e30FTGdok7m8xgQ3TPDpBi3tgtsf55fKLk,2079
65
67
  devflow_cli/resources/commands/devflow.regen.md,sha256=yjbMNAt6k0y6M9Y5HZDhCRgj_tb33Om6_-5OijmD5Bw,1169
66
68
  devflow_cli/resources/commands/devflow.status.md,sha256=tqjQ1IboU99ztG6C4nunBHGJCvMn3qDqx3AoF4SPjCk,338
67
69
  devflow_cli/resources/extensions/EXTENSION-API-REFERENCE.md,sha256=1wuAV-K8WI0BJ6SJ0HViieSHe3zazlfHemkKDT7bbKM,4478
@@ -85,8 +87,8 @@ devflow_cli/resources/templates/contracts/api-template.md,sha256=rC5v7JBz71PznCn
85
87
  devflow_cli/resources/templates/contracts/components-template.md,sha256=XIWfEXfC72sNmL5OA3V-7xIywkNDU2DBPLfyiagOWcs,484
86
88
  devflow_cli/resources/templates/contracts/interfaces-template.md,sha256=kf8B7b6HsmN7jyRlLNU7RnZl0rTMlI5s97yVRyI-6yc,336
87
89
  devflow_cli/resources/templates/contracts/services-template.md,sha256=cFafKCyLmq_a7aR7G0LXAaMO2z4Fuflq6iKdxfVg0vg,477
88
- devflow_cli-6.2.0.dist-info/METADATA,sha256=9k2DAloiyD6BLNT-24Ebalnivx2WhSasiefJeJon4nY,4275
89
- devflow_cli-6.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
90
- devflow_cli-6.2.0.dist-info/entry_points.txt,sha256=GVJ6bMvFpqyO5589iC4nFKeGTpGIW8aKn_QPPpSZ6kU,48
91
- devflow_cli-6.2.0.dist-info/licenses/LICENSE,sha256=v79PvnmqOdKhQiOa_QcWxng4q8GuGscLOwi7ygUpwpo,1070
92
- devflow_cli-6.2.0.dist-info/RECORD,,
90
+ devflow_cli-6.3.0.dist-info/METADATA,sha256=cdAoiz3DaCyQ8xqfA75HC_OGg8kfiPRWXeLnDGxjM2A,4275
91
+ devflow_cli-6.3.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
92
+ devflow_cli-6.3.0.dist-info/entry_points.txt,sha256=GVJ6bMvFpqyO5589iC4nFKeGTpGIW8aKn_QPPpSZ6kU,48
93
+ devflow_cli-6.3.0.dist-info/licenses/LICENSE,sha256=v79PvnmqOdKhQiOa_QcWxng4q8GuGscLOwi7ygUpwpo,1070
94
+ devflow_cli-6.3.0.dist-info/RECORD,,