devflow-cli 8.5.1__py3-none-any.whl → 9.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 = "8.5.1"
3
+ VERSION = "9.0.0"
4
4
 
5
5
  SUPPORTED_AGENTS = ["claude-code", "codex", "cursor"]
6
6
 
@@ -76,7 +76,11 @@ from devflow_cli.core.validation_approvals import (
76
76
  pending_approvals,
77
77
  pending_plan_approval,
78
78
  )
79
- from devflow_cli.core.contract_schemas import LegacySchemaError, diagnose_contract_file
79
+ from devflow_cli.core.contract_schemas import (
80
+ LegacySchemaError,
81
+ check_feature_files,
82
+ diagnose_contract_file,
83
+ )
80
84
  from devflow_cli.core.operational import ExitCode, exit_code_for_run_reason
81
85
  from devflow_cli.core.result_template import (
82
86
  remove_result_template,
@@ -196,6 +200,7 @@ def start(feature: str = typer.Argument(..., help="ID ou dossier de la feature")
196
200
  fail("workflow-state.json existe deja ; le workflow ne sera pas ecrase.")
197
201
  console.print(f" Suite : devflow adaptive status {feature}")
198
202
  raise typer.Exit(int(ExitCode.CONCURRENT_CHANGE))
203
+ check_feature_files(feature_dir)
199
204
  try:
200
205
  assessment = _assessment(feature_dir)
201
206
  validate_need_statement(feature_dir / NEED_STATEMENT)
@@ -483,7 +488,7 @@ def run_adaptive(
483
488
  }
484
489
  _write_json_atomic(feature_dir / "agent-execution.json", audit_payload)
485
490
  except (OSError, ValueError) as exc:
486
- return AgentActivityExecution(1, stderr=str(exc))
491
+ return AgentActivityExecution(1, stderr=str(exc), agent=agent)
487
492
  return AgentActivityExecution(
488
493
  execution.returncode,
489
494
  output,
@@ -533,6 +538,8 @@ def run_adaptive(
533
538
  recovery = _stale_preflight_recovery(current_state)
534
539
  if outcome.reason == "artifact-files-mismatch":
535
540
  recovery = _artifact_mismatch_recovery(current_state, outcome, agent)
541
+ if outcome.reason == "imputed-input":
542
+ recovery = _imputed_input_recovery(current_state, outcome, agent)
536
543
  if json_output:
537
544
  _print_execution_json(
538
545
  current_state.issueId, outcome.activity, False, outcome.reason,
@@ -721,6 +728,14 @@ def reconcile_adaptive(
721
728
  state, outcome, _attempt_agent(feature_dir, outcome.activity)
722
729
  )
723
730
  )
731
+ if outcome.reason == "imputed-input":
732
+ console.print(" Reprise guidee :")
733
+ _print_recovery_steps(
734
+ _imputed_input_recovery(
735
+ state, outcome, _attempt_agent(feature_dir, outcome.activity),
736
+ reconcile=True,
737
+ )
738
+ )
724
739
  if (
725
740
  outcome.reason == "reconcile-refused"
726
741
  and activity_attempt_file(feature_dir, outcome.activity).is_file()
@@ -1610,10 +1625,41 @@ def _stale_preflight_recovery(state: AdaptiveWorkflowState) -> dict:
1610
1625
  ARTIFACT_FILE_CAUSES = {
1611
1626
  "during-agent": "modifie pendant l'agent",
1612
1627
  "before-launch": "modifie avant le lancement de l'agent",
1613
- "agent-input": "entree du besoin modifiee par un agent de cette activite",
1614
1628
  }
1615
1629
 
1616
1630
 
1631
+ def _imputed_input_recovery(
1632
+ state: AdaptiveWorkflowState, outcome: AdaptiveRunOutcome, agent: str,
1633
+ *, reconcile: bool = False,
1634
+ ) -> dict:
1635
+ """Reprise guidee d'un refus `imputed-input` : remise en etat ou abandon audite."""
1636
+ issue = shlex.quote(state.issueId)
1637
+ not_done = "reconcile refuse" if reconcile else "l'agent n'a pas ete lance"
1638
+ return {
1639
+ "kind": "imputed-input",
1640
+ "summary": (
1641
+ "Entree du besoin modifiee par un agent de cette activite et pas remise "
1642
+ f"en etat : {not_done}. Deux sorties : remettre l'entree dans son etat de "
1643
+ "base, ou abandonner la tentative (l'entree modifiee est gardee, a verifier)."
1644
+ ),
1645
+ "files": [{"path": path} for path in outcome.imputedInputs],
1646
+ "commands": [
1647
+ {
1648
+ "command": f"devflow adaptive run {issue}"
1649
+ + (f" --agent {shlex.quote(agent)}" if agent else ""),
1650
+ "purpose": "apres remise en etat de l'entree dans son etat de base",
1651
+ },
1652
+ {
1653
+ "command": f"devflow adaptive discard-attempt {issue} --reason MOTIF",
1654
+ "purpose": (
1655
+ "sinon, garder l'entree modifiee : abandon audite de la "
1656
+ "tentative, puis devflow adaptive run"
1657
+ ),
1658
+ },
1659
+ ],
1660
+ }
1661
+
1662
+
1617
1663
  def _artifact_mismatch_recovery(
1618
1664
  state: AdaptiveWorkflowState, outcome: AdaptiveRunOutcome, agent: str
1619
1665
  ) -> dict:
@@ -1638,34 +1684,6 @@ def _artifact_mismatch_recovery(
1638
1684
  "purpose": "relancer l'agent pour qu'il ecrive l'artefact",
1639
1685
  }],
1640
1686
  }
1641
- if causes == {"agent-input"}:
1642
- # Refus avant l'agent : ni reconcile ni reopen ne leve l'imputation.
1643
- run = f"devflow adaptive run {issue}" + (
1644
- f" --agent {shlex.quote(agent)}" if agent else ""
1645
- )
1646
- return {
1647
- "kind": "artifact-files-mismatch",
1648
- "summary": (
1649
- "Entree du besoin modifiee par un agent de cette activite et pas remise "
1650
- "en etat : l'agent n'a pas ete lance. Deux sorties : remettre l'entree "
1651
- "dans son etat de base, ou abandonner la tentative (l'entree modifiee "
1652
- "est gardee, a verifier)."
1653
- ),
1654
- "files": files,
1655
- "commands": [
1656
- {
1657
- "command": run,
1658
- "purpose": "apres remise en etat de l'entree dans son etat de base",
1659
- },
1660
- {
1661
- "command": f"devflow adaptive discard-attempt {issue} --reason MOTIF",
1662
- "purpose": (
1663
- "sinon, garder l'entree modifiee : abandon audite de la "
1664
- "tentative, puis devflow adaptive run"
1665
- ),
1666
- },
1667
- ],
1668
- }
1669
1687
  commands = [{
1670
1688
  "command": f"devflow adaptive reconcile {issue}",
1671
1689
  "purpose": (
@@ -1677,7 +1695,6 @@ def _artifact_mismatch_recovery(
1677
1695
  previous = state.activities[index - 1] if index > 0 else ""
1678
1696
  if (
1679
1697
  "before-launch" in causes
1680
- and "agent-input" not in causes
1681
1698
  and previous not in {"", "assess"}
1682
1699
  and _reopenable(state, previous)
1683
1700
  ):
@@ -1688,14 +1705,6 @@ def _artifact_mismatch_recovery(
1688
1705
  "l'activite precedente pour repartir d'une base fraiche"
1689
1706
  ),
1690
1707
  })
1691
- if "agent-input" in causes:
1692
- commands.append({
1693
- "command": f"devflow adaptive discard-attempt {issue} --reason MOTIF",
1694
- "purpose": (
1695
- "sinon, garder l'entree du besoin modifiee par l'agent : abandon "
1696
- "audite de la tentative, puis devflow adaptive run"
1697
- ),
1698
- })
1699
1708
  return {
1700
1709
  "kind": "artifact-files-mismatch",
1701
1710
  "summary": (
@@ -1724,9 +1733,8 @@ def _print_recovery_steps(recovery: dict) -> None:
1724
1733
  # soft_wrap : la commande a copier reste sur une seule ligne.
1725
1734
  console.print(f" {recovery['summary']}", markup=False, soft_wrap=True)
1726
1735
  for item in recovery.get("files", []):
1727
- console.print(
1728
- f" {item['path']} : {item['label']}", markup=False, soft_wrap=True
1729
- )
1736
+ label = f" : {item['label']}" if "label" in item else ""
1737
+ console.print(f" {item['path']}{label}", markup=False, soft_wrap=True)
1730
1738
  for step in recovery["commands"]:
1731
1739
  console.print(
1732
1740
  f" {step['command']} # {step['purpose']}", markup=False, soft_wrap=True
@@ -1782,25 +1790,22 @@ def _print_run_recovery(
1782
1790
  " Les entrees du besoin ont change pendant l'execution. "
1783
1791
  "Verifiez-les avant une nouvelle tentative."
1784
1792
  )
1785
- if activity in DOCUMENT_ARTIFACTS:
1786
- console.print(
1787
- " Activite documentaire : la relance reste refusee tant que l'entree "
1788
- "n'est pas remise dans son etat de base, ou la tentative abandonnee :",
1789
- markup=False, soft_wrap=True,
1790
- )
1791
- issue, agent_arg = shlex.quote(issue_id), shlex.quote(agent)
1792
- console.print(
1793
- f" devflow adaptive discard-attempt {issue} --reason MOTIF"
1794
- " # garder l'entree modifiee : abandon audite",
1795
- markup=False, soft_wrap=True,
1796
- )
1797
- console.print(
1798
- f" devflow adaptive run {issue} --agent {agent_arg}"
1799
- " # apres remise en etat de l'entree, ou apres l'abandon",
1800
- markup=False, soft_wrap=True,
1801
- )
1802
- return
1803
- console.print(f" devflow adaptive run {issue_id} --agent {agent}")
1793
+ console.print(
1794
+ " La relance reste refusee tant que l'entree "
1795
+ "n'est pas remise dans son etat de base, ou la tentative abandonnee :",
1796
+ markup=False, soft_wrap=True,
1797
+ )
1798
+ issue, agent_arg = shlex.quote(issue_id), shlex.quote(agent)
1799
+ console.print(
1800
+ f" devflow adaptive discard-attempt {issue} --reason MOTIF"
1801
+ " # garder l'entree modifiee : abandon audite",
1802
+ markup=False, soft_wrap=True,
1803
+ )
1804
+ console.print(
1805
+ f" devflow adaptive run {issue} --agent {agent_arg}"
1806
+ " # apres remise en etat de l'entree, ou apres l'abandon",
1807
+ markup=False, soft_wrap=True,
1808
+ )
1804
1809
  return
1805
1810
  if reason == "agent-running":
1806
1811
  console.print(" Un agent execute deja cette activite : attendez sa fin.")
@@ -80,7 +80,7 @@ def _list_features() -> None:
80
80
  table.add_row(
81
81
  feature_dir.name,
82
82
  "-",
83
- "[yellow]schema 6.x[/yellow]",
83
+ "[yellow]ancien schema[/yellow]",
84
84
  "-",
85
85
  location,
86
86
  )
@@ -8,6 +8,7 @@ la declaration de l'agent ne produit qu'un avertissement.
8
8
  from __future__ import annotations
9
9
 
10
10
  import copy
11
+ import json
11
12
  import re
12
13
  import subprocess
13
14
  from dataclasses import dataclass
@@ -16,6 +17,7 @@ from pathlib import Path
16
17
  from typing import TYPE_CHECKING, Any, Iterable
17
18
 
18
19
  from devflow_cli.adapters.linear import environment_without_linear_key
20
+ from devflow_cli.core.contract_schemas import ATTEMPT_SCHEMA_VERSION
19
21
  from devflow_cli.core.worktree_snapshot import (
20
22
  _fingerprint,
21
23
  _git_paths,
@@ -24,7 +26,7 @@ from devflow_cli.core.worktree_snapshot import (
24
26
  snapshot_worktree,
25
27
  untracked_paths,
26
28
  )
27
- from devflow_cli.utils.paths import project_root_for_feature
29
+ from devflow_cli.utils.paths import activity_attempt_file, project_root_for_feature
28
30
 
29
31
  if TYPE_CHECKING:
30
32
  from devflow_cli.core.activity_contracts import ActivityContract, ActivityResult
@@ -49,6 +51,14 @@ RUNTIME_FILE_NAMES = frozenset({
49
51
  RUNTIME_FILE_SUFFIXES = ("-result.json", "-attempt.json", ".raw.txt")
50
52
  RUNTIME_DIRECTORIES = ("attempts", "evidence")
51
53
 
54
+ # Fiche de tentative (CT-04) : cles exactes, validees par ``attempt_violation`` seule.
55
+ ATTEMPT_KEYS = frozenset({
56
+ "schemaVersion", "contractId", "activity", "agent", "reason", "recordedAt",
57
+ "head", "branch", "rawOutputSha256", "observedFiles", "executionFiles",
58
+ "changedInputs",
59
+ })
60
+ RAW_OUTPUT_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
61
+
52
62
  OBSERVATION_SCHEMA: dict[str, Any] = {
53
63
  "type": "object",
54
64
  "required": ["baseRecordedAt", "baseHead", "observedAt", "head"],
@@ -61,6 +71,7 @@ OBSERVATION_SCHEMA: dict[str, Any] = {
61
71
  "type": "array", "items": {"type": "string", "minLength": 1}
62
72
  },
63
73
  "warning": {"type": "string", "minLength": 1},
74
+ "amendmentWarning": {"type": "string", "minLength": 1},
64
75
  },
65
76
  "additionalProperties": False,
66
77
  "dependentRequired": {
@@ -378,7 +389,8 @@ def recorded_observation(
378
389
  """Bloc ``observation`` ecrit par la CLI seule dans le resultat enregistre.
379
390
 
380
391
  ``exempted`` : entrees du besoin amendees avant l'agent, hors ecart de
381
- declaration ; ``amendment_warning`` les nomme dans la cle ``warning``.
392
+ declaration ; ``amendment_warning`` les nomme dans la cle ``amendmentWarning``,
393
+ distincte de ``warning`` (ecart de declaration seul).
382
394
  """
383
395
  observed = sorted(set(observation.sinceBase) - set(exempted))
384
396
  block: dict[str, Any] = {
@@ -387,17 +399,94 @@ def recorded_observation(
387
399
  "observedAt": observation.observedAt,
388
400
  "head": observation.head,
389
401
  }
390
- warning = " ; ".join(
391
- message
392
- for message in (declaration_warning(declared, observed), amendment_warning)
393
- if message
394
- )
402
+ warning = declaration_warning(declared, observed)
395
403
  if warning:
396
404
  block["declaredChangedFiles"] = list(declared)
397
405
  block["warning"] = warning
406
+ if amendment_warning:
407
+ block["amendmentWarning"] = amendment_warning
398
408
  return block
399
409
 
400
410
 
411
+ def attempt_violation(attempt: object) -> str | None:
412
+ """Premier invariant A1 a A10 viole par une fiche de tentative, ou None (CT-04).
413
+
414
+ Seule validation de la fiche : l'ecriture et la lecture l'appellent.
415
+ """
416
+ prefix = "fiche de tentative : "
417
+ if not isinstance(attempt, dict):
418
+ return prefix + "objet attendu"
419
+ missing = sorted(ATTEMPT_KEYS - set(attempt))
420
+ if missing:
421
+ return prefix + "cles manquantes : " + ", ".join(missing)
422
+ extra = sorted(set(attempt) - ATTEMPT_KEYS)
423
+ if extra:
424
+ return prefix + "cles en trop : " + ", ".join(extra)
425
+ version = attempt["schemaVersion"]
426
+ if isinstance(version, bool) or version != ATTEMPT_SCHEMA_VERSION:
427
+ return prefix + f"schemaVersion {ATTEMPT_SCHEMA_VERSION} attendu"
428
+ for key in ("contractId", "activity", "recordedAt", "head", "branch"):
429
+ if not isinstance(attempt[key], str):
430
+ return prefix + f"{key} doit etre une chaine"
431
+ for key in ("contractId", "activity", "agent", "reason", "recordedAt"):
432
+ if not isinstance(attempt[key], str) or not attempt[key]:
433
+ return prefix + f"{key} vide"
434
+ raw_sha = attempt["rawOutputSha256"]
435
+ if not isinstance(raw_sha, str) or not RAW_OUTPUT_SHA256_PATTERN.fullmatch(raw_sha):
436
+ return prefix + "rawOutputSha256 invalide"
437
+ observed = attempt["observedFiles"]
438
+ if not isinstance(observed, dict) or not all(
439
+ isinstance(path, str) and isinstance(fingerprint, str)
440
+ for path, fingerprint in observed.items()
441
+ ):
442
+ return prefix + "observedFiles invalide"
443
+ execution_files = attempt["executionFiles"]
444
+ if not isinstance(execution_files, list) or not all(
445
+ isinstance(item, str) for item in execution_files
446
+ ):
447
+ return prefix + "executionFiles invalide"
448
+ changed = attempt["changedInputs"]
449
+ if (
450
+ not isinstance(changed, list)
451
+ or not all(isinstance(item, str) and item for item in changed)
452
+ or changed != sorted(set(changed))
453
+ ):
454
+ return prefix + "changedInputs invalide"
455
+ if attempt["head"] == "" and attempt["branch"] == "" and observed == {} and not changed:
456
+ return prefix + "fiche minimale incoherente"
457
+ return None
458
+
459
+
460
+ def imputed_inputs(
461
+ feature_dir: Path,
462
+ feature_path: str,
463
+ activity: str,
464
+ contract_id: str,
465
+ since_base: dict[str, str],
466
+ ) -> list[str]:
467
+ """Entrees que la fiche de tentative impute a un agent de l'activite (CT-01).
468
+
469
+ Seules comptent celles qui different encore de la base : une entree remise en
470
+ etat n'est plus imputee. Une fiche absente, illisible, invalide, d'une autre
471
+ activite ou d'un autre contrat n'impute rien. Aucune ecriture.
472
+ """
473
+ try:
474
+ attempt = json.loads(
475
+ activity_attempt_file(feature_dir, activity).read_text(encoding="utf-8")
476
+ )
477
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
478
+ return []
479
+ if attempt_violation(attempt) is not None:
480
+ return []
481
+ if attempt["activity"] != activity or attempt["contractId"] != contract_id:
482
+ return []
483
+ return sorted(
484
+ name
485
+ for name in attempt["changedInputs"]
486
+ if f"{feature_path}/{name}" in since_base
487
+ )
488
+
489
+
401
490
  def parse_recorded_result(
402
491
  payload: object, contract: "ActivityContract"
403
492
  ) -> tuple["ActivityResult", dict[str, Any]]:
@@ -19,6 +19,7 @@ from devflow_cli.core.adaptive_workflow import (
19
19
  from devflow_cli.core.assessment import ASSESSMENT_SCHEMA_VERSION, compute_route
20
20
  from devflow_cli.core.contract_schemas import (
21
21
  activity_result_schema,
22
+ contract_schema_version,
22
23
  validate_contract_payload,
23
24
  )
24
25
  from devflow_cli.core.validators import ARTIFACT_RULES
@@ -573,7 +574,7 @@ def parse_activity_result(payload: dict[str, Any], contract: ActivityContract) -
573
574
  expected_fields.add("validationPlan")
574
575
  if expected_fields != set(payload):
575
576
  raise ValueError("champs du resultat manquants ou inattendus")
576
- if payload.get("schemaVersion") != 1:
577
+ if payload.get("schemaVersion") != contract_schema_version("activity-result"):
577
578
  raise ValueError("schema de resultat non supporte")
578
579
  if payload.get("contractId") != contract.contractId:
579
580
  raise ValueError("contractId du resultat incorrect")
@@ -678,7 +679,7 @@ def parse_activity_result(payload: dict[str, Any], contract: ActivityContract) -
678
679
  )
679
680
 
680
681
  return ActivityResult(
681
- schemaVersion=1,
682
+ schemaVersion=contract_schema_version("activity-result"),
682
683
  contractId=contract.contractId,
683
684
  status=status,
684
685
  summary=summary.strip(),
@@ -20,6 +20,7 @@ from devflow_cli.core.activity_contracts import (
20
20
  DOCUMENT_ARTIFACTS,
21
21
  NEED_STATEMENT,
22
22
  PreflightEvidenceStaleError,
23
+ activity_contract_id,
23
24
  build_activity_contract,
24
25
  contract_is_reopened_plan,
25
26
  document_artifact_name,
@@ -31,12 +32,15 @@ from devflow_cli.core.activity_contracts import (
31
32
  from devflow_cli.core.activity_base import (
32
33
  ActivityObservation,
33
34
  BaseObservationError,
35
+ attempt_violation,
34
36
  declaration_warning,
35
37
  enter_current_activity,
36
38
  files_since_activity_base,
39
+ imputed_inputs,
37
40
  is_runtime_file,
38
41
  observe_activity,
39
42
  observe_attempt,
43
+ parse_recorded_result,
40
44
  recorded_observation,
41
45
  )
42
46
  from devflow_cli.core.agent_failure import diagnose_agent_failure
@@ -59,6 +63,10 @@ from devflow_cli.core.adaptive_workflow import (
59
63
  write_adaptive_state,
60
64
  )
61
65
  from devflow_cli.core.assessment import read_assessment
66
+ from devflow_cli.core.contract_schemas import (
67
+ ATTEMPT_SCHEMA_VERSION,
68
+ contract_schema_version,
69
+ )
62
70
  from devflow_cli.core.worklog import safe_refresh_worklog
63
71
  from devflow_cli.core.traceability import analyze_traceability
64
72
  from devflow_cli.core.validators import validate_artifact
@@ -95,6 +103,8 @@ class AdaptiveRunOutcome:
95
103
  warnings: tuple[str, ...] = ()
96
104
  # artifact-files-mismatch : (chemin, cause observee) de chaque fichier en trop.
97
105
  extraFiles: tuple[tuple[str, str], ...] = ()
106
+ # imputed-input : chemins (relatifs a la racine) des entrees imputees, tries.
107
+ imputedInputs: tuple[str, ...] = ()
98
108
 
99
109
  @property
100
110
  def advanced(self) -> bool:
@@ -147,11 +157,20 @@ def run_current_activity(
147
157
  preflight_evidence=(feature_dir / "preflight-evidence.md").is_file(),
148
158
  files_since_base=since_base,
149
159
  )
150
- imputed_inputs = (
151
- _imputed_inputs(feature_dir, feature_path, contract.activity, since_base)
152
- if contract.activity in DOCUMENT_ARTIFACTS
153
- else []
160
+ # D1 : l'imputation precede la peremption du pre-vol (PL-04), qui
161
+ # compare des mtime que la reecriture d'une entree par l'agent invalide.
162
+ imputed = imputed_inputs(
163
+ feature_dir, feature_path, contract.activity, contract.contractId,
164
+ since_base,
154
165
  )
166
+ if imputed:
167
+ return AdaptiveRunOutcome(
168
+ "imputed-input",
169
+ contract.activity,
170
+ contract.activity,
171
+ _imputed_detail(imputed, "agent non lance"),
172
+ imputedInputs=tuple(f"{feature_path}/{name}" for name in imputed),
173
+ )
155
174
  if state.currentActivity == "implement":
156
175
  upstream = [
157
176
  feature_dir / Path(item).name
@@ -176,18 +195,6 @@ def run_current_activity(
176
195
  return AdaptiveRunOutcome(
177
196
  "unsupported-activity", state.currentActivity, state.currentActivity, str(exc)
178
197
  )
179
- if imputed_inputs:
180
- # N5 : refus avant toute ecriture et avant l'agent ; la fiche reste intacte.
181
- return AdaptiveRunOutcome(
182
- "artifact-files-mismatch",
183
- contract.activity,
184
- contract.activity,
185
- "entrees du besoin modifiees par un agent de cette activite et pas "
186
- "remises en etat : " + ", ".join(imputed_inputs) + " ; agent non lance",
187
- extraFiles=tuple(
188
- (f"{feature_path}/{name}", "agent-input") for name in imputed_inputs
189
- ),
190
- )
191
198
  raw_file = activity_raw_file(feature_dir, contract.activity)
192
199
  guard = ExitStack()
193
200
  try:
@@ -238,13 +245,19 @@ def run_current_activity(
238
245
  for name in set(input_fingerprints) | set(current_inputs)
239
246
  if input_fingerprints.get(name) != current_inputs.get(name)
240
247
  )
241
- # N5 : une entree modifiee par un agent reste imputee de fiche en fiche.
242
- attributed_inputs = sorted({*changed_inputs, *imputed_inputs})
248
+
249
+ def record(reason: str) -> str:
250
+ """Ecrit la fiche ; rend le suffixe de detail si elle n'a pas pu l'etre (PL-07)."""
251
+ try:
252
+ _record_failed_attempt(
253
+ feature_dir, contract, execution, reason, changed_inputs=changed_inputs
254
+ )
255
+ except ValueError as exc:
256
+ return f" ; fiche de tentative non ecrite : {exc}"
257
+ return ""
258
+
243
259
  if runtime_mutated:
244
- _record_failed_attempt(
245
- feature_dir, contract, execution, "runtime-file-mutated",
246
- changed_inputs=attributed_inputs,
247
- )
260
+ suffix = record("runtime-file-mutated")
248
261
  return AdaptiveRunOutcome(
249
262
  "runtime-file-mutated",
250
263
  contract.activity,
@@ -252,16 +265,14 @@ def run_current_activity(
252
265
  "fichiers d'execution devflow modifies pendant l'activite : "
253
266
  + ", ".join(runtime_mutated)
254
267
  + " ; remis dans leur etat d'avant l'agent : "
255
- + (", ".join(restored) if restored else "aucun fichier a restaurer"),
268
+ + (", ".join(restored) if restored else "aucun fichier a restaurer")
269
+ + suffix,
256
270
  )
257
271
  if changed_inputs:
258
- _record_failed_attempt(
259
- feature_dir, contract, execution,
260
- "inputs-changed-during-activity", changed_inputs=attributed_inputs,
261
- )
272
+ suffix = record("inputs-changed-during-activity")
262
273
  return AdaptiveRunOutcome(
263
274
  "inputs-changed-during-activity", contract.activity, contract.activity,
264
- "entrees modifiees pendant l'activite : " + ", ".join(changed_inputs),
275
+ "entrees modifiees pendant l'activite : " + ", ".join(changed_inputs) + suffix,
265
276
  )
266
277
  if execution.returncode != 0:
267
278
  diagnosis = diagnose_agent_failure(execution.output, execution.stderr)
@@ -277,22 +288,16 @@ def run_current_activity(
277
288
  reason = "agent-failed"
278
289
  if execution.observedChangedFiles:
279
290
  detail += f"; mutations observees : {len(set(execution.observedChangedFiles))} fichier(s)"
280
- _record_failed_attempt(
281
- feature_dir, contract, execution, reason, changed_inputs=attributed_inputs
282
- )
291
+ detail += record(reason)
283
292
  return AdaptiveRunOutcome(reason, contract.activity, contract.activity, detail)
284
293
 
285
294
  try:
286
295
  raw_payload = json.loads(execution.output)
287
296
  except json.JSONDecodeError as exc:
288
- outcome = AdaptiveRunOutcome(
289
- "invalid-result", contract.activity, contract.activity, str(exc)
290
- )
291
- _record_failed_attempt(
292
- feature_dir, contract, execution, outcome.reason,
293
- changed_inputs=attributed_inputs,
297
+ return AdaptiveRunOutcome(
298
+ "invalid-result", contract.activity, contract.activity,
299
+ str(exc) + record("invalid-result"),
294
300
  )
295
- return outcome
296
301
  try:
297
302
  observation = observe_activity(
298
303
  feature_dir, state, during_execution=execution.observedChangedFiles
@@ -313,14 +318,12 @@ def run_current_activity(
313
318
  source="agent",
314
319
  agent=execution.agent,
315
320
  execution_files=execution.observedChangedFiles,
316
- imputed_inputs=imputed_inputs,
317
321
  closed_activities=_closed_activities(state),
318
322
  )
319
323
  if not outcome.advanced and outcome.reason not in {"blocked", "state-changed"}:
320
- _record_failed_attempt(
321
- feature_dir, contract, execution, outcome.reason,
322
- changed_inputs=attributed_inputs,
323
- )
324
+ suffix = record(outcome.reason)
325
+ if suffix:
326
+ outcome = replace(outcome, detail=outcome.detail + suffix)
324
327
  return outcome
325
328
 
326
329
 
@@ -394,25 +397,28 @@ def reconcile_current_activity(
394
397
  f"devflow adaptive run {shlex.quote(state.issueId)}"
395
398
  ) from None
396
399
  attempt = json.loads(attempt_text)
397
- if not _valid_attempt(attempt):
398
- raise ValueError("fiche de tentative invalide")
400
+ violation = attempt_violation(attempt)
401
+ if violation is not None:
402
+ raise ValueError(f"fiche de tentative invalide : {violation}")
399
403
  agent = attempt["agent"]
400
404
  attempt_reason = attempt["reason"]
401
405
  observed_files = attempt["observedFiles"]
402
406
  execution_files = attempt["executionFiles"]
403
- imputed_inputs = sorted(
404
- name
405
- for name in attempt.get("changedInputs", [])
406
- if f"{feature_path}/{name}" in since_base
407
+ imputed = imputed_inputs(
408
+ feature_dir,
409
+ feature_path,
410
+ activity,
411
+ activity_contract_id(state.issueId, state.profile, activity),
412
+ since_base,
407
413
  )
408
- if imputed_inputs:
414
+ if imputed:
409
415
  # N5 : reconcile ne blanchit pas une entree imputee a un agent.
410
- raise ValueError(
411
- "entrees du besoin modifiees par un agent de cette activite et pas "
412
- "remises en etat : " + ", ".join(imputed_inputs) + " ; les remettre "
413
- "dans leur etat de base, ou abandonner la tentative (devflow adaptive "
414
- f"discard-attempt {shlex.quote(state.issueId)} --reason MOTIF), puis "
415
- "relancez l'activite"
416
+ return AdaptiveRunOutcome(
417
+ "imputed-input",
418
+ activity,
419
+ activity,
420
+ _imputed_detail(imputed, "reconcile refuse"),
421
+ imputedInputs=tuple(f"{feature_path}/{name}" for name in imputed),
416
422
  )
417
423
  if attempt_reason == "inputs-changed-during-activity":
418
424
  raise ValueError(
@@ -533,48 +539,6 @@ def reconcile_current_activity(
533
539
  return outcome
534
540
 
535
541
 
536
- ATTEMPT_SCHEMA_VERSION = 2
537
- ATTEMPT_KEYS = frozenset({
538
- "schemaVersion", "contractId", "activity", "agent", "reason", "recordedAt",
539
- "head", "branch", "rawOutputSha256", "observedFiles", "executionFiles",
540
- })
541
-
542
-
543
- def _valid_attempt(attempt: object) -> bool:
544
- """Fiche de tentative schema 2 aux cles exactes (CT-10)."""
545
- if not isinstance(attempt, dict):
546
- return False
547
- keys = set(attempt)
548
- if keys != ATTEMPT_KEYS and keys != ATTEMPT_KEYS | {"changedInputs"}:
549
- return False
550
- observed = attempt["observedFiles"]
551
- execution_files = attempt["executionFiles"]
552
- changed_inputs = attempt.get("changedInputs", [])
553
- return (
554
- attempt["schemaVersion"] == ATTEMPT_SCHEMA_VERSION
555
- and not isinstance(attempt["schemaVersion"], bool)
556
- and all(
557
- isinstance(attempt[key], str)
558
- for key in ("contractId", "activity", "recordedAt", "head", "branch")
559
- )
560
- and isinstance(attempt["agent"], str)
561
- and bool(attempt["agent"])
562
- and isinstance(attempt["reason"], str)
563
- and bool(attempt["reason"])
564
- and isinstance(attempt["rawOutputSha256"], str)
565
- and len(attempt["rawOutputSha256"]) == 64
566
- and isinstance(observed, dict)
567
- and all(
568
- isinstance(path, str) and isinstance(fingerprint, str)
569
- for path, fingerprint in observed.items()
570
- )
571
- and isinstance(execution_files, list)
572
- and all(isinstance(item, str) for item in execution_files)
573
- and isinstance(changed_inputs, list)
574
- and all(isinstance(item, str) for item in changed_inputs)
575
- )
576
-
577
-
578
542
  def _recover_attempt_payload(
579
543
  raw_output: str,
580
544
  contract: ActivityContract,
@@ -607,7 +571,6 @@ def _apply_activity_result(
607
571
  source: str,
608
572
  agent: str,
609
573
  execution_files: Iterable[str] = (),
610
- imputed_inputs: Iterable[str] = (),
611
574
  closed_activities: Iterable[str] = (),
612
575
  ) -> AdaptiveRunOutcome:
613
576
  """Valide un resultat agent puis transitionne ; commun a run et reconcile.
@@ -615,8 +578,7 @@ def _apply_activity_result(
615
578
  L'observation fait foi : ``O`` (fichiers depuis la base) et ``E`` (fichiers
616
579
  modifies pendant l'agent) fondent les refus ; la declaration de l'agent ne
617
580
  produit qu'un avertissement. ``execution_files`` est la liste brute des fichiers
618
- modifies pendant l'agent, ``imputed_inputs`` les entrees du besoin deja imputees
619
- a un agent de l'activite (N5) : ils fondent l'exemption N1.
581
+ modifies pendant l'agent : il fonde l'exemption N1.
620
582
  """
621
583
  state_file = feature_dir / "workflow-state.json"
622
584
  observed = sorted(observation.sinceBase)
@@ -642,7 +604,6 @@ def _apply_activity_result(
642
604
  )
643
605
 
644
606
  executed = set(execution_files)
645
- imputed_paths = {f"{feature_path}/{name}" for name in imputed_inputs}
646
607
  # N1 : entrees du besoin amendees avant le lancement de l'agent, non comptees.
647
608
  amended = (
648
609
  [
@@ -650,7 +611,6 @@ def _apply_activity_result(
650
611
  for path in _input_paths(feature_dir, feature_path)
651
612
  if path in observation.sinceBase
652
613
  and path not in executed
653
- and path not in imputed_paths
654
614
  ]
655
615
  if contract.activity in DOCUMENT_ARTIFACTS
656
616
  else []
@@ -690,9 +650,7 @@ def _apply_activity_result(
690
650
  tuple(
691
651
  (
692
652
  path,
693
- "agent-input" if path in imputed_paths
694
- else "during-agent" if path in executed
695
- else "before-launch",
653
+ "during-agent" if path in executed else "before-launch",
696
654
  )
697
655
  for path in counted
698
656
  if path != expected
@@ -743,6 +701,15 @@ def _apply_activity_result(
743
701
  recorded["observation"] = recorded_observation(
744
702
  observation, declared, exempted=amended, amendment_warning=amendment
745
703
  )
704
+ try:
705
+ # changedFiles du resultat enregistre est la liste observee, qui peut inclure une
706
+ # entree du besoin amendee (N1) : la relecture porte sur la forme du bloc et du
707
+ # resultat valide, pas sur l'ecart entre observation et artefact attendu.
708
+ parse_recorded_result(
709
+ {**result.to_dict(), "observation": recorded["observation"]}, contract
710
+ )
711
+ except ValueError as exc:
712
+ return refuse("transition-refused", f"resultat enregistre invalide : {exc}")
746
713
  _write_json_atomic(activity_result_file(feature_dir, contract.activity), recorded)
747
714
  if result.status == "blocked":
748
715
  safe_refresh_worklog(feature_dir)
@@ -992,7 +959,7 @@ def record_human_review(
992
959
  except (OSError, ValueError) as exc:
993
960
  return AdaptiveRunOutcome("unsupported-activity", activity, activity, str(exc))
994
961
  payload = {
995
- "schemaVersion": 1,
962
+ "schemaVersion": contract_schema_version("activity-result"),
996
963
  "contractId": contract.contractId,
997
964
  "status": "completed" if outcome == "passed" else "blocked",
998
965
  "summary": f"Revue humaine de {reviewer.strip()} : {outcome}",
@@ -1026,15 +993,16 @@ def _record_failed_attempt(
1026
993
  execution: AgentActivityExecution,
1027
994
  reason: str,
1028
995
  *,
1029
- changed_inputs: list[str] | None = None,
996
+ changed_inputs: list[str],
1030
997
  ) -> None:
1031
998
  """Consigne les faits d'une tentative refusee pour une reprise controlee.
1032
999
 
1033
1000
  ``executionFiles`` est la liste brute des fichiers modifies pendant
1034
- l'agent, non filtree. ``observedFiles`` vaut les fichiers depuis la base de l'activite ; si
1035
- l'observation est impossible, une fiche minimale (``head`` et ``branch`` vides,
1036
- ``observedFiles`` vide) n'est ecrite que si une entree du besoin est imputee ;
1037
- sinon aucune fiche n'est ecrite (N7).
1001
+ l'agent, non filtree. ``observedFiles`` vaut les fichiers depuis la base de l'activite ;
1002
+ si l'observation est impossible, une fiche minimale (``head`` et ``branch`` vides,
1003
+ ``observedFiles`` vide) n'est ecrite que si une entree du besoin a ete modifiee
1004
+ pendant cette tentative ; sinon aucune fiche n'est ecrite (N7). La fiche est validee
1005
+ par ``attempt_violation`` avant toute ecriture : ``ValueError`` si elle est invalide.
1038
1006
  """
1039
1007
  try:
1040
1008
  state = read_adaptive_state(feature_dir / "workflow-state.json")
@@ -1048,13 +1016,12 @@ def _record_failed_attempt(
1048
1016
  if not changed_inputs:
1049
1017
  return
1050
1018
  head, branch, observed = "", "", {}
1051
- if changed_inputs:
1052
- input_paths = {f"{contract.featurePath}/{name}" for name in changed_inputs}
1053
- observed = {
1054
- path: fingerprint
1055
- for path, fingerprint in observed.items()
1056
- if path not in input_paths
1057
- }
1019
+ input_paths = {f"{contract.featurePath}/{name}" for name in changed_inputs}
1020
+ observed = {
1021
+ path: fingerprint
1022
+ for path, fingerprint in observed.items()
1023
+ if path not in input_paths
1024
+ }
1058
1025
  payload = {
1059
1026
  "schemaVersion": ATTEMPT_SCHEMA_VERSION,
1060
1027
  "contractId": contract.contractId,
@@ -1067,9 +1034,11 @@ def _record_failed_attempt(
1067
1034
  "rawOutputSha256": _sha256(execution.output),
1068
1035
  "observedFiles": observed,
1069
1036
  "executionFiles": sorted(set(execution.observedChangedFiles)),
1037
+ "changedInputs": list(changed_inputs),
1070
1038
  }
1071
- if changed_inputs:
1072
- payload["changedInputs"] = changed_inputs
1039
+ violation = attempt_violation(payload)
1040
+ if violation is not None:
1041
+ raise ValueError(violation)
1073
1042
  _write_json_atomic(activity_attempt_file(feature_dir, contract.activity), payload)
1074
1043
 
1075
1044
 
@@ -1085,26 +1054,11 @@ def _input_paths(feature_dir: Path, feature_path: str) -> list[str]:
1085
1054
  ]
1086
1055
 
1087
1056
 
1088
- def _imputed_inputs(
1089
- feature_dir: Path, feature_path: str, activity: str, since_base: dict[str, str]
1090
- ) -> list[str]:
1091
- """Entrees que la fiche de tentative impute a un agent de l'activite (N5).
1092
-
1093
- Seules comptent celles qui different encore de la base : une entree remise en
1094
- etat n'est plus imputee.
1095
- """
1096
- try:
1097
- attempt = json.loads(
1098
- activity_attempt_file(feature_dir, activity).read_text(encoding="utf-8")
1099
- )
1100
- except (OSError, UnicodeDecodeError, json.JSONDecodeError):
1101
- return []
1102
- if not _valid_attempt(attempt) or attempt["activity"] != activity:
1103
- return []
1104
- return sorted(
1105
- name
1106
- for name in attempt.get("changedInputs", [])
1107
- if f"{feature_path}/{name}" in since_base
1057
+ def _imputed_detail(imputed: list[str], ending: str) -> str:
1058
+ """Detail du refus ``imputed-input`` de run et de reconcile (CT-02, CT-03)."""
1059
+ return (
1060
+ "entrees du besoin modifiees par un agent de cette activite et pas "
1061
+ "remises en etat : " + ", ".join(imputed) + " ; " + ending
1108
1062
  )
1109
1063
 
1110
1064
 
@@ -20,6 +20,7 @@ from devflow_cli.core.assessment import (
20
20
  from devflow_cli.core.contract_schemas import (
21
21
  ContractSchemaError,
22
22
  LegacySchemaError,
23
+ check_feature_files,
23
24
  check_schema_version,
24
25
  legacy_workflow_state_error,
25
26
  validate_contract_payload,
@@ -290,8 +291,8 @@ def adaptive_state_violation(path: Path) -> str | None:
290
291
 
291
292
 
292
293
  def read_adaptive_state(path: Path) -> AdaptiveWorkflowState | None:
293
- """Lit un etat coherent, ou None. Un etat produit par devflow 6.x ou 7.x
294
- leve ``LegacySchemaError``."""
294
+ """Lit un etat coherent, ou None. Un etat produit par devflow 6.x ou 7.x, ou un
295
+ dossier avec un resultat v1 ou une fiche de schema < 3, leve ``LegacySchemaError``."""
295
296
  try:
296
297
  data = json.loads(path.read_text(encoding="utf-8"))
297
298
  except (OSError, UnicodeDecodeError, json.JSONDecodeError):
@@ -310,6 +311,7 @@ def read_adaptive_state(path: Path) -> AdaptiveWorkflowState | None:
310
311
  ):
311
312
  # Etat sans base : aucune base n'est reconstruite (rupture 8.0.0).
312
313
  raise legacy_workflow_state_error(path, data["schemaVersion"])
314
+ check_feature_files(path.parent)
313
315
  try:
314
316
  validate_contract_payload("workflow-state", data)
315
317
  state = AdaptiveWorkflowState(**data)
@@ -17,8 +17,10 @@ PUBLIC_CONTRACT_SCHEMAS = {
17
17
  "assessment": "assessment-v4.schema.json",
18
18
  "workflow-state": "workflow-state-v6.schema.json",
19
19
  "activity-contract": "activity-contract-v4.schema.json",
20
- "activity-result": "activity-result-v1.schema.json",
20
+ "activity-result": "activity-result-v2.schema.json",
21
21
  }
22
+ # Fiche de tentative (<activite>-attempt.json) : seule definition (CT-04).
23
+ ATTEMPT_SCHEMA_VERSION = 3
22
24
 
23
25
 
24
26
  class ContractSchemaError(ValueError):
@@ -26,7 +28,7 @@ class ContractSchemaError(ValueError):
26
28
 
27
29
 
28
30
  class LegacySchemaError(ValueError):
29
- """A feature file was produced by an older devflow and cannot be read by devflow 8."""
31
+ """A feature file was produced by an older devflow and cannot be read by devflow 9."""
30
32
 
31
33
  def __init__(
32
34
  self,
@@ -44,6 +46,12 @@ class LegacySchemaError(ValueError):
44
46
  "valide (activityBase absente ou nulle) ; aucune base n'est reconstruite ; "
45
47
  "recreez le parcours"
46
48
  )
49
+ elif producer == "8.x":
50
+ message = (
51
+ f"{path} : schemaVersion {version} : parcours demarre avec devflow 8.x ; "
52
+ f"le terminer ou le consulter avec devflow 8.5.1 ({install}) ; "
53
+ "aucune migration"
54
+ )
47
55
  elif producer == "7.x":
48
56
  message = (
49
57
  f"{path} : schemaVersion {version} : parcours cree avec devflow 7.x ; "
@@ -52,13 +60,14 @@ class LegacySchemaError(ValueError):
52
60
  else:
53
61
  message = (
54
62
  f"{path} : schemaVersion {version} produit par devflow {producer}, non "
55
- f"lisible par devflow 8 ; le terminer ou le consulter avec "
63
+ f"lisible par devflow 9 ; le terminer ou le consulter avec "
56
64
  f"devflow {producer} ({install})"
57
65
  )
58
66
  super().__init__(message)
59
67
 
60
68
 
61
69
  LEGACY_7X_INSTALL = "uv tool install devflow-cli==7.2.0"
70
+ LEGACY_8X_INSTALL = "uv tool install devflow-cli==8.5.1"
62
71
  # Dernier schemaVersion de workflow-state produit par devflow 7.x.
63
72
  WORKFLOW_STATE_7X_VERSION = 5
64
73
 
@@ -91,6 +100,37 @@ def check_schema_version(name: str, path: Path, payload: object) -> None:
91
100
  )
92
101
 
93
102
 
103
+ def check_feature_files(feature_dir: Path) -> None:
104
+ """Refuse un dossier dont la racine porte un resultat v1 ou une fiche de schema < 3.
105
+
106
+ Seuls les fichiers ordinaires de la racine sont parcourus, par ordre de nom ; un
107
+ fichier illisible, non JSON, non objet ou sans version entiere est laisse aux
108
+ validations existantes.
109
+ """
110
+ try:
111
+ entries = sorted(feature_dir.iterdir(), key=lambda entry: entry.name)
112
+ except OSError:
113
+ return
114
+ for entry in entries:
115
+ if entry.name.endswith("-result.json"):
116
+ current = contract_schema_version("activity-result")
117
+ elif entry.name.endswith("-attempt.json"):
118
+ current = ATTEMPT_SCHEMA_VERSION
119
+ else:
120
+ continue
121
+ try:
122
+ if not entry.is_file():
123
+ continue
124
+ payload = json.loads(entry.read_text(encoding="utf-8"))
125
+ except (OSError, ValueError):
126
+ continue
127
+ version = payload.get("schemaVersion") if isinstance(payload, dict) else None
128
+ if isinstance(version, int) and not isinstance(version, bool) and version < current:
129
+ raise LegacySchemaError(
130
+ entry, version, producer="8.x", install=LEGACY_8X_INSTALL
131
+ )
132
+
133
+
94
134
  @lru_cache(maxsize=None)
95
135
  def _schema(name: str) -> dict[str, Any]:
96
136
  try:
@@ -24,6 +24,7 @@ from devflow_cli import VERSION
24
24
  from devflow_cli.adapters.linear import environment_without_linear_key
25
25
  from devflow_cli.core.contract_schemas import (
26
26
  LegacySchemaError,
27
+ check_feature_files,
27
28
  check_schema_version,
28
29
  contract_schema_version,
29
30
  legacy_workflow_state_error,
@@ -162,6 +163,7 @@ def _read_state(feature_dir: Path) -> tuple[str | None, str | None]:
162
163
  and state.get("activityBase") is None
163
164
  ):
164
165
  raise legacy_workflow_state_error(path, state["schemaVersion"])
166
+ check_feature_files(feature_dir)
165
167
  activity = state.get("currentActivity")
166
168
  profile = state.get("profile")
167
169
  return (
@@ -4,13 +4,21 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import os
7
+ import shlex
7
8
  import shutil
8
9
  import tempfile
9
10
  from dataclasses import replace
10
11
  from datetime import datetime, timezone
11
12
  from pathlib import Path
12
13
 
13
- from devflow_cli.core.activity_base import BaseObservationError, files_since_activity_base
14
+ from devflow_cli.core.activity_base import (
15
+ BaseObservationError,
16
+ attempt_violation,
17
+ feature_path_for,
18
+ files_since_activity_base,
19
+ imputed_inputs,
20
+ )
21
+ from devflow_cli.core.activity_contracts import activity_contract_id
14
22
  from devflow_cli.core.adaptive_workflow import (
15
23
  AdaptiveWorkflowState,
16
24
  StateInvariantError,
@@ -18,7 +26,11 @@ from devflow_cli.core.adaptive_workflow import (
18
26
  state_invariant_violation,
19
27
  )
20
28
  from devflow_cli.core.assessment import compute_route
21
- from devflow_cli.utils.paths import activity_attempt_file, activity_raw_file
29
+ from devflow_cli.utils.paths import (
30
+ activity_attempt_file,
31
+ activity_raw_file,
32
+ project_root_for_feature,
33
+ )
22
34
 
23
35
 
24
36
  def reopen_activity(
@@ -46,6 +58,8 @@ def reopen_activity(
46
58
  raise ValueError(
47
59
  "la cible doit etre une activite deja completee avant l'activite courante"
48
60
  )
61
+ if state.currentActivity != "done":
62
+ _refuse_imputed_inputs(feature_dir, state)
49
63
 
50
64
  activities = list(
51
65
  compute_route(
@@ -98,6 +112,18 @@ def reopen_activity(
98
112
  }
99
113
  payload["entries"].append(entry)
100
114
  _write_json_atomic(log_file, payload)
115
+ # Les fiches copiees dans l'archive quittent la racine (leur trace est conservee).
116
+ for name in archived_files:
117
+ if "/" in name or not name.endswith("-attempt.json"):
118
+ continue
119
+ try:
120
+ (feature_dir / name).unlink()
121
+ except OSError as exc:
122
+ raise ValueError(
123
+ f"reouverture interrompue : fiche de tentative non retiree : {name} : "
124
+ f"{exc} ; l'archive {entry['archivePath']} et reopen-log.json "
125
+ "en gardent la trace"
126
+ ) from exc
101
127
 
102
128
  state.activities = reopened.activities
103
129
  state.currentActivity = reopened.currentActivity
@@ -107,6 +133,45 @@ def reopen_activity(
107
133
  return entry
108
134
 
109
135
 
136
+ def _refuse_imputed_inputs(feature_dir: Path, state: AdaptiveWorkflowState) -> None:
137
+ """Refuse la reouverture si la fiche courante impute des entrees encore modifiees."""
138
+ activity = state.currentActivity
139
+ try:
140
+ attempt = json.loads(
141
+ activity_attempt_file(feature_dir, activity).read_text(encoding="utf-8")
142
+ )
143
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
144
+ return
145
+ if attempt_violation(attempt) is not None or attempt["activity"] != activity:
146
+ return
147
+ contract_id = activity_contract_id(state.issueId, state.profile, activity)
148
+ if attempt["contractId"] != contract_id or not attempt["changedInputs"]:
149
+ return
150
+ issue = shlex.quote(state.issueId)
151
+ discard = f"devflow adaptive discard-attempt {issue} --reason MOTIF"
152
+ try:
153
+ feature_path = feature_path_for(project_root_for_feature(feature_dir), feature_dir)
154
+ if feature_path is None:
155
+ raise ValueError("dossier de la feature hors du depot")
156
+ since_base = files_since_activity_base(feature_dir, state)
157
+ except (BaseObservationError, OSError, ValueError) as exc:
158
+ cause = f"{exc.reason}: {exc}" if isinstance(exc, BaseObservationError) else str(exc)
159
+ raise ValueError(
160
+ f"reouverture refusee : entrees du besoin imputees a un agent de '{activity}' "
161
+ f"({', '.join(attempt['changedInputs'])}) et fichiers depuis la base non "
162
+ f"calcules : {cause} ; abandonner la tentative ({discard}), puis relancer "
163
+ "la reouverture"
164
+ ) from exc
165
+ names = imputed_inputs(feature_dir, feature_path, activity, contract_id, since_base)
166
+ if names:
167
+ raise ValueError(
168
+ f"reouverture refusee : entrees du besoin modifiees par un agent de "
169
+ f"'{activity}' et pas remises en etat : {', '.join(names)} ; les remettre "
170
+ f"dans leur etat de base, ou abandonner la tentative ({discard}), puis "
171
+ "relancer la reouverture"
172
+ )
173
+
174
+
110
175
  def discard_attempt(
111
176
  feature_dir: Path,
112
177
  state: AdaptiveWorkflowState,
@@ -217,14 +217,20 @@ def _render_managed(
217
217
 
218
218
 
219
219
  def _observation_warnings(feature_dir: Path, activities: list[str]) -> list[str]:
220
- """Une ligne par resultat enregistre portant un ecart de declaration (S9)."""
220
+ """Une ligne par resultat enregistre portant un ecart de declaration ou amendement (S9)."""
221
221
  lines: list[str] = []
222
222
  for activity in activities:
223
223
  result = _read_json(activity_result_file(feature_dir, activity), required=False)
224
224
  observation = result.get("observation") if isinstance(result, dict) else None
225
- warning = observation.get("warning") if isinstance(observation, dict) else None
226
- if isinstance(warning, str) and warning:
227
- lines.append(f"- `{activity}` : {warning}")
225
+ if not isinstance(observation, dict):
226
+ continue
227
+ texts = [
228
+ observation[key]
229
+ for key in ("warning", "amendmentWarning")
230
+ if isinstance(observation.get(key), str) and observation[key]
231
+ ]
232
+ if texts:
233
+ lines.append(f"- `{activity}` : {' ; '.join(texts)}")
228
234
  return lines
229
235
 
230
236
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "$id": "https://devflow.dev/schemas/activity-result/v1",
4
- "title": "Devflow Activity Result v1",
3
+ "$id": "https://devflow.dev/schemas/activity-result/v2",
4
+ "title": "Devflow Activity Result v2",
5
5
  "type": "object",
6
6
  "required": [
7
7
  "schemaVersion",
@@ -15,7 +15,7 @@
15
15
  "blockers"
16
16
  ],
17
17
  "properties": {
18
- "schemaVersion": {"type": "integer", "const": 1},
18
+ "schemaVersion": {"type": "integer", "const": 2},
19
19
  "contractId": {"type": "string", "minLength": 1},
20
20
  "status": {"type": "string", "enum": ["completed", "blocked"]},
21
21
  "summary": {"type": "string", "minLength": 1},
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: devflow-cli
3
- Version: 8.5.1
3
+ Version: 9.0.0
4
4
  Summary: Spec-Driven Development workflow CLI
5
5
  Project-URL: Homepage, https://github.com/sopequenoteck/devflow
6
6
  Project-URL: Repository, https://github.com/sopequenoteck/devflow
@@ -150,6 +150,15 @@ devflow adaptive run KS-123 --agent claude
150
150
  devflow adaptive run KS-123 --agent cursor
151
151
  ```
152
152
 
153
+ ## Features créées avant devflow 9
154
+
155
+ Devflow 9 refuse, sans migration, un dossier de feature dont la racine contient
156
+ un `*-result.json` de `schemaVersion` 1 ou un `*-attempt.json` de schéma 2 ou
157
+ moins (parcours démarré avec devflow 8.x). Terminez les parcours en cours avec
158
+ devflow 8.5.1, ou gardez la CLI épinglée :
159
+ `uv tool install devflow-cli==8.5.1`. Voir les
160
+ [notes de version 9.0.0](docs/releases/9.0.0.md).
161
+
153
162
  ## Features créées avant devflow 8
154
163
 
155
164
  Devflow 8 refuse, sans migration, un `workflow-state.json` (schéma 5) produit
@@ -1,10 +1,10 @@
1
- devflow_cli/__init__.py,sha256=yUxpKLKephIrgPv-hHWMO6tznMd47ejm6oL_zFuipxA,925
1
+ devflow_cli/__init__.py,sha256=TVrbUn4dfhBCXtCVCypThAPpg7SGLsuYX076rhwMzac,925
2
2
  devflow_cli/cli.py,sha256=0_SmhJX_k7OQfp-jNV_jq9EOgWY4rTjStGIoc9pH6Ec,2686
3
3
  devflow_cli/adapters/__init__.py,sha256=RkjHPU2Lp4hSVIq2iOmyTrv2zYU9NuiIF9wEMWzLdMI,377
4
4
  devflow_cli/adapters/agents.py,sha256=01ZQb96koWMKH-mma3AtttdEjNEbu3mM89vFetD9iR8,29689
5
5
  devflow_cli/adapters/linear.py,sha256=HE1YzjBxwoj2jJ_3bmh9Pn_7Mt5u3EOLk891vMXBEj4,8700
6
6
  devflow_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
- devflow_cli/commands/adaptive.py,sha256=8zYUrtPxU3NRpQ4yv0ScHmn8IjQfnx4U24qMW8Y4_Uo,72797
7
+ devflow_cli/commands/adaptive.py,sha256=iCS0BvaD2Z1fgI3cUsdrpQs52w_THS-v2I8AyvJy2N8,72771
8
8
  devflow_cli/commands/assess.py,sha256=V-E3ft41ljCrqp2WLjR1Dqw-JtswcqsK2nD1-euNbNE,5655
9
9
  devflow_cli/commands/check.py,sha256=hsfeOg9yDOTAIvNm6XhRmTIFgv7w9wC8zvrwm4ojfZs,6923
10
10
  devflow_cli/commands/context.py,sha256=hwTitOHxIpJMx1X8R1jHHy9CpE4HVZpJKkyeOujxAio,6246
@@ -17,31 +17,31 @@ devflow_cli/commands/hooks_cmd.py,sha256=BMzNBKHIpQkMj5y-2bxtCNqAa8cncGfGTWMgx6S
17
17
  devflow_cli/commands/init_cmd.py,sha256=VXR52ocqkO23J18pgij0Mhc_BTBl2PolxArjoCOuNow,5383
18
18
  devflow_cli/commands/linear.py,sha256=i066d5ryyBzZVfrSudbD0VWg9fSLivnB8yNe4vAMMW0,972
19
19
  devflow_cli/commands/stale.py,sha256=UiaUb48lrXmPyjfvbFkChfLE-rs58KhgKu-zgL_NBIo,3182
20
- devflow_cli/commands/status.py,sha256=4aIQHLmOCikZR3VCaaQw9j1TjHKtWqiHxBHKI1FSUXo,8644
20
+ devflow_cli/commands/status.py,sha256=Votdh_sZkRuC_iui1krrdXE9cZ5dpY2OO2au4PVa7IQ,8647
21
21
  devflow_cli/commands/traceability.py,sha256=DhUOBzSmKVk17j1Qz0yxD5zD2-iNWaR9F133VNPjy70,1767
22
22
  devflow_cli/commands/upgrade_cmd.py,sha256=d5uBFUPzPsCJDLxhQBFaeHWJv3GWWLmPpkYHRVd1gwM,5664
23
23
  devflow_cli/commands/usage.py,sha256=ehH1AH5J-XfkebFx6qLBwpKN-MOoX2uUbq4LejBpNsk,3148
24
24
  devflow_cli/commands/verify.py,sha256=GBFCfsN-xWyROCcngOANz-3ftNVeNZ4Y_JeWFo2muwE,4139
25
25
  devflow_cli/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
26
- devflow_cli/core/activity_base.py,sha256=YId_tUxiL6TYmnVpG2VYGkA31nPjIrskx6Dof27XR6E,16696
27
- devflow_cli/core/activity_contracts.py,sha256=ui-9JYwtZYcjEVppgcD5YNW_O7gkxxCkfXlIevLrJ-c,39295
28
- devflow_cli/core/adaptive_runner.py,sha256=ePnwJLPG2hpt76yE6WTTzXgljb7jkpVWAyTZRRdKVhs,48856
29
- devflow_cli/core/adaptive_workflow.py,sha256=CGfKOzCDwKGlp2ez9IDgBq6-FNjFYRhpJagZORpBGSc,14562
26
+ devflow_cli/core/activity_base.py,sha256=eEC_YIr5O7WhCglA7xAEPypVzhcdC6f2h9d-SDDecVA,20475
27
+ devflow_cli/core/activity_contracts.py,sha256=oN8ATaRhsEz-o4PhdX9ASwpCooXK0Wo_wExmWXkiRCY,39406
28
+ devflow_cli/core/adaptive_runner.py,sha256=BuVGk-P0nmtD5PRuHBXNOS4epwA6RFivPRxn3zb_LrU,47068
29
+ devflow_cli/core/adaptive_workflow.py,sha256=NgDEr67monxalcGiO3R_ACVUrZu_OX6LQRhQJbMTWT8,14687
30
30
  devflow_cli/core/agent_failure.py,sha256=iX4q249lAHeVdD_ZF1aObmU-FX6sjfYP9dfLSnWGAD0,3771
31
31
  devflow_cli/core/agent_run_marker.py,sha256=QW3ScA2ZpqfQdUo5ANTbenDVyY2iHpB_wTaN6VUKlzc,3451
32
32
  devflow_cli/core/assessment.py,sha256=ACtJbXq1qZjVfeERjy8Qg6-ioLfSMUBVQm2l-mtGwgI,11522
33
33
  devflow_cli/core/automatic_evidence.py,sha256=wBsOwQvwNSETefSMkZK6tcb3aDErgbVK9WANknT8HN4,42592
34
34
  devflow_cli/core/catalog.py,sha256=nLPc78JkbISaSxJnHlWiMi_lvepaumbfwcjgLuVy7Zc,3102
35
- devflow_cli/core/contract_schemas.py,sha256=TOGXwpNmm_MEzDkxR1wYvcCqASv1j4C1vxT6RjDFMiM,6744
35
+ devflow_cli/core/contract_schemas.py,sha256=JXI8PVgjdagTlFxnyuIkNRRrVi319Mv8GwuMxopJ_GI,8424
36
36
  devflow_cli/core/docgen.py,sha256=47JoVDWetkcctgHwJZC67AVebOKgl10VB9hZ3Xr63mo,3908
37
- devflow_cli/core/feedback.py,sha256=4LJk-stAz_1toVVK4kGlWs6yUIQEEjjXVLglDXc37JU,18965
37
+ devflow_cli/core/feedback.py,sha256=_YvUBA4nMzXYZchvQagMbazNIvZIqRsnqL94ESfOde4,19027
38
38
  devflow_cli/core/git.py,sha256=gsDfT-hDNtMQFRUl1WzZSqltrdwZQ2BJBcCIh4TwDkA,2780
39
39
  devflow_cli/core/hooks.py,sha256=nFPaziFJgeZgAPKPxbTibrB_0MJAST3cAmg3gtcvDBQ,8067
40
40
  devflow_cli/core/installer.py,sha256=fmfaJs5ApkhRmQi1B4vz52iAHBtqgeyBoAnE8CXAzJI,6303
41
41
  devflow_cli/core/manifest.py,sha256=h058_m5-HJrYsNUdgfwnKts2riHiYPXUwMzPImHOs2g,7023
42
42
  devflow_cli/core/operational.py,sha256=11iLTbyZF0ZjYTQ5HDmVCaw4bpbXpLtH2v9f1WQOrYg,1228
43
43
  devflow_cli/core/redaction.py,sha256=o_1MDtCyvRLrk_dWlV6_7E6_mPVB1B5a0cCfTBj9j0Q,1973
44
- devflow_cli/core/remediation.py,sha256=gvoemlDDC6GmIwAu4BLWnrBTJ240Z6cFCjyxSVrX61c,9407
44
+ devflow_cli/core/remediation.py,sha256=ywOOJUyT6SYbK79NnsIZcpn90HBZhavFxl4JSB9Vz3s,12254
45
45
  devflow_cli/core/result_template.py,sha256=XfrjUlSUDYJxE2nN8EJjHCXq1l06hn-wW1gDn1c1rBA,2493
46
46
  devflow_cli/core/run_progress.py,sha256=hawaMo5sGcxvugexeryMWanFajdf46SY6b37O7dToZM,1855
47
47
  devflow_cli/core/runtime_backup.py,sha256=dowz6a1vcJsqkl-m9wGhS4F6XGfsU2rwSNJvIZMJjhI,4345
@@ -53,10 +53,10 @@ devflow_cli/core/validation_approvals.py,sha256=3AHOpnW3BtY3BQrRZr38K7VNYRvbxGNh
53
53
  devflow_cli/core/validators.py,sha256=fREjEx2fBW36q3vB6WJNqoGUM9ejYlQUXie5A9lNuPs,7440
54
54
  devflow_cli/core/verification.py,sha256=rvbdAdLyrZYb20oauti7D8nV5TDFBIf_8-hqLahfRag,23576
55
55
  devflow_cli/core/waivers.py,sha256=9XXzMmoWCn-2Dc3486WhZwtGJQLQPXjP0qAaLuL_3qU,13299
56
- devflow_cli/core/worklog.py,sha256=i4zgJIlS1OSIBMrhT1AKU97bh7IHAhNYbnr4eJVGv_Y,14692
56
+ devflow_cli/core/worklog.py,sha256=itKIgzduDvl5STv3B_Yle9qi9W11Xhbg4_bDyCFVBqQ,14850
57
57
  devflow_cli/core/worktree_snapshot.py,sha256=Q2_KTkhJTGvdM6Ay4RK9OHkyPEMrHQRTb-f-Eptek-8,5255
58
58
  devflow_cli/schemas/activity-contract-v4.schema.json,sha256=IeduWDiJ5IV6EL7FDZWnHBjzyUt1uwig2rB-SMbcpOU,2006
59
- devflow_cli/schemas/activity-result-v1.schema.json,sha256=xQuyD5fwHGIdeoJoDJxJn26qBGJX74lD6Dc5gi5CLQo,2772
59
+ devflow_cli/schemas/activity-result-v2.schema.json,sha256=2yDf6S--mlNWMxE8h8ruwTsM3Tnaa96SJDjluc4ePbI,2772
60
60
  devflow_cli/schemas/assessment-v4.schema.json,sha256=byFBaoG_Vu-qS7vERREKThtpQa3dzNtPiFCMyEpWFVQ,2254
61
61
  devflow_cli/schemas/workflow-state-v6.schema.json,sha256=fYyJS-6Aq-Ea7y2bVqJYSmKzdF2DvL3Nb6IdDNgFGfY,3239
62
62
  devflow_cli/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -90,8 +90,8 @@ devflow_cli/resources/templates/contracts/api-template.md,sha256=rC5v7JBz71PznCn
90
90
  devflow_cli/resources/templates/contracts/components-template.md,sha256=XIWfEXfC72sNmL5OA3V-7xIywkNDU2DBPLfyiagOWcs,484
91
91
  devflow_cli/resources/templates/contracts/interfaces-template.md,sha256=kf8B7b6HsmN7jyRlLNU7RnZl0rTMlI5s97yVRyI-6yc,336
92
92
  devflow_cli/resources/templates/contracts/services-template.md,sha256=cFafKCyLmq_a7aR7G0LXAaMO2z4Fuflq6iKdxfVg0vg,477
93
- devflow_cli-8.5.1.dist-info/METADATA,sha256=Vaqrf7U9UaMMnfl_sHbt_bK6B0WeyDBdaouxnd0E6Yo,9982
94
- devflow_cli-8.5.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
95
- devflow_cli-8.5.1.dist-info/entry_points.txt,sha256=GVJ6bMvFpqyO5589iC4nFKeGTpGIW8aKn_QPPpSZ6kU,48
96
- devflow_cli-8.5.1.dist-info/licenses/LICENSE,sha256=v79PvnmqOdKhQiOa_QcWxng4q8GuGscLOwi7ygUpwpo,1070
97
- devflow_cli-8.5.1.dist-info/RECORD,,
93
+ devflow_cli-9.0.0.dist-info/METADATA,sha256=P54H3nX_kUKvDUIgpC69PPpSkrT59fM-yLGl1Q2K9rs,10403
94
+ devflow_cli-9.0.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
95
+ devflow_cli-9.0.0.dist-info/entry_points.txt,sha256=GVJ6bMvFpqyO5589iC4nFKeGTpGIW8aKn_QPPpSZ6kU,48
96
+ devflow_cli-9.0.0.dist-info/licenses/LICENSE,sha256=v79PvnmqOdKhQiOa_QcWxng4q8GuGscLOwi7ygUpwpo,1070
97
+ devflow_cli-9.0.0.dist-info/RECORD,,