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