devflow-cli 6.0.0__py3-none-any.whl → 6.1.1__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/adapters/agents.py +200 -3
- devflow_cli/cli.py +2 -0
- devflow_cli/commands/adaptive.py +63 -2
- devflow_cli/commands/usage.py +92 -0
- devflow_cli/core/adaptive_runner.py +2 -2
- devflow_cli/core/remediation.py +2 -2
- devflow_cli/core/telemetry.py +168 -0
- devflow_cli/core/worktree_snapshot.py +31 -3
- {devflow_cli-6.0.0.dist-info → devflow_cli-6.1.1.dist-info}/METADATA +1 -1
- {devflow_cli-6.0.0.dist-info → devflow_cli-6.1.1.dist-info}/RECORD +14 -12
- {devflow_cli-6.0.0.dist-info → devflow_cli-6.1.1.dist-info}/WHEEL +0 -0
- {devflow_cli-6.0.0.dist-info → devflow_cli-6.1.1.dist-info}/entry_points.txt +0 -0
- {devflow_cli-6.0.0.dist-info → devflow_cli-6.1.1.dist-info}/licenses/LICENSE +0 -0
devflow_cli/__init__.py
CHANGED
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
|
|
|
@@ -25,6 +27,38 @@ _SAFE_DIAGNOSTIC_MESSAGES = {
|
|
|
25
27
|
}
|
|
26
28
|
|
|
27
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
|
+
|
|
28
62
|
@dataclass(frozen=True)
|
|
29
63
|
class AgentExecution:
|
|
30
64
|
returncode: int
|
|
@@ -36,6 +70,7 @@ class AgentExecution:
|
|
|
36
70
|
final_output_bytes: int = 0
|
|
37
71
|
final_output_diagnostic: str = "not-evaluated"
|
|
38
72
|
session_id: str | None = None
|
|
73
|
+
usage: AgentUsage | None = None
|
|
39
74
|
|
|
40
75
|
|
|
41
76
|
class StructuredOutputError(ValueError):
|
|
@@ -138,6 +173,9 @@ class AgentAdapter(ABC):
|
|
|
138
173
|
final_output_diagnostic="process-failed" if result.returncode else "validating",
|
|
139
174
|
session_id=self.session_id(result.stdout or "", result.stderr or ""),
|
|
140
175
|
)
|
|
176
|
+
execution = replace(
|
|
177
|
+
execution, usage=self.safe_usage(execution.stdout, execution.session_id)
|
|
178
|
+
)
|
|
141
179
|
if execution.returncode != 0:
|
|
142
180
|
return execution
|
|
143
181
|
try:
|
|
@@ -169,6 +207,7 @@ class AgentAdapter(ABC):
|
|
|
169
207
|
_output_size(output_file),
|
|
170
208
|
"validated",
|
|
171
209
|
execution.session_id,
|
|
210
|
+
execution.usage,
|
|
172
211
|
)
|
|
173
212
|
|
|
174
213
|
status = _output_status(output_file)
|
|
@@ -189,16 +228,21 @@ class AgentAdapter(ABC):
|
|
|
189
228
|
final_output_bytes=size,
|
|
190
229
|
final_output_diagnostic=diagnostic,
|
|
191
230
|
session_id=execution.session_id,
|
|
231
|
+
usage=execution.usage,
|
|
192
232
|
)
|
|
193
233
|
except subprocess.TimeoutExpired as exc:
|
|
234
|
+
stdout, stderr = _text(exc.stdout), _text(exc.stderr)
|
|
235
|
+
session_id = self.session_id(stdout, stderr)
|
|
194
236
|
return AgentExecution(
|
|
195
237
|
124,
|
|
196
|
-
|
|
197
|
-
|
|
238
|
+
stdout,
|
|
239
|
+
stderr,
|
|
198
240
|
timed_out=True,
|
|
199
241
|
duration_seconds=time.monotonic() - started,
|
|
200
242
|
final_output_status=_output_status(output_file),
|
|
201
243
|
final_output_bytes=_output_size(output_file),
|
|
244
|
+
session_id=session_id,
|
|
245
|
+
usage=self.safe_usage(stdout, session_id),
|
|
202
246
|
)
|
|
203
247
|
except FileNotFoundError:
|
|
204
248
|
return AgentExecution(127, stderr=f"Executable '{self.executable}' introuvable", duration_seconds=time.monotonic() - started)
|
|
@@ -207,6 +251,17 @@ class AgentAdapter(ABC):
|
|
|
207
251
|
"""Extrait un identifiant opaque de session, sans conserver la sortie."""
|
|
208
252
|
return _session_id(stdout, stderr)
|
|
209
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
|
+
|
|
210
265
|
def recover_structured_payload(self, raw_output: str) -> object:
|
|
211
266
|
"""Extrait la reponse structuree d'une trace brute conservee apres un echec."""
|
|
212
267
|
return self._structured_payload(raw_output, None)
|
|
@@ -270,6 +325,33 @@ class ClaudeCodeAdapter(AgentAdapter):
|
|
|
270
325
|
return json.loads(_strip_json_fence(result))
|
|
271
326
|
raise ValueError("champ structured_output absent")
|
|
272
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
|
+
)
|
|
354
|
+
|
|
273
355
|
|
|
274
356
|
class CodexAdapter(AgentAdapter):
|
|
275
357
|
name = "codex"
|
|
@@ -338,6 +420,32 @@ class CodexAdapter(AgentAdapter):
|
|
|
338
420
|
return json.loads(_strip_json_fence(final_message))
|
|
339
421
|
raise StructuredOutputError(diagnostic, message)
|
|
340
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),
|
|
446
|
+
)
|
|
447
|
+
return replace(usage, **session) if session else usage
|
|
448
|
+
|
|
341
449
|
|
|
342
450
|
class CursorAdapter(AgentAdapter):
|
|
343
451
|
name = "cursor"
|
|
@@ -492,3 +600,92 @@ def _validate_structured_payload(payload: object, schema_file: Path) -> None:
|
|
|
492
600
|
validator_type = validator_for(schema)
|
|
493
601
|
validator_type.check_schema(schema)
|
|
494
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
|
@@ -22,6 +22,7 @@ from devflow_cli.commands.docs_sync import docs_sync
|
|
|
22
22
|
from devflow_cli.commands.traceability import traceability
|
|
23
23
|
from devflow_cli.commands.verify import verify
|
|
24
24
|
from devflow_cli.commands.assess import assess
|
|
25
|
+
from devflow_cli.commands.usage import usage
|
|
25
26
|
|
|
26
27
|
app = typer.Typer(
|
|
27
28
|
name="devflow",
|
|
@@ -61,6 +62,7 @@ app.command(name="docs-sync")(docs_sync)
|
|
|
61
62
|
app.command()(traceability)
|
|
62
63
|
app.command()(verify)
|
|
63
64
|
app.command()(assess)
|
|
65
|
+
app.command()(usage)
|
|
64
66
|
|
|
65
67
|
|
|
66
68
|
if __name__ == "__main__":
|
devflow_cli/commands/adaptive.py
CHANGED
|
@@ -6,6 +6,8 @@ import json
|
|
|
6
6
|
import os
|
|
7
7
|
import shlex
|
|
8
8
|
import tempfile
|
|
9
|
+
import uuid
|
|
10
|
+
from datetime import datetime, timezone
|
|
9
11
|
from pathlib import Path
|
|
10
12
|
from typing import Literal, Optional
|
|
11
13
|
|
|
@@ -44,6 +46,7 @@ from devflow_cli.core.automatic_evidence import (
|
|
|
44
46
|
stale_review_evidence,
|
|
45
47
|
)
|
|
46
48
|
from devflow_cli.core.worklog import refresh_worklog, safe_refresh_worklog
|
|
49
|
+
from devflow_cli.core.telemetry import append_record, telemetry_enabled, usage_payload
|
|
47
50
|
from devflow_cli.core.waivers import create_validation_waiver
|
|
48
51
|
from devflow_cli.core.remediation import (
|
|
49
52
|
carried_changed_files,
|
|
@@ -366,10 +369,15 @@ def run_adaptive(
|
|
|
366
369
|
else:
|
|
367
370
|
fail(str(exc))
|
|
368
371
|
raise typer.Exit(int(ExitCode.INVALID_CONTRACT)) from exc
|
|
372
|
+
run_id = str(uuid.uuid4())
|
|
373
|
+
attempt: dict = {}
|
|
374
|
+
|
|
369
375
|
def execute(activity_contract) -> AgentActivityExecution:
|
|
370
376
|
prompt_text = render_contract_prompt(activity_contract)
|
|
377
|
+
attempt["contract"] = activity_contract
|
|
378
|
+
attempt["startedAt"] = _utc_now()
|
|
371
379
|
try:
|
|
372
|
-
before = snapshot_worktree(project_root)
|
|
380
|
+
before = snapshot_worktree(project_root, feature_dir)
|
|
373
381
|
with tempfile.TemporaryDirectory(prefix="devflow-adaptive-") as temp_dir:
|
|
374
382
|
temp_path = Path(temp_dir)
|
|
375
383
|
schema_file = temp_path / "output-schema.json"
|
|
@@ -389,15 +397,18 @@ def run_adaptive(
|
|
|
389
397
|
if "write" in activity_contract.allowedActions
|
|
390
398
|
else "read-only",
|
|
391
399
|
)
|
|
400
|
+
attempt["execution"] = execution
|
|
392
401
|
output = (
|
|
393
402
|
output_file.read_text(encoding="utf-8")
|
|
394
403
|
if output_file.is_file()
|
|
395
404
|
else execution.stdout
|
|
396
405
|
)
|
|
397
|
-
after = snapshot_worktree(project_root)
|
|
406
|
+
after = snapshot_worktree(project_root, feature_dir)
|
|
398
407
|
observed = tuple(changed_paths(before, after))
|
|
408
|
+
attempt["changedFileCount"] = len(observed)
|
|
399
409
|
audit_payload = {
|
|
400
410
|
"schemaVersion": 1,
|
|
411
|
+
"runId": run_id,
|
|
401
412
|
"agent": agent,
|
|
402
413
|
"activity": activity_contract.activity,
|
|
403
414
|
"returnCode": execution.returncode,
|
|
@@ -423,6 +434,10 @@ def run_adaptive(
|
|
|
423
434
|
)
|
|
424
435
|
|
|
425
436
|
outcome = run_current_activity(feature_dir, feature_path, execute)
|
|
437
|
+
_record_telemetry(
|
|
438
|
+
run_id, attempt, agent, project_root, outcome.reason,
|
|
439
|
+
json_output=json_output,
|
|
440
|
+
)
|
|
426
441
|
if outcome.advanced:
|
|
427
442
|
if json_output:
|
|
428
443
|
_print_execution_json(
|
|
@@ -827,6 +842,52 @@ def _print_execution_json(
|
|
|
827
842
|
}, ensure_ascii=False))
|
|
828
843
|
|
|
829
844
|
|
|
845
|
+
def _utc_now() -> str:
|
|
846
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _record_telemetry(
|
|
850
|
+
run_id: str,
|
|
851
|
+
attempt: dict,
|
|
852
|
+
agent: str,
|
|
853
|
+
project_root: Path,
|
|
854
|
+
reason: str,
|
|
855
|
+
*,
|
|
856
|
+
json_output: bool,
|
|
857
|
+
) -> None:
|
|
858
|
+
"""Consigne la consommation d'une tentative ; ne bloque jamais l'activite."""
|
|
859
|
+
execution = attempt.get("execution")
|
|
860
|
+
contract = attempt.get("contract")
|
|
861
|
+
if execution is None or contract is None or not telemetry_enabled():
|
|
862
|
+
return
|
|
863
|
+
record = {
|
|
864
|
+
"schemaVersion": 1,
|
|
865
|
+
"runId": run_id,
|
|
866
|
+
"recordedAt": _utc_now(),
|
|
867
|
+
"startedAt": attempt.get("startedAt"),
|
|
868
|
+
"project": str(project_root),
|
|
869
|
+
"issueId": contract.issueId,
|
|
870
|
+
"profile": contract.profile,
|
|
871
|
+
"activity": contract.activity,
|
|
872
|
+
"contractId": contract.contractId,
|
|
873
|
+
"agent": agent,
|
|
874
|
+
"sessionId": execution.session_id,
|
|
875
|
+
"outcome": reason,
|
|
876
|
+
"advanced": reason == "advanced",
|
|
877
|
+
"returnCode": execution.returncode,
|
|
878
|
+
"timedOut": execution.timed_out,
|
|
879
|
+
"durationSeconds": round(execution.duration_seconds, 3),
|
|
880
|
+
"finalOutputDiagnostic": execution.final_output_diagnostic,
|
|
881
|
+
"worktreeChangedFileCount": attempt.get("changedFileCount"),
|
|
882
|
+
"usage": usage_payload(execution.usage),
|
|
883
|
+
}
|
|
884
|
+
try:
|
|
885
|
+
append_record(record)
|
|
886
|
+
except OSError as exc:
|
|
887
|
+
if not json_output:
|
|
888
|
+
warn(f"Telemetrie non enregistree : {exc}")
|
|
889
|
+
|
|
890
|
+
|
|
830
891
|
def _write_json_atomic(path: Path, payload: dict) -> None:
|
|
831
892
|
temporary: Path | None = None
|
|
832
893
|
try:
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""devflow usage — Resume de la consommation enregistree des activites agent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from devflow_cli.core.telemetry import (
|
|
12
|
+
GROUP_KEYS,
|
|
13
|
+
filter_records,
|
|
14
|
+
parse_since,
|
|
15
|
+
read_records,
|
|
16
|
+
summarize,
|
|
17
|
+
telemetry_enabled,
|
|
18
|
+
telemetry_file,
|
|
19
|
+
)
|
|
20
|
+
from devflow_cli.utils.console import console, fail, warn
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def usage(
|
|
24
|
+
since: Optional[str] = typer.Option(
|
|
25
|
+
None, "--since", help="Periode : 7d, 12h ou une date ISO"
|
|
26
|
+
),
|
|
27
|
+
by: str = typer.Option(
|
|
28
|
+
"activity", "--by", help=f"Regroupement : {', '.join(GROUP_KEYS)}"
|
|
29
|
+
),
|
|
30
|
+
project: Optional[str] = typer.Option(
|
|
31
|
+
None, "--project", help="Ne garder que les projets dont le chemin contient ce texte"
|
|
32
|
+
),
|
|
33
|
+
json_output: bool = typer.Option(False, "--json", help="Sortie JSON"),
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Resume la consommation des tentatives agent (~/.devflow/telemetry.jsonl)."""
|
|
36
|
+
try:
|
|
37
|
+
start = parse_since(since) if since else None
|
|
38
|
+
rows = summarize(
|
|
39
|
+
filter_records(read_records(), since=start, project=project), by
|
|
40
|
+
)
|
|
41
|
+
except ValueError as exc:
|
|
42
|
+
fail(str(exc))
|
|
43
|
+
raise typer.Exit(2) from exc
|
|
44
|
+
|
|
45
|
+
if json_output:
|
|
46
|
+
console.print_json(json.dumps({"source": str(telemetry_file()), "by": by, "rows": rows}))
|
|
47
|
+
return
|
|
48
|
+
if not telemetry_enabled():
|
|
49
|
+
warn("Collecte desactivee (DEVFLOW_TELEMETRY=0) : aucune nouvelle tentative n'est enregistree.")
|
|
50
|
+
if not rows:
|
|
51
|
+
console.print(f"Aucune tentative enregistree dans {telemetry_file()}.")
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
table = Table(title=f"Consommation par {by}")
|
|
55
|
+
for column in (
|
|
56
|
+
by, "essais", "ok", "entree", "cache lu", "sortie", "USD", "tours", "q.5h", "q.sem",
|
|
57
|
+
):
|
|
58
|
+
table.add_column(column, no_wrap=True, justify="left" if column == by else "right")
|
|
59
|
+
for row in rows:
|
|
60
|
+
table.add_row(
|
|
61
|
+
row[by],
|
|
62
|
+
str(row["attempts"]),
|
|
63
|
+
str(row["advanced"]),
|
|
64
|
+
_tokens(row["inputTokens"]),
|
|
65
|
+
_tokens(row["cacheReadTokens"]),
|
|
66
|
+
_tokens(row["outputTokens"]),
|
|
67
|
+
_optional(row["costUsd"], "{:.2f}"),
|
|
68
|
+
_optional(row["meanTurns"], "{:g}"),
|
|
69
|
+
_optional(row["quota5hPoints"], "{:g}"),
|
|
70
|
+
_optional(row["quotaWeeklyPoints"], "{:g}"),
|
|
71
|
+
)
|
|
72
|
+
console.print(table)
|
|
73
|
+
console.print(
|
|
74
|
+
"essais = tentatives agent ; ok = tentatives avancees ; q. = points de quota Codex ; "
|
|
75
|
+
"detail complet (cache ecrit, contexte au 1er tour) avec --json. "
|
|
76
|
+
"Les tokens de Claude et de Codex ne sont pas comparables entre eux ; "
|
|
77
|
+
"le cout USD est l'equivalent API declare par Claude, les quotas sont "
|
|
78
|
+
"releves dans les journaux Codex."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _tokens(value: int | None) -> str:
|
|
83
|
+
if value is None:
|
|
84
|
+
return "-"
|
|
85
|
+
for suffix, divisor in (("M", 1_000_000), ("k", 1_000)):
|
|
86
|
+
if value >= divisor:
|
|
87
|
+
return f"{value / divisor:.1f}{suffix}"
|
|
88
|
+
return str(value)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _optional(value, pattern: str) -> str:
|
|
92
|
+
return "-" if value is None else pattern.format(value)
|
|
@@ -296,7 +296,7 @@ def reconcile_current_activity(
|
|
|
296
296
|
raise ValueError(
|
|
297
297
|
"la tentative ne correspond pas au contrat de l'activite courante"
|
|
298
298
|
)
|
|
299
|
-
snapshot = snapshot_worktree(project_root_for_feature(feature_dir))
|
|
299
|
+
snapshot = snapshot_worktree(project_root_for_feature(feature_dir), feature_dir)
|
|
300
300
|
if snapshot.head != attempt.get("head") or snapshot.branch != attempt.get("branch"):
|
|
301
301
|
raise ValueError("HEAD ou la branche a change depuis la tentative")
|
|
302
302
|
drifted = sorted(
|
|
@@ -784,7 +784,7 @@ def _record_failed_attempt(
|
|
|
784
784
|
) -> None:
|
|
785
785
|
"""Consigne les faits d'une tentative refusee pour une reprise controlee."""
|
|
786
786
|
try:
|
|
787
|
-
snapshot = snapshot_worktree(project_root_for_feature(feature_dir))
|
|
787
|
+
snapshot = snapshot_worktree(project_root_for_feature(feature_dir), feature_dir)
|
|
788
788
|
except (OSError, ValueError):
|
|
789
789
|
return
|
|
790
790
|
payload = {
|
devflow_cli/core/remediation.py
CHANGED
|
@@ -208,7 +208,7 @@ def _active_attempt_files(feature_dir: Path, activity: str) -> list[str]:
|
|
|
208
208
|
or not all(isinstance(path, str) for path in carried)
|
|
209
209
|
):
|
|
210
210
|
raise StaleAttemptError(f"fiche de tentative {activity} invalide")
|
|
211
|
-
snapshot = snapshot_worktree(project_root_for_feature(feature_dir))
|
|
211
|
+
snapshot = snapshot_worktree(project_root_for_feature(feature_dir), feature_dir)
|
|
212
212
|
if snapshot.head != payload["head"] or snapshot.branch != payload["branch"]:
|
|
213
213
|
raise StaleAttemptError(
|
|
214
214
|
f"HEAD ou la branche a change depuis la tentative {activity}"
|
|
@@ -243,7 +243,7 @@ def _still_modified(feature_dir: Path, files: list[str]) -> list[str]:
|
|
|
243
243
|
if not files:
|
|
244
244
|
return []
|
|
245
245
|
try:
|
|
246
|
-
snapshot = snapshot_worktree(project_root_for_feature(feature_dir))
|
|
246
|
+
snapshot = snapshot_worktree(project_root_for_feature(feature_dir), feature_dir)
|
|
247
247
|
except (OSError, ValueError):
|
|
248
248
|
return files
|
|
249
249
|
return [path for path in files if path in snapshot.files]
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Telemetrie locale de consommation des activites agent.
|
|
2
|
+
|
|
3
|
+
Chaque tentative `adaptive run` confiee a un agent ajoute une ligne JSON a
|
|
4
|
+
`~/.devflow/telemetry.jsonl`. Le fichier est commun a tous les projets, car les
|
|
5
|
+
quotas des agents sont partages par le compte. Il ne contient ni prompt, ni
|
|
6
|
+
reponse, ni contenu de fichier. `DEVFLOW_TELEMETRY=0` desactive la collecte ;
|
|
7
|
+
`DEVFLOW_HOME` deplace le repertoire.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import statistics
|
|
15
|
+
from collections import defaultdict
|
|
16
|
+
from dataclasses import asdict
|
|
17
|
+
from datetime import datetime, timedelta, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Iterable
|
|
20
|
+
|
|
21
|
+
from devflow_cli.adapters.agents import AgentUsage
|
|
22
|
+
|
|
23
|
+
TELEMETRY_SCHEMA_VERSION = 1
|
|
24
|
+
TELEMETRY_ENV = "DEVFLOW_TELEMETRY"
|
|
25
|
+
DEVFLOW_HOME_ENV = "DEVFLOW_HOME"
|
|
26
|
+
_DISABLED_VALUES = {"0", "false", "no", "off"}
|
|
27
|
+
GROUP_KEYS = ("activity", "profile", "agent", "model", "project")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def telemetry_enabled() -> bool:
|
|
31
|
+
return os.environ.get(TELEMETRY_ENV, "1").strip().casefold() not in _DISABLED_VALUES
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def telemetry_file() -> Path:
|
|
35
|
+
home = os.environ.get(DEVFLOW_HOME_ENV)
|
|
36
|
+
return (Path(home) if home else Path.home() / ".devflow") / "telemetry.jsonl"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def usage_payload(usage: AgentUsage | None) -> dict[str, Any] | None:
|
|
40
|
+
if usage is None:
|
|
41
|
+
return None
|
|
42
|
+
payload = asdict(usage)
|
|
43
|
+
payload["models"] = list(usage.models)
|
|
44
|
+
payload["quota"] = [asdict(window) for window in usage.quota]
|
|
45
|
+
return payload
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def append_record(record: dict[str, Any], path: Path | None = None) -> Path:
|
|
49
|
+
"""Ajoute une ligne en une seule ecriture O_APPEND ; leve OSError en cas d'echec."""
|
|
50
|
+
target = path or telemetry_file()
|
|
51
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
line = (json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n").encode("utf-8")
|
|
53
|
+
descriptor = os.open(target, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
|
|
54
|
+
try:
|
|
55
|
+
os.write(descriptor, line)
|
|
56
|
+
finally:
|
|
57
|
+
os.close(descriptor)
|
|
58
|
+
return target
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def read_records(path: Path | None = None) -> list[dict[str, Any]]:
|
|
62
|
+
"""Lit les tentatives enregistrees en ignorant les lignes illisibles."""
|
|
63
|
+
target = path or telemetry_file()
|
|
64
|
+
if not target.is_file():
|
|
65
|
+
return []
|
|
66
|
+
records: list[dict[str, Any]] = []
|
|
67
|
+
for line in target.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
68
|
+
try:
|
|
69
|
+
record = json.loads(line)
|
|
70
|
+
except json.JSONDecodeError:
|
|
71
|
+
continue
|
|
72
|
+
if isinstance(record, dict) and record.get("schemaVersion") == TELEMETRY_SCHEMA_VERSION:
|
|
73
|
+
records.append(record)
|
|
74
|
+
return records
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def parse_since(value: str, now: datetime | None = None) -> datetime:
|
|
78
|
+
"""Accepte une duree (`7d`, `12h`) ou une date ISO."""
|
|
79
|
+
reference = now or datetime.now(timezone.utc)
|
|
80
|
+
text = value.strip()
|
|
81
|
+
if len(text) > 1 and text[-1] in {"d", "h"} and text[:-1].isdigit():
|
|
82
|
+
amount = int(text[:-1])
|
|
83
|
+
return reference - (timedelta(days=amount) if text[-1] == "d" else timedelta(hours=amount))
|
|
84
|
+
try:
|
|
85
|
+
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
86
|
+
except ValueError as exc:
|
|
87
|
+
raise ValueError(f"--since invalide : {value} (attendu 7d, 12h ou une date ISO)") from exc
|
|
88
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def filter_records(
|
|
92
|
+
records: Iterable[dict[str, Any]],
|
|
93
|
+
*,
|
|
94
|
+
since: datetime | None = None,
|
|
95
|
+
project: str | None = None,
|
|
96
|
+
) -> list[dict[str, Any]]:
|
|
97
|
+
selected = []
|
|
98
|
+
for record in records:
|
|
99
|
+
if project and project not in str(record.get("project", "")):
|
|
100
|
+
continue
|
|
101
|
+
if since is not None:
|
|
102
|
+
try:
|
|
103
|
+
recorded = datetime.fromisoformat(str(record.get("recordedAt", "")).replace("Z", "+00:00"))
|
|
104
|
+
except ValueError:
|
|
105
|
+
continue
|
|
106
|
+
if recorded < since:
|
|
107
|
+
continue
|
|
108
|
+
selected.append(record)
|
|
109
|
+
return selected
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _group_value(record: dict[str, Any], key: str) -> str:
|
|
113
|
+
if key == "model":
|
|
114
|
+
usage = record.get("usage") or {}
|
|
115
|
+
models = ",".join(usage.get("models") or []) or "?"
|
|
116
|
+
effort = usage.get("effort")
|
|
117
|
+
return f"{models}/{effort}" if effort else models
|
|
118
|
+
if key == "project":
|
|
119
|
+
return Path(str(record.get("project") or "?")).name
|
|
120
|
+
return str(record.get(key) or "?")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _quota_delta(record: dict[str, Any], minutes: int) -> float | None:
|
|
124
|
+
usage = record.get("usage") or {}
|
|
125
|
+
for window in usage.get("quota") or []:
|
|
126
|
+
if window.get("windowMinutes") == minutes and window.get("deltaPoints") is not None:
|
|
127
|
+
return float(window["deltaPoints"])
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def summarize(records: list[dict[str, Any]], by: str) -> list[dict[str, Any]]:
|
|
132
|
+
"""Agrege les tentatives par cle, sans melanger les unites des agents."""
|
|
133
|
+
if by not in GROUP_KEYS:
|
|
134
|
+
raise ValueError(f"--by invalide : {by} (valeurs : {', '.join(GROUP_KEYS)})")
|
|
135
|
+
groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
136
|
+
for record in records:
|
|
137
|
+
groups[_group_value(record, by)].append(record)
|
|
138
|
+
rows = []
|
|
139
|
+
for name, items in groups.items():
|
|
140
|
+
usages = [record.get("usage") or {} for record in items]
|
|
141
|
+
measured = [usage for usage in usages if usage]
|
|
142
|
+
costs = [usage["costUsd"] for usage in measured if usage.get("costUsd") is not None]
|
|
143
|
+
turns = [usage["turns"] for usage in measured if usage.get("turns") is not None]
|
|
144
|
+
contexts = [
|
|
145
|
+
usage["firstContextTokens"]
|
|
146
|
+
for usage in measured
|
|
147
|
+
if usage.get("firstContextTokens") is not None
|
|
148
|
+
]
|
|
149
|
+
five_hours = [d for d in (_quota_delta(r, 300) for r in items) if d is not None]
|
|
150
|
+
weekly = [d for d in (_quota_delta(r, 10080) for r in items) if d is not None]
|
|
151
|
+
rows.append({
|
|
152
|
+
by: name,
|
|
153
|
+
"attempts": len(items),
|
|
154
|
+
"advanced": sum(1 for record in items if record.get("advanced")),
|
|
155
|
+
"contracts": len({record.get("contractId") for record in items}),
|
|
156
|
+
"measured": len(measured),
|
|
157
|
+
"durationSeconds": round(sum(float(r.get("durationSeconds") or 0) for r in items), 1),
|
|
158
|
+
"inputTokens": sum(int(u.get("inputTokens") or 0) for u in measured),
|
|
159
|
+
"cacheReadTokens": sum(int(u.get("cacheReadTokens") or 0) for u in measured),
|
|
160
|
+
"cacheWriteTokens": sum(int(u.get("cacheWriteTokens") or 0) for u in measured),
|
|
161
|
+
"outputTokens": sum(int(u.get("outputTokens") or 0) for u in measured),
|
|
162
|
+
"costUsd": round(sum(costs), 4) if costs else None,
|
|
163
|
+
"meanTurns": round(statistics.mean(turns), 1) if turns else None,
|
|
164
|
+
"medianFirstContextTokens": int(statistics.median(contexts)) if contexts else None,
|
|
165
|
+
"quota5hPoints": sum(five_hours) if five_hours else None,
|
|
166
|
+
"quotaWeeklyPoints": sum(weekly) if weekly else None,
|
|
167
|
+
})
|
|
168
|
+
return sorted(rows, key=lambda row: (-row["attempts"], row[by]))
|
|
@@ -17,8 +17,19 @@ class WorktreeSnapshot:
|
|
|
17
17
|
files: dict[str, str]
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
# Traces brutes d'agent ecrites par devflow ; ignorees par le .gitignore de la feature.
|
|
21
|
+
TRACE_SUFFIX = ".raw.txt"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def snapshot_worktree(
|
|
25
|
+
project_root: Path, feature_dir: Path | None = None
|
|
26
|
+
) -> WorktreeSnapshot:
|
|
27
|
+
"""Capture HEAD, branche et contenu des fichiers modifies/non suivis.
|
|
28
|
+
|
|
29
|
+
Avec ``feature_dir``, les fichiers ignores par git dans ce seul dossier
|
|
30
|
+
sont aussi captures, hors traces brutes : un projet qui ignore ``.specify``
|
|
31
|
+
doit tout de meme voir les artefacts de sa feature.
|
|
32
|
+
"""
|
|
22
33
|
head = _git_text(project_root, ["rev-parse", "--verify", "HEAD"], allow_failure=True)
|
|
23
34
|
branch = _git_text(project_root, ["branch", "--show-current"], allow_failure=True)
|
|
24
35
|
tracked = _git_paths(
|
|
@@ -30,7 +41,15 @@ def snapshot_worktree(project_root: Path) -> WorktreeSnapshot:
|
|
|
30
41
|
project_root,
|
|
31
42
|
["ls-files", "--others", "--exclude-standard", "-z"],
|
|
32
43
|
)
|
|
33
|
-
paths =
|
|
44
|
+
paths = set(tracked) | set(untracked)
|
|
45
|
+
subtree = _subtree(project_root, feature_dir)
|
|
46
|
+
if subtree is not None:
|
|
47
|
+
ignored = _git_paths(
|
|
48
|
+
project_root,
|
|
49
|
+
["ls-files", "--others", "--ignored", "--exclude-standard", "-z", "--", subtree],
|
|
50
|
+
)
|
|
51
|
+
paths |= {path for path in ignored if not path.endswith(TRACE_SUFFIX)}
|
|
52
|
+
paths = sorted(paths)
|
|
34
53
|
return WorktreeSnapshot(
|
|
35
54
|
head=head,
|
|
36
55
|
branch=branch,
|
|
@@ -64,6 +83,15 @@ def changed_paths(before: WorktreeSnapshot, after: WorktreeSnapshot) -> list[str
|
|
|
64
83
|
return sorted(path for path in paths if before.files.get(path) != after.files.get(path))
|
|
65
84
|
|
|
66
85
|
|
|
86
|
+
def _subtree(project_root: Path, feature_dir: Path | None) -> str | None:
|
|
87
|
+
if feature_dir is None:
|
|
88
|
+
return None
|
|
89
|
+
try:
|
|
90
|
+
return feature_dir.resolve().relative_to(project_root.resolve()).as_posix()
|
|
91
|
+
except ValueError:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
67
95
|
def _git_text(project_root: Path, args: list[str], *, allow_failure: bool = False) -> str:
|
|
68
96
|
result = subprocess.run(
|
|
69
97
|
["git", *args],
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
devflow_cli/__init__.py,sha256=
|
|
2
|
-
devflow_cli/cli.py,sha256=
|
|
1
|
+
devflow_cli/__init__.py,sha256=VmCwbQc6bNPKKQvcsirS32ZlbjHbOt8DbCAfd3EcBfA,1002
|
|
2
|
+
devflow_cli/cli.py,sha256=nst8kP2QneJZJg6K5bBf9uO8Nj9lYmoHrEhcxA7nsIM,2129
|
|
3
3
|
devflow_cli/adapters/__init__.py,sha256=RkjHPU2Lp4hSVIq2iOmyTrv2zYU9NuiIF9wEMWzLdMI,377
|
|
4
|
-
devflow_cli/adapters/agents.py,sha256=
|
|
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=_CICxGw27XOSnXteudn75IriY12zz5jhcZoH__zNfns,45797
|
|
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
|
|
@@ -19,10 +19,11 @@ devflow_cli/commands/stale.py,sha256=UiaUb48lrXmPyjfvbFkChfLE-rs58KhgKu-zgL_NBIo
|
|
|
19
19
|
devflow_cli/commands/status.py,sha256=b7r8lFcUOuph57BPSQzSVGr4039sIzrsSV1gW77KM54,7244
|
|
20
20
|
devflow_cli/commands/traceability.py,sha256=DhUOBzSmKVk17j1Qz0yxD5zD2-iNWaR9F133VNPjy70,1767
|
|
21
21
|
devflow_cli/commands/upgrade_cmd.py,sha256=d5uBFUPzPsCJDLxhQBFaeHWJv3GWWLmPpkYHRVd1gwM,5664
|
|
22
|
+
devflow_cli/commands/usage.py,sha256=ehH1AH5J-XfkebFx6qLBwpKN-MOoX2uUbq4LejBpNsk,3148
|
|
22
23
|
devflow_cli/commands/verify.py,sha256=sKmoHfA-PRWoDbgLC0nW2nvd89Zdktlkep0sxF7q3jQ,3968
|
|
23
24
|
devflow_cli/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
24
25
|
devflow_cli/core/activity_contracts.py,sha256=U9AknLNFIusjuh4MonY_8i3__uDeo_NF2z3zfGJynA8,42631
|
|
25
|
-
devflow_cli/core/adaptive_runner.py,sha256=
|
|
26
|
+
devflow_cli/core/adaptive_runner.py,sha256=o9HHPNZJyA9u_zui9pNZrzMkH_jzszbI-GMweoRjqB8,33713
|
|
26
27
|
devflow_cli/core/adaptive_workflow.py,sha256=qsjkahK9yklf6LjyQn69PVl9v0AoX2YPQiG2_umpb_U,13065
|
|
27
28
|
devflow_cli/core/agent_failure.py,sha256=iX4q249lAHeVdD_ZF1aObmU-FX6sjfYP9dfLSnWGAD0,3771
|
|
28
29
|
devflow_cli/core/assessment.py,sha256=l5QRntLJyBx3MdN-kepmMAsruHdJsG4TDgSdgOSHkqU,10062
|
|
@@ -37,16 +38,17 @@ devflow_cli/core/manifest.py,sha256=964iYthqhzf3qQgxN4hZ33BvS2r7BKDZoKdiOWRybag,
|
|
|
37
38
|
devflow_cli/core/omissions.py,sha256=-9dqaQLRIy-1t6it7gecKn_0KpqbEjPIT0ec86KvXCk,7449
|
|
38
39
|
devflow_cli/core/operational.py,sha256=ZEWh6DKJfHYLpV9r3vZwaDFREQwlJF0wTtgB1VleXog,1204
|
|
39
40
|
devflow_cli/core/redaction.py,sha256=o_1MDtCyvRLrk_dWlV6_7E6_mPVB1B5a0cCfTBj9j0Q,1973
|
|
40
|
-
devflow_cli/core/remediation.py,sha256=
|
|
41
|
+
devflow_cli/core/remediation.py,sha256=FknpUBR9WGZkmeDf6F2HINKKm3rG4AB5C7NTgrPhPVc,13575
|
|
41
42
|
devflow_cli/core/scanner.py,sha256=bcoC91Nc0Hw2P7aHpL-9ITxynLZhLTEKpOXo553a-18,4395
|
|
42
43
|
devflow_cli/core/staleness.py,sha256=Bn4jN8r601Ifn0AU8BHZy-Z-5NLPjj_JTK-V0KU9TUM,5256
|
|
44
|
+
devflow_cli/core/telemetry.py,sha256=emcYo6Qikc86TYKT5Mc_9-3Thkt3nztkPZrpTy9VlvA,6952
|
|
43
45
|
devflow_cli/core/traceability.py,sha256=jfNE9MZzGkmaPer9Wa6Y18yA9Y3sqdeR-nzH9h_TB-8,2968
|
|
44
46
|
devflow_cli/core/validation_approvals.py,sha256=BMjwDbkkY9K-uqk19duKWIbh7-Dk5ceprNPgQHx3lrg,8421
|
|
45
47
|
devflow_cli/core/validators.py,sha256=c2T6NAe80sPh2uS8gCQfyiCkLT3eBPLizCbDsE0PgNo,9934
|
|
46
48
|
devflow_cli/core/verification.py,sha256=a2-pAnMQqBhGnV87LwhMc03zFRjDECB7sWYGxQu21lo,16879
|
|
47
49
|
devflow_cli/core/waivers.py,sha256=w3bza1OicSxAViYU2hvbrylQTNVgmN7Rd9y2L6Wm2OE,11302
|
|
48
50
|
devflow_cli/core/worklog.py,sha256=w_R_K-xI7gx9lYQhcjJbI_nBQqztMHsOPWj1tQCXjjA,12668
|
|
49
|
-
devflow_cli/core/worktree_snapshot.py,sha256=
|
|
51
|
+
devflow_cli/core/worktree_snapshot.py,sha256=5ycGW5sYSeoht-ofUqoNhrscZuhTJlynfObzisJal90,4669
|
|
50
52
|
devflow_cli/schemas/activity-contract-v3.schema.json,sha256=_R6oKY2K3LxNCIATQunDM7e41J6w4Dg15TrN3B-NL0c,1977
|
|
51
53
|
devflow_cli/schemas/activity-result-v1.schema.json,sha256=UybTDGJcPZ_CJoD0oAlgBWJOEsQTp06KRnogoa1pFFY,3140
|
|
52
54
|
devflow_cli/schemas/assessment-v3.schema.json,sha256=aCybk-TaE5aJDKA2dhadSq58a-t2hFy6A-hCDlIl2Yo,1798
|
|
@@ -83,8 +85,8 @@ devflow_cli/resources/templates/contracts/api-template.md,sha256=rC5v7JBz71PznCn
|
|
|
83
85
|
devflow_cli/resources/templates/contracts/components-template.md,sha256=XIWfEXfC72sNmL5OA3V-7xIywkNDU2DBPLfyiagOWcs,484
|
|
84
86
|
devflow_cli/resources/templates/contracts/interfaces-template.md,sha256=kf8B7b6HsmN7jyRlLNU7RnZl0rTMlI5s97yVRyI-6yc,336
|
|
85
87
|
devflow_cli/resources/templates/contracts/services-template.md,sha256=cFafKCyLmq_a7aR7G0LXAaMO2z4Fuflq6iKdxfVg0vg,477
|
|
86
|
-
devflow_cli-6.
|
|
87
|
-
devflow_cli-6.
|
|
88
|
-
devflow_cli-6.
|
|
89
|
-
devflow_cli-6.
|
|
90
|
-
devflow_cli-6.
|
|
88
|
+
devflow_cli-6.1.1.dist-info/METADATA,sha256=KHMl28FYPV7eUHT9Hn6GH4VwneeUhK_H8Zb9NaD5Kqs,4061
|
|
89
|
+
devflow_cli-6.1.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
90
|
+
devflow_cli-6.1.1.dist-info/entry_points.txt,sha256=GVJ6bMvFpqyO5589iC4nFKeGTpGIW8aKn_QPPpSZ6kU,48
|
|
91
|
+
devflow_cli-6.1.1.dist-info/licenses/LICENSE,sha256=v79PvnmqOdKhQiOa_QcWxng4q8GuGscLOwi7ygUpwpo,1070
|
|
92
|
+
devflow_cli-6.1.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|