devflow-cli 3.0.1__py3-none-any.whl → 6.0.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 +4 -2
- devflow_cli/adapters/agents.py +177 -28
- devflow_cli/cli.py +0 -2
- devflow_cli/commands/adaptive.py +569 -75
- 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/verify.py +30 -0
- devflow_cli/core/activity_contracts.py +277 -29
- devflow_cli/core/adaptive_runner.py +572 -40
- devflow_cli/core/adaptive_workflow.py +90 -34
- devflow_cli/core/agent_failure.py +113 -0
- devflow_cli/core/assessment.py +15 -4
- devflow_cli/core/automatic_evidence.py +249 -41
- devflow_cli/core/contract_schemas.py +21 -4
- devflow_cli/core/docgen.py +4 -3
- devflow_cli/core/manifest.py +9 -1
- devflow_cli/core/omissions.py +168 -0
- devflow_cli/core/operational.py +6 -2
- devflow_cli/core/remediation.py +203 -25
- 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 +32 -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-v3.schema.json +32 -0
- devflow_cli/schemas/activity-result-v1.schema.json +13 -1
- devflow_cli/schemas/{assessment-v1.schema.json → assessment-v3.schema.json} +8 -32
- devflow_cli/schemas/workflow-state-v4.schema.json +55 -0
- devflow_cli/utils/paths.py +17 -2
- {devflow_cli-3.0.1.dist-info → devflow_cli-6.0.0.dist-info}/METADATA +11 -9
- {devflow_cli-3.0.1.dist-info → devflow_cli-6.0.0.dist-info}/RECORD +38 -36
- {devflow_cli-3.0.1.dist-info → devflow_cli-6.0.0.dist-info}/WHEEL +1 -1
- devflow_cli/commands/migrate_cmd.py +0 -200
- devflow_cli/schemas/activity-contract-v1.schema.json +0 -76
- devflow_cli/schemas/workflow-state-v2.schema.json +0 -66
- {devflow_cli-3.0.1.dist-info → devflow_cli-6.0.0.dist-info}/entry_points.txt +0 -0
- {devflow_cli-3.0.1.dist-info → devflow_cli-6.0.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.0.0"
|
|
4
4
|
|
|
5
5
|
SUPPORTED_AGENTS = ["claude-code", "codex", "cursor"]
|
|
6
6
|
|
|
@@ -13,6 +13,7 @@ ACTIVITY_LINEAR_MAPPING = {
|
|
|
13
13
|
"contracts": "Plan",
|
|
14
14
|
"plan": "Plan",
|
|
15
15
|
"tasks": "Tasks",
|
|
16
|
+
"preflight-evidence": "Plan",
|
|
16
17
|
"implement": "In Progress",
|
|
17
18
|
"verify": "Review",
|
|
18
19
|
"review": "Review",
|
|
@@ -38,9 +39,10 @@ ARTIFACTS = [
|
|
|
38
39
|
"contracts.md",
|
|
39
40
|
"plan.md",
|
|
40
41
|
"tasks.md",
|
|
42
|
+
"preflight-evidence.md",
|
|
41
43
|
"worklog.md",
|
|
42
44
|
"activity-contract.json",
|
|
43
|
-
"
|
|
45
|
+
"implement-result.json",
|
|
44
46
|
"verification.json",
|
|
45
47
|
]
|
|
46
48
|
|
devflow_cli/adapters/agents.py
CHANGED
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
import subprocess
|
|
7
7
|
import re
|
|
8
|
+
import time
|
|
8
9
|
from abc import ABC, abstractmethod
|
|
9
10
|
from dataclasses import dataclass
|
|
10
11
|
from pathlib import Path
|
|
@@ -17,6 +18,12 @@ from jsonschema.validators import validator_for
|
|
|
17
18
|
PermissionMode = Literal["auto", "acceptEdits", "manual"]
|
|
18
19
|
SandboxMode = Literal["read-only", "workspace-write"]
|
|
19
20
|
|
|
21
|
+
_SAFE_DIAGNOSTIC_MESSAGES = {
|
|
22
|
+
"final-output-empty": "--output-last-message est vide",
|
|
23
|
+
"final-output-absent": "--output-last-message est absent",
|
|
24
|
+
"agent-turn-failed": "le tour agent a echoue avant la reponse finale",
|
|
25
|
+
}
|
|
26
|
+
|
|
20
27
|
|
|
21
28
|
@dataclass(frozen=True)
|
|
22
29
|
class AgentExecution:
|
|
@@ -24,6 +31,19 @@ class AgentExecution:
|
|
|
24
31
|
stdout: str = ""
|
|
25
32
|
stderr: str = ""
|
|
26
33
|
timed_out: bool = False
|
|
34
|
+
duration_seconds: float = 0.0
|
|
35
|
+
final_output_status: str = "unknown"
|
|
36
|
+
final_output_bytes: int = 0
|
|
37
|
+
final_output_diagnostic: str = "not-evaluated"
|
|
38
|
+
session_id: str | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class StructuredOutputError(ValueError):
|
|
42
|
+
"""Erreur de sortie agent dont le diagnostic peut etre expose sans contenu."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, diagnostic: str, message: str) -> None:
|
|
45
|
+
super().__init__(message)
|
|
46
|
+
self.diagnostic = diagnostic
|
|
27
47
|
|
|
28
48
|
|
|
29
49
|
class AgentAdapter(ABC):
|
|
@@ -66,6 +86,7 @@ class AgentAdapter(ABC):
|
|
|
66
86
|
timeout=timeout,
|
|
67
87
|
text=True,
|
|
68
88
|
capture_output=capture_output,
|
|
89
|
+
stdin=subprocess.DEVNULL,
|
|
69
90
|
shell=False,
|
|
70
91
|
)
|
|
71
92
|
return AgentExecution(
|
|
@@ -96,6 +117,7 @@ class AgentAdapter(ABC):
|
|
|
96
117
|
command = self.build_structured_command(
|
|
97
118
|
prompt, permission_mode, schema_file, output_file, sandbox_mode
|
|
98
119
|
)
|
|
120
|
+
started = time.monotonic()
|
|
99
121
|
try:
|
|
100
122
|
result = subprocess.run(
|
|
101
123
|
command,
|
|
@@ -103,12 +125,18 @@ class AgentAdapter(ABC):
|
|
|
103
125
|
timeout=timeout,
|
|
104
126
|
text=True,
|
|
105
127
|
capture_output=True,
|
|
128
|
+
stdin=subprocess.DEVNULL,
|
|
106
129
|
shell=False,
|
|
107
130
|
)
|
|
108
131
|
execution = AgentExecution(
|
|
109
132
|
result.returncode,
|
|
110
133
|
result.stdout or "",
|
|
111
134
|
result.stderr or "",
|
|
135
|
+
duration_seconds=time.monotonic() - started,
|
|
136
|
+
final_output_status=_output_status(output_file),
|
|
137
|
+
final_output_bytes=_output_size(output_file),
|
|
138
|
+
final_output_diagnostic="process-failed" if result.returncode else "validating",
|
|
139
|
+
session_id=self.session_id(result.stdout or "", result.stderr or ""),
|
|
112
140
|
)
|
|
113
141
|
if execution.returncode != 0:
|
|
114
142
|
return execution
|
|
@@ -118,28 +146,73 @@ class AgentAdapter(ABC):
|
|
|
118
146
|
output_file.write_text(
|
|
119
147
|
json.dumps(payload, ensure_ascii=False), encoding="utf-8"
|
|
120
148
|
)
|
|
121
|
-
except
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
149
|
+
except StructuredOutputError as exc:
|
|
150
|
+
diagnostic = exc.diagnostic
|
|
151
|
+
except json.JSONDecodeError:
|
|
152
|
+
diagnostic = "final-output-invalid-json"
|
|
153
|
+
except ValidationError:
|
|
154
|
+
diagnostic = "final-output-schema-rejected"
|
|
155
|
+
except SchemaError:
|
|
156
|
+
diagnostic = "output-schema-invalid"
|
|
157
|
+
except OSError:
|
|
158
|
+
diagnostic = "final-output-unreadable"
|
|
159
|
+
except ValueError:
|
|
160
|
+
diagnostic = "final-output-invalid"
|
|
161
|
+
else:
|
|
125
162
|
return AgentExecution(
|
|
126
|
-
|
|
163
|
+
execution.returncode,
|
|
127
164
|
execution.stdout,
|
|
128
|
-
|
|
165
|
+
execution.stderr,
|
|
166
|
+
execution.timed_out,
|
|
167
|
+
execution.duration_seconds,
|
|
168
|
+
_output_status(output_file),
|
|
169
|
+
_output_size(output_file),
|
|
170
|
+
"validated",
|
|
171
|
+
execution.session_id,
|
|
129
172
|
)
|
|
130
|
-
|
|
173
|
+
|
|
174
|
+
status = _output_status(output_file)
|
|
175
|
+
size = _output_size(output_file)
|
|
176
|
+
output_file.unlink(missing_ok=True)
|
|
177
|
+
return AgentExecution(
|
|
178
|
+
1,
|
|
179
|
+
execution.stdout,
|
|
180
|
+
"Sortie structuree invalide "
|
|
181
|
+
f"({self.name}) [{diagnostic}]"
|
|
182
|
+
+ (
|
|
183
|
+
f" : {_SAFE_DIAGNOSTIC_MESSAGES[diagnostic]}"
|
|
184
|
+
if diagnostic in _SAFE_DIAGNOSTIC_MESSAGES
|
|
185
|
+
else ""
|
|
186
|
+
),
|
|
187
|
+
duration_seconds=execution.duration_seconds,
|
|
188
|
+
final_output_status=status,
|
|
189
|
+
final_output_bytes=size,
|
|
190
|
+
final_output_diagnostic=diagnostic,
|
|
191
|
+
session_id=execution.session_id,
|
|
192
|
+
)
|
|
131
193
|
except subprocess.TimeoutExpired as exc:
|
|
132
194
|
return AgentExecution(
|
|
133
195
|
124,
|
|
134
196
|
_text(exc.stdout),
|
|
135
197
|
_text(exc.stderr),
|
|
136
198
|
timed_out=True,
|
|
199
|
+
duration_seconds=time.monotonic() - started,
|
|
200
|
+
final_output_status=_output_status(output_file),
|
|
201
|
+
final_output_bytes=_output_size(output_file),
|
|
137
202
|
)
|
|
138
203
|
except FileNotFoundError:
|
|
139
|
-
return AgentExecution(127, stderr=f"Executable '{self.executable}' introuvable")
|
|
204
|
+
return AgentExecution(127, stderr=f"Executable '{self.executable}' introuvable", duration_seconds=time.monotonic() - started)
|
|
205
|
+
|
|
206
|
+
def session_id(self, stdout: str, stderr: str) -> str | None:
|
|
207
|
+
"""Extrait un identifiant opaque de session, sans conserver la sortie."""
|
|
208
|
+
return _session_id(stdout, stderr)
|
|
209
|
+
|
|
210
|
+
def recover_structured_payload(self, raw_output: str) -> object:
|
|
211
|
+
"""Extrait la reponse structuree d'une trace brute conservee apres un echec."""
|
|
212
|
+
return self._structured_payload(raw_output, None)
|
|
140
213
|
|
|
141
|
-
def _structured_payload(self, stdout: str, output_file: Path) -> object:
|
|
142
|
-
if output_file.is_file():
|
|
214
|
+
def _structured_payload(self, stdout: str, output_file: Path | None) -> object:
|
|
215
|
+
if output_file is not None and output_file.is_file():
|
|
143
216
|
return json.loads(output_file.read_text(encoding="utf-8"))
|
|
144
217
|
return json.loads(stdout)
|
|
145
218
|
|
|
@@ -169,7 +242,7 @@ class ClaudeCodeAdapter(AgentAdapter):
|
|
|
169
242
|
sandbox_mode: SandboxMode = "workspace-write",
|
|
170
243
|
) -> list[str]:
|
|
171
244
|
del output_file
|
|
172
|
-
schema = schema_file
|
|
245
|
+
schema = _claude_compatible_schema(schema_file)
|
|
173
246
|
effective_mode = "plan" if sandbox_mode == "read-only" else permission_mode
|
|
174
247
|
return [
|
|
175
248
|
self.executable,
|
|
@@ -183,16 +256,19 @@ class ClaudeCodeAdapter(AgentAdapter):
|
|
|
183
256
|
prompt,
|
|
184
257
|
]
|
|
185
258
|
|
|
186
|
-
def _structured_payload(self, stdout: str, output_file: Path) -> object:
|
|
259
|
+
def _structured_payload(self, stdout: str, output_file: Path | None) -> object:
|
|
187
260
|
del output_file
|
|
188
261
|
envelope = json.loads(stdout)
|
|
189
262
|
if not isinstance(envelope, dict):
|
|
190
263
|
raise ValueError("enveloppe JSON Claude absente")
|
|
191
264
|
if envelope.get("subtype") not in {None, "success"}:
|
|
192
265
|
raise ValueError(f"execution Claude terminee avec {envelope.get('subtype')}")
|
|
193
|
-
if "structured_output"
|
|
194
|
-
|
|
195
|
-
|
|
266
|
+
if "structured_output" in envelope:
|
|
267
|
+
return envelope["structured_output"]
|
|
268
|
+
result = envelope.get("result")
|
|
269
|
+
if isinstance(result, str) and result.strip():
|
|
270
|
+
return json.loads(_strip_json_fence(result))
|
|
271
|
+
raise ValueError("champ structured_output absent")
|
|
196
272
|
|
|
197
273
|
|
|
198
274
|
class CodexAdapter(AgentAdapter):
|
|
@@ -222,6 +298,7 @@ class CodexAdapter(AgentAdapter):
|
|
|
222
298
|
else:
|
|
223
299
|
command.extend(["--sandbox", sandbox_mode])
|
|
224
300
|
command.extend([
|
|
301
|
+
"--json",
|
|
225
302
|
"--output-schema",
|
|
226
303
|
str(schema_file),
|
|
227
304
|
"--output-last-message",
|
|
@@ -230,22 +307,36 @@ class CodexAdapter(AgentAdapter):
|
|
|
230
307
|
command.append(prompt)
|
|
231
308
|
return command
|
|
232
309
|
|
|
233
|
-
def
|
|
234
|
-
"""
|
|
235
|
-
|
|
310
|
+
def session_id(self, stdout: str, stderr: str) -> str | None:
|
|
311
|
+
"""Prefere l'identifiant de fil annonce par le flux d'evenements."""
|
|
312
|
+
for event in json_line_events(stdout):
|
|
313
|
+
if event.get("type") == "thread.started" and isinstance(
|
|
314
|
+
event.get("thread_id"), str
|
|
315
|
+
):
|
|
316
|
+
return event["thread_id"]
|
|
317
|
+
return _session_id(stdout, stderr)
|
|
318
|
+
|
|
319
|
+
def _structured_payload(self, stdout: str, output_file: Path | None) -> object:
|
|
320
|
+
"""Lit la reponse finale Codex, sinon la recupere depuis le flux d'evenements."""
|
|
321
|
+
if output_file is not None and output_file.is_file():
|
|
236
322
|
payload = output_file.read_text(encoding="utf-8")
|
|
237
323
|
if payload.strip():
|
|
238
324
|
return json.loads(payload)
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
325
|
+
diagnostic = "final-output-empty"
|
|
326
|
+
message = "Codex a termine sans reponse finale structuree"
|
|
327
|
+
else:
|
|
328
|
+
diagnostic = "final-output-absent"
|
|
329
|
+
message = "Codex n'a pas produit le fichier de reponse finale structuree"
|
|
330
|
+
events = json_line_events(stdout)
|
|
331
|
+
if any(event.get("type") == "turn.failed" for event in events):
|
|
332
|
+
raise StructuredOutputError(
|
|
333
|
+
"agent-turn-failed",
|
|
334
|
+
"Codex a interrompu son tour avant la reponse finale",
|
|
242
335
|
)
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
"ni de sortie standard exploitable"
|
|
248
|
-
)
|
|
336
|
+
final_message = _codex_final_message(events)
|
|
337
|
+
if final_message is not None:
|
|
338
|
+
return json.loads(_strip_json_fence(final_message))
|
|
339
|
+
raise StructuredOutputError(diagnostic, message)
|
|
249
340
|
|
|
250
341
|
|
|
251
342
|
class CursorAdapter(AgentAdapter):
|
|
@@ -281,7 +372,7 @@ class CursorAdapter(AgentAdapter):
|
|
|
281
372
|
command.append(structured_prompt)
|
|
282
373
|
return command
|
|
283
374
|
|
|
284
|
-
def _structured_payload(self, stdout: str, output_file: Path) -> object:
|
|
375
|
+
def _structured_payload(self, stdout: str, output_file: Path | None) -> object:
|
|
285
376
|
del output_file
|
|
286
377
|
envelope = json.loads(stdout)
|
|
287
378
|
if not isinstance(envelope, dict):
|
|
@@ -326,12 +417,70 @@ def _portable_prompt(prompt: str) -> str:
|
|
|
326
417
|
)
|
|
327
418
|
|
|
328
419
|
|
|
420
|
+
def _claude_compatible_schema(schema_file: Path) -> str:
|
|
421
|
+
"""Adapte l'en-tete Draft 2020-12 au format accepte par Claude Code."""
|
|
422
|
+
raw_schema = schema_file.read_text(encoding="utf-8")
|
|
423
|
+
schema = json.loads(raw_schema)
|
|
424
|
+
if not isinstance(schema, dict):
|
|
425
|
+
raise ValueError("schema JSON Claude invalide")
|
|
426
|
+
if schema.get("$schema") != "https://json-schema.org/draft/2020-12/schema":
|
|
427
|
+
return raw_schema
|
|
428
|
+
schema["$schema"] = "http://json-schema.org/draft-07/schema#"
|
|
429
|
+
return json.dumps(schema, ensure_ascii=False)
|
|
430
|
+
|
|
431
|
+
|
|
329
432
|
def _text(value: str | bytes | None) -> str:
|
|
330
433
|
if value is None:
|
|
331
434
|
return ""
|
|
332
435
|
return value.decode(errors="replace") if isinstance(value, bytes) else value
|
|
333
436
|
|
|
334
437
|
|
|
438
|
+
def _output_status(path: Path) -> str:
|
|
439
|
+
if not path.is_file():
|
|
440
|
+
return "absent"
|
|
441
|
+
return "empty" if _output_size(path) == 0 else "present"
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _output_size(path: Path) -> int:
|
|
445
|
+
try:
|
|
446
|
+
return path.stat().st_size
|
|
447
|
+
except OSError:
|
|
448
|
+
return 0
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _session_id(stdout: str, stderr: str) -> str | None:
|
|
452
|
+
# Only retain an opaque identifier, never the surrounding agent output.
|
|
453
|
+
match = re.search(r'(?i)(?:session[_ -]?id|session)\s*[=:]\s*["\']?([a-z0-9-]{8,})', stdout + "\n" + stderr)
|
|
454
|
+
return match.group(1) if match else None
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def json_line_events(stdout: str) -> list[dict]:
|
|
458
|
+
"""Decode un flux JSONL d'agent en ignorant les lignes non JSON ou non objet."""
|
|
459
|
+
events: list[dict] = []
|
|
460
|
+
for line in stdout.splitlines():
|
|
461
|
+
line = line.strip()
|
|
462
|
+
if not line.startswith("{"):
|
|
463
|
+
continue
|
|
464
|
+
try:
|
|
465
|
+
event = json.loads(line)
|
|
466
|
+
except json.JSONDecodeError:
|
|
467
|
+
continue
|
|
468
|
+
if isinstance(event, dict):
|
|
469
|
+
events.append(event)
|
|
470
|
+
return events
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _codex_final_message(events: list[dict]) -> str | None:
|
|
474
|
+
"""Retourne le dernier message d'agent complet du flux d'evenements."""
|
|
475
|
+
for event in reversed(events):
|
|
476
|
+
item = event.get("item")
|
|
477
|
+
if event.get("type") != "item.completed" or not isinstance(item, dict):
|
|
478
|
+
continue
|
|
479
|
+
if item.get("type") == "agent_message" and isinstance(item.get("text"), str):
|
|
480
|
+
return item["text"]
|
|
481
|
+
return None
|
|
482
|
+
|
|
483
|
+
|
|
335
484
|
def _strip_json_fence(value: str) -> str:
|
|
336
485
|
text = value.strip()
|
|
337
486
|
match = re.fullmatch(r"```(?:json)?\s*([\s\S]*?)\s*```", text, re.IGNORECASE)
|
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
|
|
@@ -53,7 +52,6 @@ app.command()(feature)
|
|
|
53
52
|
app.command()(context)
|
|
54
53
|
app.command(name="export")(export)
|
|
55
54
|
app.command()(stale)
|
|
56
|
-
app.command()(migrate)
|
|
57
55
|
app.command()(upgrade)
|
|
58
56
|
app.add_typer(extension.app, name="extension")
|
|
59
57
|
app.add_typer(hooks_cmd.app, name="hooks")
|