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 CHANGED
@@ -1,6 +1,6 @@
1
1
  """devflow CLI — workflow de développement adaptatif."""
2
2
 
3
- VERSION = "3.0.0"
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,
@@ -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)
@@ -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 carried_changed_files, reopen_activity
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":
@@ -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:
@@ -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") != 1:
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 = DOCUMENT_ARTIFACTS[activity]
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 activity == "implement" and carried_changed_files:
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=ACTIVITY_CONTRACT_SCHEMA_VERSION,
356
- contractId=f"{state.issueId}:{state.profile}:{activity}:v1",
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=_result_schema(activity),
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}"