devflow-cli 8.5.1__py3-none-any.whl → 9.0.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.5.1"
3
+ VERSION = "9.0.1"
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()
@@ -1336,7 +1351,7 @@ def _operational_status(
1336
1351
  ) -> dict:
1337
1352
  result_file, result = _latest_activity_result(feature_dir, state.currentActivity)
1338
1353
  blockers = result.get("blockers", []) if isinstance(result, dict) else []
1339
- risks = latest_remaining_risks(feature_dir, state.activities)
1354
+ risks = latest_remaining_risks(feature_dir, state)
1340
1355
  if not isinstance(blockers, list):
1341
1356
  blockers = []
1342
1357
  stale: set[str] = set()
@@ -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.")
@@ -58,6 +58,27 @@ def _ignore_agent_traces(feature_dir) -> None:
58
58
  )
59
59
 
60
60
 
61
+ LOCK_IGNORE_RULES = (".workflow-state.lock", ".agent-run.lock", ".feature-lock")
62
+
63
+
64
+ def _ignore_cli_locks(specs_root) -> None:
65
+ """Exclut du depot les verrous de la CLI, a toute profondeur sous les specs."""
66
+ gitignore = specs_root / ".gitignore"
67
+ content = gitignore.read_text(encoding="utf-8") if gitignore.is_file() else ""
68
+ present = content.splitlines()
69
+ missing = [rule for rule in LOCK_IGNORE_RULES if rule not in present]
70
+ if not missing:
71
+ return
72
+ prefix = "" if not content or content.endswith("\n") else "\n"
73
+ gitignore.write_text(
74
+ content
75
+ + prefix
76
+ + "# Verrous de la CLI devflow : fichiers d'execution, jamais suivis.\n"
77
+ + "".join(f"{rule}\n" for rule in missing),
78
+ encoding="utf-8",
79
+ )
80
+
81
+
61
82
  def feature(
62
83
  issue_id: str = typer.Argument(..., help="Identifiant de l'issue (ex: KS-123)"),
63
84
  short_name: Optional[str] = typer.Option(None, "--short-name", help="Nom court pour le repertoire (ex: user-auth)"),
@@ -107,6 +128,7 @@ def feature(
107
128
  feature_dir.mkdir(parents=True)
108
129
  ok(f"Dossier {feature_dir} cree")
109
130
  _ignore_agent_traces(feature_dir)
131
+ _ignore_cli_locks(specs_root)
110
132
 
111
133
  console.print()
112
134
  console.print(f"Feature [bold]{issue_id}[/bold] initialisee :")
@@ -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(),