devflow-cli 3.0.0__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 +117 -9
- devflow_cli/commands/adaptive.py +90 -3
- devflow_cli/commands/assess.py +1 -0
- devflow_cli/core/activity_contracts.py +94 -10
- devflow_cli/core/adaptive_runner.py +50 -15
- 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 +12 -0
- devflow_cli/core/v4_migration.py +134 -0
- devflow_cli/core/validators.py +1 -1
- 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.0.dist-info → devflow_cli-4.0.2.dist-info}/METADATA +18 -1
- {devflow_cli-3.0.0.dist-info → devflow_cli-4.0.2.dist-info}/RECORD +24 -20
- {devflow_cli-3.0.0.dist-info → devflow_cli-4.0.2.dist-info}/WHEEL +0 -0
- {devflow_cli-3.0.0.dist-info → devflow_cli-4.0.2.dist-info}/entry_points.txt +0 -0
- {devflow_cli-3.0.0.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,
|
|
@@ -230,6 +292,21 @@ class CodexAdapter(AgentAdapter):
|
|
|
230
292
|
command.append(prompt)
|
|
231
293
|
return command
|
|
232
294
|
|
|
295
|
+
def _structured_payload(self, stdout: str, output_file: Path) -> object:
|
|
296
|
+
"""Lit la reponse finale Codex et distingue son absence d'un JSON invalide."""
|
|
297
|
+
if output_file.is_file():
|
|
298
|
+
payload = output_file.read_text(encoding="utf-8")
|
|
299
|
+
if payload.strip():
|
|
300
|
+
return json.loads(payload)
|
|
301
|
+
raise StructuredOutputError(
|
|
302
|
+
"final-output-empty",
|
|
303
|
+
"Codex a termine sans reponse finale structuree",
|
|
304
|
+
)
|
|
305
|
+
raise StructuredOutputError(
|
|
306
|
+
"final-output-absent",
|
|
307
|
+
"Codex n'a pas produit le fichier de reponse finale structuree",
|
|
308
|
+
)
|
|
309
|
+
|
|
233
310
|
|
|
234
311
|
class CursorAdapter(AgentAdapter):
|
|
235
312
|
name = "cursor"
|
|
@@ -309,12 +386,43 @@ def _portable_prompt(prompt: str) -> str:
|
|
|
309
386
|
)
|
|
310
387
|
|
|
311
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
|
+
|
|
312
401
|
def _text(value: str | bytes | None) -> str:
|
|
313
402
|
if value is None:
|
|
314
403
|
return ""
|
|
315
404
|
return value.decode(errors="replace") if isinstance(value, bytes) else value
|
|
316
405
|
|
|
317
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
|
+
|
|
318
426
|
def _strip_json_fence(value: str) -> str:
|
|
319
427
|
text = value.strip()
|
|
320
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,10 +31,15 @@ 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
|
|
35
|
-
from devflow_cli.core.remediation import
|
|
38
|
+
from devflow_cli.core.remediation import (
|
|
39
|
+
carried_changed_files,
|
|
40
|
+
is_reopened_activity,
|
|
41
|
+
reopen_activity,
|
|
42
|
+
)
|
|
36
43
|
from devflow_cli.core.contract_schemas import diagnose_contract_file
|
|
37
44
|
from devflow_cli.core.operational import ExitCode, exit_code_for_run_reason
|
|
38
45
|
from devflow_cli.adapters.agents import get_agent_adapter
|
|
@@ -43,6 +50,32 @@ from devflow_cli.utils.paths import project_root_for_feature, resolve_feature_di
|
|
|
43
50
|
app = typer.Typer(help="Machine d'etat des parcours adaptatifs")
|
|
44
51
|
|
|
45
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
|
+
|
|
46
79
|
def _feature(identifier: str) -> Path:
|
|
47
80
|
feature_dir = resolve_feature_dir(identifier)
|
|
48
81
|
if feature_dir is None:
|
|
@@ -63,6 +96,12 @@ def _state(feature_dir: Path):
|
|
|
63
96
|
)
|
|
64
97
|
console.print(" Diagnostic : corrigez ou migrez le fichier avant de relancer le workflow.")
|
|
65
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))
|
|
66
105
|
return state
|
|
67
106
|
|
|
68
107
|
|
|
@@ -92,6 +131,11 @@ def start(feature: str = typer.Argument(..., help="ID ou dossier de la feature")
|
|
|
92
131
|
raise typer.Exit(int(ExitCode.CONCURRENT_CHANGE))
|
|
93
132
|
try:
|
|
94
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
|
+
)
|
|
95
139
|
state = create_adaptive_workflow(assessment.to_dict())
|
|
96
140
|
except ValueError as exc:
|
|
97
141
|
fail(f"Impossible de demarrer le workflow : {exc}")
|
|
@@ -132,6 +176,8 @@ def complete(
|
|
|
132
176
|
with workflow_lock(feature_dir):
|
|
133
177
|
state = _state(feature_dir)
|
|
134
178
|
try:
|
|
179
|
+
if activity == "preflight-evidence":
|
|
180
|
+
validate_preflight_evidence(feature_dir / "preflight-evidence.md")
|
|
135
181
|
complete_activity(state, activity)
|
|
136
182
|
except ValueError as exc:
|
|
137
183
|
fail(str(exc))
|
|
@@ -193,6 +239,8 @@ def contract(
|
|
|
193
239
|
assessment.to_dict(),
|
|
194
240
|
feature_path,
|
|
195
241
|
carried_changed_files(feature_dir, state.currentActivity),
|
|
242
|
+
reopened=is_reopened_activity(feature_dir, state.currentActivity),
|
|
243
|
+
preflight_evidence=(feature_dir / "preflight-evidence.md").is_file(),
|
|
196
244
|
)
|
|
197
245
|
except ValueError as exc:
|
|
198
246
|
fail(f"Impossible de generer le contrat : {exc}")
|
|
@@ -230,7 +278,7 @@ def run_adaptive(
|
|
|
230
278
|
return
|
|
231
279
|
if current_state.currentActivity not in {
|
|
232
280
|
"prepare", "risk-assessment", "spec", "research", "contracts", "plan",
|
|
233
|
-
"tasks", "implement", "review", "rollback-check",
|
|
281
|
+
"tasks", "preflight-evidence", "implement", "review", "rollback-check",
|
|
234
282
|
}:
|
|
235
283
|
fail(
|
|
236
284
|
f"L'activite '{current_state.currentActivity}' ne necessite pas d'agent."
|
|
@@ -268,10 +316,25 @@ def run_adaptive(
|
|
|
268
316
|
output = (
|
|
269
317
|
output_file.read_text(encoding="utf-8")
|
|
270
318
|
if output_file.is_file()
|
|
271
|
-
else
|
|
319
|
+
else execution.stdout
|
|
272
320
|
)
|
|
273
321
|
after = snapshot_worktree(project_root)
|
|
274
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)
|
|
275
338
|
except (OSError, ValueError) as exc:
|
|
276
339
|
return AgentActivityExecution(1, stderr=str(exc))
|
|
277
340
|
return AgentActivityExecution(
|
|
@@ -289,6 +352,7 @@ def run_adaptive(
|
|
|
289
352
|
f"Prochaine : {outcome.nextActivity}"
|
|
290
353
|
)
|
|
291
354
|
return
|
|
355
|
+
safe_refresh_worklog(feature_dir)
|
|
292
356
|
fail(
|
|
293
357
|
f"Activite '{outcome.activity}' non validee : {outcome.reason}"
|
|
294
358
|
+ (f" ({outcome.detail})" if outcome.detail else "")
|
|
@@ -391,6 +455,25 @@ def _execute_local_verification(
|
|
|
391
455
|
raise typer.Exit(int(ExitCode.VERIFICATION_FAILURE))
|
|
392
456
|
|
|
393
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
|
+
|
|
394
477
|
@app.command("worklog")
|
|
395
478
|
def worklog(
|
|
396
479
|
feature: str = typer.Argument(..., help="ID ou dossier de la feature"),
|
|
@@ -584,6 +667,10 @@ def _print_run_recovery(
|
|
|
584
667
|
) -> None:
|
|
585
668
|
console.print(" Reprise guidee :")
|
|
586
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
|
|
587
674
|
if reason == "agent-failed":
|
|
588
675
|
console.print(f" devflow check --ai {agent}")
|
|
589
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:
|
|
@@ -97,18 +105,65 @@ class ActivityResult:
|
|
|
97
105
|
return payload
|
|
98
106
|
|
|
99
107
|
|
|
108
|
+
def document_artifact_name(activity: str, *, reopened: bool = False) -> str:
|
|
109
|
+
"""Retourne l'artefact unique attendu pour une activite documentaire."""
|
|
110
|
+
del reopened
|
|
111
|
+
return DOCUMENT_ARTIFACTS[activity]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def contract_is_reopened_plan(contract: ActivityContract) -> bool:
|
|
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,}",
|
|
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)))
|
|
148
|
+
|
|
149
|
+
|
|
100
150
|
def build_activity_contract(
|
|
101
151
|
state: AdaptiveWorkflowState,
|
|
102
152
|
assessment: dict[str, Any],
|
|
103
153
|
feature_path: str,
|
|
104
154
|
carried_changed_files: list[str] | None = None,
|
|
155
|
+
reopened: bool = False,
|
|
156
|
+
preflight_evidence: bool = False,
|
|
105
157
|
) -> ActivityContract:
|
|
106
158
|
"""Construit le contrat de l'activite courante sans notion d'agent."""
|
|
107
159
|
activity = state.currentActivity
|
|
108
160
|
if activity not in SUPPORTED_AGENT_ACTIVITIES:
|
|
109
161
|
raise ValueError(f"l'activite '{activity}' n'a pas de contrat agent")
|
|
110
|
-
if assessment.get("schemaVersion")
|
|
162
|
+
if assessment.get("schemaVersion") not in {1, 2}:
|
|
111
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")
|
|
112
167
|
if assessment.get("issueId") != state.issueId:
|
|
113
168
|
raise ValueError("issueId incoherent entre assessment et workflow")
|
|
114
169
|
if assessment.get("selectedProfile") != state.profile:
|
|
@@ -126,7 +181,7 @@ def build_activity_contract(
|
|
|
126
181
|
"Signaler tout blocage au lieu de contourner une validation.",
|
|
127
182
|
]
|
|
128
183
|
if activity in DOCUMENT_ACTIVITIES:
|
|
129
|
-
artifact_name =
|
|
184
|
+
artifact_name = document_artifact_name(activity, reopened=reopened)
|
|
130
185
|
allowed_actions = ["read", "write", "execute"]
|
|
131
186
|
constraints = [
|
|
132
187
|
*common_constraints,
|
|
@@ -134,11 +189,24 @@ def build_activity_contract(
|
|
|
134
189
|
"Produire un artefact complet sans placeholder ni section vide.",
|
|
135
190
|
"Declarer uniquement cet artefact dans changedFiles.",
|
|
136
191
|
]
|
|
192
|
+
if activity == "preflight-evidence":
|
|
193
|
+
constraints.append(
|
|
194
|
+
"Documenter les sections Faits observes, Source, Reproductibilite, "
|
|
195
|
+
"Perimetre, Confidentialite et Limites."
|
|
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
|
+
])
|
|
137
201
|
completion_criteria = [
|
|
138
202
|
f"{artifact_name} existe et respecte la structure devflow.",
|
|
139
203
|
"Le contenu est coherent avec l'objectif et les artefacts en entree.",
|
|
140
204
|
"Les risques ou blocages restants sont explicites.",
|
|
141
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
|
+
)
|
|
142
210
|
if activity == "tasks":
|
|
143
211
|
constraints.extend(
|
|
144
212
|
[
|
|
@@ -301,10 +369,22 @@ def build_activity_contract(
|
|
|
301
369
|
f"{feature_path}/assessment.json",
|
|
302
370
|
f"{feature_path}/workflow-state.json",
|
|
303
371
|
]
|
|
304
|
-
if
|
|
372
|
+
if reopened:
|
|
305
373
|
inputs.append(f"{feature_path}/reopen-log.json")
|
|
374
|
+
constraints.append(
|
|
375
|
+
"Cette execution suit une reouverture auditee : lire reopen-log.json et corriger explicitement le motif qui y est consigne."
|
|
376
|
+
)
|
|
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")
|
|
380
|
+
inputs.append(f"{feature_path}/preflight-evidence.md")
|
|
381
|
+
constraints.append(
|
|
382
|
+
"Prendre en compte la preuve de pre-vol archivee et ne pas la presenter comme absente."
|
|
383
|
+
)
|
|
306
384
|
if activity == "implement" and state.profile == "standard":
|
|
307
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")
|
|
308
388
|
if state.profile == "deep":
|
|
309
389
|
if activity in {"plan", "tasks", "implement"}:
|
|
310
390
|
inputs.append(f"{feature_path}/spec.md")
|
|
@@ -312,6 +392,8 @@ def build_activity_contract(
|
|
|
312
392
|
inputs.append(f"{feature_path}/plan.md")
|
|
313
393
|
if activity == "implement":
|
|
314
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"])
|
|
315
397
|
if activity == "review":
|
|
316
398
|
inputs.extend(
|
|
317
399
|
[
|
|
@@ -331,6 +413,7 @@ def build_activity_contract(
|
|
|
331
413
|
"plan": ["risk-assessment.md", "spec.md", "research.md", "contracts.md"],
|
|
332
414
|
"tasks": ["spec.md", "contracts.md", "plan.md"],
|
|
333
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"],
|
|
334
417
|
}
|
|
335
418
|
for name in critical_inputs.get(activity, []):
|
|
336
419
|
inputs.append(f"{feature_path}/{name}")
|
|
@@ -351,9 +434,12 @@ def build_activity_contract(
|
|
|
351
434
|
if activity == "rollback-check":
|
|
352
435
|
inputs.append(f"{feature_path}/review-result.json")
|
|
353
436
|
|
|
437
|
+
expected_output = _result_schema(activity)
|
|
438
|
+
|
|
439
|
+
contract_version = 2 if state.schemaVersion == 3 else ACTIVITY_CONTRACT_SCHEMA_VERSION
|
|
354
440
|
return ActivityContract(
|
|
355
|
-
schemaVersion=
|
|
356
|
-
contractId=f"{state.issueId}:{state.profile}:{activity}:
|
|
441
|
+
schemaVersion=contract_version,
|
|
442
|
+
contractId=f"{state.issueId}:{state.profile}:{activity}:v{contract_version}",
|
|
357
443
|
issueId=state.issueId,
|
|
358
444
|
profile=state.profile,
|
|
359
445
|
activity=activity,
|
|
@@ -364,7 +450,7 @@ def build_activity_contract(
|
|
|
364
450
|
constraints=constraints,
|
|
365
451
|
completionCriteria=completion_criteria,
|
|
366
452
|
requiredEvidence=required_evidence,
|
|
367
|
-
expectedOutput=
|
|
453
|
+
expectedOutput=expected_output,
|
|
368
454
|
)
|
|
369
455
|
|
|
370
456
|
|
|
@@ -432,9 +518,7 @@ def parse_activity_result(payload: dict[str, Any], contract: ActivityContract) -
|
|
|
432
518
|
if contract.activity == "prepare" and status == "completed" and not validation_plan:
|
|
433
519
|
raise ValueError("prepare complete requiert un plan de validation")
|
|
434
520
|
if contract.activity in DOCUMENT_ACTIVITIES and status == "completed":
|
|
435
|
-
expected_artifact = (
|
|
436
|
-
f"{contract.featurePath}/{DOCUMENT_ARTIFACTS[contract.activity]}"
|
|
437
|
-
)
|
|
521
|
+
expected_artifact = f"{contract.featurePath}/{document_artifact_name(contract.activity, reopened=contract_is_reopened_plan(contract))}"
|
|
438
522
|
if changed_files != [expected_artifact]:
|
|
439
523
|
raise ValueError(
|
|
440
524
|
f"{contract.activity} doit declarer uniquement {expected_artifact}"
|