devflow-cli 8.2.0__py3-none-any.whl → 8.4.1__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.2.0"
3
+ VERSION = "8.4.1"
4
4
 
5
5
  SUPPORTED_AGENTS = ["claude-code", "codex", "cursor"]
6
6
 
@@ -27,9 +27,11 @@ from devflow_cli.core.adaptive_workflow import (
27
27
  from devflow_cli.core.assessment import read_assessment
28
28
  from devflow_cli.core.activity_base import (
29
29
  enter_current_activity,
30
+ feature_path_for,
30
31
  files_since_activity_base,
31
32
  )
32
33
  from devflow_cli.core.activity_contracts import (
34
+ DOCUMENT_ARTIFACTS,
33
35
  build_activity_contract,
34
36
  NEED_STATEMENT,
35
37
  render_contract_prompt,
@@ -39,6 +41,7 @@ from devflow_cli.core.activity_contracts import (
39
41
  )
40
42
  from devflow_cli.core.adaptive_runner import (
41
43
  MANUAL_RECONCILIATION_ACTIVITIES,
44
+ AdaptiveRunOutcome,
42
45
  AgentActivityExecution,
43
46
  reconcile_current_activity,
44
47
  record_human_review,
@@ -510,12 +513,17 @@ def run_adaptive(
510
513
  _missing_output_recovery(
511
514
  feature_dir, current_state.issueId, outcome.activity, agent,
512
515
  execution.stdout,
516
+ mutated=outcome.reason == "agent-failed-after-mutation",
513
517
  )
514
518
  if outcome.reason in {"agent-failed", "agent-failed-after-mutation"}
515
519
  and execution is not None
516
520
  and execution.final_output_diagnostic in MISSING_STRUCTURED_OUTPUT_DIAGNOSTICS
517
521
  else None
518
522
  )
523
+ if outcome.reason == "preflight-evidence-stale":
524
+ recovery = _stale_preflight_recovery(current_state)
525
+ if outcome.reason == "artifact-files-mismatch":
526
+ recovery = _artifact_mismatch_recovery(current_state, outcome, agent)
519
527
  if json_output:
520
528
  _print_execution_json(
521
529
  current_state.issueId, outcome.activity, False, outcome.reason,
@@ -697,7 +705,17 @@ def reconcile_adaptive(
697
705
  console.print(" Suite : attendez la fin de l'activite en cours, puis relancez.")
698
706
  raise typer.Exit(int(exit_code_for_run_reason(outcome.reason)))
699
707
  console.print(f" Suite : devflow adaptive status {state.issueId}")
700
- if outcome.reason == "reconcile-refused":
708
+ if outcome.reason == "artifact-files-mismatch":
709
+ console.print(" Reprise guidee :")
710
+ _print_recovery_steps(
711
+ _artifact_mismatch_recovery(
712
+ state, outcome, _attempt_agent(feature_dir, outcome.activity)
713
+ )
714
+ )
715
+ if (
716
+ outcome.reason == "reconcile-refused"
717
+ and activity_attempt_file(feature_dir, outcome.activity).is_file()
718
+ ):
701
719
  console.print(
702
720
  f" Abandon audite : devflow adaptive discard-attempt {state.issueId} --reason MOTIF"
703
721
  )
@@ -915,16 +933,45 @@ def discard_attempt_command(
915
933
  f"Tentative '{entry['activity']}' abandonnee (audit #{entry['sequence']}, "
916
934
  f"archive : {entry['archivePath']})."
917
935
  )
918
- if entry["discardedFiles"]:
936
+ discarded = list(entry["discardedFiles"])
937
+ inputs = (
938
+ [
939
+ path for path in discarded
940
+ if _is_need_input(path, feature_path_for(
941
+ project_root_for_feature(feature_dir), feature_dir
942
+ ))
943
+ ]
944
+ if entry["activity"] in DOCUMENT_ARTIFACTS
945
+ else []
946
+ )
947
+ others = [path for path in discarded if path not in inputs]
948
+ if inputs:
949
+ warn(
950
+ "Entrees du besoin modifiees depuis le debut de l'activite "
951
+ f"'{entry['activity']}' : " + ", ".join(inputs) + " ; elles ne sont plus "
952
+ "imputees a un agent apres cet abandon et seront acceptees comme amendees "
953
+ "a la relance : a verifier"
954
+ )
955
+ if others:
919
956
  warn(
920
957
  "Fichiers modifies depuis le debut de l'activite "
921
958
  f"'{entry['activity']}', comptes dans le lot a la relance ; remise en "
922
- "etat a la charge du superviseur : " + ", ".join(entry["discardedFiles"])
959
+ "etat a la charge du superviseur : " + ", ".join(others)
923
960
  )
924
961
  if entry.get("observationError"):
925
962
  warn(f"Fichiers depuis la base non calcules : {entry['observationError']}")
926
963
 
927
964
 
965
+ def _is_need_input(path: str, feature_path: str | None) -> bool:
966
+ """Vrai pour need.md ou issue-*.md directement sous le dossier de la feature."""
967
+ if feature_path is None:
968
+ return False
969
+ parent, _, name = path.rpartition("/")
970
+ return parent == feature_path and (
971
+ name == NEED_STATEMENT or (name.startswith("issue-") and name.endswith(".md"))
972
+ )
973
+
974
+
928
975
  def _execute_local_verification(
929
976
  feature_dir: Path, project_root: Path, issue_id: str, *,
930
977
  refresh: bool = False, json_output: bool = False,
@@ -937,7 +984,8 @@ def _execute_local_verification(
937
984
  sync_activity_status(issue_id, "verify", stream)
938
985
  outcome = run_automatic_verification(feature_dir, project_root, refresh=refresh)
939
986
  recovery = (
940
- None if outcome.passed else _verify_recovery(feature_dir, issue_id, outcome)
987
+ None if outcome.passed
988
+ else _verify_recovery(feature_dir, issue_id, outcome, refresh=refresh)
941
989
  )
942
990
  if json_output:
943
991
  _print_execution_json(
@@ -954,6 +1002,8 @@ def _execute_local_verification(
954
1002
  ok(f"Preuves de verify rafraichies. Activite courante : {outcome.nextActivity}")
955
1003
  else:
956
1004
  ok(f"Preuves generees et validees. Prochaine : {outcome.nextActivity}")
1005
+ if outcome.detail:
1006
+ console.print(f" Motif : {outcome.detail}", markup=False, soft_wrap=True)
957
1007
  return
958
1008
  fail(outcome.detail or outcome.reason)
959
1009
  console.print(f" Verification automatique refusee : {outcome.reason}")
@@ -976,17 +1026,20 @@ PLANNED_PREFIXES = {"PREP-": "prepare", "PLAN-": "plan"}
976
1026
 
977
1027
 
978
1028
  def _verify_recovery(
979
- feature_dir: Path, issue_id: str, outcome: AutomaticVerificationOutcome
1029
+ feature_dir: Path, issue_id: str, outcome: AutomaticVerificationOutcome,
1030
+ *, refresh: bool = False,
980
1031
  ) -> dict:
981
1032
  """Reprise guidee d'un refus de verify, par motif, sans heuristique (B2).
982
1033
 
983
1034
  Approbation manquante : chaque approve-command puis verify. Executable introuvable
984
- (code 127) : corriger l'environnement puis verify. Echec d'un controle : deux voies,
985
- le code (reopen implement) ou la validation (reopen de l'activite qui l'a inscrite).
1035
+ (code 127) : corriger l'environnement puis verify. Echec d'un controle : trois voies,
1036
+ le code (reopen implement), la validation (reopen de l'activite qui l'a inscrite) ou
1037
+ un echec passager (relancer verify). Apres un verify --refresh refuse, toutes les
1038
+ commandes verify portent --refresh.
986
1039
  """
987
1040
  issue = shlex.quote(issue_id)
988
1041
  verify_command = {
989
- "command": f"devflow adaptive verify {issue}",
1042
+ "command": f"devflow adaptive verify {issue}" + (" --refresh" if refresh else ""),
990
1043
  "purpose": "relancer la verification",
991
1044
  }
992
1045
  state = read_adaptive_state(feature_dir / "workflow-state.json")
@@ -1064,6 +1117,13 @@ def _verify_recovery(
1064
1117
  "command": f"devflow adaptive reopen {issue} {origin} --reason {reason_text}",
1065
1118
  "purpose": f"voie 2 : la validation {names}, inscrite par {origin}, est en cause",
1066
1119
  })
1120
+ commands.append({
1121
+ "command": verify_command["command"],
1122
+ "purpose": (
1123
+ "voie 3 : echec passager (reseau, conteneur, service externe), "
1124
+ "relancer la verification"
1125
+ ),
1126
+ })
1067
1127
  return {
1068
1128
  "kind": "check-failed",
1069
1129
  "summary": (
@@ -1248,8 +1308,10 @@ def _operational_status(
1248
1308
  risks = _remaining_risks(feature_dir)
1249
1309
  if not isinstance(blockers, list):
1250
1310
  blockers = []
1251
- stale = set(stale_devflow_evidence(feature_dir, state))
1252
- stale.update(stale_review_evidence(feature_dir, state))
1311
+ stale: set[str] = set()
1312
+ if state.status != "completed": # aucune gate ne consulte la peremption apres done
1313
+ stale.update(stale_devflow_evidence(feature_dir, state))
1314
+ stale.update(stale_review_evidence(feature_dir, state))
1253
1315
  evidence = [
1254
1316
  {
1255
1317
  "id": evidence_id,
@@ -1442,7 +1504,8 @@ def _last_run_missed_structured_output(feature_dir: Path, activity: str) -> bool
1442
1504
 
1443
1505
 
1444
1506
  def _missing_output_recovery(
1445
- feature_dir: Path, issue_id: str, activity: str, agent: str, raw_output: str
1507
+ feature_dir: Path, issue_id: str, activity: str, agent: str, raw_output: str,
1508
+ *, mutated: bool | None = None,
1446
1509
  ) -> dict:
1447
1510
  """Reprise guidee d'une sortie structuree absente apres un agent termine sans erreur.
1448
1511
 
@@ -1467,6 +1530,8 @@ def _missing_output_recovery(
1467
1530
  attempt = json.loads(
1468
1531
  activity_attempt_file(feature_dir, activity).read_text(encoding="utf-8")
1469
1532
  )
1533
+ if mutated is None: # reconcile : le motif vient de la fiche de tentative
1534
+ mutated = attempt["reason"] == "agent-failed-after-mutation"
1470
1535
  contract = json.loads(
1471
1536
  (feature_dir / "activity-contract.json").read_text(encoding="utf-8")
1472
1537
  )
@@ -1504,12 +1569,155 @@ def _missing_output_recovery(
1504
1569
  ),
1505
1570
  },
1506
1571
  ])
1572
+ if not mutated:
1573
+ # Apres des ecritures, la relance contredirait « reconciliez d'abord ».
1574
+ recovery["commands"].append({
1575
+ "command": f"devflow adaptive run {issue} --agent {shlex.quote(agent)}",
1576
+ "purpose": "relancer l'agent : il n'a rien ecrit, la tentative est remplacee",
1577
+ })
1507
1578
  return recovery
1508
1579
 
1509
1580
 
1581
+ def _stale_preflight_recovery(state: AdaptiveWorkflowState) -> dict:
1582
+ """Reprise guidee d'une preuve de pre-vol perimee au lancement d'implement."""
1583
+ issue = shlex.quote(state.issueId)
1584
+ if _reopenable(state, "preflight-evidence"):
1585
+ command = {
1586
+ "command": f"devflow adaptive reopen {issue} preflight-evidence --reason MOTIF",
1587
+ "purpose": "refaire la preuve de pre-vol sur les entrees actuelles",
1588
+ }
1589
+ else:
1590
+ command = {
1591
+ "command": f"devflow adaptive status {issue}",
1592
+ "purpose": "consulter l'etat du parcours",
1593
+ }
1594
+ return {
1595
+ "kind": "preflight-evidence-stale",
1596
+ "summary": "Preuve de pre-vol perimee : la refaire avant implement.",
1597
+ "commands": [command],
1598
+ }
1599
+
1600
+
1601
+ ARTIFACT_FILE_CAUSES = {
1602
+ "during-agent": "modifie pendant l'agent",
1603
+ "before-launch": "modifie avant le lancement de l'agent",
1604
+ "agent-input": "entree du besoin modifiee par un agent de cette activite",
1605
+ }
1606
+
1607
+
1608
+ def _artifact_mismatch_recovery(
1609
+ state: AdaptiveWorkflowState, outcome: AdaptiveRunOutcome, agent: str
1610
+ ) -> dict:
1611
+ """Reprise guidee d'un refus d'artefact, par cause observee de chaque fichier en trop."""
1612
+ issue = shlex.quote(state.issueId)
1613
+ files = [
1614
+ {"path": path, "cause": cause, "label": ARTIFACT_FILE_CAUSES[cause]}
1615
+ for path, cause in outcome.extraFiles
1616
+ ]
1617
+ causes = {cause for _, cause in outcome.extraFiles}
1618
+ if not files:
1619
+ return {
1620
+ "kind": "artifact-files-mismatch",
1621
+ "summary": (
1622
+ "Artefact attendu absent ou inchange depuis la base, sans fichier en "
1623
+ "trop : l'agent doit l'ecrire."
1624
+ ),
1625
+ "files": files,
1626
+ "commands": [{
1627
+ "command": f"devflow adaptive run {issue}"
1628
+ + (f" --agent {shlex.quote(agent)}" if agent else ""),
1629
+ "purpose": "relancer l'agent pour qu'il ecrive l'artefact",
1630
+ }],
1631
+ }
1632
+ if causes == {"agent-input"}:
1633
+ # Refus avant l'agent : ni reconcile ni reopen ne leve l'imputation.
1634
+ run = f"devflow adaptive run {issue}" + (
1635
+ f" --agent {shlex.quote(agent)}" if agent else ""
1636
+ )
1637
+ return {
1638
+ "kind": "artifact-files-mismatch",
1639
+ "summary": (
1640
+ "Entree du besoin modifiee par un agent de cette activite et pas remise "
1641
+ "en etat : l'agent n'a pas ete lance. Deux sorties : remettre l'entree "
1642
+ "dans son etat de base, ou abandonner la tentative (l'entree modifiee "
1643
+ "est gardee, a verifier)."
1644
+ ),
1645
+ "files": files,
1646
+ "commands": [
1647
+ {
1648
+ "command": run,
1649
+ "purpose": "apres remise en etat de l'entree dans son etat de base",
1650
+ },
1651
+ {
1652
+ "command": f"devflow adaptive discard-attempt {issue} --reason MOTIF",
1653
+ "purpose": (
1654
+ "sinon, garder l'entree modifiee : abandon audite de la "
1655
+ "tentative, puis devflow adaptive run"
1656
+ ),
1657
+ },
1658
+ ],
1659
+ }
1660
+ commands = [{
1661
+ "command": f"devflow adaptive reconcile {issue}",
1662
+ "purpose": (
1663
+ "une fois chaque fichier en trop retire ou remis dans son etat de base : "
1664
+ "valider l'artefact sans relancer l'agent"
1665
+ ),
1666
+ }]
1667
+ index = state.activities.index(state.currentActivity)
1668
+ previous = state.activities[index - 1] if index > 0 else ""
1669
+ if (
1670
+ "before-launch" in causes
1671
+ and "agent-input" not in causes
1672
+ and previous not in {"", "assess"}
1673
+ and _reopenable(state, previous)
1674
+ ):
1675
+ commands.append({
1676
+ "command": f"devflow adaptive reopen {issue} {previous} --reason MOTIF",
1677
+ "purpose": (
1678
+ "sinon, garder le fichier modifie avant le lancement : rouvrir "
1679
+ "l'activite precedente pour repartir d'une base fraiche"
1680
+ ),
1681
+ })
1682
+ if "agent-input" in causes:
1683
+ commands.append({
1684
+ "command": f"devflow adaptive discard-attempt {issue} --reason MOTIF",
1685
+ "purpose": (
1686
+ "sinon, garder l'entree du besoin modifiee par l'agent : abandon "
1687
+ "audite de la tentative, puis devflow adaptive run"
1688
+ ),
1689
+ })
1690
+ return {
1691
+ "kind": "artifact-files-mismatch",
1692
+ "summary": (
1693
+ "Fichiers en trop pour une activite documentaire : retirer ceux ecrits "
1694
+ "pendant l'agent, remettre dans leur etat de base ceux modifies avant son "
1695
+ "lancement. Relancer l'agent a l'identique donnerait le meme refus."
1696
+ ),
1697
+ "files": files,
1698
+ "commands": commands,
1699
+ }
1700
+
1701
+
1702
+ def _attempt_agent(feature_dir: Path, activity: str) -> str:
1703
+ """Agent de la fiche de tentative, ou '' si elle est illisible."""
1704
+ try:
1705
+ attempt = json.loads(
1706
+ activity_attempt_file(feature_dir, activity).read_text(encoding="utf-8")
1707
+ )
1708
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
1709
+ return ""
1710
+ agent = attempt.get("agent") if isinstance(attempt, dict) else None
1711
+ return agent if isinstance(agent, str) else ""
1712
+
1713
+
1510
1714
  def _print_recovery_steps(recovery: dict) -> None:
1511
1715
  # soft_wrap : la commande a copier reste sur une seule ligne.
1512
1716
  console.print(f" {recovery['summary']}", markup=False, soft_wrap=True)
1717
+ for item in recovery.get("files", []):
1718
+ console.print(
1719
+ f" {item['path']} : {item['label']}", markup=False, soft_wrap=True
1720
+ )
1513
1721
  for step in recovery["commands"]:
1514
1722
  console.print(
1515
1723
  f" {step['command']} # {step['purpose']}", markup=False, soft_wrap=True
@@ -1549,6 +1757,24 @@ def _print_run_recovery(
1549
1757
  " Les entrees du besoin ont change pendant l'execution. "
1550
1758
  "Verifiez-les avant une nouvelle tentative."
1551
1759
  )
1760
+ if activity in DOCUMENT_ARTIFACTS:
1761
+ console.print(
1762
+ " Activite documentaire : la relance reste refusee tant que l'entree "
1763
+ "n'est pas remise dans son etat de base, ou la tentative abandonnee :",
1764
+ markup=False, soft_wrap=True,
1765
+ )
1766
+ issue, agent_arg = shlex.quote(issue_id), shlex.quote(agent)
1767
+ console.print(
1768
+ f" devflow adaptive discard-attempt {issue} --reason MOTIF"
1769
+ " # garder l'entree modifiee : abandon audite",
1770
+ markup=False, soft_wrap=True,
1771
+ )
1772
+ console.print(
1773
+ f" devflow adaptive run {issue} --agent {agent_arg}"
1774
+ " # apres remise en etat de l'entree, ou apres l'abandon",
1775
+ markup=False, soft_wrap=True,
1776
+ )
1777
+ return
1552
1778
  console.print(f" devflow adaptive run {issue_id} --agent {agent}")
1553
1779
  return
1554
1780
  if reason == "agent-running":
@@ -16,6 +16,7 @@ from devflow_cli.core.assessment import (
16
16
  write_assessment,
17
17
  )
18
18
  from devflow_cli.core.adaptive_workflow import workflow_lock
19
+ from devflow_cli.core.activity_contracts import NEED_STATEMENT, validate_need_statement
19
20
  from devflow_cli.core.contract_schemas import check_schema_version
20
21
  from devflow_cli.utils.console import console, fail, ok
21
22
  from devflow_cli.utils.paths import resolve_feature_dir
@@ -138,4 +139,13 @@ def assess(
138
139
  console.print(f" Activites : {' -> '.join(assessment.activities)}")
139
140
  console.print(f" Preuves : {', '.join(assessment.requiredEvidence)}")
140
141
  console.print(f" Rapport : {output_file}")
141
- console.print(f" Suite : devflow adaptive start {assessment.issueId}")
142
+ try:
143
+ validate_need_statement(feature_dir / NEED_STATEMENT)
144
+ except ValueError:
145
+ console.print(
146
+ f" Suite : rediger {NEED_STATEMENT} ({feature_dir / NEED_STATEMENT}), "
147
+ f"puis devflow adaptive start {assessment.issueId}",
148
+ markup=False, soft_wrap=True,
149
+ )
150
+ else:
151
+ console.print(f" Suite : devflow adaptive start {assessment.issueId}")
@@ -369,17 +369,29 @@ def declaration_warning(declared: list[str], observed: list[str]) -> str:
369
369
 
370
370
 
371
371
  def recorded_observation(
372
- observation: ActivityObservation, declared: list[str]
372
+ observation: ActivityObservation,
373
+ declared: list[str],
374
+ *,
375
+ exempted: Iterable[str] = (),
376
+ amendment_warning: str = "",
373
377
  ) -> dict[str, Any]:
374
- """Bloc ``observation`` ecrit par la CLI seule dans le resultat enregistre."""
375
- observed = sorted(observation.sinceBase)
378
+ """Bloc ``observation`` ecrit par la CLI seule dans le resultat enregistre.
379
+
380
+ ``exempted`` : entrees du besoin amendees avant l'agent, hors ecart de
381
+ declaration ; ``amendment_warning`` les nomme dans la cle ``warning``.
382
+ """
383
+ observed = sorted(set(observation.sinceBase) - set(exempted))
376
384
  block: dict[str, Any] = {
377
385
  "baseRecordedAt": observation.base.recordedAt,
378
386
  "baseHead": observation.base.head,
379
387
  "observedAt": observation.observedAt,
380
388
  "head": observation.head,
381
389
  }
382
- warning = declaration_warning(declared, observed)
390
+ warning = " ; ".join(
391
+ message
392
+ for message in (declaration_warning(declared, observed), amendment_warning)
393
+ if message
394
+ )
383
395
  if warning:
384
396
  block["declaredChangedFiles"] = list(declared)
385
397
  block["warning"] = warning
@@ -54,7 +54,9 @@ SUBAGENT_CONSTRAINT = (
54
54
  PROTECTED_VALIDATION_CONSTRAINT = (
55
55
  "Les garde-fous sont actifs : inclure dans validationPlan au moins une validation "
56
56
  "bloquante explicitement consacree a la securite, aux donnees, a l'authentification, "
57
- "a une migration ou au rollback, dont id ou purpose contient l'un de ces termes."
57
+ "a une migration ou au rollback, dont id ou purpose contient l'un de ces termes. "
58
+ "Toute validation protegee est bloquante et doit reussir en code 0 : une recherche "
59
+ "dont l'absence de correspondance est le resultat attendu ne s'exprime pas ainsi."
58
60
  )
59
61
 
60
62
  PREFLIGHT_HEADINGS = (
@@ -178,6 +180,10 @@ def validate_need_statement(path: Path) -> None:
178
180
  raise ValueError(f"{NEED_STATEMENT} est vide : decrivez le besoin avant de demarrer")
179
181
 
180
182
 
183
+ class PreflightEvidenceStaleError(ValueError):
184
+ """Preuve de pre-vol anterieure a une entree : motif de refus dedie."""
185
+
186
+
181
187
  def validate_preflight_evidence(path: Path, *, newer_than: list[Path] | None = None) -> None:
182
188
  """Validate the V4 preflight artifact without exporting its contents."""
183
189
  try:
@@ -205,7 +211,7 @@ def validate_preflight_evidence(path: Path, *, newer_than: list[Path] | None = N
205
211
  artifact_mtime = path.stat().st_mtime_ns
206
212
  stale_inputs = [item.name for item in newer_than if item.is_file() and item.stat().st_mtime_ns > artifact_mtime]
207
213
  if stale_inputs:
208
- raise ValueError("preuve de pre-vol desuete apres : " + ", ".join(sorted(stale_inputs)))
214
+ raise PreflightEvidenceStaleError("preuve de pre-vol desuete apres : " + ", ".join(sorted(stale_inputs)))
209
215
 
210
216
 
211
217
  def build_activity_contract(
@@ -6,18 +6,20 @@ import hashlib
6
6
  import json
7
7
  import os
8
8
  import re
9
+ import shlex
9
10
  import tempfile
10
11
  from contextlib import ExitStack
11
12
  from dataclasses import dataclass, replace
12
13
  from datetime import datetime, timezone
13
14
  from pathlib import Path
14
- from typing import Callable
15
+ from typing import Callable, Iterable
15
16
 
16
17
  from devflow_cli.core.activity_contracts import (
17
18
  ActivityContract,
18
19
  ActivityResult,
19
20
  DOCUMENT_ARTIFACTS,
20
21
  NEED_STATEMENT,
22
+ PreflightEvidenceStaleError,
21
23
  build_activity_contract,
22
24
  contract_is_reopened_plan,
23
25
  document_artifact_name,
@@ -49,6 +51,7 @@ from devflow_cli.core.automatic_evidence import (
49
51
  review_worktree_fingerprint,
50
52
  )
51
53
  from devflow_cli.core.adaptive_workflow import (
54
+ AdaptiveWorkflowState,
52
55
  complete_activity,
53
56
  read_adaptive_state,
54
57
  record_evidence,
@@ -90,6 +93,8 @@ class AdaptiveRunOutcome:
90
93
  detail: str = ""
91
94
  result: ActivityResult | None = None
92
95
  warnings: tuple[str, ...] = ()
96
+ # artifact-files-mismatch : (chemin, cause observee) de chaque fichier en trop.
97
+ extraFiles: tuple[tuple[str, str], ...] = ()
93
98
 
94
99
  @property
95
100
  def advanced(self) -> bool:
@@ -142,6 +147,11 @@ def run_current_activity(
142
147
  preflight_evidence=(feature_dir / "preflight-evidence.md").is_file(),
143
148
  files_since_base=since_base,
144
149
  )
150
+ imputed_inputs = (
151
+ _imputed_inputs(feature_dir, feature_path, contract.activity, since_base)
152
+ if contract.activity in DOCUMENT_ARTIFACTS
153
+ else []
154
+ )
145
155
  if state.currentActivity == "implement":
146
156
  upstream = [
147
157
  feature_dir / Path(item).name
@@ -155,10 +165,29 @@ def run_current_activity(
155
165
  return AdaptiveRunOutcome(
156
166
  exc.reason, state.currentActivity, state.currentActivity, str(exc)
157
167
  )
168
+ except PreflightEvidenceStaleError as exc:
169
+ return AdaptiveRunOutcome(
170
+ "preflight-evidence-stale",
171
+ state.currentActivity,
172
+ state.currentActivity,
173
+ str(exc),
174
+ )
158
175
  except (OSError, ValueError) as exc:
159
176
  return AdaptiveRunOutcome(
160
177
  "unsupported-activity", state.currentActivity, state.currentActivity, str(exc)
161
178
  )
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
+ )
162
191
  raw_file = activity_raw_file(feature_dir, contract.activity)
163
192
  guard = ExitStack()
164
193
  try:
@@ -203,8 +232,19 @@ def run_current_activity(
203
232
  restored = backup.restore() if runtime_mutated else []
204
233
  guard.close()
205
234
  _write_text_atomic(raw_file, execution.output)
235
+ current_inputs = _input_fingerprints(feature_dir)
236
+ changed_inputs = sorted(
237
+ name
238
+ for name in set(input_fingerprints) | set(current_inputs)
239
+ if input_fingerprints.get(name) != current_inputs.get(name)
240
+ )
241
+ # N5 : une entree modifiee par un agent reste imputee de fiche en fiche.
242
+ attributed_inputs = sorted({*changed_inputs, *imputed_inputs})
206
243
  if runtime_mutated:
207
- _record_failed_attempt(feature_dir, contract, execution, "runtime-file-mutated")
244
+ _record_failed_attempt(
245
+ feature_dir, contract, execution, "runtime-file-mutated",
246
+ changed_inputs=attributed_inputs,
247
+ )
208
248
  return AdaptiveRunOutcome(
209
249
  "runtime-file-mutated",
210
250
  contract.activity,
@@ -214,16 +254,10 @@ def run_current_activity(
214
254
  + " ; remis dans leur etat d'avant l'agent : "
215
255
  + (", ".join(restored) if restored else "aucun fichier a restaurer"),
216
256
  )
217
- current_inputs = _input_fingerprints(feature_dir)
218
- changed_inputs = sorted(
219
- name
220
- for name in set(input_fingerprints) | set(current_inputs)
221
- if input_fingerprints.get(name) != current_inputs.get(name)
222
- )
223
257
  if changed_inputs:
224
258
  _record_failed_attempt(
225
259
  feature_dir, contract, execution,
226
- "inputs-changed-during-activity", changed_inputs=changed_inputs,
260
+ "inputs-changed-during-activity", changed_inputs=attributed_inputs,
227
261
  )
228
262
  return AdaptiveRunOutcome(
229
263
  "inputs-changed-during-activity", contract.activity, contract.activity,
@@ -243,7 +277,9 @@ def run_current_activity(
243
277
  reason = "agent-failed"
244
278
  if execution.observedChangedFiles:
245
279
  detail += f"; mutations observees : {len(set(execution.observedChangedFiles))} fichier(s)"
246
- _record_failed_attempt(feature_dir, contract, execution, reason)
280
+ _record_failed_attempt(
281
+ feature_dir, contract, execution, reason, changed_inputs=attributed_inputs
282
+ )
247
283
  return AdaptiveRunOutcome(reason, contract.activity, contract.activity, detail)
248
284
 
249
285
  try:
@@ -252,7 +288,10 @@ def run_current_activity(
252
288
  outcome = AdaptiveRunOutcome(
253
289
  "invalid-result", contract.activity, contract.activity, str(exc)
254
290
  )
255
- _record_failed_attempt(feature_dir, contract, execution, outcome.reason)
291
+ _record_failed_attempt(
292
+ feature_dir, contract, execution, outcome.reason,
293
+ changed_inputs=attributed_inputs,
294
+ )
256
295
  return outcome
257
296
  try:
258
297
  observation = observe_activity(
@@ -273,9 +312,15 @@ def run_current_activity(
273
312
  observation,
274
313
  source="agent",
275
314
  agent=execution.agent,
315
+ execution_files=execution.observedChangedFiles,
316
+ imputed_inputs=imputed_inputs,
317
+ closed_activities=_closed_activities(state),
276
318
  )
277
319
  if not outcome.advanced and outcome.reason not in {"blocked", "state-changed"}:
278
- _record_failed_attempt(feature_dir, contract, execution, outcome.reason)
320
+ _record_failed_attempt(
321
+ feature_dir, contract, execution, outcome.reason,
322
+ changed_inputs=attributed_inputs,
323
+ )
279
324
  return outcome
280
325
 
281
326
 
@@ -337,15 +382,38 @@ def reconcile_current_activity(
337
382
  raise ValueError("le resultat corrige doit etre un objet JSON")
338
383
  if not correction_reason.strip():
339
384
  raise ValueError("un motif est obligatoire pour corriger le resultat")
340
- attempt = json.loads(
341
- activity_attempt_file(feature_dir, activity).read_text(encoding="utf-8")
342
- )
385
+ try:
386
+ attempt_text = activity_attempt_file(feature_dir, activity).read_text(
387
+ encoding="utf-8"
388
+ )
389
+ except FileNotFoundError:
390
+ raise ValueError(
391
+ f"aucune tentative echouee a reconcilier pour '{activity}' : "
392
+ "reconcile ne s'applique qu'a une tentative agent echouee ; apres "
393
+ "un resultat blocked, lever le blocage puis relancer "
394
+ f"devflow adaptive run {shlex.quote(state.issueId)}"
395
+ ) from None
396
+ attempt = json.loads(attempt_text)
343
397
  if not _valid_attempt(attempt):
344
398
  raise ValueError("fiche de tentative invalide")
345
399
  agent = attempt["agent"]
346
400
  attempt_reason = attempt["reason"]
347
401
  observed_files = attempt["observedFiles"]
348
402
  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
+ )
408
+ if imputed_inputs:
409
+ # 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
+ )
349
417
  if attempt_reason == "inputs-changed-during-activity":
350
418
  raise ValueError(
351
419
  "les entrees ont change pendant la tentative ; inspectez-les "
@@ -365,10 +433,18 @@ def reconcile_current_activity(
365
433
  )
366
434
  if head != attempt["head"] or branch != attempt["branch"]:
367
435
  raise ValueError("HEAD ou la branche a change depuis la tentative")
436
+ # N6 : pour une activite documentaire, un fichier de la fiche autre que
437
+ # l'artefact peut etre revenu a son etat de base (fichier en trop retire).
438
+ artifact = (
439
+ f"{feature_path}/{DOCUMENT_ARTIFACTS[activity]}"
440
+ if activity in DOCUMENT_ARTIFACTS
441
+ else None
442
+ )
368
443
  drifted = sorted(
369
444
  path
370
445
  for path in set(observed_files) | set(since_base)
371
446
  if observed_files.get(path) != since_base.get(path)
447
+ and (artifact is None or path == artifact or path in since_base)
372
448
  )
373
449
  if drifted:
374
450
  raise ValueError(
@@ -449,6 +525,8 @@ def reconcile_current_activity(
449
525
  observation,
450
526
  source="reconciled",
451
527
  agent=agent,
528
+ execution_files=execution_files,
529
+ closed_activities=_closed_activities(state),
452
530
  )
453
531
  if orphan_warning:
454
532
  outcome = replace(outcome, warnings=(orphan_warning, *outcome.warnings))
@@ -528,12 +606,17 @@ def _apply_activity_result(
528
606
  *,
529
607
  source: str,
530
608
  agent: str,
609
+ execution_files: Iterable[str] = (),
610
+ imputed_inputs: Iterable[str] = (),
611
+ closed_activities: Iterable[str] = (),
531
612
  ) -> AdaptiveRunOutcome:
532
613
  """Valide un resultat agent puis transitionne ; commun a run et reconcile.
533
614
 
534
615
  L'observation fait foi : ``O`` (fichiers depuis la base) et ``E`` (fichiers
535
616
  modifies pendant l'agent) fondent les refus ; la declaration de l'agent ne
536
- produit qu'un avertissement.
617
+ 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.
537
620
  """
538
621
  state_file = feature_dir / "workflow-state.json"
539
622
  observed = sorted(observation.sinceBase)
@@ -558,13 +641,35 @@ def _apply_activity_result(
558
641
  "invalid-result", contract.activity, contract.activity, str(exc)
559
642
  )
560
643
 
644
+ executed = set(execution_files)
645
+ imputed_paths = {f"{feature_path}/{name}" for name in imputed_inputs}
646
+ # N1 : entrees du besoin amendees avant le lancement de l'agent, non comptees.
647
+ amended = (
648
+ [
649
+ path
650
+ for path in _input_paths(feature_dir, feature_path)
651
+ if path in observation.sinceBase
652
+ and path not in executed
653
+ and path not in imputed_paths
654
+ ]
655
+ if contract.activity in DOCUMENT_ARTIFACTS
656
+ else []
657
+ )
658
+ counted = [path for path in observed if path not in amended]
561
659
  declared = sorted(set(result.changedFiles))
562
- warning = declaration_warning(declared, observed)
563
- warnings = (warning,) if warning else ()
660
+ amendment = _amendment_warning(contract.issueId, amended, closed_activities)
661
+ warnings = tuple(
662
+ message
663
+ for message in (declaration_warning(declared, counted), amendment)
664
+ if message
665
+ )
564
666
 
565
- def refuse(reason: str, detail: str) -> AdaptiveRunOutcome:
667
+ def refuse(
668
+ reason: str, detail: str, extra_files: tuple[tuple[str, str], ...] = ()
669
+ ) -> AdaptiveRunOutcome:
566
670
  return AdaptiveRunOutcome(
567
- reason, contract.activity, contract.activity, detail, result, warnings
671
+ reason, contract.activity, contract.activity, detail, result, warnings,
672
+ extra_files,
568
673
  )
569
674
 
570
675
  if contract.activity == "implement" and result.status == "completed" and not observed:
@@ -576,11 +681,22 @@ def _apply_activity_result(
576
681
  contract.activity, reopened=contract_is_reopened_plan(contract)
577
682
  )
578
683
  expected = f"{feature_path}/{artifact_name}"
579
- if any(path != expected for path in observed) or (
580
- result.status == "completed" and observed != [expected]
684
+ if any(path != expected for path in counted) or (
685
+ result.status == "completed" and counted != [expected]
581
686
  ):
582
687
  return refuse(
583
- "artifact-files-mismatch", f"attendu={[expected]}, observes={observed}"
688
+ "artifact-files-mismatch",
689
+ f"attendu={[expected]}, observes={counted}",
690
+ tuple(
691
+ (
692
+ path,
693
+ "agent-input" if path in imputed_paths
694
+ else "during-agent" if path in executed
695
+ else "before-launch",
696
+ )
697
+ for path in counted
698
+ if path != expected
699
+ ),
584
700
  )
585
701
  if contract.activity in DOCUMENT_ARTIFACTS and result.status == "completed":
586
702
  artifact_name = document_artifact_name(
@@ -624,7 +740,9 @@ def _apply_activity_result(
624
740
 
625
741
  recorded = result.to_dict()
626
742
  recorded["changedFiles"] = observed
627
- recorded["observation"] = recorded_observation(observation, declared)
743
+ recorded["observation"] = recorded_observation(
744
+ observation, declared, exempted=amended, amendment_warning=amendment
745
+ )
628
746
  _write_json_atomic(activity_result_file(feature_dir, contract.activity), recorded)
629
747
  if result.status == "blocked":
630
748
  safe_refresh_worklog(feature_dir)
@@ -733,6 +851,17 @@ def _validated_result(
733
851
  raise ValueError(
734
852
  f"validation {item.id}, commande : {command_label} — {exc}"
735
853
  ) from exc
854
+ if contract.guardrails:
855
+ for item in result.validationPlan:
856
+ if is_protected_validation(item.id, item.purpose) and not item.blocking:
857
+ raise ValueError(
858
+ f"validation {item.id} : un controle protege (securite, "
859
+ "donnees, authentification, migration, rollback) est toujours "
860
+ "bloquant et doit reussir en code 0 ; declarer blocking a true. "
861
+ "Une recherche dont l'absence de correspondance est le resultat "
862
+ "attendu ne s'exprime pas ainsi : utiliser une commande qui "
863
+ "renvoie 0 en cas de succes."
864
+ )
736
865
  if contract.guardrails and not any(
737
866
  item.blocking and is_protected_validation(item.id, item.purpose)
738
867
  for item in result.validationPlan
@@ -903,15 +1032,22 @@ def _record_failed_attempt(
903
1032
 
904
1033
  ``executionFiles`` est la liste brute des fichiers modifies pendant
905
1034
  l'agent, non filtree. ``observedFiles`` vaut les fichiers depuis la base de l'activite ; si
906
- l'observation est impossible, aucune fiche n'est ecrite.
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).
907
1038
  """
908
1039
  try:
909
1040
  state = read_adaptive_state(feature_dir / "workflow-state.json")
910
- if state is None:
911
- return
912
- head, branch, observed = observe_attempt(feature_dir, state)
913
1041
  except (OSError, ValueError):
914
1042
  return
1043
+ if state is None:
1044
+ return
1045
+ try:
1046
+ head, branch, observed = observe_attempt(feature_dir, state)
1047
+ except (OSError, ValueError):
1048
+ if not changed_inputs:
1049
+ return
1050
+ head, branch, observed = "", "", {}
915
1051
  if changed_inputs:
916
1052
  input_paths = {f"{contract.featurePath}/{name}" for name in changed_inputs}
917
1053
  observed = {
@@ -937,6 +1073,74 @@ def _record_failed_attempt(
937
1073
  _write_json_atomic(activity_attempt_file(feature_dir, contract.activity), payload)
938
1074
 
939
1075
 
1076
+ def _input_paths(feature_dir: Path, feature_path: str) -> list[str]:
1077
+ """Chemins des entrees du besoin qui sont des fichiers ordinaires lisibles.
1078
+
1079
+ Un lien symbolique ou une entree illisible n'en fait pas partie : il reste compte.
1080
+ """
1081
+ return [
1082
+ f"{feature_path}/{name}"
1083
+ for name, fingerprint in sorted(_input_fingerprints(feature_dir).items())
1084
+ if not fingerprint.startswith(("symlink:", "unreadable:"))
1085
+ ]
1086
+
1087
+
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
1108
+ )
1109
+
1110
+
1111
+ def _closed_activities(state: AdaptiveWorkflowState) -> list[str]:
1112
+ """Activites closes qu'``adaptive reopen`` accepte comme cible."""
1113
+ return [
1114
+ activity
1115
+ for activity in state.completedActivities
1116
+ if activity not in {"assess", "done"}
1117
+ ]
1118
+
1119
+
1120
+ def _amendment_warning(
1121
+ issue_id: str, amended: list[str], closed_activities: Iterable[str]
1122
+ ) -> str:
1123
+ """Avertissement d'une entree du besoin amendee avant l'agent (N2), ou ''."""
1124
+ if not amended:
1125
+ return ""
1126
+ parts = [
1127
+ "entrees du besoin amendees avant le lancement de l'agent, non comptees "
1128
+ "contre l'artefact attendu : " + ", ".join(amended)
1129
+ ]
1130
+ closed = list(closed_activities)
1131
+ if closed:
1132
+ parts.append(
1133
+ "activites deja closes, produites sur l'ancien besoin : " + ", ".join(closed)
1134
+ )
1135
+ parts.append(
1136
+ "si l'amendement change leur resultat : devflow adaptive reopen "
1137
+ f"{shlex.quote(issue_id)} ACTIVITE --reason MOTIF"
1138
+ )
1139
+ else:
1140
+ parts.append("aucune activite close a rouvrir")
1141
+ return " ; ".join(parts)
1142
+
1143
+
940
1144
  def _input_fingerprints(feature_dir: Path) -> dict[str, str]:
941
1145
  """Empreinte les entrees modifiables sans conserver leur contenu."""
942
1146
  paths = [feature_dir / NEED_STATEMENT, *sorted(feature_dir.glob("issue-*.md"))]
@@ -22,7 +22,13 @@ from devflow_cli.core.adaptive_workflow import (
22
22
  workflow_lock,
23
23
  write_adaptive_state,
24
24
  )
25
- from devflow_cli.core.activity_base import enter_current_activity, parse_recorded_result
25
+ from devflow_cli.core.activity_base import (
26
+ ActivityBase,
27
+ BaseObservationError,
28
+ enter_current_activity,
29
+ files_since_base,
30
+ parse_recorded_result,
31
+ )
26
32
  from devflow_cli.core.activity_contracts import (
27
33
  DOCUMENT_ARTIFACTS,
28
34
  activity_contract_id,
@@ -297,6 +303,38 @@ DOCUMENT_INSENSITIVE_EVIDENCE_IDS = frozenset({"test-suite", "requirements-cover
297
303
  NON_DOCUMENT_FINGERPRINT_PREFIX = "non-document-v1:"
298
304
 
299
305
 
306
+ def is_document_path(path: str) -> bool:
307
+ """Vrai pour un README ou un fichier Markdown du dossier docs."""
308
+ return Path(path).name.casefold() == "readme.md" or (
309
+ path.startswith("docs/") and path.casefold().endswith(".md")
310
+ )
311
+
312
+
313
+ def _observed_document_batch(
314
+ state: AdaptiveWorkflowState, feature_dir: Path, project_root: Path
315
+ ) -> list[str] | None:
316
+ """Fichiers observes depuis la base d'implement si tous sont documentaires.
317
+
318
+ None quand le lot n'est pas eligible : base inobservable, lot vide, fichier
319
+ non documentaire ou lien symbolique. L'observation decide, pas l'agent.
320
+ """
321
+ try:
322
+ base = ActivityBase.from_dict(
323
+ state.implementBase,
324
+ feature_path=feature_dir.resolve().relative_to(project_root.resolve()).as_posix(),
325
+ )
326
+ observed = sorted(
327
+ files_since_base(project_root, feature_dir, base, issue_id=state.issueId)
328
+ )
329
+ except (BaseObservationError, ValueError, OSError):
330
+ return None
331
+ if not observed or not all(is_document_path(path) for path in observed):
332
+ return None
333
+ if any((project_root / path).is_symlink() for path in observed):
334
+ return None
335
+ return observed
336
+
337
+
300
338
  def _non_document_fingerprint(snapshot: WorktreeSnapshot, excluded_dir: str) -> str:
301
339
  """Empreinte des sources, sans README ni Markdown du dossier docs.
302
340
 
@@ -306,10 +344,7 @@ def _non_document_fingerprint(snapshot: WorktreeSnapshot, excluded_dir: str) ->
306
344
  source_files = {
307
345
  path: fingerprint
308
346
  for path, fingerprint in snapshot.files.items()
309
- if not (
310
- Path(path).name.casefold() == "readme.md"
311
- or (path.startswith("docs/") and path.casefold().endswith(".md"))
312
- )
347
+ if not is_document_path(path)
313
348
  }
314
349
  return NON_DOCUMENT_FINGERPRINT_PREFIX + worktree_fingerprint(
315
350
  replace(snapshot, files=source_files), excluded_dir
@@ -562,7 +597,10 @@ def run_automatic_verification(
562
597
  # par run_verifications avant leur execution locale par devflow.
563
598
  commands = regression_commands
564
599
  reuse_targeted_as_regression = True
565
- if not commands:
600
+ document_batch: list[str] | None = None
601
+ if not commands and state.profile == "quick" and not guardrails_active(state):
602
+ document_batch = _observed_document_batch(state, feature_dir, project_root)
603
+ if not commands and document_batch is None:
566
604
  return AutomaticVerificationOutcome(
567
605
  False,
568
606
  "no-safe-validation",
@@ -661,7 +699,7 @@ def run_automatic_verification(
661
699
  break
662
700
  return AutomaticVerificationOutcome(False, "validation-policy-refused", detail)
663
701
  completed_at = _now_iso()
664
- checks_passed = bool(results) and all(
702
+ checks_passed = (bool(results) or document_batch is not None) and all(
665
703
  result.succeeded or not result.blocking for result in results
666
704
  )
667
705
  regression_passed = all(
@@ -673,7 +711,9 @@ def run_automatic_verification(
673
711
  and all(result.succeeded for result in security_results)
674
712
  and not security_findings
675
713
  )
676
- diff_payload = _inspect_diff(project_root, changed_files)
714
+ diff_payload = _inspect_diff(
715
+ project_root, document_batch if document_batch is not None else changed_files
716
+ )
677
717
  final_snapshot = snapshot_worktree(project_root)
678
718
  try:
679
719
  test_mutations = changed_paths(before, final_snapshot)
@@ -883,7 +923,15 @@ def run_automatic_verification(
883
923
  write_adaptive_state(state_file, latest)
884
924
  next_activity = latest.currentActivity
885
925
  safe_refresh_worklog(feature_dir)
886
- return AutomaticVerificationOutcome(True, "passed", nextActivity=next_activity)
926
+ detail = (
927
+ "lot documentaire, aucun controle executable : verification sur "
928
+ "inspection du diff (" + ", ".join(document_batch) + ")"
929
+ if document_batch is not None
930
+ else ""
931
+ )
932
+ return AutomaticVerificationOutcome(
933
+ True, "passed", detail, nextActivity=next_activity
934
+ )
887
935
 
888
936
 
889
937
  def _failures(results: list[VerificationResult]) -> tuple[VerificationFailure, ...]:
@@ -203,10 +203,51 @@ def _find_failed_command(
203
203
  if not matches:
204
204
  raise ValueError(f"aucun echec execute trouve pour {command_id}")
205
205
  if len(matches) > 1:
206
- raise ValueError(f"validation ambigue dans les preuves : {command_id}")
206
+ return _select_duplicated_failure(command_id, matches)
207
207
  return matches[0]
208
208
 
209
209
 
210
+ _TIE_BREAK_ORDER = {"targeted-tests": 0, "regression-check": 1}
211
+
212
+
213
+ def _select_duplicated_failure(
214
+ command_id: str, matches: list[tuple[dict[str, Any], dict[str, Any]]]
215
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
216
+ """Retient la preuve la plus récente si toutes les occurrences sont cohérentes."""
217
+ names = sorted(str(payload.get("id")) for payload, _ in matches)
218
+ protected = [name for name in names if name in PROTECTED_EVIDENCE]
219
+ if protected:
220
+ raise ValueError(
221
+ f"validation {command_id} presente dans une preuve protegee "
222
+ f"non derogable : {', '.join(protected)}"
223
+ )
224
+ reference = matches[0][1]
225
+ return_code = reference.get("returncode")
226
+ consistent = isinstance(return_code, int) and return_code != 0
227
+ for _, command in matches:
228
+ consistent = consistent and (
229
+ command.get("command") == reference.get("command")
230
+ and command.get("workingDirectory", ".") == reference.get("workingDirectory", ".")
231
+ and command.get("blocking") is True
232
+ and command.get("succeeded") is False
233
+ and command.get("timedOut") is not True
234
+ and command.get("returncode") == return_code
235
+ )
236
+ if not consistent:
237
+ raise ValueError(
238
+ f"validation {command_id} presente dans plusieurs preuves "
239
+ f"({', '.join(names)}) avec des commandes, dossiers ou resultats differents"
240
+ )
241
+ ordered = sorted(
242
+ matches,
243
+ key=lambda match: (
244
+ _TIE_BREAK_ORDER.get(str(match[0].get("id")), len(_TIE_BREAK_ORDER)),
245
+ str(match[0].get("id")),
246
+ ),
247
+ )
248
+ return max(ordered, key=lambda match: str(match[0].get("completedAt") or ""))
249
+
250
+
210
251
  def _read_registry(path: Path, issue_id: str) -> dict[str, Any]:
211
252
  if not path.is_file():
212
253
  return {
@@ -288,6 +288,16 @@ def _verification_lines(
288
288
  f"`{waiver.get('id', 'inconnue')}` sur `{waiver.get('commandId', 'inconnu')}` "
289
289
  f"par {waiver.get('author', 'inconnu')} : {waiver.get('reason', 'sans justification')}"
290
290
  )
291
+ if (
292
+ targeted
293
+ and targeted.get("id") == "targeted-check"
294
+ and targeted.get("outcome") == "passed"
295
+ and commands == []
296
+ ):
297
+ lines.append(
298
+ " - Lot documentaire, aucun contrôle exécutable : "
299
+ "vérification sur inspection du diff."
300
+ )
291
301
  return lines or ["- Vérification non exécutée."]
292
302
 
293
303
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: devflow-cli
3
- Version: 8.2.0
3
+ Version: 8.4.1
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
@@ -45,7 +45,8 @@ Le profil fixe la route documentaire :
45
45
 
46
46
  Un bloc de garde-fous s'y ajoute quand `--data-security` est déclaré ou que
47
47
  `--risk high` : `risk-assessment` après l'évaluation, validation bloquante de
48
- sécurité ou de données dans le plan de validation, preuve
48
+ sécurité ou de données dans le plan de validation (une validation protégée non
49
+ bloquante est refusée dès `prepare` ou `plan`), preuve
49
50
  `security-or-data-check` produite par `verify`, revue `independent-review`,
50
51
  puis `rollback-check` avec `rollback-plan`. Un bloc actif impose au minimum le
51
52
  profil `standard`, ne se désactive par aucune option et interdit les
@@ -90,8 +91,11 @@ devflow adaptive status KS-123
90
91
  distinguent explicitement blocage métier, contrat invalide, panne agent,
91
92
  validation échouée et conflit concurrent ; chaque refus affiche la commande de
92
93
  reprise recommandée. Après un refus de `verify`, cette reprise dépend du motif :
93
- approbations à donner, environnement à corriger, ou deux voies de réouverture
94
- (`implement` si le code est en cause, `prepare` ou `plan` si la validation l'est).
94
+ approbations à donner, environnement à corriger, ou trois voies après un
95
+ contrôle échoué (rouvrir `implement` si le code est en cause, rouvrir `prepare`
96
+ ou `plan` si la validation l'est, relancer `verify` après un échec passager).
97
+ En `quick`, un lot purement documentaire (`README.md`, Markdown sous `docs/`)
98
+ sans contrôle détecté franchit `verify` sur l'inspection du diff.
95
99
 
96
100
  Un plan de `prepare` ou de `plan` refusé par les validateurs se corrige sans
97
101
  relancer l'agent : `devflow adaptive reconcile KS-123 --result FICHIER.json
@@ -112,6 +116,22 @@ nomme la cause (`structured-output-missing`) et propose
112
116
  `devflow adaptive reconcile KS-123 --result MODELE --reason MOTIF` avec un
113
117
  modèle de résultat prérempli, écrit dans `~/.devflow/reconcile/` et à compléter.
114
118
 
119
+ `need.md` peut être amendé en cours de parcours. Avant une activité
120
+ documentaire, l'amendement est accepté avec un avertissement qui nomme les
121
+ activités déjà closes, à rouvrir si leur résultat en dépend ; avant `prepare`,
122
+ il est accepté sans cet avertissement ; pendant l'exécution d'un agent, il est refusé ;
123
+ avant `implement`, il périme la preuve de pré-vol ; après `implement`, il
124
+ périme une revue déjà faite. Pour arbitrer après un blocage d'`implement` :
125
+ amender `need.md`, puis `devflow adaptive reopen KS-123 preflight-evidence
126
+ --reason MOTIF`, refaire le pré-vol et relancer `implement`. Un refus
127
+ d'artefact (`artifact-files-mismatch`) nomme chaque fichier en trop et sa
128
+ cause : retirer ou remettre en état le fichier, puis
129
+ `devflow adaptive reconcile KS-123` ; la relance à l'identique n'est plus
130
+ proposée. Pour une entrée du besoin modifiée par un agent, la relance est
131
+ refusée avant le lancement de l'agent : remettre l'entrée en état, ou
132
+ `devflow adaptive discard-attempt KS-123 --reason MOTIF`, puis
133
+ `devflow adaptive run KS-123`.
134
+
115
135
  Chaque invocation de `adaptive run` traite une activité. La politique est dans
116
136
  `assessment.json`; la progression et les preuves sont dans
117
137
  `workflow-state.json`. Les résultats agent sont validés par schéma et les
@@ -1,11 +1,11 @@
1
- devflow_cli/__init__.py,sha256=Y5UdblxgAc6mD_GvD_R7nfzo_uyAi09mJw3AJoB8Stk,925
1
+ devflow_cli/__init__.py,sha256=MQEclHX75ybzzUEyJ3iAVMLUfzOpBzaQ_E5gi0d-TUc,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=kYSpQWZ-xUTw1Kquv5fE2kpFjoVClqNl4Nd7pUj7FX8,62199
8
- devflow_cli/commands/assess.py,sha256=lyj6TI5hXUWX55dY589fG4bKJSMztvMPxZ0HZjo0O-w,5230
7
+ devflow_cli/commands/adaptive.py,sha256=5D8K4aevxMlehsE65wnEn6ny6g_4A1Bes_OBVIUrbSM,71520
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
11
11
  devflow_cli/commands/docs_sync.py,sha256=Xc0mulpNTjDyeUDumKL7VNG0eumAf3016wEM_dqXpSU,1021
@@ -23,14 +23,14 @@ devflow_cli/commands/upgrade_cmd.py,sha256=d5uBFUPzPsCJDLxhQBFaeHWJv3GWWLmPpkYHR
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=haEVVgZAi3-iX75oo2-fK9GQZeFgN_xRw5U0Z0bdV5w,16343
27
- devflow_cli/core/activity_contracts.py,sha256=2-pjjxMyS7iER2eim118U5QSgOqwrP47X_HcfZ7jZ58,38717
28
- devflow_cli/core/adaptive_runner.py,sha256=MLws62PW986dd00nA4m_3Y4BAR-ZmmYT3q8N6KiPU-Q,40375
26
+ devflow_cli/core/activity_base.py,sha256=YId_tUxiL6TYmnVpG2VYGkA31nPjIrskx6Dof27XR6E,16696
27
+ devflow_cli/core/activity_contracts.py,sha256=n9arBw-SWEFqTlCDNed6lV4WolkZKAvmQg1qcfk57ss,39036
28
+ devflow_cli/core/adaptive_runner.py,sha256=BU3LZzM2f7dNvgKm_ul0CZYIs8z7WN1hM--TrF9o07s,48852
29
29
  devflow_cli/core/adaptive_workflow.py,sha256=F4uV5okQpFidZmneVZwW52XAO7gAmtuBAqBO9K9q7Uk,12173
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
- devflow_cli/core/automatic_evidence.py,sha256=y1f6L3tsJpV57I54zcTySetho1ASHklu5mUQF1PaRDY,40600
33
+ devflow_cli/core/automatic_evidence.py,sha256=Ntsy2eQKLRl_hUD27V9NRBLavyhLxxkEcbPySKmd4W4,42337
34
34
  devflow_cli/core/catalog.py,sha256=nLPc78JkbISaSxJnHlWiMi_lvepaumbfwcjgLuVy7Zc,3102
35
35
  devflow_cli/core/contract_schemas.py,sha256=-7SlJCwQ31OjPfwA-tP4ctgbp9hxQAVwNvvOV09Hoho,6313
36
36
  devflow_cli/core/docgen.py,sha256=47JoVDWetkcctgHwJZC67AVebOKgl10VB9hZ3Xr63mo,3908
@@ -52,8 +52,8 @@ devflow_cli/core/traceability.py,sha256=jfNE9MZzGkmaPer9Wa6Y18yA9Y3sqdeR-nzH9h_T
52
52
  devflow_cli/core/validation_approvals.py,sha256=3AHOpnW3BtY3BQrRZr38K7VNYRvbxGNhmQRYAufyhSA,9279
53
53
  devflow_cli/core/validators.py,sha256=fREjEx2fBW36q3vB6WJNqoGUM9ejYlQUXie5A9lNuPs,7440
54
54
  devflow_cli/core/verification.py,sha256=rvbdAdLyrZYb20oauti7D8nV5TDFBIf_8-hqLahfRag,23576
55
- devflow_cli/core/waivers.py,sha256=xpfSiC4K51ZVlBOZTRNJ5ftS05-bsLyMW-W9CdQHqH4,11595
56
- devflow_cli/core/worklog.py,sha256=ECmGJL63eVwfKQl94-r-Zd56ubhApuufAqDq1h5xcAU,13854
55
+ devflow_cli/core/waivers.py,sha256=9XXzMmoWCn-2Dc3486WhZwtGJQLQPXjP0qAaLuL_3qU,13299
56
+ devflow_cli/core/worklog.py,sha256=GkaXCsx22jntcWuC75OystUoqb5AlzBbXXLYH581Mwg,14164
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
59
  devflow_cli/schemas/activity-result-v1.schema.json,sha256=xQuyD5fwHGIdeoJoDJxJn26qBGJX74lD6Dc5gi5CLQo,2772
@@ -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.2.0.dist-info/METADATA,sha256=9zPuMV6XtqesVYitXPdUdkg2KqmqP_zQ7LSRCa9jksQ,8470
94
- devflow_cli-8.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
95
- devflow_cli-8.2.0.dist-info/entry_points.txt,sha256=GVJ6bMvFpqyO5589iC4nFKeGTpGIW8aKn_QPPpSZ6kU,48
96
- devflow_cli-8.2.0.dist-info/licenses/LICENSE,sha256=v79PvnmqOdKhQiOa_QcWxng4q8GuGscLOwi7ygUpwpo,1070
97
- devflow_cli-8.2.0.dist-info/RECORD,,
93
+ devflow_cli-8.4.1.dist-info/METADATA,sha256=Z1ZlFp1ND90t29jlf-ofmvs_JOnZUOk7KMJB8M-HmEA,9846
94
+ devflow_cli-8.4.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
95
+ devflow_cli-8.4.1.dist-info/entry_points.txt,sha256=GVJ6bMvFpqyO5589iC4nFKeGTpGIW8aKn_QPPpSZ6kU,48
96
+ devflow_cli-8.4.1.dist-info/licenses/LICENSE,sha256=v79PvnmqOdKhQiOa_QcWxng4q8GuGscLOwi7ygUpwpo,1070
97
+ devflow_cli-8.4.1.dist-info/RECORD,,