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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
devflow_cli/__init__.py CHANGED
@@ -1,18 +1,18 @@
1
1
  """devflow CLI — workflow de développement adaptatif."""
2
2
 
3
- VERSION = "7.1.1"
3
+ VERSION = "8.0.0"
4
4
 
5
5
  SUPPORTED_AGENTS = ["claude-code", "codex", "cursor"]
6
6
 
7
7
  ACTIVITY_LINEAR_MAPPING = {
8
8
  "assess": "Backlog",
9
- "prepare": "Plan",
10
- "risk-assessment": "Research",
11
- "spec": "Spec",
12
- "contracts": "Plan",
13
- "plan": "Plan",
14
- "tasks": "Tasks",
15
- "preflight-evidence": "Plan",
9
+ "risk-assessment": "Design",
10
+ "prepare": "Design",
11
+ "spec": "Design",
12
+ "contracts": "Design",
13
+ "plan": "Design",
14
+ "tasks": "Design",
15
+ "preflight-evidence": "Design",
16
16
  "implement": "In Progress",
17
17
  "verify": "Review",
18
18
  "review": "Review",
@@ -22,10 +22,7 @@ ACTIVITY_LINEAR_MAPPING = {
22
22
 
23
23
  LINEAR_STATUS_ORDER = [
24
24
  "Backlog",
25
- "Spec",
26
- "Research",
27
- "Plan",
28
- "Tasks",
25
+ "Design",
29
26
  "In Progress",
30
27
  "Review",
31
28
  "Done",
@@ -16,6 +16,8 @@ from typing import Literal
16
16
  from jsonschema.exceptions import SchemaError, ValidationError
17
17
  from jsonschema.validators import validator_for
18
18
 
19
+ from devflow_cli.adapters.linear import environment_without_linear_key
20
+
19
21
 
20
22
  PermissionMode = Literal["auto", "acceptEdits", "manual"]
21
23
  SandboxMode = Literal["read-only", "workspace-write"]
@@ -123,6 +125,7 @@ class AgentAdapter(ABC):
123
125
  capture_output=capture_output,
124
126
  stdin=subprocess.DEVNULL,
125
127
  shell=False,
128
+ env=environment_without_linear_key(),
126
129
  )
127
130
  return AgentExecution(
128
131
  result.returncode,
@@ -162,6 +165,7 @@ class AgentAdapter(ABC):
162
165
  capture_output=True,
163
166
  stdin=subprocess.DEVNULL,
164
167
  shell=False,
168
+ env=environment_without_linear_key(),
165
169
  )
166
170
  execution = AgentExecution(
167
171
  result.returncode,
@@ -1,11 +1,32 @@
1
- """Politique de synchronisation Linear, indépendante du transport MCP/API."""
1
+ """Politique de synchronisation Linear et transport GraphQL."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
6
+ import os
7
+ import urllib.error
8
+ import urllib.request
5
9
  from dataclasses import dataclass
6
- from typing import Literal, Protocol
10
+ from typing import Literal, Protocol, TextIO
7
11
 
8
- from devflow_cli import ACTIVITY_LINEAR_MAPPING, LINEAR_STATUS_ORDER
12
+ from devflow_cli import ACTIVITY_LINEAR_MAPPING, LINEAR_STATUS_ORDER, VERSION
13
+ from devflow_cli.core.run_progress import emit_progress
14
+
15
+
16
+ LINEAR_API_KEY_ENV = "LINEAR_API_KEY"
17
+ LINEAR_API_URL = "https://api.linear.app/graphql"
18
+ LINEAR_TIMEOUT_SECONDS = 5
19
+
20
+ _ISSUE_QUERY = (
21
+ "query($id: String!) { issue(id: $id) { id state { name } "
22
+ "team { states { nodes { id name } } } } }"
23
+ )
24
+ _UPDATE_MUTATION = (
25
+ "mutation($id: String!, $stateId: String!) { "
26
+ "issueUpdate(id: $id, input: { stateId: $stateId }) { success } }"
27
+ )
28
+
29
+ _missing_key_warned = False
9
30
 
10
31
 
11
32
  LinearAction = Literal["update", "keep", "skip"]
@@ -81,3 +102,142 @@ def sync_linear_status(
81
102
  if decision.should_update and decision.target_status is not None:
82
103
  adapter.update_status(issue_id, decision.target_status)
83
104
  return decision
105
+
106
+
107
+ def environment_without_linear_key() -> dict[str, str]:
108
+ """Environnement transmis aux processus lancés par la CLI, sans la clé Linear."""
109
+ return {key: value for key, value in os.environ.items() if key != LINEAR_API_KEY_ENV}
110
+
111
+
112
+ class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
113
+ """Refuse de suivre une redirection : l'en-tête Authorization ne doit pas la suivre."""
114
+
115
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
116
+ return None
117
+
118
+
119
+ class LinearSyncError(Exception):
120
+ """Échec de synchronisation ; le libellé ne contient aucune donnée externe."""
121
+
122
+ def __init__(self, label: str) -> None:
123
+ super().__init__(label)
124
+ self.label = label
125
+
126
+
127
+ class LinearGraphQLAdapter:
128
+ """Transport par l'API GraphQL de Linear, en bibliothèque standard."""
129
+
130
+ def __init__(self, api_key: str, timeout: float = LINEAR_TIMEOUT_SECONDS) -> None:
131
+ self._api_key = api_key
132
+ self._timeout = timeout
133
+ self._issue_uuid: str | None = None
134
+ self._state_ids: dict[str, str] = {}
135
+
136
+ def get_status(self, issue_id: str) -> str | None:
137
+ data = self._request(_ISSUE_QUERY, {"id": issue_id})
138
+ issue = data.get("issue")
139
+ if issue is None:
140
+ raise LinearSyncError("issue inconnue de Linear")
141
+ try:
142
+ self._issue_uuid = str(issue["id"])
143
+ self._state_ids = {
144
+ str(node["name"]): str(node["id"])
145
+ for node in issue["team"]["states"]["nodes"]
146
+ }
147
+ return str(issue["state"]["name"])
148
+ except (KeyError, TypeError):
149
+ raise LinearSyncError("reponse Linear invalide") from None
150
+
151
+ def update_status(self, issue_id: str, status: str) -> None:
152
+ state_id = self._state_ids.get(status)
153
+ if self._issue_uuid is None or state_id is None:
154
+ raise LinearSyncError(f"statut '{status}' absent de l'equipe Linear")
155
+ data = self._request(
156
+ _UPDATE_MUTATION, {"id": self._issue_uuid, "stateId": state_id}
157
+ )
158
+ result = data.get("issueUpdate")
159
+ if not isinstance(result, dict) or result.get("success") is not True:
160
+ raise LinearSyncError("mise a jour refusee par Linear")
161
+
162
+ def _request(self, query: str, variables: dict[str, str]) -> dict:
163
+ request = urllib.request.Request(
164
+ LINEAR_API_URL,
165
+ data=json.dumps({"query": query, "variables": variables}).encode("utf-8"),
166
+ headers={
167
+ "Content-Type": "application/json",
168
+ "Authorization": self._api_key,
169
+ "User-Agent": f"devflow-cli/{VERSION}",
170
+ },
171
+ method="POST",
172
+ )
173
+ opener = urllib.request.build_opener(_NoRedirectHandler())
174
+ try:
175
+ with opener.open(request, timeout=self._timeout) as response:
176
+ payload = json.loads(response.read().decode("utf-8"))
177
+ except urllib.error.HTTPError as exc:
178
+ raise LinearSyncError(f"erreur HTTP {exc.code}") from None
179
+ except TimeoutError:
180
+ raise LinearSyncError("delai depasse") from None
181
+ except urllib.error.URLError as exc:
182
+ if isinstance(exc.reason, TimeoutError):
183
+ raise LinearSyncError("delai depasse") from None
184
+ raise LinearSyncError("erreur reseau") from None
185
+ except OSError:
186
+ raise LinearSyncError("erreur reseau") from None
187
+ except ValueError:
188
+ raise LinearSyncError("reponse Linear invalide") from None
189
+ if not isinstance(payload, dict):
190
+ raise LinearSyncError("reponse Linear invalide")
191
+ if payload.get("errors"):
192
+ raise LinearSyncError("erreur GraphQL")
193
+ data = payload.get("data")
194
+ if not isinstance(data, dict):
195
+ raise LinearSyncError("reponse Linear invalide")
196
+ return data
197
+
198
+
199
+ def sync_activity_status(
200
+ issue_id: str, activity: str, stream: TextIO
201
+ ) -> LinearDecision | None:
202
+ """Synchronise le statut au démarrage d'une activité, sans jamais bloquer."""
203
+ global _missing_key_warned
204
+ api_key = os.environ.get(LINEAR_API_KEY_ENV, "").strip()
205
+ if not api_key:
206
+ if not _missing_key_warned:
207
+ _missing_key_warned = True
208
+ emit_progress(
209
+ f"Linear : avertissement, {LINEAR_API_KEY_ENV} absente ; "
210
+ "statuts non synchronises",
211
+ stream,
212
+ )
213
+ return None
214
+ try:
215
+ decision = sync_linear_status(LinearGraphQLAdapter(api_key), issue_id, activity)
216
+ except LinearSyncError as exc:
217
+ label = exc.label
218
+ except Exception:
219
+ label = "erreur inattendue"
220
+ else:
221
+ emit_progress(_describe_decision(issue_id, decision), stream)
222
+ return decision
223
+ emit_progress(
224
+ f"Linear : avertissement, {issue_id} non synchronisee ({label})", stream
225
+ )
226
+ return None
227
+
228
+
229
+ def _describe_decision(issue_id: str, decision: LinearDecision) -> str:
230
+ if decision.action == "update":
231
+ return (
232
+ f"Linear : {issue_id} {decision.current_status} -> "
233
+ f"{decision.target_status} (activite {decision.activity})"
234
+ )
235
+ if decision.action == "keep":
236
+ return (
237
+ f"Linear : {issue_id} reste en {decision.current_status} "
238
+ f"(activite {decision.activity}, {decision.reason})"
239
+ )
240
+ return (
241
+ f"Linear : {issue_id} ignoree, statut {decision.current_status or 'inconnu'} "
242
+ f"({decision.reason})"
243
+ )
@@ -14,6 +14,7 @@ from typing import Literal, Optional
14
14
  import typer
15
15
 
16
16
  from devflow_cli import VERSION
17
+ from devflow_cli.adapters.linear import sync_activity_status
17
18
  from devflow_cli.core.adaptive_workflow import (
18
19
  AdaptiveWorkflowState,
19
20
  complete_activity,
@@ -24,6 +25,10 @@ from devflow_cli.core.adaptive_workflow import (
24
25
  write_adaptive_state,
25
26
  )
26
27
  from devflow_cli.core.assessment import read_assessment
28
+ from devflow_cli.core.activity_base import (
29
+ enter_current_activity,
30
+ files_since_activity_base,
31
+ )
27
32
  from devflow_cli.core.activity_contracts import (
28
33
  build_activity_contract,
29
34
  NEED_STATEMENT,
@@ -50,7 +55,6 @@ from devflow_cli.core.worklog import refresh_worklog, safe_refresh_worklog
50
55
  from devflow_cli.core.telemetry import append_record, telemetry_enabled, usage_payload
51
56
  from devflow_cli.core.waivers import create_validation_waiver
52
57
  from devflow_cli.core.remediation import (
53
- carried_changed_files,
54
58
  discard_attempt,
55
59
  is_reopened_activity,
56
60
  reopen_activity,
@@ -76,6 +80,9 @@ app = typer.Typer(help="Machine d'etat des parcours adaptatifs")
76
80
 
77
81
  LEGACY_SCHEMA_EXIT_CODE = 1
78
82
 
83
+ # Refus avant tout agent : base inutilisable (branche changee, historique reecrit).
84
+ BASE_REFUSALS = frozenset({"base-history-rewritten", "base-branch-changed"})
85
+
79
86
  RESULT_VALIDATED_ACTIVITIES = frozenset({
80
87
  "prepare",
81
88
  "risk-assessment",
@@ -166,7 +173,10 @@ def start(feature: str = typer.Argument(..., help="ID ou dossier de la feature")
166
173
  assessment = _assessment(feature_dir)
167
174
  validate_need_statement(feature_dir / NEED_STATEMENT)
168
175
  state = create_adaptive_workflow(assessment.to_dict())
169
- except ValueError as exc:
176
+ enter_current_activity(
177
+ state, project_root_for_feature(feature_dir), feature_dir
178
+ )
179
+ except (OSError, ValueError) as exc:
170
180
  fail(f"Impossible de demarrer le workflow : {exc}")
171
181
  raise typer.Exit(int(ExitCode.INVALID_CONTRACT)) from exc
172
182
  write_adaptive_state(state_file, state)
@@ -220,12 +230,17 @@ def complete(
220
230
  validate_preflight_evidence(feature_dir / "preflight-evidence.md")
221
231
  ensure_fresh_devflow_evidence(feature_dir, state, activity)
222
232
  complete_activity(state, activity)
223
- except ValueError as exc:
233
+ enter_current_activity(
234
+ state, project_root_for_feature(feature_dir), feature_dir
235
+ )
236
+ except (OSError, ValueError) as exc:
224
237
  fail(str(exc))
225
238
  console.print(f" Suite : devflow adaptive status {state.issueId}")
226
239
  raise typer.Exit(int(ExitCode.BLOCKED)) from exc
227
240
  write_adaptive_state(feature_dir / "workflow-state.json", state)
228
241
  safe_refresh_worklog(feature_dir)
242
+ if activity == "done":
243
+ sync_activity_status(state.issueId, "done", run_progress.progress_stream(False))
229
244
  if state.status == "completed":
230
245
  ok(f"Workflow {state.profile} termine.")
231
246
  _print_completion_summary(feature_dir, state)
@@ -281,11 +296,15 @@ def contract(
281
296
  state,
282
297
  assessment.to_dict(),
283
298
  feature_path,
284
- carried_changed_files(feature_dir, state.currentActivity),
285
299
  reopened=is_reopened_activity(feature_dir, state.currentActivity),
286
300
  preflight_evidence=(feature_dir / "preflight-evidence.md").is_file(),
301
+ files_since_base=(
302
+ files_since_activity_base(feature_dir, state)
303
+ if state.activityBase is not None
304
+ else ()
305
+ ),
287
306
  )
288
- except ValueError as exc:
307
+ except (OSError, ValueError) as exc:
289
308
  fail(f"Impossible de generer le contrat : {exc}")
290
309
  raise typer.Exit(int(ExitCode.INVALID_CONTRACT)) from exc
291
310
  output_file = feature_dir / "activity-contract.json"
@@ -388,6 +407,9 @@ def run_adaptive(
388
407
  f"agent {agent}, delai {timeout} s",
389
408
  stream,
390
409
  )
410
+ sync_activity_status(
411
+ current_state.issueId, activity_contract.activity, stream
412
+ )
391
413
  with run_progress.heartbeat(
392
414
  activity_contract.activity,
393
415
  agent,
@@ -451,12 +473,14 @@ def run_adaptive(
451
473
  _print_execution_json(
452
474
  current_state.issueId, outcome.activity, True, outcome.reason,
453
475
  outcome.detail, outcome.nextActivity, ExitCode.OK,
476
+ warnings=outcome.warnings,
454
477
  )
455
478
  else:
456
479
  ok(
457
480
  f"Activite '{outcome.activity}' validee. "
458
481
  f"Prochaine : {outcome.nextActivity}"
459
482
  )
483
+ _print_warnings(outcome.warnings)
460
484
  return
461
485
  safe_refresh_worklog(feature_dir)
462
486
  exit_code = exit_code_for_run_reason(outcome.reason)
@@ -464,9 +488,14 @@ def run_adaptive(
464
488
  _print_execution_json(
465
489
  current_state.issueId, outcome.activity, False, outcome.reason,
466
490
  outcome.detail, outcome.nextActivity, exit_code,
491
+ warnings=outcome.warnings,
467
492
  )
493
+ elif outcome.reason in BASE_REFUSALS:
494
+ fail(outcome.detail)
495
+ console.print(f" Suite : devflow adaptive status {current_state.issueId}")
468
496
  else:
469
497
  fail(outcome.detail or outcome.reason)
498
+ _print_warnings(outcome.warnings)
470
499
  console.print(
471
500
  f" Activite '{outcome.activity}' non validee : {outcome.reason}"
472
501
  )
@@ -585,12 +614,17 @@ def reconcile_adaptive(
585
614
  f"Activite '{outcome.activity}' reconciliee. "
586
615
  f"Prochaine : {outcome.nextActivity}"
587
616
  )
617
+ _print_warnings(outcome.warnings)
588
618
  return
589
619
  safe_refresh_worklog(feature_dir)
590
- fail(
591
- f"Reconciliation de '{outcome.activity}' refusee : {outcome.reason}"
592
- + (f" ({outcome.detail})" if outcome.detail else "")
593
- )
620
+ if outcome.reason in BASE_REFUSALS:
621
+ fail(outcome.detail)
622
+ else:
623
+ fail(
624
+ f"Reconciliation de '{outcome.activity}' refusee : {outcome.reason}"
625
+ + (f" ({outcome.detail})" if outcome.detail else "")
626
+ )
627
+ _print_warnings(outcome.warnings)
594
628
  console.print(f" Suite : devflow adaptive status {state.issueId}")
595
629
  if outcome.reason == "reconcile-refused":
596
630
  console.print(
@@ -765,6 +799,9 @@ def reopen(
765
799
  entry = reopen_activity(
766
800
  feature_dir, state, activity, reason, author=author
767
801
  )
802
+ enter_current_activity(
803
+ state, project_root_for_feature(feature_dir), feature_dir
804
+ )
768
805
  except (OSError, ValueError) as exc:
769
806
  fail(str(exc))
770
807
  console.print(f" Suite : devflow adaptive status {state.issueId}")
@@ -800,19 +837,24 @@ def discard_attempt_command(
800
837
  )
801
838
  if entry["discardedFiles"]:
802
839
  warn(
803
- "Changements laisses dans le working tree, non reportes : "
804
- + ", ".join(entry["discardedFiles"])
840
+ "Fichiers modifies depuis le debut de l'activite "
841
+ f"'{entry['activity']}', comptes dans le lot a la relance ; remise en "
842
+ "etat a la charge du superviseur : " + ", ".join(entry["discardedFiles"])
805
843
  )
844
+ if entry.get("observationError"):
845
+ warn(f"Fichiers depuis la base non calcules : {entry['observationError']}")
806
846
 
807
847
 
808
848
  def _execute_local_verification(
809
849
  feature_dir: Path, project_root: Path, issue_id: str, *,
810
850
  refresh: bool = False, json_output: bool = False,
811
851
  ) -> None:
852
+ stream = run_progress.progress_stream(json_output)
812
853
  run_progress.emit_progress(
813
- "Activite 'verify' demarree : verification locale",
814
- run_progress.progress_stream(json_output),
854
+ "Activite 'verify' demarree : verification locale", stream
815
855
  )
856
+ if not refresh:
857
+ sync_activity_status(issue_id, "verify", stream)
816
858
  outcome = run_automatic_verification(feature_dir, project_root, refresh=refresh)
817
859
  if json_output:
818
860
  _print_execution_json(
@@ -843,6 +885,7 @@ def _execute_local_verification(
843
885
  def _print_execution_json(
844
886
  issue_id: str, activity: str | None, passed: bool, reason: str,
845
887
  detail: str, next_activity: str | None, exit_code: ExitCode,
888
+ *, warnings: tuple[str, ...] = (),
846
889
  ) -> None:
847
890
  typer.echo(json.dumps({
848
891
  "schemaVersion": 1,
@@ -853,9 +896,16 @@ def _print_execution_json(
853
896
  "detail": detail,
854
897
  "nextActivity": next_activity,
855
898
  "exitCode": int(exit_code),
899
+ "warnings": list(warnings),
856
900
  }, ensure_ascii=False))
857
901
 
858
902
 
903
+ def _print_warnings(warnings: tuple[str, ...]) -> None:
904
+ """Ecart de declaration : avertissement seul, sans effet sur le code de sortie."""
905
+ for message in warnings:
906
+ warn(message)
907
+
908
+
859
909
  def _utc_now() -> str:
860
910
  return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
861
911
 
@@ -1147,13 +1197,6 @@ def _print_run_recovery(
1147
1197
  console.print(" Ne relancez pas automatiquement l'agent : reconciliez d'abord les changements partiels.")
1148
1198
  console.print(f" devflow adaptive reconcile {issue_id}")
1149
1199
  return
1150
- if reason == "stale-attempt":
1151
- console.print(" git status --short # inspecter les changements de la tentative")
1152
- console.print(
1153
- " Restaurez les fichiers de la tentative, ou abandonnez-la explicitement :"
1154
- )
1155
- console.print(f" devflow adaptive discard-attempt {issue_id} --reason MOTIF")
1156
- return
1157
1200
  if reason == "inputs-changed-during-activity":
1158
1201
  console.print(
1159
1202
  " Les entrees du besoin ont change pendant l'execution. "
@@ -8,6 +8,7 @@ from pathlib import Path
8
8
 
9
9
  import typer
10
10
  from devflow_cli.adapters.agents import get_agent_adapter
11
+ from devflow_cli.adapters.linear import environment_without_linear_key
11
12
  from devflow_cli.core.agent_failure import diagnose_agent_failure
12
13
  from devflow_cli.utils.console import console, ok, warn, fail
13
14
  from devflow_cli.utils.logging import get_logger
@@ -78,7 +79,12 @@ def check(
78
79
  # git
79
80
  git_path = _cmd_exists("git")
80
81
  if git_path:
81
- result = subprocess.run(["git", "--version"], capture_output=True, text=True)
82
+ result = subprocess.run(
83
+ ["git", "--version"],
84
+ capture_output=True,
85
+ text=True,
86
+ env=environment_without_linear_key(),
87
+ )
82
88
  version = result.stdout.strip().replace("git version ", "")
83
89
  ok(f"git -- {git_path} (version {version})")
84
90
  ok_count += 1
@@ -90,7 +96,10 @@ def check(
90
96
  codex_path = _cmd_exists("codex")
91
97
  if codex_path:
92
98
  result = subprocess.run(
93
- ["codex", "--version"], capture_output=True, text=True
99
+ ["codex", "--version"],
100
+ capture_output=True,
101
+ text=True,
102
+ env=environment_without_linear_key(),
94
103
  )
95
104
  version = (result.stdout or result.stderr).strip()
96
105
  if result.returncode == 0:
@@ -8,6 +8,7 @@ from pathlib import Path
8
8
 
9
9
  import typer
10
10
 
11
+ from devflow_cli.adapters.linear import environment_without_linear_key
11
12
  from devflow_cli.core.git import is_git_repo
12
13
  from devflow_cli.core.hooks import (
13
14
  DEFAULT_HOOK_TIMEOUT,
@@ -28,6 +29,7 @@ def _configure_hooks_path(repo: Path) -> None:
28
29
  ["git", "-C", str(repo), "config", "core.hooksPath", HOOKS_DIR_NAME],
29
30
  check=True,
30
31
  capture_output=True,
32
+ env=environment_without_linear_key(),
31
33
  )
32
34
 
33
35
 
@@ -36,6 +38,7 @@ def _current_hooks_path(repo: Path) -> str | None:
36
38
  ["git", "-C", str(repo), "config", "--get", "core.hooksPath"],
37
39
  capture_output=True,
38
40
  text=True,
41
+ env=environment_without_linear_key(),
39
42
  )
40
43
  if result.returncode != 0:
41
44
  return None
@@ -46,6 +49,7 @@ def _unset_hooks_path(repo: Path) -> None:
46
49
  subprocess.run(
47
50
  ["git", "-C", str(repo), "config", "--unset", "core.hooksPath"],
48
51
  capture_output=True,
52
+ env=environment_without_linear_key(),
49
53
  )
50
54
 
51
55