devflow-cli 10.1.2__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 +1 -1
- devflow_cli/cli.py +2 -0
- devflow_cli/commands/adaptive.py +68 -24
- devflow_cli/commands/report.py +324 -0
- devflow_cli/core/decisions.py +192 -0
- {devflow_cli-10.1.2.dist-info → devflow_cli-10.2.0.dist-info}/METADATA +5 -1
- {devflow_cli-10.1.2.dist-info → devflow_cli-10.2.0.dist-info}/RECORD +10 -8
- {devflow_cli-10.1.2.dist-info → devflow_cli-10.2.0.dist-info}/WHEEL +1 -1
- {devflow_cli-10.1.2.dist-info → devflow_cli-10.2.0.dist-info}/entry_points.txt +0 -0
- {devflow_cli-10.1.2.dist-info → devflow_cli-10.2.0.dist-info}/licenses/LICENSE +0 -0
devflow_cli/__init__.py
CHANGED
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
|
|
|
@@ -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
|
|
devflow_cli/commands/adaptive.py
CHANGED
|
@@ -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
|
|
669
|
-
|
|
670
|
-
|
|
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
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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.
|
|
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=
|
|
2
|
-
devflow_cli/cli.py,sha256=
|
|
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=
|
|
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.
|
|
60
|
-
devflow_cli-10.
|
|
61
|
-
devflow_cli-10.
|
|
62
|
-
devflow_cli-10.
|
|
63
|
-
devflow_cli-10.
|
|
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,,
|
|
File without changes
|
|
File without changes
|