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 +1 -1
- devflow_cli/cli.py +2 -0
- devflow_cli/commands/adaptive.py +2 -0
- devflow_cli/commands/feedback.py +282 -0
- devflow_cli/core/feedback.py +495 -0
- devflow_cli/resources/commands/devflow.md +20 -0
- {devflow_cli-6.2.0.dist-info → devflow_cli-6.3.0.dist-info}/METADATA +1 -1
- {devflow_cli-6.2.0.dist-info → devflow_cli-6.3.0.dist-info}/RECORD +11 -9
- {devflow_cli-6.2.0.dist-info → devflow_cli-6.3.0.dist-info}/WHEEL +0 -0
- {devflow_cli-6.2.0.dist-info → devflow_cli-6.3.0.dist-info}/entry_points.txt +0 -0
- {devflow_cli-6.2.0.dist-info → devflow_cli-6.3.0.dist-info}/licenses/LICENSE +0 -0
devflow_cli/__init__.py
CHANGED
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)
|
devflow_cli/commands/adaptive.py
CHANGED
|
@@ -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,10 +1,10 @@
|
|
|
1
|
-
devflow_cli/__init__.py,sha256=
|
|
2
|
-
devflow_cli/cli.py,sha256=
|
|
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=
|
|
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=
|
|
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.
|
|
89
|
-
devflow_cli-6.
|
|
90
|
-
devflow_cli-6.
|
|
91
|
-
devflow_cli-6.
|
|
92
|
-
devflow_cli-6.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|