devflow-cli 3.0.1__py3-none-any.whl → 4.0.2__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 +3 -1
- devflow_cli/adapters/agents.py +108 -17
- devflow_cli/commands/adaptive.py +83 -2
- devflow_cli/commands/assess.py +1 -0
- devflow_cli/core/activity_contracts.py +68 -15
- devflow_cli/core/adaptive_runner.py +19 -5
- devflow_cli/core/adaptive_workflow.py +22 -6
- devflow_cli/core/assessment.py +17 -4
- devflow_cli/core/automatic_evidence.py +32 -5
- devflow_cli/core/contract_schemas.py +28 -1
- devflow_cli/core/docgen.py +3 -3
- devflow_cli/core/operational.py +1 -1
- devflow_cli/core/remediation.py +1 -0
- devflow_cli/core/v4_migration.py +134 -0
- devflow_cli/core/worklog.py +18 -0
- devflow_cli/schemas/activity-contract-v2.schema.json +32 -0
- devflow_cli/schemas/assessment-v2.schema.json +36 -0
- devflow_cli/schemas/workflow-state-v3.schema.json +36 -0
- {devflow_cli-3.0.1.dist-info → devflow_cli-4.0.2.dist-info}/METADATA +18 -1
- {devflow_cli-3.0.1.dist-info → devflow_cli-4.0.2.dist-info}/RECORD +23 -19
- {devflow_cli-3.0.1.dist-info → devflow_cli-4.0.2.dist-info}/WHEEL +0 -0
- {devflow_cli-3.0.1.dist-info → devflow_cli-4.0.2.dist-info}/entry_points.txt +0 -0
- {devflow_cli-3.0.1.dist-info → devflow_cli-4.0.2.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 = "4.0.2"
|
|
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,6 +39,7 @@ 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
|
"activity-result.json",
|
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,11 @@ 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
|
+
}
|
|
25
|
+
|
|
20
26
|
|
|
21
27
|
@dataclass(frozen=True)
|
|
22
28
|
class AgentExecution:
|
|
@@ -24,6 +30,19 @@ class AgentExecution:
|
|
|
24
30
|
stdout: str = ""
|
|
25
31
|
stderr: str = ""
|
|
26
32
|
timed_out: bool = False
|
|
33
|
+
duration_seconds: float = 0.0
|
|
34
|
+
final_output_status: str = "unknown"
|
|
35
|
+
final_output_bytes: int = 0
|
|
36
|
+
final_output_diagnostic: str = "not-evaluated"
|
|
37
|
+
session_id: str | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class StructuredOutputError(ValueError):
|
|
41
|
+
"""Erreur de sortie agent dont le diagnostic peut etre expose sans contenu."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, diagnostic: str, message: str) -> None:
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
self.diagnostic = diagnostic
|
|
27
46
|
|
|
28
47
|
|
|
29
48
|
class AgentAdapter(ABC):
|
|
@@ -96,6 +115,7 @@ class AgentAdapter(ABC):
|
|
|
96
115
|
command = self.build_structured_command(
|
|
97
116
|
prompt, permission_mode, schema_file, output_file, sandbox_mode
|
|
98
117
|
)
|
|
118
|
+
started = time.monotonic()
|
|
99
119
|
try:
|
|
100
120
|
result = subprocess.run(
|
|
101
121
|
command,
|
|
@@ -109,6 +129,11 @@ class AgentAdapter(ABC):
|
|
|
109
129
|
result.returncode,
|
|
110
130
|
result.stdout or "",
|
|
111
131
|
result.stderr or "",
|
|
132
|
+
duration_seconds=time.monotonic() - started,
|
|
133
|
+
final_output_status=_output_status(output_file),
|
|
134
|
+
final_output_bytes=_output_size(output_file),
|
|
135
|
+
final_output_diagnostic="process-failed" if result.returncode else "validating",
|
|
136
|
+
session_id=_session_id(result.stdout or "", result.stderr or ""),
|
|
112
137
|
)
|
|
113
138
|
if execution.returncode != 0:
|
|
114
139
|
return execution
|
|
@@ -118,25 +143,62 @@ class AgentAdapter(ABC):
|
|
|
118
143
|
output_file.write_text(
|
|
119
144
|
json.dumps(payload, ensure_ascii=False), encoding="utf-8"
|
|
120
145
|
)
|
|
121
|
-
except
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
146
|
+
except StructuredOutputError as exc:
|
|
147
|
+
diagnostic = exc.diagnostic
|
|
148
|
+
except json.JSONDecodeError:
|
|
149
|
+
diagnostic = "final-output-invalid-json"
|
|
150
|
+
except ValidationError:
|
|
151
|
+
diagnostic = "final-output-schema-rejected"
|
|
152
|
+
except SchemaError:
|
|
153
|
+
diagnostic = "output-schema-invalid"
|
|
154
|
+
except OSError:
|
|
155
|
+
diagnostic = "final-output-unreadable"
|
|
156
|
+
except ValueError:
|
|
157
|
+
diagnostic = "final-output-invalid"
|
|
158
|
+
else:
|
|
125
159
|
return AgentExecution(
|
|
126
|
-
|
|
160
|
+
execution.returncode,
|
|
127
161
|
execution.stdout,
|
|
128
|
-
|
|
162
|
+
execution.stderr,
|
|
163
|
+
execution.timed_out,
|
|
164
|
+
execution.duration_seconds,
|
|
165
|
+
_output_status(output_file),
|
|
166
|
+
_output_size(output_file),
|
|
167
|
+
"validated",
|
|
168
|
+
execution.session_id,
|
|
129
169
|
)
|
|
130
|
-
|
|
170
|
+
|
|
171
|
+
status = _output_status(output_file)
|
|
172
|
+
size = _output_size(output_file)
|
|
173
|
+
output_file.unlink(missing_ok=True)
|
|
174
|
+
return AgentExecution(
|
|
175
|
+
1,
|
|
176
|
+
execution.stdout,
|
|
177
|
+
"Sortie structuree invalide "
|
|
178
|
+
f"({self.name}) [{diagnostic}]"
|
|
179
|
+
+ (
|
|
180
|
+
f" : {_SAFE_DIAGNOSTIC_MESSAGES[diagnostic]}"
|
|
181
|
+
if diagnostic in _SAFE_DIAGNOSTIC_MESSAGES
|
|
182
|
+
else ""
|
|
183
|
+
),
|
|
184
|
+
duration_seconds=execution.duration_seconds,
|
|
185
|
+
final_output_status=status,
|
|
186
|
+
final_output_bytes=size,
|
|
187
|
+
final_output_diagnostic=diagnostic,
|
|
188
|
+
session_id=execution.session_id,
|
|
189
|
+
)
|
|
131
190
|
except subprocess.TimeoutExpired as exc:
|
|
132
191
|
return AgentExecution(
|
|
133
192
|
124,
|
|
134
193
|
_text(exc.stdout),
|
|
135
194
|
_text(exc.stderr),
|
|
136
195
|
timed_out=True,
|
|
196
|
+
duration_seconds=time.monotonic() - started,
|
|
197
|
+
final_output_status=_output_status(output_file),
|
|
198
|
+
final_output_bytes=_output_size(output_file),
|
|
137
199
|
)
|
|
138
200
|
except FileNotFoundError:
|
|
139
|
-
return AgentExecution(127, stderr=f"Executable '{self.executable}' introuvable")
|
|
201
|
+
return AgentExecution(127, stderr=f"Executable '{self.executable}' introuvable", duration_seconds=time.monotonic() - started)
|
|
140
202
|
|
|
141
203
|
def _structured_payload(self, stdout: str, output_file: Path) -> object:
|
|
142
204
|
if output_file.is_file():
|
|
@@ -169,7 +231,7 @@ class ClaudeCodeAdapter(AgentAdapter):
|
|
|
169
231
|
sandbox_mode: SandboxMode = "workspace-write",
|
|
170
232
|
) -> list[str]:
|
|
171
233
|
del output_file
|
|
172
|
-
schema = schema_file
|
|
234
|
+
schema = _claude_compatible_schema(schema_file)
|
|
173
235
|
effective_mode = "plan" if sandbox_mode == "read-only" else permission_mode
|
|
174
236
|
return [
|
|
175
237
|
self.executable,
|
|
@@ -236,15 +298,13 @@ class CodexAdapter(AgentAdapter):
|
|
|
236
298
|
payload = output_file.read_text(encoding="utf-8")
|
|
237
299
|
if payload.strip():
|
|
238
300
|
return json.loads(payload)
|
|
239
|
-
raise
|
|
240
|
-
"
|
|
241
|
-
"
|
|
301
|
+
raise StructuredOutputError(
|
|
302
|
+
"final-output-empty",
|
|
303
|
+
"Codex a termine sans reponse finale structuree",
|
|
242
304
|
)
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
"Codex n'a pas produit de reponse finale structuree "
|
|
247
|
-
"ni de sortie standard exploitable"
|
|
305
|
+
raise StructuredOutputError(
|
|
306
|
+
"final-output-absent",
|
|
307
|
+
"Codex n'a pas produit le fichier de reponse finale structuree",
|
|
248
308
|
)
|
|
249
309
|
|
|
250
310
|
|
|
@@ -326,12 +386,43 @@ def _portable_prompt(prompt: str) -> str:
|
|
|
326
386
|
)
|
|
327
387
|
|
|
328
388
|
|
|
389
|
+
def _claude_compatible_schema(schema_file: Path) -> str:
|
|
390
|
+
"""Adapte l'en-tete Draft 2020-12 au format accepte par Claude Code."""
|
|
391
|
+
raw_schema = schema_file.read_text(encoding="utf-8")
|
|
392
|
+
schema = json.loads(raw_schema)
|
|
393
|
+
if not isinstance(schema, dict):
|
|
394
|
+
raise ValueError("schema JSON Claude invalide")
|
|
395
|
+
if schema.get("$schema") != "https://json-schema.org/draft/2020-12/schema":
|
|
396
|
+
return raw_schema
|
|
397
|
+
schema["$schema"] = "http://json-schema.org/draft-07/schema#"
|
|
398
|
+
return json.dumps(schema, ensure_ascii=False)
|
|
399
|
+
|
|
400
|
+
|
|
329
401
|
def _text(value: str | bytes | None) -> str:
|
|
330
402
|
if value is None:
|
|
331
403
|
return ""
|
|
332
404
|
return value.decode(errors="replace") if isinstance(value, bytes) else value
|
|
333
405
|
|
|
334
406
|
|
|
407
|
+
def _output_status(path: Path) -> str:
|
|
408
|
+
if not path.is_file():
|
|
409
|
+
return "absent"
|
|
410
|
+
return "empty" if _output_size(path) == 0 else "present"
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _output_size(path: Path) -> int:
|
|
414
|
+
try:
|
|
415
|
+
return path.stat().st_size
|
|
416
|
+
except OSError:
|
|
417
|
+
return 0
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _session_id(stdout: str, stderr: str) -> str | None:
|
|
421
|
+
# Only retain an opaque identifier, never the surrounding agent output.
|
|
422
|
+
match = re.search(r'(?i)(?:session[_ -]?id|session)\s*[=:]\s*["\']?([a-z0-9-]{8,})', stdout + "\n" + stderr)
|
|
423
|
+
return match.group(1) if match else None
|
|
424
|
+
|
|
425
|
+
|
|
335
426
|
def _strip_json_fence(value: str) -> str:
|
|
336
427
|
text = value.strip()
|
|
337
428
|
match = re.fullmatch(r"```(?:json)?\s*([\s\S]*?)\s*```", text, re.IGNORECASE)
|
devflow_cli/commands/adaptive.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import os
|
|
6
7
|
import tempfile
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
from typing import Literal, Optional
|
|
@@ -22,6 +23,7 @@ from devflow_cli.core.assessment import read_assessment
|
|
|
22
23
|
from devflow_cli.core.activity_contracts import (
|
|
23
24
|
build_activity_contract,
|
|
24
25
|
render_contract_prompt,
|
|
26
|
+
validate_preflight_evidence,
|
|
25
27
|
write_activity_contract,
|
|
26
28
|
)
|
|
27
29
|
from devflow_cli.core.adaptive_runner import (
|
|
@@ -29,6 +31,7 @@ from devflow_cli.core.adaptive_runner import (
|
|
|
29
31
|
run_current_activity,
|
|
30
32
|
)
|
|
31
33
|
from devflow_cli.core.worktree_snapshot import changed_paths, snapshot_worktree
|
|
34
|
+
from devflow_cli.core.v4_migration import migrate_to_v4
|
|
32
35
|
from devflow_cli.core.automatic_evidence import run_automatic_verification
|
|
33
36
|
from devflow_cli.core.worklog import refresh_worklog, safe_refresh_worklog
|
|
34
37
|
from devflow_cli.core.waivers import create_validation_waiver
|
|
@@ -47,6 +50,32 @@ from devflow_cli.utils.paths import project_root_for_feature, resolve_feature_di
|
|
|
47
50
|
app = typer.Typer(help="Machine d'etat des parcours adaptatifs")
|
|
48
51
|
|
|
49
52
|
|
|
53
|
+
@app.command("migrate")
|
|
54
|
+
def migrate_v4(
|
|
55
|
+
feature: str = typer.Argument(..., help="ID ou dossier de la feature"),
|
|
56
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Valide sans modifier les fichiers"),
|
|
57
|
+
json_output: bool = typer.Option(False, "--json", help="Sortie stable pour automatisation"),
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Migre atomiquement assessment v1 + workflow-state v2 vers V4."""
|
|
60
|
+
feature_dir = _feature(feature)
|
|
61
|
+
with workflow_lock(feature_dir):
|
|
62
|
+
try:
|
|
63
|
+
report = migrate_to_v4(feature_dir, dry_run=dry_run)
|
|
64
|
+
except (OSError, ValueError) as exc:
|
|
65
|
+
fail(f"Migration V4 refusee : {exc}")
|
|
66
|
+
raise typer.Exit(int(ExitCode.INVALID_CONTRACT)) from exc
|
|
67
|
+
if json_output:
|
|
68
|
+
console.print_json(data=report)
|
|
69
|
+
elif dry_run:
|
|
70
|
+
ok("Dry-run V4 valide ; aucun fichier modifie.")
|
|
71
|
+
console.print(f" Activite apres migration : {report['currentActivityAfter']}")
|
|
72
|
+
else:
|
|
73
|
+
safe_refresh_worklog(feature_dir)
|
|
74
|
+
ok("Workflow migre vers Devflow V4.")
|
|
75
|
+
console.print(f" Sauvegarde : {report['backupPath']}")
|
|
76
|
+
console.print(f" Activite : {report['currentActivityAfter']}")
|
|
77
|
+
|
|
78
|
+
|
|
50
79
|
def _feature(identifier: str) -> Path:
|
|
51
80
|
feature_dir = resolve_feature_dir(identifier)
|
|
52
81
|
if feature_dir is None:
|
|
@@ -67,6 +96,12 @@ def _state(feature_dir: Path):
|
|
|
67
96
|
)
|
|
68
97
|
console.print(" Diagnostic : corrigez ou migrez le fichier avant de relancer le workflow.")
|
|
69
98
|
raise typer.Exit(int(ExitCode.INVALID_CONTRACT))
|
|
99
|
+
if state.schemaVersion != 3:
|
|
100
|
+
fail("workflow-state.json est au format V3 ; migrez-le avant toute commande V4.")
|
|
101
|
+
console.print(
|
|
102
|
+
f" Suite : devflow adaptive migrate {state.issueId} --dry-run"
|
|
103
|
+
)
|
|
104
|
+
raise typer.Exit(int(ExitCode.INVALID_CONTRACT))
|
|
70
105
|
return state
|
|
71
106
|
|
|
72
107
|
|
|
@@ -96,6 +131,11 @@ def start(feature: str = typer.Argument(..., help="ID ou dossier de la feature")
|
|
|
96
131
|
raise typer.Exit(int(ExitCode.CONCURRENT_CHANGE))
|
|
97
132
|
try:
|
|
98
133
|
assessment = _assessment(feature_dir)
|
|
134
|
+
if assessment.schemaVersion != 2:
|
|
135
|
+
raise ValueError(
|
|
136
|
+
"assessment.json V3 ne peut pas demarrer en V4 ; "
|
|
137
|
+
"un workflow V3 existant doit etre migre"
|
|
138
|
+
)
|
|
99
139
|
state = create_adaptive_workflow(assessment.to_dict())
|
|
100
140
|
except ValueError as exc:
|
|
101
141
|
fail(f"Impossible de demarrer le workflow : {exc}")
|
|
@@ -136,6 +176,8 @@ def complete(
|
|
|
136
176
|
with workflow_lock(feature_dir):
|
|
137
177
|
state = _state(feature_dir)
|
|
138
178
|
try:
|
|
179
|
+
if activity == "preflight-evidence":
|
|
180
|
+
validate_preflight_evidence(feature_dir / "preflight-evidence.md")
|
|
139
181
|
complete_activity(state, activity)
|
|
140
182
|
except ValueError as exc:
|
|
141
183
|
fail(str(exc))
|
|
@@ -236,7 +278,7 @@ def run_adaptive(
|
|
|
236
278
|
return
|
|
237
279
|
if current_state.currentActivity not in {
|
|
238
280
|
"prepare", "risk-assessment", "spec", "research", "contracts", "plan",
|
|
239
|
-
"tasks", "implement", "review", "rollback-check",
|
|
281
|
+
"tasks", "preflight-evidence", "implement", "review", "rollback-check",
|
|
240
282
|
}:
|
|
241
283
|
fail(
|
|
242
284
|
f"L'activite '{current_state.currentActivity}' ne necessite pas d'agent."
|
|
@@ -274,10 +316,25 @@ def run_adaptive(
|
|
|
274
316
|
output = (
|
|
275
317
|
output_file.read_text(encoding="utf-8")
|
|
276
318
|
if output_file.is_file()
|
|
277
|
-
else
|
|
319
|
+
else execution.stdout
|
|
278
320
|
)
|
|
279
321
|
after = snapshot_worktree(project_root)
|
|
280
322
|
observed = tuple(changed_paths(before, after))
|
|
323
|
+
audit_payload = {
|
|
324
|
+
"schemaVersion": 1,
|
|
325
|
+
"agent": agent,
|
|
326
|
+
"activity": activity_contract.activity,
|
|
327
|
+
"returnCode": execution.returncode,
|
|
328
|
+
"durationSeconds": round(execution.duration_seconds, 3),
|
|
329
|
+
"finalOutputStatus": execution.final_output_status,
|
|
330
|
+
"finalOutputBytes": execution.final_output_bytes,
|
|
331
|
+
"finalOutputDiagnostic": execution.final_output_diagnostic,
|
|
332
|
+
"sessionId": execution.session_id,
|
|
333
|
+
"timedOut": execution.timed_out,
|
|
334
|
+
"worktreeChanged": bool(observed),
|
|
335
|
+
"worktreeChangedFileCount": len(observed),
|
|
336
|
+
}
|
|
337
|
+
_write_json_atomic(feature_dir / "agent-execution.json", audit_payload)
|
|
281
338
|
except (OSError, ValueError) as exc:
|
|
282
339
|
return AgentActivityExecution(1, stderr=str(exc))
|
|
283
340
|
return AgentActivityExecution(
|
|
@@ -295,6 +352,7 @@ def run_adaptive(
|
|
|
295
352
|
f"Prochaine : {outcome.nextActivity}"
|
|
296
353
|
)
|
|
297
354
|
return
|
|
355
|
+
safe_refresh_worklog(feature_dir)
|
|
298
356
|
fail(
|
|
299
357
|
f"Activite '{outcome.activity}' non validee : {outcome.reason}"
|
|
300
358
|
+ (f" ({outcome.detail})" if outcome.detail else "")
|
|
@@ -397,6 +455,25 @@ def _execute_local_verification(
|
|
|
397
455
|
raise typer.Exit(int(ExitCode.VERIFICATION_FAILURE))
|
|
398
456
|
|
|
399
457
|
|
|
458
|
+
def _write_json_atomic(path: Path, payload: dict) -> None:
|
|
459
|
+
temporary: Path | None = None
|
|
460
|
+
try:
|
|
461
|
+
with tempfile.NamedTemporaryFile(
|
|
462
|
+
mode="w", encoding="utf-8", dir=path.parent,
|
|
463
|
+
prefix=f".{path.name}.", suffix=".tmp", delete=False,
|
|
464
|
+
) as handle:
|
|
465
|
+
temporary = Path(handle.name)
|
|
466
|
+
json.dump(payload, handle, indent=2, ensure_ascii=False)
|
|
467
|
+
handle.write("\n")
|
|
468
|
+
handle.flush()
|
|
469
|
+
os.fsync(handle.fileno())
|
|
470
|
+
os.replace(temporary, path)
|
|
471
|
+
temporary = None
|
|
472
|
+
finally:
|
|
473
|
+
if temporary is not None:
|
|
474
|
+
temporary.unlink(missing_ok=True)
|
|
475
|
+
|
|
476
|
+
|
|
400
477
|
@app.command("worklog")
|
|
401
478
|
def worklog(
|
|
402
479
|
feature: str = typer.Argument(..., help="ID ou dossier de la feature"),
|
|
@@ -590,6 +667,10 @@ def _print_run_recovery(
|
|
|
590
667
|
) -> None:
|
|
591
668
|
console.print(" Reprise guidee :")
|
|
592
669
|
console.print(f" devflow adaptive status {issue_id}")
|
|
670
|
+
if reason == "agent-failed-after-mutation":
|
|
671
|
+
console.print(" git status --short # inspecter les ecritures deja effectuees")
|
|
672
|
+
console.print(" Ne relancez pas automatiquement l'agent : reconciliez d'abord les changements partiels.")
|
|
673
|
+
return
|
|
593
674
|
if reason == "agent-failed":
|
|
594
675
|
console.print(f" devflow check --ai {agent}")
|
|
595
676
|
elif reason == "state-changed":
|
devflow_cli/commands/assess.py
CHANGED
|
@@ -5,6 +5,8 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
7
|
import tempfile
|
|
8
|
+
import re
|
|
9
|
+
import unicodedata
|
|
8
10
|
from dataclasses import asdict, dataclass, field
|
|
9
11
|
from pathlib import Path
|
|
10
12
|
from typing import Any, Literal
|
|
@@ -19,7 +21,7 @@ from devflow_cli.core.contract_schemas import (
|
|
|
19
21
|
ACTIVITY_CONTRACT_SCHEMA_VERSION = 1
|
|
20
22
|
SUPPORTED_AGENT_ACTIVITIES = {
|
|
21
23
|
"prepare", "risk-assessment", "spec", "research", "contracts", "plan",
|
|
22
|
-
"tasks", "implement", "verify", "review", "rollback-check",
|
|
24
|
+
"tasks", "preflight-evidence", "implement", "verify", "review", "rollback-check",
|
|
23
25
|
}
|
|
24
26
|
DOCUMENT_ARTIFACTS = {
|
|
25
27
|
"risk-assessment": "risk-assessment.md",
|
|
@@ -28,10 +30,16 @@ DOCUMENT_ARTIFACTS = {
|
|
|
28
30
|
"contracts": "contracts.md",
|
|
29
31
|
"plan": "plan.md",
|
|
30
32
|
"tasks": "tasks.md",
|
|
33
|
+
"preflight-evidence": "preflight-evidence.md",
|
|
31
34
|
}
|
|
32
35
|
DOCUMENT_ACTIVITIES = set(DOCUMENT_ARTIFACTS)
|
|
33
36
|
ActivityStatus = Literal["completed", "blocked"]
|
|
34
37
|
|
|
38
|
+
PREFLIGHT_HEADINGS = (
|
|
39
|
+
"faits observes", "source", "reproductibilite", "perimetre",
|
|
40
|
+
"confidentialite", "limites",
|
|
41
|
+
)
|
|
42
|
+
|
|
35
43
|
|
|
36
44
|
@dataclass(frozen=True)
|
|
37
45
|
class ActivityContract:
|
|
@@ -99,16 +107,44 @@ class ActivityResult:
|
|
|
99
107
|
|
|
100
108
|
def document_artifact_name(activity: str, *, reopened: bool = False) -> str:
|
|
101
109
|
"""Retourne l'artefact unique attendu pour une activite documentaire."""
|
|
102
|
-
|
|
103
|
-
return "preflight-evidence.md"
|
|
110
|
+
del reopened
|
|
104
111
|
return DOCUMENT_ARTIFACTS[activity]
|
|
105
112
|
|
|
106
113
|
|
|
107
114
|
def contract_is_reopened_plan(contract: ActivityContract) -> bool:
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
115
|
+
"""Compatibility shim: V4 no longer overloads a reopened plan."""
|
|
116
|
+
del contract
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def validate_preflight_evidence(path: Path, *, newer_than: list[Path] | None = None) -> None:
|
|
121
|
+
"""Validate the V4 preflight artifact without exporting its contents."""
|
|
122
|
+
try:
|
|
123
|
+
content = path.read_text(encoding="utf-8")
|
|
124
|
+
except OSError as exc:
|
|
125
|
+
raise ValueError("preflight-evidence.md absent ou illisible") from exc
|
|
126
|
+
if not content.strip():
|
|
127
|
+
raise ValueError("preflight-evidence.md est vide")
|
|
128
|
+
normalized = "".join(
|
|
129
|
+
character
|
|
130
|
+
for character in unicodedata.normalize("NFKD", content.casefold())
|
|
131
|
+
if not unicodedata.combining(character)
|
|
132
|
+
)
|
|
133
|
+
missing = [heading for heading in PREFLIGHT_HEADINGS if heading not in normalized]
|
|
134
|
+
if missing:
|
|
135
|
+
raise ValueError("sections de pre-vol manquantes : " + ", ".join(missing))
|
|
136
|
+
sensitive_patterns = (
|
|
137
|
+
r"-----begin (?:rsa |ec |openssh )?private key-----",
|
|
138
|
+
r"\b(?:api[_-]?key|access[_-]?token|client[_-]?secret)\s*[:=]\s*\S{12,}",
|
|
139
|
+
r"\bbearer\s+[a-z0-9._~-]{20,}",
|
|
111
140
|
)
|
|
141
|
+
if any(re.search(pattern, content, flags=re.IGNORECASE) for pattern in sensitive_patterns):
|
|
142
|
+
raise ValueError("preflight-evidence.md semble contenir un secret")
|
|
143
|
+
if newer_than:
|
|
144
|
+
artifact_mtime = path.stat().st_mtime_ns
|
|
145
|
+
stale_inputs = [item.name for item in newer_than if item.is_file() and item.stat().st_mtime_ns > artifact_mtime]
|
|
146
|
+
if stale_inputs:
|
|
147
|
+
raise ValueError("preuve de pre-vol desuete apres : " + ", ".join(sorted(stale_inputs)))
|
|
112
148
|
|
|
113
149
|
|
|
114
150
|
def build_activity_contract(
|
|
@@ -123,8 +159,11 @@ def build_activity_contract(
|
|
|
123
159
|
activity = state.currentActivity
|
|
124
160
|
if activity not in SUPPORTED_AGENT_ACTIVITIES:
|
|
125
161
|
raise ValueError(f"l'activite '{activity}' n'a pas de contrat agent")
|
|
126
|
-
if assessment.get("schemaVersion")
|
|
162
|
+
if assessment.get("schemaVersion") not in {1, 2}:
|
|
127
163
|
raise ValueError("schema assessment.json non supporte")
|
|
164
|
+
expected_state_version = 2 if assessment["schemaVersion"] == 1 else 3
|
|
165
|
+
if state.schemaVersion != expected_state_version:
|
|
166
|
+
raise ValueError("versions assessment/workflow-state incoherentes ; migrez le couple")
|
|
128
167
|
if assessment.get("issueId") != state.issueId:
|
|
129
168
|
raise ValueError("issueId incoherent entre assessment et workflow")
|
|
130
169
|
if assessment.get("selectedProfile") != state.profile:
|
|
@@ -150,16 +189,24 @@ def build_activity_contract(
|
|
|
150
189
|
"Produire un artefact complet sans placeholder ni section vide.",
|
|
151
190
|
"Declarer uniquement cet artefact dans changedFiles.",
|
|
152
191
|
]
|
|
153
|
-
if activity == "
|
|
192
|
+
if activity == "preflight-evidence":
|
|
154
193
|
constraints.append(
|
|
155
|
-
"
|
|
156
|
-
"
|
|
194
|
+
"Documenter les sections Faits observes, Source, Reproductibilite, "
|
|
195
|
+
"Perimetre, Confidentialite et Limites."
|
|
157
196
|
)
|
|
197
|
+
constraints.extend([
|
|
198
|
+
"Ne recopier aucun secret, jeton, donnee utilisateur ou contenu prive dans l'artefact.",
|
|
199
|
+
"Toute preuve doit etre reproductible ou indiquer explicitement pourquoi elle ne l'est pas.",
|
|
200
|
+
])
|
|
158
201
|
completion_criteria = [
|
|
159
202
|
f"{artifact_name} existe et respecte la structure devflow.",
|
|
160
203
|
"Le contenu est coherent avec l'objectif et les artefacts en entree.",
|
|
161
204
|
"Les risques ou blocages restants sont explicites.",
|
|
162
205
|
]
|
|
206
|
+
if activity == "preflight-evidence":
|
|
207
|
+
completion_criteria.append(
|
|
208
|
+
"La preuve est non vide, actuelle, reproductible et ne contient aucune donnee sensible."
|
|
209
|
+
)
|
|
163
210
|
if activity == "tasks":
|
|
164
211
|
constraints.extend(
|
|
165
212
|
[
|
|
@@ -327,15 +374,17 @@ def build_activity_contract(
|
|
|
327
374
|
constraints.append(
|
|
328
375
|
"Cette execution suit une reouverture auditee : lire reopen-log.json et corriger explicitement le motif qui y est consigne."
|
|
329
376
|
)
|
|
330
|
-
if activity == "
|
|
331
|
-
|
|
332
|
-
|
|
377
|
+
if activity == "implement" and (assessment.get("schemaVersion") == 2 or preflight_evidence):
|
|
378
|
+
if not preflight_evidence:
|
|
379
|
+
raise ValueError("preflight-evidence.md absent avant implement")
|
|
333
380
|
inputs.append(f"{feature_path}/preflight-evidence.md")
|
|
334
381
|
constraints.append(
|
|
335
382
|
"Prendre en compte la preuve de pre-vol archivee et ne pas la presenter comme absente."
|
|
336
383
|
)
|
|
337
384
|
if activity == "implement" and state.profile == "standard":
|
|
338
385
|
inputs.append(f"{feature_path}/prepare-result.json")
|
|
386
|
+
if activity == "preflight-evidence" and state.profile == "standard":
|
|
387
|
+
inputs.append(f"{feature_path}/prepare-result.json")
|
|
339
388
|
if state.profile == "deep":
|
|
340
389
|
if activity in {"plan", "tasks", "implement"}:
|
|
341
390
|
inputs.append(f"{feature_path}/spec.md")
|
|
@@ -343,6 +392,8 @@ def build_activity_contract(
|
|
|
343
392
|
inputs.append(f"{feature_path}/plan.md")
|
|
344
393
|
if activity == "implement":
|
|
345
394
|
inputs.append(f"{feature_path}/tasks.md")
|
|
395
|
+
if activity == "preflight-evidence":
|
|
396
|
+
inputs.extend([f"{feature_path}/spec.md", f"{feature_path}/plan.md", f"{feature_path}/tasks.md"])
|
|
346
397
|
if activity == "review":
|
|
347
398
|
inputs.extend(
|
|
348
399
|
[
|
|
@@ -362,6 +413,7 @@ def build_activity_contract(
|
|
|
362
413
|
"plan": ["risk-assessment.md", "spec.md", "research.md", "contracts.md"],
|
|
363
414
|
"tasks": ["spec.md", "contracts.md", "plan.md"],
|
|
364
415
|
"implement": ["risk-assessment.md", "spec.md", "contracts.md", "plan.md", "tasks.md"],
|
|
416
|
+
"preflight-evidence": ["risk-assessment.md", "spec.md", "research.md", "contracts.md", "plan.md", "tasks.md"],
|
|
365
417
|
}
|
|
366
418
|
for name in critical_inputs.get(activity, []):
|
|
367
419
|
inputs.append(f"{feature_path}/{name}")
|
|
@@ -384,9 +436,10 @@ def build_activity_contract(
|
|
|
384
436
|
|
|
385
437
|
expected_output = _result_schema(activity)
|
|
386
438
|
|
|
439
|
+
contract_version = 2 if state.schemaVersion == 3 else ACTIVITY_CONTRACT_SCHEMA_VERSION
|
|
387
440
|
return ActivityContract(
|
|
388
|
-
schemaVersion=
|
|
389
|
-
contractId=f"{state.issueId}:{state.profile}:{activity}:
|
|
441
|
+
schemaVersion=contract_version,
|
|
442
|
+
contractId=f"{state.issueId}:{state.profile}:{activity}:v{contract_version}",
|
|
390
443
|
issueId=state.issueId,
|
|
391
444
|
profile=state.profile,
|
|
392
445
|
activity=activity,
|
|
@@ -17,6 +17,7 @@ from devflow_cli.core.activity_contracts import (
|
|
|
17
17
|
contract_is_reopened_plan,
|
|
18
18
|
document_artifact_name,
|
|
19
19
|
parse_activity_result,
|
|
20
|
+
validate_preflight_evidence,
|
|
20
21
|
write_activity_contract,
|
|
21
22
|
)
|
|
22
23
|
from devflow_cli.core.adaptive_workflow import (
|
|
@@ -90,6 +91,15 @@ def run_current_activity(
|
|
|
90
91
|
reopened=is_reopened_activity(feature_dir, state.currentActivity),
|
|
91
92
|
preflight_evidence=(feature_dir / "preflight-evidence.md").is_file(),
|
|
92
93
|
)
|
|
94
|
+
if state.currentActivity == "implement" and state.schemaVersion == 3:
|
|
95
|
+
upstream = [
|
|
96
|
+
feature_dir / Path(item).name
|
|
97
|
+
for item in contract.inputs
|
|
98
|
+
if item.endswith(".md") and not item.endswith("preflight-evidence.md")
|
|
99
|
+
]
|
|
100
|
+
validate_preflight_evidence(
|
|
101
|
+
feature_dir / "preflight-evidence.md", newer_than=upstream
|
|
102
|
+
)
|
|
93
103
|
except ValueError as exc:
|
|
94
104
|
return AdaptiveRunOutcome(
|
|
95
105
|
"unsupported-activity", state.currentActivity, state.currentActivity, str(exc)
|
|
@@ -109,9 +119,14 @@ def run_current_activity(
|
|
|
109
119
|
detail = execution.stderr.strip() or f"code de sortie {execution.returncode}"
|
|
110
120
|
if execution.timedOut:
|
|
111
121
|
detail = f"timeout agent ({detail})"
|
|
112
|
-
|
|
113
|
-
"agent-failed"
|
|
122
|
+
reason = (
|
|
123
|
+
"agent-failed-after-mutation"
|
|
124
|
+
if execution.observedChangedFiles
|
|
125
|
+
else "agent-failed"
|
|
114
126
|
)
|
|
127
|
+
if execution.observedChangedFiles:
|
|
128
|
+
detail += f"; mutations observees : {len(set(execution.observedChangedFiles))} fichier(s)"
|
|
129
|
+
return AdaptiveRunOutcome(reason, contract.activity, contract.activity, detail)
|
|
115
130
|
|
|
116
131
|
try:
|
|
117
132
|
raw_payload = json.loads(execution.output)
|
|
@@ -207,9 +222,8 @@ def run_current_activity(
|
|
|
207
222
|
)
|
|
208
223
|
if artifact_name == "preflight-evidence.md":
|
|
209
224
|
try:
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
except OSError as exc:
|
|
225
|
+
validate_preflight_evidence(feature_dir / artifact_name)
|
|
226
|
+
except ValueError as exc:
|
|
213
227
|
return AdaptiveRunOutcome(
|
|
214
228
|
"invalid-artifact", contract.activity, contract.activity, str(exc), result
|
|
215
229
|
)
|