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 CHANGED
@@ -1,6 +1,6 @@
1
1
  """devflow CLI — workflow de développement adaptatif."""
2
2
 
3
- VERSION = "3.0.1"
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",
@@ -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
- OSError, ValueError, json.JSONDecodeError, SchemaError, ValidationError
123
- ) as exc:
124
- output_file.unlink(missing_ok=True)
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
- 1,
160
+ execution.returncode,
127
161
  execution.stdout,
128
- f"Sortie structuree invalide ({self.name}) : {exc}",
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
- return execution
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.read_text(encoding="utf-8")
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 ValueError(
240
- "Codex a termine sans reponse finale structuree "
241
- "(--output-last-message est vide)"
301
+ raise StructuredOutputError(
302
+ "final-output-empty",
303
+ "Codex a termine sans reponse finale structuree",
242
304
  )
243
- if stdout.strip():
244
- return json.loads(stdout)
245
- raise ValueError(
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)
@@ -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":
@@ -89,6 +89,7 @@ def assess(
89
89
  ),
90
90
  summary=summary,
91
91
  profile_override=profile,
92
+ schema_version=2,
92
93
  )
93
94
  except ValueError as exc:
94
95
  fail(str(exc))
@@ -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
- if activity == "plan" and reopened:
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
- return (
109
- contract.activity == "plan"
110
- and f"{contract.featurePath}/reopen-log.json" in contract.inputs
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") != 1:
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 == "plan" and reopened:
192
+ if activity == "preflight-evidence":
154
193
  constraints.append(
155
- "Archiver les faits, la source, la reproductibilite et les limites connues "
156
- "dans preflight-evidence.md, sans y recopier de donnees sensibles."
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 == "plan" and reopened:
331
- inputs.append(f"{feature_path}/plan.md")
332
- if activity == "implement" and preflight_evidence:
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=ACTIVITY_CONTRACT_SCHEMA_VERSION,
389
- contractId=f"{state.issueId}:{state.profile}:{activity}:v1",
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
- return AdaptiveRunOutcome(
113
- "agent-failed", contract.activity, contract.activity, detail
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
- if not (feature_dir / artifact_name).read_text(encoding="utf-8").strip():
211
- raise OSError("artefact vide")
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
  )