devflow-cli 7.2.0__py3-none-any.whl → 8.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
devflow_cli/__init__.py CHANGED
@@ -1,6 +1,6 @@
1
1
  """devflow CLI — workflow de développement adaptatif."""
2
2
 
3
- VERSION = "7.2.0"
3
+ VERSION = "8.0.0"
4
4
 
5
5
  SUPPORTED_AGENTS = ["claude-code", "codex", "cursor"]
6
6
 
@@ -25,6 +25,10 @@ from devflow_cli.core.adaptive_workflow import (
25
25
  write_adaptive_state,
26
26
  )
27
27
  from devflow_cli.core.assessment import read_assessment
28
+ from devflow_cli.core.activity_base import (
29
+ enter_current_activity,
30
+ files_since_activity_base,
31
+ )
28
32
  from devflow_cli.core.activity_contracts import (
29
33
  build_activity_contract,
30
34
  NEED_STATEMENT,
@@ -51,7 +55,6 @@ from devflow_cli.core.worklog import refresh_worklog, safe_refresh_worklog
51
55
  from devflow_cli.core.telemetry import append_record, telemetry_enabled, usage_payload
52
56
  from devflow_cli.core.waivers import create_validation_waiver
53
57
  from devflow_cli.core.remediation import (
54
- carried_changed_files,
55
58
  discard_attempt,
56
59
  is_reopened_activity,
57
60
  reopen_activity,
@@ -77,6 +80,9 @@ app = typer.Typer(help="Machine d'etat des parcours adaptatifs")
77
80
 
78
81
  LEGACY_SCHEMA_EXIT_CODE = 1
79
82
 
83
+ # Refus avant tout agent : base inutilisable (branche changee, historique reecrit).
84
+ BASE_REFUSALS = frozenset({"base-history-rewritten", "base-branch-changed"})
85
+
80
86
  RESULT_VALIDATED_ACTIVITIES = frozenset({
81
87
  "prepare",
82
88
  "risk-assessment",
@@ -167,7 +173,10 @@ def start(feature: str = typer.Argument(..., help="ID ou dossier de la feature")
167
173
  assessment = _assessment(feature_dir)
168
174
  validate_need_statement(feature_dir / NEED_STATEMENT)
169
175
  state = create_adaptive_workflow(assessment.to_dict())
170
- except ValueError as exc:
176
+ enter_current_activity(
177
+ state, project_root_for_feature(feature_dir), feature_dir
178
+ )
179
+ except (OSError, ValueError) as exc:
171
180
  fail(f"Impossible de demarrer le workflow : {exc}")
172
181
  raise typer.Exit(int(ExitCode.INVALID_CONTRACT)) from exc
173
182
  write_adaptive_state(state_file, state)
@@ -221,7 +230,10 @@ def complete(
221
230
  validate_preflight_evidence(feature_dir / "preflight-evidence.md")
222
231
  ensure_fresh_devflow_evidence(feature_dir, state, activity)
223
232
  complete_activity(state, activity)
224
- except ValueError as exc:
233
+ enter_current_activity(
234
+ state, project_root_for_feature(feature_dir), feature_dir
235
+ )
236
+ except (OSError, ValueError) as exc:
225
237
  fail(str(exc))
226
238
  console.print(f" Suite : devflow adaptive status {state.issueId}")
227
239
  raise typer.Exit(int(ExitCode.BLOCKED)) from exc
@@ -284,11 +296,15 @@ def contract(
284
296
  state,
285
297
  assessment.to_dict(),
286
298
  feature_path,
287
- carried_changed_files(feature_dir, state.currentActivity),
288
299
  reopened=is_reopened_activity(feature_dir, state.currentActivity),
289
300
  preflight_evidence=(feature_dir / "preflight-evidence.md").is_file(),
301
+ files_since_base=(
302
+ files_since_activity_base(feature_dir, state)
303
+ if state.activityBase is not None
304
+ else ()
305
+ ),
290
306
  )
291
- except ValueError as exc:
307
+ except (OSError, ValueError) as exc:
292
308
  fail(f"Impossible de generer le contrat : {exc}")
293
309
  raise typer.Exit(int(ExitCode.INVALID_CONTRACT)) from exc
294
310
  output_file = feature_dir / "activity-contract.json"
@@ -457,12 +473,14 @@ def run_adaptive(
457
473
  _print_execution_json(
458
474
  current_state.issueId, outcome.activity, True, outcome.reason,
459
475
  outcome.detail, outcome.nextActivity, ExitCode.OK,
476
+ warnings=outcome.warnings,
460
477
  )
461
478
  else:
462
479
  ok(
463
480
  f"Activite '{outcome.activity}' validee. "
464
481
  f"Prochaine : {outcome.nextActivity}"
465
482
  )
483
+ _print_warnings(outcome.warnings)
466
484
  return
467
485
  safe_refresh_worklog(feature_dir)
468
486
  exit_code = exit_code_for_run_reason(outcome.reason)
@@ -470,9 +488,14 @@ def run_adaptive(
470
488
  _print_execution_json(
471
489
  current_state.issueId, outcome.activity, False, outcome.reason,
472
490
  outcome.detail, outcome.nextActivity, exit_code,
491
+ warnings=outcome.warnings,
473
492
  )
493
+ elif outcome.reason in BASE_REFUSALS:
494
+ fail(outcome.detail)
495
+ console.print(f" Suite : devflow adaptive status {current_state.issueId}")
474
496
  else:
475
497
  fail(outcome.detail or outcome.reason)
498
+ _print_warnings(outcome.warnings)
476
499
  console.print(
477
500
  f" Activite '{outcome.activity}' non validee : {outcome.reason}"
478
501
  )
@@ -591,12 +614,17 @@ def reconcile_adaptive(
591
614
  f"Activite '{outcome.activity}' reconciliee. "
592
615
  f"Prochaine : {outcome.nextActivity}"
593
616
  )
617
+ _print_warnings(outcome.warnings)
594
618
  return
595
619
  safe_refresh_worklog(feature_dir)
596
- fail(
597
- f"Reconciliation de '{outcome.activity}' refusee : {outcome.reason}"
598
- + (f" ({outcome.detail})" if outcome.detail else "")
599
- )
620
+ if outcome.reason in BASE_REFUSALS:
621
+ fail(outcome.detail)
622
+ else:
623
+ fail(
624
+ f"Reconciliation de '{outcome.activity}' refusee : {outcome.reason}"
625
+ + (f" ({outcome.detail})" if outcome.detail else "")
626
+ )
627
+ _print_warnings(outcome.warnings)
600
628
  console.print(f" Suite : devflow adaptive status {state.issueId}")
601
629
  if outcome.reason == "reconcile-refused":
602
630
  console.print(
@@ -771,6 +799,9 @@ def reopen(
771
799
  entry = reopen_activity(
772
800
  feature_dir, state, activity, reason, author=author
773
801
  )
802
+ enter_current_activity(
803
+ state, project_root_for_feature(feature_dir), feature_dir
804
+ )
774
805
  except (OSError, ValueError) as exc:
775
806
  fail(str(exc))
776
807
  console.print(f" Suite : devflow adaptive status {state.issueId}")
@@ -806,9 +837,12 @@ def discard_attempt_command(
806
837
  )
807
838
  if entry["discardedFiles"]:
808
839
  warn(
809
- "Changements laisses dans le working tree, non reportes : "
810
- + ", ".join(entry["discardedFiles"])
840
+ "Fichiers modifies depuis le debut de l'activite "
841
+ f"'{entry['activity']}', comptes dans le lot a la relance ; remise en "
842
+ "etat a la charge du superviseur : " + ", ".join(entry["discardedFiles"])
811
843
  )
844
+ if entry.get("observationError"):
845
+ warn(f"Fichiers depuis la base non calcules : {entry['observationError']}")
812
846
 
813
847
 
814
848
  def _execute_local_verification(
@@ -851,6 +885,7 @@ def _execute_local_verification(
851
885
  def _print_execution_json(
852
886
  issue_id: str, activity: str | None, passed: bool, reason: str,
853
887
  detail: str, next_activity: str | None, exit_code: ExitCode,
888
+ *, warnings: tuple[str, ...] = (),
854
889
  ) -> None:
855
890
  typer.echo(json.dumps({
856
891
  "schemaVersion": 1,
@@ -861,9 +896,16 @@ def _print_execution_json(
861
896
  "detail": detail,
862
897
  "nextActivity": next_activity,
863
898
  "exitCode": int(exit_code),
899
+ "warnings": list(warnings),
864
900
  }, ensure_ascii=False))
865
901
 
866
902
 
903
+ def _print_warnings(warnings: tuple[str, ...]) -> None:
904
+ """Ecart de declaration : avertissement seul, sans effet sur le code de sortie."""
905
+ for message in warnings:
906
+ warn(message)
907
+
908
+
867
909
  def _utc_now() -> str:
868
910
  return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
869
911
 
@@ -1155,13 +1197,6 @@ def _print_run_recovery(
1155
1197
  console.print(" Ne relancez pas automatiquement l'agent : reconciliez d'abord les changements partiels.")
1156
1198
  console.print(f" devflow adaptive reconcile {issue_id}")
1157
1199
  return
1158
- if reason == "stale-attempt":
1159
- console.print(" git status --short # inspecter les changements de la tentative")
1160
- console.print(
1161
- " Restaurez les fichiers de la tentative, ou abandonnez-la explicitement :"
1162
- )
1163
- console.print(f" devflow adaptive discard-attempt {issue_id} --reason MOTIF")
1164
- return
1165
1200
  if reason == "inputs-changed-during-activity":
1166
1201
  console.print(
1167
1202
  " Les entrees du besoin ont change pendant l'execution. "
@@ -0,0 +1,408 @@
1
+ """Base d'une activite : reference stable des fichiers observes (KKS-508).
2
+
3
+ La CLI enregistre une base a l'entree dans une activite ; les fichiers de
4
+ l'activite sont tout ce qui differe de cette base. L'observation fait foi :
5
+ la declaration de l'agent ne produit qu'un avertissement.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import copy
11
+ import re
12
+ import subprocess
13
+ from dataclasses import dataclass
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING, Any, Iterable
17
+
18
+ from devflow_cli.adapters.linear import environment_without_linear_key
19
+ from devflow_cli.core.worktree_snapshot import (
20
+ _fingerprint,
21
+ _git_paths,
22
+ _git_text,
23
+ _subtree,
24
+ snapshot_worktree,
25
+ untracked_paths,
26
+ )
27
+ from devflow_cli.utils.paths import project_root_for_feature
28
+
29
+ if TYPE_CHECKING:
30
+ from devflow_cli.core.activity_contracts import ActivityContract, ActivityResult
31
+ from devflow_cli.core.adaptive_workflow import AdaptiveWorkflowState
32
+
33
+
34
+ HEAD_PATTERN = re.compile(r"^(?:[0-9a-f]{40}|[0-9a-f]{64})?$")
35
+ FINGERPRINT_PATTERN = re.compile(r"^(?:file:[0-7]+:[0-9a-f]{64}|symlink:[0-7]+:.*|deleted)$")
36
+ BASE_ACTIVITIES = frozenset({
37
+ "prepare", "risk-assessment", "spec", "contracts", "plan", "tasks",
38
+ "preflight-evidence", "implement", "verify", "review", "rollback-check",
39
+ })
40
+ BASE_KEYS = frozenset({"activity", "recordedAt", "head", "branch", "files"})
41
+
42
+ # Seule definition des fichiers ecrits par la CLI dans le dossier de la feature (S4).
43
+ RUNTIME_FILE_NAMES = frozenset({
44
+ "workflow-state.json", ".workflow-state.lock", "activity-contract.json",
45
+ "agent-execution.json", "worklog.md", "reopen-log.json", "attempt-discard-log.json",
46
+ "reconcile-log.json", "validation-approvals.json", "waivers.json", "verification.json",
47
+ })
48
+ RUNTIME_FILE_SUFFIXES = ("-result.json", "-attempt.json", ".raw.txt")
49
+ RUNTIME_DIRECTORIES = ("attempts", "evidence")
50
+
51
+ OBSERVATION_SCHEMA: dict[str, Any] = {
52
+ "type": "object",
53
+ "required": ["baseRecordedAt", "baseHead", "observedAt", "head"],
54
+ "properties": {
55
+ "baseRecordedAt": {"type": "string", "minLength": 1},
56
+ "baseHead": {"type": "string", "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})?$"},
57
+ "observedAt": {"type": "string", "minLength": 1},
58
+ "head": {"type": "string", "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})?$"},
59
+ "declaredChangedFiles": {
60
+ "type": "array", "items": {"type": "string", "minLength": 1}
61
+ },
62
+ "warning": {"type": "string", "minLength": 1},
63
+ },
64
+ "additionalProperties": False,
65
+ "dependentRequired": {
66
+ "declaredChangedFiles": ["warning"],
67
+ "warning": ["declaredChangedFiles"],
68
+ },
69
+ }
70
+
71
+
72
+ def _now_iso() -> str:
73
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
74
+
75
+
76
+ def _is_runtime_name(name: str) -> bool:
77
+ return name in RUNTIME_FILE_NAMES or name.endswith(RUNTIME_FILE_SUFFIXES)
78
+
79
+
80
+ def is_runtime_file(path: str, feature_path: str | None) -> bool:
81
+ """Vrai pour un fichier ecrit par la CLI dans le dossier de la feature (S4)."""
82
+ if not feature_path:
83
+ return False
84
+ prefix = feature_path.rstrip("/") + "/"
85
+ if not path.startswith(prefix):
86
+ return False
87
+ parts = path[len(prefix):].split("/")
88
+ if parts[0] in RUNTIME_DIRECTORIES:
89
+ return True
90
+ if len(parts) != 1:
91
+ return False
92
+ name = parts[0]
93
+ if _is_runtime_name(name):
94
+ return True
95
+ # Temporaire d'ecriture atomique « .<nom>.<aleatoire>.tmp » laisse par une interruption.
96
+ if name.startswith(".") and name.endswith(".tmp"):
97
+ inner = name[1:-len(".tmp")]
98
+ if "." in inner and _is_runtime_name(inner.rsplit(".", 1)[0]):
99
+ return True
100
+ return False
101
+
102
+
103
+ def _check_path(path: object, feature_path: str | None) -> str:
104
+ if not isinstance(path, str) or not path:
105
+ raise ValueError("base invalide : chemin vide ou non textuel")
106
+ if path.startswith("/") or "\\" in path or ".." in path.split("/"):
107
+ raise ValueError(f"base invalide : chemin non portable {path!r}")
108
+ if is_runtime_file(path, feature_path):
109
+ raise ValueError(f"base invalide : fichier d'execution devflow {path!r}")
110
+ return path
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class ActivityBase:
115
+ activity: str
116
+ recordedAt: str
117
+ head: str
118
+ branch: str
119
+ files: dict[str, str]
120
+
121
+ def to_dict(self) -> dict[str, Any]:
122
+ return {
123
+ "activity": self.activity,
124
+ "recordedAt": self.recordedAt,
125
+ "head": self.head,
126
+ "branch": self.branch,
127
+ "files": dict(sorted(self.files.items())),
128
+ }
129
+
130
+ @classmethod
131
+ def from_dict(
132
+ cls, payload: object, *, feature_path: str | None = None
133
+ ) -> "ActivityBase":
134
+ """Valide une base lue ; ``feature_path`` permet de refuser un chemin S4."""
135
+ if not isinstance(payload, dict) or set(payload) != BASE_KEYS:
136
+ raise ValueError("base invalide : cles manquantes ou inattendues")
137
+ activity = payload["activity"]
138
+ recorded_at = payload["recordedAt"]
139
+ head = payload["head"]
140
+ branch = payload["branch"]
141
+ files = payload["files"]
142
+ if activity not in BASE_ACTIVITIES:
143
+ raise ValueError(f"base invalide : activite {activity!r}")
144
+ if not isinstance(recorded_at, str) or not recorded_at:
145
+ raise ValueError("base invalide : recordedAt")
146
+ if not isinstance(head, str) or not HEAD_PATTERN.fullmatch(head):
147
+ raise ValueError("base invalide : head")
148
+ if not isinstance(branch, str):
149
+ raise ValueError("base invalide : branch")
150
+ if not isinstance(files, dict):
151
+ raise ValueError("base invalide : files")
152
+ checked: dict[str, str] = {}
153
+ for path, fingerprint in files.items():
154
+ _check_path(path, feature_path)
155
+ if not isinstance(fingerprint, str) or not FINGERPRINT_PATTERN.fullmatch(fingerprint):
156
+ raise ValueError(f"base invalide : empreinte de {path!r}")
157
+ checked[path] = fingerprint
158
+ return cls(activity, recorded_at, head, branch, checked)
159
+
160
+
161
+ class BaseObservationError(ValueError):
162
+ """Les fichiers depuis la base ne sont pas calculables : branche ou historique."""
163
+
164
+ def __init__(self, reason: str, message: str) -> None:
165
+ self.reason = reason
166
+ super().__init__(message)
167
+
168
+
169
+ @dataclass(frozen=True)
170
+ class ActivityObservation:
171
+ base: ActivityBase
172
+ observedAt: str
173
+ head: str
174
+ sinceBase: dict[str, str]
175
+ duringExecution: tuple[str, ...]
176
+
177
+
178
+ def feature_path_for(project_root: Path, feature_dir: Path) -> str | None:
179
+ """Chemin POSIX du dossier de la feature relatif au projet, ou None."""
180
+ return _subtree(project_root, feature_dir)
181
+
182
+
183
+ def capture_base(project_root: Path, feature_dir: Path, activity: str) -> ActivityBase:
184
+ """Capture HEAD, branche et empreintes des fichiers modifies, hors S4."""
185
+ snapshot = snapshot_worktree(project_root, feature_dir)
186
+ feature_path = feature_path_for(project_root, feature_dir)
187
+ return ActivityBase(
188
+ activity=activity,
189
+ recordedAt=_now_iso(),
190
+ head=snapshot.head,
191
+ branch=snapshot.branch,
192
+ files={
193
+ path: fingerprint
194
+ for path, fingerprint in snapshot.files.items()
195
+ if not is_runtime_file(path, feature_path)
196
+ },
197
+ )
198
+
199
+
200
+ def files_since_base(
201
+ project_root: Path, feature_dir: Path, base: ActivityBase, *, issue_id: str
202
+ ) -> dict[str, str]:
203
+ """Fichiers qui different de la base (CT-06), avec leur empreinte actuelle."""
204
+ return _observe_since(project_root, feature_dir, base, issue_id)[2]
205
+
206
+
207
+ def _observe_since(
208
+ project_root: Path, feature_dir: Path, base: ActivityBase, issue_id: str
209
+ ) -> tuple[str, str, dict[str, str]]:
210
+ if not HEAD_PATTERN.fullmatch(base.head):
211
+ raise ValueError(f"base invalide : head {base.head!r}")
212
+ head = _git_text(project_root, ["rev-parse", "--verify", "HEAD"], allow_failure=True)
213
+ branch = _git_text(project_root, ["branch", "--show-current"], allow_failure=True)
214
+ if base.branch != branch:
215
+ base_branch = base.branch or "(HEAD detache)"
216
+ current = branch or "(HEAD detache)"
217
+ raise BaseObservationError(
218
+ "base-branch-changed",
219
+ f"base de '{base.activity}' prise sur la branche '{base_branch}', "
220
+ f"branche courante '{current}' ; revenir sur '{base_branch}' "
221
+ f"(git switch {base_branch}) puis relancer devflow adaptive run {issue_id}",
222
+ )
223
+ if base.head and base.head != head:
224
+ code, stderr = _git_status(
225
+ project_root, ["merge-base", "--is-ancestor", base.head, "HEAD"]
226
+ )
227
+ if code == 1:
228
+ raise BaseObservationError(
229
+ "base-history-rewritten",
230
+ f"base de '{base.activity}' : le commit {base.head} n'est plus un "
231
+ f"ancetre de HEAD ({head}) : rebase, amend ou reset depuis l'entree dans "
232
+ f"'{base.activity}' ; retablir un historique qui contient {base.head} "
233
+ f"(voir git reflog), puis relancer devflow adaptive run {issue_id} ; "
234
+ "sinon terminer ou abandonner le parcours",
235
+ )
236
+ if code != 0:
237
+ raise ValueError(stderr or "git merge-base --is-ancestor a echoue")
238
+ if base.head:
239
+ tracked = _git_paths(
240
+ project_root, ["diff", "--no-renames", "--name-only", "-z", base.head, "--"]
241
+ )
242
+ else:
243
+ tracked = _git_paths(project_root, ["ls-files", "-z"])
244
+ candidates = set(tracked) | set(untracked_paths(project_root, feature_dir)) | set(base.files)
245
+ feature_path = feature_path_for(project_root, feature_dir)
246
+ since: dict[str, str] = {}
247
+ for path in sorted(candidates):
248
+ if is_runtime_file(path, feature_path):
249
+ continue
250
+ current_fingerprint = _fingerprint(project_root / path)
251
+ if base.files.get(path) != current_fingerprint:
252
+ since[path] = current_fingerprint
253
+ return head, branch, since
254
+
255
+
256
+ def _git_status(project_root: Path, args: list[str]) -> tuple[int, str]:
257
+ result = subprocess.run(
258
+ ["git", *args],
259
+ cwd=project_root,
260
+ capture_output=True,
261
+ text=True,
262
+ timeout=30,
263
+ env=environment_without_linear_key(),
264
+ )
265
+ return result.returncode, result.stderr.strip()
266
+
267
+
268
+ def current_base(state: "AdaptiveWorkflowState", feature_dir: Path) -> ActivityBase:
269
+ """Base de l'activite courante, validee."""
270
+ if state.activityBase is None:
271
+ raise ValueError("aucune base pour l'activite courante")
272
+ project_root = project_root_for_feature(feature_dir)
273
+ return ActivityBase.from_dict(
274
+ state.activityBase, feature_path=feature_path_for(project_root, feature_dir)
275
+ )
276
+
277
+
278
+ def files_since_activity_base(
279
+ feature_dir: Path, state: "AdaptiveWorkflowState"
280
+ ) -> dict[str, str]:
281
+ """Fichiers depuis la base de l'activite courante."""
282
+ return files_since_base(
283
+ project_root_for_feature(feature_dir),
284
+ feature_dir,
285
+ current_base(state, feature_dir),
286
+ issue_id=state.issueId,
287
+ )
288
+
289
+
290
+ def observe_activity(
291
+ feature_dir: Path,
292
+ state: "AdaptiveWorkflowState",
293
+ *,
294
+ during_execution: Iterable[str] = (),
295
+ ) -> ActivityObservation:
296
+ """Seule construction de l'observation d'une activite (run, reconcile, revue)."""
297
+ base = current_base(state, feature_dir)
298
+ project_root = project_root_for_feature(feature_dir)
299
+ head, _branch, since = _observe_since(project_root, feature_dir, base, state.issueId)
300
+ # Liste brute, sans filtre S4 : la CLI n'ecrit rien dans le dossier de la
301
+ # feature pendant l'agent, tout fichier S4 modifie vient donc de l'agent.
302
+ during = tuple(sorted(set(during_execution)))
303
+ return ActivityObservation(base, _now_iso(), head, since, during)
304
+
305
+
306
+ def observe_attempt(
307
+ feature_dir: Path, state: "AdaptiveWorkflowState"
308
+ ) -> tuple[str, str, dict[str, str]]:
309
+ """HEAD, branche et fichiers depuis la base, pour une fiche de tentative."""
310
+ project_root = project_root_for_feature(feature_dir)
311
+ return _observe_since(
312
+ project_root, feature_dir, current_base(state, feature_dir), state.issueId
313
+ )
314
+
315
+
316
+ def enter_current_activity(
317
+ state: "AdaptiveWorkflowState", project_root: Path, feature_dir: Path
318
+ ) -> None:
319
+ """Pose la base de l'activite courante (S3) ; une erreur de capture se propage."""
320
+ activity = state.currentActivity
321
+ if activity == "done":
322
+ state.activityBase = None
323
+ return
324
+ if activity == "implement":
325
+ if state.implementBase is None:
326
+ state.implementBase = capture_base(project_root, feature_dir, "implement").to_dict()
327
+ state.activityBase = copy.deepcopy(state.implementBase)
328
+ return
329
+ state.activityBase = capture_base(project_root, feature_dir, activity).to_dict()
330
+
331
+
332
+ def validate_state_bases(
333
+ state: "AdaptiveWorkflowState", *, feature_path: str | None = None
334
+ ) -> None:
335
+ """Invariants I1 a I6 des bases d'un etat adaptatif (CT-02)."""
336
+ activity_base = state.activityBase
337
+ implement_base = state.implementBase
338
+ if (activity_base is None) != (state.currentActivity == "done"):
339
+ raise ValueError("activityBase absente hors de done, ou presente dans done")
340
+ for base in (activity_base, implement_base):
341
+ if base is not None:
342
+ ActivityBase.from_dict(base, feature_path=feature_path)
343
+ if activity_base is not None and activity_base["activity"] != state.currentActivity:
344
+ raise ValueError("activityBase ne porte pas sur l'activite courante")
345
+ if state.currentActivity == "implement" and activity_base != implement_base:
346
+ raise ValueError("activityBase differe de implementBase pendant implement")
347
+ if implement_base is not None and implement_base["activity"] != "implement":
348
+ raise ValueError("implementBase ne porte pas sur implement")
349
+ if (
350
+ "implement" in state.completedActivities or state.currentActivity == "implement"
351
+ ) and implement_base is None:
352
+ raise ValueError("implementBase absente alors qu'implement a ete atteint")
353
+
354
+
355
+ def declaration_warning(declared: list[str], observed: list[str]) -> str:
356
+ """Avertissement d'ecart entre declaration et observation (S9), ou ''."""
357
+ if declared == observed:
358
+ return ""
359
+ parts = ["changedFiles declare differe de l'observation depuis la base"]
360
+ undeclared = sorted(set(observed) - set(declared))
361
+ unobserved = sorted(set(declared) - set(observed))
362
+ if undeclared:
363
+ parts.append("non declares : " + ", ".join(undeclared))
364
+ if unobserved:
365
+ parts.append("declares non observes : " + ", ".join(unobserved))
366
+ parts.append("la liste observee est enregistree")
367
+ return " ; ".join(parts)
368
+
369
+
370
+ def recorded_observation(
371
+ observation: ActivityObservation, declared: list[str]
372
+ ) -> dict[str, Any]:
373
+ """Bloc ``observation`` ecrit par la CLI seule dans le resultat enregistre."""
374
+ observed = sorted(observation.sinceBase)
375
+ block: dict[str, Any] = {
376
+ "baseRecordedAt": observation.base.recordedAt,
377
+ "baseHead": observation.base.head,
378
+ "observedAt": observation.observedAt,
379
+ "head": observation.head,
380
+ }
381
+ warning = declaration_warning(declared, observed)
382
+ if warning:
383
+ block["declaredChangedFiles"] = list(declared)
384
+ block["warning"] = warning
385
+ return block
386
+
387
+
388
+ def parse_recorded_result(
389
+ payload: object, contract: "ActivityContract"
390
+ ) -> tuple["ActivityResult", dict[str, Any]]:
391
+ """Relit un resultat enregistre par la CLI : exige et retire ``observation``."""
392
+ from devflow_cli.core.activity_contracts import parse_activity_result
393
+ from devflow_cli.core.contract_schemas import (
394
+ ContractSchemaError,
395
+ validate_contract_payload,
396
+ )
397
+
398
+ if not isinstance(payload, dict) or "observation" not in payload:
399
+ raise ValueError("resultat enregistre sans bloc observation")
400
+ observation = payload["observation"]
401
+ try:
402
+ validate_contract_payload(
403
+ "activity-result", observation, schema=OBSERVATION_SCHEMA
404
+ )
405
+ except ContractSchemaError as exc:
406
+ raise ValueError(f"bloc observation invalide : {exc}") from exc
407
+ rest = {key: value for key, value in payload.items() if key != "observation"}
408
+ return parse_activity_result(rest, contract), dict(observation)
@@ -9,7 +9,7 @@ import re
9
9
  import unicodedata
10
10
  from dataclasses import asdict, dataclass, field
11
11
  from pathlib import Path
12
- from typing import Any, Literal
12
+ from typing import Any, Iterable, Literal
13
13
 
14
14
  from devflow_cli.core.adaptive_workflow import (
15
15
  ADAPTIVE_STATE_SCHEMA_VERSION,
@@ -204,9 +204,10 @@ def build_activity_contract(
204
204
  state: AdaptiveWorkflowState,
205
205
  assessment: dict[str, Any],
206
206
  feature_path: str,
207
- carried_changed_files: list[str] | None = None,
207
+ *,
208
208
  reopened: bool = False,
209
209
  preflight_evidence: bool = False,
210
+ files_since_base: Iterable[str] = (),
210
211
  ) -> ActivityContract:
211
212
  """Construit le contrat de l'activite courante sans notion d'agent."""
212
213
  activity = state.currentActivity
@@ -253,11 +254,6 @@ def build_activity_contract(
253
254
  "Produire un artefact complet sans placeholder ni section vide.",
254
255
  "Declarer uniquement cet artefact dans changedFiles.",
255
256
  ]
256
- if carried_changed_files:
257
- constraints.append(
258
- "Une tentative precedente a deja modifie cet artefact : inspecter son "
259
- "contenu avant de le completer, puis declarer l'artefact dans changedFiles."
260
- )
261
257
  rules = ARTIFACT_RULES.get(activity, {})
262
258
  for alternatives in rules.get("required_section_groups", []):
263
259
  constraints.append(
@@ -408,19 +404,6 @@ def build_activity_contract(
408
404
  "Les fichiers modifies sont listes dans le resultat.",
409
405
  "Les risques ou blocages restants sont explicites.",
410
406
  ]
411
- if carried_changed_files:
412
- carried = sorted(set(carried_changed_files))
413
- constraints.extend(
414
- [
415
- "Des changements d'implementation sont deja presents dans le working tree : cette execution reprend leur perimetre.",
416
- "Inspecter les changements de la tentative precedente avant de les corriger.",
417
- "changedFiles doit contenir le perimetre cumule de l'implementation : "
418
- + ", ".join(carried),
419
- ]
420
- )
421
- completion_criteria.append(
422
- "Les changements precedents et ceux de la remediation forment une implementation coherente."
423
- )
424
407
  required_evidence: list[str] = []
425
408
  else:
426
409
  allowed_actions = ["read", "execute"]
@@ -437,6 +420,17 @@ def build_activity_contract(
437
420
  ]
438
421
  required_evidence = gate_evidence(state, activity)
439
422
 
423
+ already_changed = sorted(set(files_since_base))
424
+ if activity in DOCUMENT_ACTIVITIES:
425
+ artifact = f"{feature_path}/{document_artifact_name(activity, reopened=reopened)}"
426
+ already_changed = [artifact] if artifact in already_changed else []
427
+ if already_changed:
428
+ constraints.append(
429
+ "Fichiers deja modifies depuis le debut de l'activite (comptes dans son "
430
+ "resultat) : " + ", ".join(already_changed)
431
+ + " ; les inspecter avant de poursuivre."
432
+ )
433
+
440
434
  inputs = [
441
435
  f"{feature_path}/{NEED_STATEMENT}",
442
436
  f"{feature_path}/assessment.json",