devflow-cli 4.0.2__py3-none-any.whl → 6.1.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 +2 -2
- devflow_cli/adapters/agents.py +274 -19
- devflow_cli/cli.py +2 -2
- devflow_cli/commands/adaptive.py +584 -110
- devflow_cli/commands/assess.py +0 -1
- devflow_cli/commands/check.py +41 -0
- devflow_cli/commands/feature.py +25 -3
- devflow_cli/commands/status.py +3 -3
- devflow_cli/commands/upgrade_cmd.py +14 -8
- devflow_cli/commands/usage.py +92 -0
- devflow_cli/commands/verify.py +30 -0
- devflow_cli/core/activity_contracts.py +218 -23
- devflow_cli/core/adaptive_runner.py +559 -41
- devflow_cli/core/adaptive_workflow.py +90 -50
- devflow_cli/core/agent_failure.py +113 -0
- devflow_cli/core/assessment.py +17 -19
- devflow_cli/core/automatic_evidence.py +252 -71
- devflow_cli/core/contract_schemas.py +21 -31
- devflow_cli/core/docgen.py +6 -5
- devflow_cli/core/manifest.py +9 -1
- devflow_cli/core/omissions.py +168 -0
- devflow_cli/core/operational.py +5 -1
- devflow_cli/core/remediation.py +202 -25
- devflow_cli/core/telemetry.py +168 -0
- devflow_cli/core/validation_approvals.py +220 -0
- devflow_cli/core/validators.py +59 -9
- devflow_cli/core/verification.py +95 -8
- devflow_cli/core/worklog.py +14 -3
- devflow_cli/core/worktree_snapshot.py +17 -0
- devflow_cli/resources/commands/devflow.md +8 -4
- devflow_cli/resources/templates/plan-template.md +4 -4
- devflow_cli/schemas/{activity-contract-v2.schema.json → activity-contract-v3.schema.json} +3 -3
- devflow_cli/schemas/activity-result-v1.schema.json +13 -1
- devflow_cli/schemas/{assessment-v2.schema.json → assessment-v3.schema.json} +3 -3
- devflow_cli/schemas/{workflow-state-v3.schema.json → workflow-state-v4.schema.json} +23 -4
- devflow_cli/utils/paths.py +17 -2
- {devflow_cli-4.0.2.dist-info → devflow_cli-6.1.0.dist-info}/METADATA +8 -23
- {devflow_cli-4.0.2.dist-info → devflow_cli-6.1.0.dist-info}/RECORD +41 -41
- {devflow_cli-4.0.2.dist-info → devflow_cli-6.1.0.dist-info}/WHEEL +1 -1
- devflow_cli/commands/migrate_cmd.py +0 -200
- devflow_cli/core/v4_migration.py +0 -134
- devflow_cli/schemas/activity-contract-v1.schema.json +0 -76
- devflow_cli/schemas/assessment-v1.schema.json +0 -60
- devflow_cli/schemas/workflow-state-v2.schema.json +0 -66
- {devflow_cli-4.0.2.dist-info → devflow_cli-6.1.0.dist-info}/entry_points.txt +0 -0
- {devflow_cli-4.0.2.dist-info → devflow_cli-6.1.0.dist-info}/licenses/LICENSE +0 -0
devflow_cli/__init__.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""devflow CLI — workflow de développement adaptatif."""
|
|
2
2
|
|
|
3
|
-
VERSION = "
|
|
3
|
+
VERSION = "6.1.0"
|
|
4
4
|
|
|
5
5
|
SUPPORTED_AGENTS = ["claude-code", "codex", "cursor"]
|
|
6
6
|
|
|
@@ -42,7 +42,7 @@ ARTIFACTS = [
|
|
|
42
42
|
"preflight-evidence.md",
|
|
43
43
|
"worklog.md",
|
|
44
44
|
"activity-contract.json",
|
|
45
|
-
"
|
|
45
|
+
"implement-result.json",
|
|
46
46
|
"verification.json",
|
|
47
47
|
]
|
|
48
48
|
|
devflow_cli/adapters/agents.py
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import os
|
|
6
7
|
import subprocess
|
|
7
8
|
import re
|
|
8
9
|
import time
|
|
9
10
|
from abc import ABC, abstractmethod
|
|
10
|
-
from dataclasses import dataclass
|
|
11
|
+
from dataclasses import dataclass, replace
|
|
12
|
+
from datetime import datetime, timedelta
|
|
11
13
|
from pathlib import Path
|
|
12
14
|
from typing import Literal
|
|
13
15
|
|
|
@@ -21,9 +23,42 @@ SandboxMode = Literal["read-only", "workspace-write"]
|
|
|
21
23
|
_SAFE_DIAGNOSTIC_MESSAGES = {
|
|
22
24
|
"final-output-empty": "--output-last-message est vide",
|
|
23
25
|
"final-output-absent": "--output-last-message est absent",
|
|
26
|
+
"agent-turn-failed": "le tour agent a echoue avant la reponse finale",
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class QuotaWindow:
|
|
32
|
+
"""Evolution d'une fenetre de quota observee pendant une session agent."""
|
|
33
|
+
|
|
34
|
+
windowMinutes: int | None
|
|
35
|
+
usedPercentStart: float
|
|
36
|
+
usedPercentEnd: float
|
|
37
|
+
# None si la fenetre a ete reinitialisee entre les deux releves.
|
|
38
|
+
deltaPoints: float | None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class AgentUsage:
|
|
43
|
+
"""Compteurs de consommation declares par l'agent, sans aucun contenu.
|
|
44
|
+
|
|
45
|
+
inputTokens exclut toujours les tokens lus depuis le cache.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
inputTokens: int = 0
|
|
49
|
+
cacheReadTokens: int = 0
|
|
50
|
+
cacheWriteTokens: int = 0
|
|
51
|
+
outputTokens: int = 0
|
|
52
|
+
reasoningTokens: int = 0
|
|
53
|
+
models: tuple[str, ...] = ()
|
|
54
|
+
effort: str | None = None
|
|
55
|
+
turns: int | None = None
|
|
56
|
+
costUsd: float | None = None
|
|
57
|
+
subagents: int | None = None
|
|
58
|
+
firstContextTokens: int | None = None
|
|
59
|
+
quota: tuple[QuotaWindow, ...] = ()
|
|
60
|
+
|
|
61
|
+
|
|
27
62
|
@dataclass(frozen=True)
|
|
28
63
|
class AgentExecution:
|
|
29
64
|
returncode: int
|
|
@@ -35,6 +70,7 @@ class AgentExecution:
|
|
|
35
70
|
final_output_bytes: int = 0
|
|
36
71
|
final_output_diagnostic: str = "not-evaluated"
|
|
37
72
|
session_id: str | None = None
|
|
73
|
+
usage: AgentUsage | None = None
|
|
38
74
|
|
|
39
75
|
|
|
40
76
|
class StructuredOutputError(ValueError):
|
|
@@ -85,6 +121,7 @@ class AgentAdapter(ABC):
|
|
|
85
121
|
timeout=timeout,
|
|
86
122
|
text=True,
|
|
87
123
|
capture_output=capture_output,
|
|
124
|
+
stdin=subprocess.DEVNULL,
|
|
88
125
|
shell=False,
|
|
89
126
|
)
|
|
90
127
|
return AgentExecution(
|
|
@@ -123,6 +160,7 @@ class AgentAdapter(ABC):
|
|
|
123
160
|
timeout=timeout,
|
|
124
161
|
text=True,
|
|
125
162
|
capture_output=True,
|
|
163
|
+
stdin=subprocess.DEVNULL,
|
|
126
164
|
shell=False,
|
|
127
165
|
)
|
|
128
166
|
execution = AgentExecution(
|
|
@@ -133,7 +171,10 @@ class AgentAdapter(ABC):
|
|
|
133
171
|
final_output_status=_output_status(output_file),
|
|
134
172
|
final_output_bytes=_output_size(output_file),
|
|
135
173
|
final_output_diagnostic="process-failed" if result.returncode else "validating",
|
|
136
|
-
session_id=
|
|
174
|
+
session_id=self.session_id(result.stdout or "", result.stderr or ""),
|
|
175
|
+
)
|
|
176
|
+
execution = replace(
|
|
177
|
+
execution, usage=self.safe_usage(execution.stdout, execution.session_id)
|
|
137
178
|
)
|
|
138
179
|
if execution.returncode != 0:
|
|
139
180
|
return execution
|
|
@@ -166,6 +207,7 @@ class AgentAdapter(ABC):
|
|
|
166
207
|
_output_size(output_file),
|
|
167
208
|
"validated",
|
|
168
209
|
execution.session_id,
|
|
210
|
+
execution.usage,
|
|
169
211
|
)
|
|
170
212
|
|
|
171
213
|
status = _output_status(output_file)
|
|
@@ -186,22 +228,46 @@ class AgentAdapter(ABC):
|
|
|
186
228
|
final_output_bytes=size,
|
|
187
229
|
final_output_diagnostic=diagnostic,
|
|
188
230
|
session_id=execution.session_id,
|
|
231
|
+
usage=execution.usage,
|
|
189
232
|
)
|
|
190
233
|
except subprocess.TimeoutExpired as exc:
|
|
234
|
+
stdout, stderr = _text(exc.stdout), _text(exc.stderr)
|
|
235
|
+
session_id = self.session_id(stdout, stderr)
|
|
191
236
|
return AgentExecution(
|
|
192
237
|
124,
|
|
193
|
-
|
|
194
|
-
|
|
238
|
+
stdout,
|
|
239
|
+
stderr,
|
|
195
240
|
timed_out=True,
|
|
196
241
|
duration_seconds=time.monotonic() - started,
|
|
197
242
|
final_output_status=_output_status(output_file),
|
|
198
243
|
final_output_bytes=_output_size(output_file),
|
|
244
|
+
session_id=session_id,
|
|
245
|
+
usage=self.safe_usage(stdout, session_id),
|
|
199
246
|
)
|
|
200
247
|
except FileNotFoundError:
|
|
201
248
|
return AgentExecution(127, stderr=f"Executable '{self.executable}' introuvable", duration_seconds=time.monotonic() - started)
|
|
202
249
|
|
|
203
|
-
def
|
|
204
|
-
|
|
250
|
+
def session_id(self, stdout: str, stderr: str) -> str | None:
|
|
251
|
+
"""Extrait un identifiant opaque de session, sans conserver la sortie."""
|
|
252
|
+
return _session_id(stdout, stderr)
|
|
253
|
+
|
|
254
|
+
def usage(self, stdout: str, session_id: str | None) -> AgentUsage | None:
|
|
255
|
+
"""Extrait les compteurs de consommation ; None si l'agent n'en fournit pas."""
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
def safe_usage(self, stdout: str, session_id: str | None) -> AgentUsage | None:
|
|
259
|
+
"""La mesure de consommation ne doit jamais faire echouer une activite."""
|
|
260
|
+
try:
|
|
261
|
+
return self.usage(stdout, session_id)
|
|
262
|
+
except Exception: # noqa: BLE001 - format externe non contractuel
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
def recover_structured_payload(self, raw_output: str) -> object:
|
|
266
|
+
"""Extrait la reponse structuree d'une trace brute conservee apres un echec."""
|
|
267
|
+
return self._structured_payload(raw_output, None)
|
|
268
|
+
|
|
269
|
+
def _structured_payload(self, stdout: str, output_file: Path | None) -> object:
|
|
270
|
+
if output_file is not None and output_file.is_file():
|
|
205
271
|
return json.loads(output_file.read_text(encoding="utf-8"))
|
|
206
272
|
return json.loads(stdout)
|
|
207
273
|
|
|
@@ -245,16 +311,46 @@ class ClaudeCodeAdapter(AgentAdapter):
|
|
|
245
311
|
prompt,
|
|
246
312
|
]
|
|
247
313
|
|
|
248
|
-
def _structured_payload(self, stdout: str, output_file: Path) -> object:
|
|
314
|
+
def _structured_payload(self, stdout: str, output_file: Path | None) -> object:
|
|
249
315
|
del output_file
|
|
250
316
|
envelope = json.loads(stdout)
|
|
251
317
|
if not isinstance(envelope, dict):
|
|
252
318
|
raise ValueError("enveloppe JSON Claude absente")
|
|
253
319
|
if envelope.get("subtype") not in {None, "success"}:
|
|
254
320
|
raise ValueError(f"execution Claude terminee avec {envelope.get('subtype')}")
|
|
255
|
-
if "structured_output"
|
|
256
|
-
|
|
257
|
-
|
|
321
|
+
if "structured_output" in envelope:
|
|
322
|
+
return envelope["structured_output"]
|
|
323
|
+
result = envelope.get("result")
|
|
324
|
+
if isinstance(result, str) and result.strip():
|
|
325
|
+
return json.loads(_strip_json_fence(result))
|
|
326
|
+
raise ValueError("champ structured_output absent")
|
|
327
|
+
|
|
328
|
+
def usage(self, stdout: str, session_id: str | None) -> AgentUsage | None:
|
|
329
|
+
"""Lit l'enveloppe --output-format json : usage cumule de tous les tours."""
|
|
330
|
+
envelope = json.loads(stdout)
|
|
331
|
+
if not isinstance(envelope, dict) or not isinstance(envelope.get("usage"), dict):
|
|
332
|
+
return None
|
|
333
|
+
usage = envelope["usage"]
|
|
334
|
+
details = usage.get("output_tokens_details")
|
|
335
|
+
subagents = envelope.get("subagent_stats")
|
|
336
|
+
model_usage = envelope.get("modelUsage")
|
|
337
|
+
cost = envelope.get("total_cost_usd")
|
|
338
|
+
turns = envelope.get("num_turns")
|
|
339
|
+
return AgentUsage(
|
|
340
|
+
inputTokens=_int(usage.get("input_tokens")),
|
|
341
|
+
cacheReadTokens=_int(usage.get("cache_read_input_tokens")),
|
|
342
|
+
cacheWriteTokens=_int(usage.get("cache_creation_input_tokens")),
|
|
343
|
+
outputTokens=_int(usage.get("output_tokens")),
|
|
344
|
+
reasoningTokens=_int(details.get("thinking_tokens")) if isinstance(details, dict) else 0,
|
|
345
|
+
models=tuple(model_usage) if isinstance(model_usage, dict) else (),
|
|
346
|
+
turns=turns if isinstance(turns, int) else None,
|
|
347
|
+
costUsd=float(cost) if isinstance(cost, (int, float)) else None,
|
|
348
|
+
subagents=(
|
|
349
|
+
subagents.get("spawned")
|
|
350
|
+
if isinstance(subagents, dict) and isinstance(subagents.get("spawned"), int)
|
|
351
|
+
else None
|
|
352
|
+
),
|
|
353
|
+
)
|
|
258
354
|
|
|
259
355
|
|
|
260
356
|
class CodexAdapter(AgentAdapter):
|
|
@@ -284,6 +380,7 @@ class CodexAdapter(AgentAdapter):
|
|
|
284
380
|
else:
|
|
285
381
|
command.extend(["--sandbox", sandbox_mode])
|
|
286
382
|
command.extend([
|
|
383
|
+
"--json",
|
|
287
384
|
"--output-schema",
|
|
288
385
|
str(schema_file),
|
|
289
386
|
"--output-last-message",
|
|
@@ -292,20 +389,62 @@ class CodexAdapter(AgentAdapter):
|
|
|
292
389
|
command.append(prompt)
|
|
293
390
|
return command
|
|
294
391
|
|
|
295
|
-
def
|
|
296
|
-
"""
|
|
297
|
-
|
|
392
|
+
def session_id(self, stdout: str, stderr: str) -> str | None:
|
|
393
|
+
"""Prefere l'identifiant de fil annonce par le flux d'evenements."""
|
|
394
|
+
for event in json_line_events(stdout):
|
|
395
|
+
if event.get("type") == "thread.started" and isinstance(
|
|
396
|
+
event.get("thread_id"), str
|
|
397
|
+
):
|
|
398
|
+
return event["thread_id"]
|
|
399
|
+
return _session_id(stdout, stderr)
|
|
400
|
+
|
|
401
|
+
def _structured_payload(self, stdout: str, output_file: Path | None) -> object:
|
|
402
|
+
"""Lit la reponse finale Codex, sinon la recupere depuis le flux d'evenements."""
|
|
403
|
+
if output_file is not None and output_file.is_file():
|
|
298
404
|
payload = output_file.read_text(encoding="utf-8")
|
|
299
405
|
if payload.strip():
|
|
300
406
|
return json.loads(payload)
|
|
407
|
+
diagnostic = "final-output-empty"
|
|
408
|
+
message = "Codex a termine sans reponse finale structuree"
|
|
409
|
+
else:
|
|
410
|
+
diagnostic = "final-output-absent"
|
|
411
|
+
message = "Codex n'a pas produit le fichier de reponse finale structuree"
|
|
412
|
+
events = json_line_events(stdout)
|
|
413
|
+
if any(event.get("type") == "turn.failed" for event in events):
|
|
301
414
|
raise StructuredOutputError(
|
|
302
|
-
"
|
|
303
|
-
"Codex a
|
|
415
|
+
"agent-turn-failed",
|
|
416
|
+
"Codex a interrompu son tour avant la reponse finale",
|
|
304
417
|
)
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
418
|
+
final_message = _codex_final_message(events)
|
|
419
|
+
if final_message is not None:
|
|
420
|
+
return json.loads(_strip_json_fence(final_message))
|
|
421
|
+
raise StructuredOutputError(diagnostic, message)
|
|
422
|
+
|
|
423
|
+
def usage(self, stdout: str, session_id: str | None) -> AgentUsage | None:
|
|
424
|
+
"""Combine le flux --json et, au mieux, le journal de session Codex.
|
|
425
|
+
|
|
426
|
+
Le flux ne donne que les tokens ; le modele, l'effort, le nombre de
|
|
427
|
+
requetes et les quotas ne figurent que dans le journal de session, dont
|
|
428
|
+
le format est interne a Codex.
|
|
429
|
+
"""
|
|
430
|
+
totals = [
|
|
431
|
+
event["usage"]
|
|
432
|
+
for event in json_line_events(stdout)
|
|
433
|
+
if event.get("type") == "turn.completed" and isinstance(event.get("usage"), dict)
|
|
434
|
+
]
|
|
435
|
+
session = _codex_session_facts(session_id)
|
|
436
|
+
if not totals and session is None:
|
|
437
|
+
return None
|
|
438
|
+
raw_input = sum(_int(item.get("input_tokens")) for item in totals)
|
|
439
|
+
cached = sum(_int(item.get("cached_input_tokens")) for item in totals)
|
|
440
|
+
usage = AgentUsage(
|
|
441
|
+
inputTokens=max(raw_input - cached, 0),
|
|
442
|
+
cacheReadTokens=cached,
|
|
443
|
+
cacheWriteTokens=sum(_int(item.get("cache_write_input_tokens")) for item in totals),
|
|
444
|
+
outputTokens=sum(_int(item.get("output_tokens")) for item in totals),
|
|
445
|
+
reasoningTokens=sum(_int(item.get("reasoning_output_tokens")) for item in totals),
|
|
308
446
|
)
|
|
447
|
+
return replace(usage, **session) if session else usage
|
|
309
448
|
|
|
310
449
|
|
|
311
450
|
class CursorAdapter(AgentAdapter):
|
|
@@ -341,7 +480,7 @@ class CursorAdapter(AgentAdapter):
|
|
|
341
480
|
command.append(structured_prompt)
|
|
342
481
|
return command
|
|
343
482
|
|
|
344
|
-
def _structured_payload(self, stdout: str, output_file: Path) -> object:
|
|
483
|
+
def _structured_payload(self, stdout: str, output_file: Path | None) -> object:
|
|
345
484
|
del output_file
|
|
346
485
|
envelope = json.loads(stdout)
|
|
347
486
|
if not isinstance(envelope, dict):
|
|
@@ -423,6 +562,33 @@ def _session_id(stdout: str, stderr: str) -> str | None:
|
|
|
423
562
|
return match.group(1) if match else None
|
|
424
563
|
|
|
425
564
|
|
|
565
|
+
def json_line_events(stdout: str) -> list[dict]:
|
|
566
|
+
"""Decode un flux JSONL d'agent en ignorant les lignes non JSON ou non objet."""
|
|
567
|
+
events: list[dict] = []
|
|
568
|
+
for line in stdout.splitlines():
|
|
569
|
+
line = line.strip()
|
|
570
|
+
if not line.startswith("{"):
|
|
571
|
+
continue
|
|
572
|
+
try:
|
|
573
|
+
event = json.loads(line)
|
|
574
|
+
except json.JSONDecodeError:
|
|
575
|
+
continue
|
|
576
|
+
if isinstance(event, dict):
|
|
577
|
+
events.append(event)
|
|
578
|
+
return events
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _codex_final_message(events: list[dict]) -> str | None:
|
|
582
|
+
"""Retourne le dernier message d'agent complet du flux d'evenements."""
|
|
583
|
+
for event in reversed(events):
|
|
584
|
+
item = event.get("item")
|
|
585
|
+
if event.get("type") != "item.completed" or not isinstance(item, dict):
|
|
586
|
+
continue
|
|
587
|
+
if item.get("type") == "agent_message" and isinstance(item.get("text"), str):
|
|
588
|
+
return item["text"]
|
|
589
|
+
return None
|
|
590
|
+
|
|
591
|
+
|
|
426
592
|
def _strip_json_fence(value: str) -> str:
|
|
427
593
|
text = value.strip()
|
|
428
594
|
match = re.fullmatch(r"```(?:json)?\s*([\s\S]*?)\s*```", text, re.IGNORECASE)
|
|
@@ -434,3 +600,92 @@ def _validate_structured_payload(payload: object, schema_file: Path) -> None:
|
|
|
434
600
|
validator_type = validator_for(schema)
|
|
435
601
|
validator_type.check_schema(schema)
|
|
436
602
|
validator_type(schema).validate(payload)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _int(value: object) -> int:
|
|
606
|
+
return value if isinstance(value, int) and not isinstance(value, bool) else 0
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _codex_home() -> Path:
|
|
610
|
+
return Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex")
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _codex_session_file(session_id: str | None) -> Path | None:
|
|
614
|
+
"""Retrouve le journal d'une session Codex recente a partir de son identifiant."""
|
|
615
|
+
if not session_id or not re.fullmatch(r"[A-Za-z0-9-]{8,}", session_id):
|
|
616
|
+
return None
|
|
617
|
+
sessions = _codex_home() / "sessions"
|
|
618
|
+
today = datetime.now()
|
|
619
|
+
for offset in range(3):
|
|
620
|
+
day = today - timedelta(days=offset)
|
|
621
|
+
directory = sessions / f"{day:%Y}" / f"{day:%m}" / f"{day:%d}"
|
|
622
|
+
matches = sorted(directory.glob(f"rollout-*{session_id}.jsonl"))
|
|
623
|
+
if matches:
|
|
624
|
+
return matches[-1]
|
|
625
|
+
return None
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _codex_session_facts(session_id: str | None) -> dict | None:
|
|
629
|
+
"""Extrait modele, effort, requetes et quotas du journal de session Codex."""
|
|
630
|
+
path = _codex_session_file(session_id)
|
|
631
|
+
if path is None:
|
|
632
|
+
return None
|
|
633
|
+
models: list[str] = []
|
|
634
|
+
effort: str | None = None
|
|
635
|
+
totals: set[int] = set()
|
|
636
|
+
first_context: int | None = None
|
|
637
|
+
limits: list[dict] = []
|
|
638
|
+
with path.open(encoding="utf-8", errors="replace") as handle:
|
|
639
|
+
for event in json_line_events(handle.read()):
|
|
640
|
+
payload = event.get("payload")
|
|
641
|
+
if not isinstance(payload, dict):
|
|
642
|
+
continue
|
|
643
|
+
if event.get("type") == "turn_context":
|
|
644
|
+
model = payload.get("model")
|
|
645
|
+
if isinstance(model, str) and model not in models:
|
|
646
|
+
models.append(model)
|
|
647
|
+
if isinstance(payload.get("effort"), str):
|
|
648
|
+
effort = payload["effort"]
|
|
649
|
+
elif event.get("type") == "event_msg" and payload.get("type") == "token_count":
|
|
650
|
+
info = payload.get("info")
|
|
651
|
+
if isinstance(info, dict) and isinstance(info.get("total_token_usage"), dict):
|
|
652
|
+
marker = _int(info["total_token_usage"].get("total_tokens"))
|
|
653
|
+
if marker not in totals:
|
|
654
|
+
totals.add(marker)
|
|
655
|
+
last = info.get("last_token_usage")
|
|
656
|
+
if first_context is None and isinstance(last, dict):
|
|
657
|
+
first_context = _int(last.get("input_tokens")) or None
|
|
658
|
+
if isinstance(payload.get("rate_limits"), dict):
|
|
659
|
+
limits.append(payload["rate_limits"])
|
|
660
|
+
return {
|
|
661
|
+
"models": tuple(models),
|
|
662
|
+
"effort": effort,
|
|
663
|
+
"turns": len(totals) or None,
|
|
664
|
+
"firstContextTokens": first_context,
|
|
665
|
+
"quota": _quota_windows(limits),
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _quota_windows(limits: list[dict]) -> tuple[QuotaWindow, ...]:
|
|
670
|
+
"""Compare le premier et le dernier releve de chaque fenetre de quota."""
|
|
671
|
+
windows: list[QuotaWindow] = []
|
|
672
|
+
if not limits:
|
|
673
|
+
return ()
|
|
674
|
+
for key in ("primary", "secondary"):
|
|
675
|
+
first, last = limits[0].get(key), limits[-1].get(key)
|
|
676
|
+
if not isinstance(first, dict) or not isinstance(last, dict):
|
|
677
|
+
continue
|
|
678
|
+
start, end = first.get("used_percent"), last.get("used_percent")
|
|
679
|
+
if not isinstance(start, (int, float)) or not isinstance(end, (int, float)):
|
|
680
|
+
continue
|
|
681
|
+
minutes = last.get("window_minutes")
|
|
682
|
+
same_window = first.get("resets_at") == last.get("resets_at")
|
|
683
|
+
windows.append(
|
|
684
|
+
QuotaWindow(
|
|
685
|
+
windowMinutes=minutes if isinstance(minutes, int) else None,
|
|
686
|
+
usedPercentStart=float(start),
|
|
687
|
+
usedPercentEnd=float(end),
|
|
688
|
+
deltaPoints=float(end) - float(start) if same_window else None,
|
|
689
|
+
)
|
|
690
|
+
)
|
|
691
|
+
return tuple(windows)
|
devflow_cli/cli.py
CHANGED
|
@@ -13,7 +13,6 @@ from devflow_cli.commands.feature import feature
|
|
|
13
13
|
from devflow_cli.commands.context import context
|
|
14
14
|
from devflow_cli.commands.export_cmd import export
|
|
15
15
|
from devflow_cli.commands.stale import stale
|
|
16
|
-
from devflow_cli.commands.migrate_cmd import migrate
|
|
17
16
|
from devflow_cli.commands.upgrade_cmd import upgrade
|
|
18
17
|
from devflow_cli.commands import extension
|
|
19
18
|
from devflow_cli.commands import hooks_cmd
|
|
@@ -23,6 +22,7 @@ from devflow_cli.commands.docs_sync import docs_sync
|
|
|
23
22
|
from devflow_cli.commands.traceability import traceability
|
|
24
23
|
from devflow_cli.commands.verify import verify
|
|
25
24
|
from devflow_cli.commands.assess import assess
|
|
25
|
+
from devflow_cli.commands.usage import usage
|
|
26
26
|
|
|
27
27
|
app = typer.Typer(
|
|
28
28
|
name="devflow",
|
|
@@ -53,7 +53,6 @@ app.command()(feature)
|
|
|
53
53
|
app.command()(context)
|
|
54
54
|
app.command(name="export")(export)
|
|
55
55
|
app.command()(stale)
|
|
56
|
-
app.command()(migrate)
|
|
57
56
|
app.command()(upgrade)
|
|
58
57
|
app.add_typer(extension.app, name="extension")
|
|
59
58
|
app.add_typer(hooks_cmd.app, name="hooks")
|
|
@@ -63,6 +62,7 @@ app.command(name="docs-sync")(docs_sync)
|
|
|
63
62
|
app.command()(traceability)
|
|
64
63
|
app.command()(verify)
|
|
65
64
|
app.command()(assess)
|
|
65
|
+
app.command()(usage)
|
|
66
66
|
|
|
67
67
|
|
|
68
68
|
if __name__ == "__main__":
|