devflow-cli 2.8.0__py3-none-any.whl → 2.9.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 +2 -2
- devflow_cli/adapters/agents.py +73 -1
- devflow_cli/cli.py +4 -0
- devflow_cli/commands/adaptive.py +357 -0
- devflow_cli/commands/assess.py +115 -0
- devflow_cli/commands/check.py +44 -1
- devflow_cli/commands/init_cmd.py +16 -10
- devflow_cli/core/activity_contracts.py +735 -0
- devflow_cli/core/adaptive_runner.py +332 -0
- devflow_cli/core/adaptive_workflow.py +303 -0
- devflow_cli/core/assessment.py +161 -0
- devflow_cli/core/automatic_evidence.py +717 -0
- devflow_cli/core/remediation.py +155 -0
- devflow_cli/core/validators.py +24 -0
- devflow_cli/core/verification.py +47 -4
- devflow_cli/core/waivers.py +285 -0
- devflow_cli/core/worklog.py +304 -0
- devflow_cli/core/worktree_snapshot.py +91 -0
- devflow_cli-2.9.0.dist-info/METADATA +223 -0
- {devflow_cli-2.8.0.dist-info → devflow_cli-2.9.0.dist-info}/RECORD +23 -12
- devflow_cli-2.8.0.dist-info/METADATA +0 -149
- {devflow_cli-2.8.0.dist-info → devflow_cli-2.9.0.dist-info}/WHEEL +0 -0
- {devflow_cli-2.8.0.dist-info → devflow_cli-2.9.0.dist-info}/entry_points.txt +0 -0
- {devflow_cli-2.8.0.dist-info → devflow_cli-2.9.0.dist-info}/licenses/LICENSE +0 -0
devflow_cli/__init__.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""devflow CLI — Spec-Driven Development workflow."""
|
|
2
2
|
|
|
3
|
-
VERSION = "2.
|
|
3
|
+
VERSION = "2.9.0"
|
|
4
4
|
STATE_SCHEMA_VERSION = 3
|
|
5
5
|
|
|
6
6
|
STEPS = [
|
|
@@ -72,7 +72,7 @@ STEP_LINEAR_ORDER = [
|
|
|
72
72
|
"Done",
|
|
73
73
|
]
|
|
74
74
|
|
|
75
|
-
SUPPORTED_AGENTS = ["claude-code", "cursor"]
|
|
75
|
+
SUPPORTED_AGENTS = ["claude-code", "codex", "cursor"]
|
|
76
76
|
|
|
77
77
|
# Mapping speckit → devflow pour la migration
|
|
78
78
|
SPECKIT_STEP_MAP = {
|
devflow_cli/adapters/agents.py
CHANGED
|
@@ -11,6 +11,7 @@ from typing import Literal
|
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
PermissionMode = Literal["auto", "acceptEdits", "manual"]
|
|
14
|
+
SandboxMode = Literal["read-only", "workspace-write"]
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
@dataclass(frozen=True)
|
|
@@ -34,6 +35,16 @@ class AgentAdapter(ABC):
|
|
|
34
35
|
def build_named_agent_command(self, agent_name: str, prompt: str) -> list[str]:
|
|
35
36
|
raise ValueError(f"L'adaptateur {self.name} ne supporte pas les agents nommés")
|
|
36
37
|
|
|
38
|
+
def build_structured_command(
|
|
39
|
+
self,
|
|
40
|
+
prompt: str,
|
|
41
|
+
permission_mode: PermissionMode,
|
|
42
|
+
schema_file: Path,
|
|
43
|
+
output_file: Path,
|
|
44
|
+
sandbox_mode: SandboxMode = "workspace-write",
|
|
45
|
+
) -> list[str]:
|
|
46
|
+
raise ValueError(f"L'adaptateur {self.name} ne supporte pas les sorties structurees")
|
|
47
|
+
|
|
37
48
|
def execute(
|
|
38
49
|
self,
|
|
39
50
|
prompt: str,
|
|
@@ -68,6 +79,43 @@ class AgentAdapter(ABC):
|
|
|
68
79
|
except FileNotFoundError:
|
|
69
80
|
return AgentExecution(127, stderr=f"Executable '{self.executable}' introuvable")
|
|
70
81
|
|
|
82
|
+
def execute_structured(
|
|
83
|
+
self,
|
|
84
|
+
prompt: str,
|
|
85
|
+
cwd: Path,
|
|
86
|
+
timeout: int,
|
|
87
|
+
schema_file: Path,
|
|
88
|
+
output_file: Path,
|
|
89
|
+
permission_mode: PermissionMode = "auto",
|
|
90
|
+
sandbox_mode: SandboxMode = "workspace-write",
|
|
91
|
+
) -> AgentExecution:
|
|
92
|
+
command = self.build_structured_command(
|
|
93
|
+
prompt, permission_mode, schema_file, output_file, sandbox_mode
|
|
94
|
+
)
|
|
95
|
+
try:
|
|
96
|
+
result = subprocess.run(
|
|
97
|
+
command,
|
|
98
|
+
cwd=cwd,
|
|
99
|
+
timeout=timeout,
|
|
100
|
+
text=True,
|
|
101
|
+
capture_output=True,
|
|
102
|
+
shell=False,
|
|
103
|
+
)
|
|
104
|
+
return AgentExecution(
|
|
105
|
+
result.returncode,
|
|
106
|
+
result.stdout or "",
|
|
107
|
+
result.stderr or "",
|
|
108
|
+
)
|
|
109
|
+
except subprocess.TimeoutExpired as exc:
|
|
110
|
+
return AgentExecution(
|
|
111
|
+
124,
|
|
112
|
+
_text(exc.stdout),
|
|
113
|
+
_text(exc.stderr),
|
|
114
|
+
timed_out=True,
|
|
115
|
+
)
|
|
116
|
+
except FileNotFoundError:
|
|
117
|
+
return AgentExecution(127, stderr=f"Executable '{self.executable}' introuvable")
|
|
118
|
+
|
|
71
119
|
|
|
72
120
|
class ClaudeCodeAdapter(AgentAdapter):
|
|
73
121
|
name = "claude-code"
|
|
@@ -91,12 +139,36 @@ class CodexAdapter(AgentAdapter):
|
|
|
91
139
|
executable = "codex"
|
|
92
140
|
|
|
93
141
|
def build_command(self, prompt: str, permission_mode: PermissionMode) -> list[str]:
|
|
94
|
-
command = [self.executable, "exec"
|
|
142
|
+
command = [self.executable, "exec"]
|
|
95
143
|
if permission_mode in {"auto", "acceptEdits"}:
|
|
96
144
|
command.append("--approve-for-me")
|
|
145
|
+
else:
|
|
146
|
+
command.extend(["--sandbox", "workspace-write"])
|
|
97
147
|
command.append(_portable_prompt(prompt))
|
|
98
148
|
return command
|
|
99
149
|
|
|
150
|
+
def build_structured_command(
|
|
151
|
+
self,
|
|
152
|
+
prompt: str,
|
|
153
|
+
permission_mode: PermissionMode,
|
|
154
|
+
schema_file: Path,
|
|
155
|
+
output_file: Path,
|
|
156
|
+
sandbox_mode: SandboxMode = "workspace-write",
|
|
157
|
+
) -> list[str]:
|
|
158
|
+
command = [self.executable, "exec"]
|
|
159
|
+
if permission_mode in {"auto", "acceptEdits"} and sandbox_mode == "workspace-write":
|
|
160
|
+
command.append("--approve-for-me")
|
|
161
|
+
else:
|
|
162
|
+
command.extend(["--sandbox", sandbox_mode])
|
|
163
|
+
command.extend([
|
|
164
|
+
"--output-schema",
|
|
165
|
+
str(schema_file),
|
|
166
|
+
"--output-last-message",
|
|
167
|
+
str(output_file),
|
|
168
|
+
])
|
|
169
|
+
command.append(prompt)
|
|
170
|
+
return command
|
|
171
|
+
|
|
100
172
|
|
|
101
173
|
class CursorAdapter(AgentAdapter):
|
|
102
174
|
name = "cursor"
|
devflow_cli/cli.py
CHANGED
|
@@ -22,10 +22,12 @@ from devflow_cli.commands import hooks_cmd
|
|
|
22
22
|
from devflow_cli.commands import linear
|
|
23
23
|
from devflow_cli.commands import review
|
|
24
24
|
from devflow_cli.commands import step
|
|
25
|
+
from devflow_cli.commands import adaptive
|
|
25
26
|
from devflow_cli.commands.run import run
|
|
26
27
|
from devflow_cli.commands.docs_sync import docs_sync
|
|
27
28
|
from devflow_cli.commands.traceability import traceability
|
|
28
29
|
from devflow_cli.commands.verify import verify
|
|
30
|
+
from devflow_cli.commands.assess import assess
|
|
29
31
|
|
|
30
32
|
app = typer.Typer(
|
|
31
33
|
name="devflow",
|
|
@@ -65,10 +67,12 @@ app.add_typer(hooks_cmd.app, name="hooks")
|
|
|
65
67
|
app.add_typer(linear.app, name="linear")
|
|
66
68
|
app.add_typer(step.app, name="step")
|
|
67
69
|
app.add_typer(review.app, name="review")
|
|
70
|
+
app.add_typer(adaptive.app, name="adaptive")
|
|
68
71
|
app.command()(run)
|
|
69
72
|
app.command(name="docs-sync")(docs_sync)
|
|
70
73
|
app.command()(traceability)
|
|
71
74
|
app.command()(verify)
|
|
75
|
+
app.command()(assess)
|
|
72
76
|
|
|
73
77
|
|
|
74
78
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""Commandes de transition du workflow adaptatif."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Literal, Optional
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from devflow_cli.core.adaptive_workflow import (
|
|
13
|
+
complete_activity,
|
|
14
|
+
create_adaptive_workflow,
|
|
15
|
+
read_adaptive_state,
|
|
16
|
+
record_evidence,
|
|
17
|
+
workflow_lock,
|
|
18
|
+
write_adaptive_state,
|
|
19
|
+
)
|
|
20
|
+
from devflow_cli.core.state import read_state
|
|
21
|
+
from devflow_cli.core.activity_contracts import (
|
|
22
|
+
build_activity_contract,
|
|
23
|
+
render_contract_prompt,
|
|
24
|
+
write_activity_contract,
|
|
25
|
+
)
|
|
26
|
+
from devflow_cli.core.adaptive_runner import (
|
|
27
|
+
AgentActivityExecution,
|
|
28
|
+
run_current_activity,
|
|
29
|
+
)
|
|
30
|
+
from devflow_cli.core.worktree_snapshot import changed_paths, snapshot_worktree
|
|
31
|
+
from devflow_cli.core.automatic_evidence import run_automatic_verification
|
|
32
|
+
from devflow_cli.core.worklog import refresh_worklog, safe_refresh_worklog
|
|
33
|
+
from devflow_cli.core.waivers import create_validation_waiver
|
|
34
|
+
from devflow_cli.core.remediation import carried_changed_files, reopen_activity
|
|
35
|
+
from devflow_cli.adapters.agents import get_agent_adapter
|
|
36
|
+
from devflow_cli.utils.console import console, fail, ok
|
|
37
|
+
from devflow_cli.utils.paths import project_root_for_feature, resolve_feature_dir
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
app = typer.Typer(help="Machine d'etat des parcours adaptatifs")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _feature(identifier: str) -> Path:
|
|
44
|
+
feature_dir = resolve_feature_dir(identifier)
|
|
45
|
+
if feature_dir is None:
|
|
46
|
+
fail(f"Feature '{identifier}' introuvable.")
|
|
47
|
+
raise typer.Exit(1)
|
|
48
|
+
return feature_dir
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _state(feature_dir: Path):
|
|
52
|
+
state = read_adaptive_state(feature_dir / "workflow-state.json")
|
|
53
|
+
if state is None:
|
|
54
|
+
fail("workflow-state.json invalide ou absent. Lancez 'devflow adaptive start'.")
|
|
55
|
+
raise typer.Exit(1)
|
|
56
|
+
return state
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.command("start")
|
|
60
|
+
def start(feature: str = typer.Argument(..., help="ID ou dossier de la feature")) -> None:
|
|
61
|
+
"""Initialise le parcours selectionne par assessment.json."""
|
|
62
|
+
feature_dir = _feature(feature)
|
|
63
|
+
state_file = feature_dir / "workflow-state.json"
|
|
64
|
+
with workflow_lock(feature_dir):
|
|
65
|
+
if state_file.exists():
|
|
66
|
+
fail("workflow-state.json existe deja ; le workflow ne sera pas ecrase.")
|
|
67
|
+
raise typer.Exit(1)
|
|
68
|
+
try:
|
|
69
|
+
assessment = json.loads(
|
|
70
|
+
(feature_dir / "assessment.json").read_text(encoding="utf-8")
|
|
71
|
+
)
|
|
72
|
+
state = create_adaptive_workflow(assessment)
|
|
73
|
+
legacy_state = read_state(feature_dir / "state.json")
|
|
74
|
+
if legacy_state is None or legacy_state.issueId != state.issueId:
|
|
75
|
+
raise ValueError("issueId incoherent entre assessment.json et state.json")
|
|
76
|
+
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
77
|
+
fail(f"Impossible de demarrer le workflow : {exc}")
|
|
78
|
+
raise typer.Exit(1) from exc
|
|
79
|
+
write_adaptive_state(state_file, state)
|
|
80
|
+
safe_refresh_worklog(feature_dir)
|
|
81
|
+
ok(f"Workflow {state.profile} demarre. Activite courante : {state.currentActivity}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@app.command("status")
|
|
85
|
+
def status(feature: str = typer.Argument(..., help="ID ou dossier de la feature")) -> None:
|
|
86
|
+
"""Affiche la progression et les preuves restantes."""
|
|
87
|
+
feature_dir = _feature(feature)
|
|
88
|
+
with workflow_lock(feature_dir):
|
|
89
|
+
state = _state(feature_dir)
|
|
90
|
+
missing = [item for item in state.requiredEvidence if item not in state.evidence]
|
|
91
|
+
console.print(f"Profil : {state.profile}")
|
|
92
|
+
console.print(f"Statut : {state.status}")
|
|
93
|
+
console.print(f"Activite : {state.currentActivity}")
|
|
94
|
+
console.print(f"Completees: {', '.join(state.completedActivities)}")
|
|
95
|
+
console.print(f"Preuves manquantes: {', '.join(missing) if missing else 'aucune'}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@app.command("complete")
|
|
99
|
+
def complete(
|
|
100
|
+
feature: str = typer.Argument(..., help="ID ou dossier de la feature"),
|
|
101
|
+
activity: str = typer.Argument(..., help="Activite a terminer"),
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Complete l'activite courante si ses gates sont satisfaites."""
|
|
104
|
+
feature_dir = _feature(feature)
|
|
105
|
+
with workflow_lock(feature_dir):
|
|
106
|
+
state = _state(feature_dir)
|
|
107
|
+
try:
|
|
108
|
+
complete_activity(state, activity)
|
|
109
|
+
except ValueError as exc:
|
|
110
|
+
fail(str(exc))
|
|
111
|
+
raise typer.Exit(1) from exc
|
|
112
|
+
write_adaptive_state(feature_dir / "workflow-state.json", state)
|
|
113
|
+
safe_refresh_worklog(feature_dir)
|
|
114
|
+
if state.status == "completed":
|
|
115
|
+
ok(f"Workflow {state.profile} termine.")
|
|
116
|
+
else:
|
|
117
|
+
ok(f"Activite '{activity}' completee. Prochaine : {state.currentActivity}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@app.command("evidence")
|
|
121
|
+
def evidence(
|
|
122
|
+
feature: str = typer.Argument(..., help="ID ou dossier de la feature"),
|
|
123
|
+
evidence_id: str = typer.Argument(..., help="Identifiant de la preuve"),
|
|
124
|
+
report: Optional[str] = typer.Option(None, help="Chemin ou resume de la preuve"),
|
|
125
|
+
) -> None:
|
|
126
|
+
"""Enregistre une preuve attendue par le profil."""
|
|
127
|
+
feature_dir = _feature(feature)
|
|
128
|
+
with workflow_lock(feature_dir):
|
|
129
|
+
state = _state(feature_dir)
|
|
130
|
+
try:
|
|
131
|
+
record_evidence(state, evidence_id, report or "")
|
|
132
|
+
except ValueError as exc:
|
|
133
|
+
fail(str(exc))
|
|
134
|
+
raise typer.Exit(1) from exc
|
|
135
|
+
write_adaptive_state(feature_dir / "workflow-state.json", state)
|
|
136
|
+
safe_refresh_worklog(feature_dir)
|
|
137
|
+
ok(f"Preuve '{evidence_id}' enregistree.")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@app.command("contract")
|
|
141
|
+
def contract(
|
|
142
|
+
feature: str = typer.Argument(..., help="ID ou dossier de la feature"),
|
|
143
|
+
json_output: bool = typer.Option(False, "--json", help="Affiche le contrat JSON"),
|
|
144
|
+
prompt: bool = typer.Option(False, "--prompt", help="Affiche le prompt portable"),
|
|
145
|
+
) -> None:
|
|
146
|
+
"""Genere le contrat portable de l'activite courante."""
|
|
147
|
+
if json_output and prompt:
|
|
148
|
+
fail("Les options --json et --prompt sont mutuellement exclusives.")
|
|
149
|
+
raise typer.Exit(1)
|
|
150
|
+
feature_dir = _feature(feature)
|
|
151
|
+
project_root = project_root_for_feature(feature_dir)
|
|
152
|
+
try:
|
|
153
|
+
feature_path = feature_dir.relative_to(project_root).as_posix()
|
|
154
|
+
except ValueError as exc:
|
|
155
|
+
fail(f"Feature hors du projet courant : {exc}")
|
|
156
|
+
raise typer.Exit(1) from exc
|
|
157
|
+
|
|
158
|
+
with workflow_lock(feature_dir):
|
|
159
|
+
state = _state(feature_dir)
|
|
160
|
+
try:
|
|
161
|
+
assessment = json.loads(
|
|
162
|
+
(feature_dir / "assessment.json").read_text(encoding="utf-8")
|
|
163
|
+
)
|
|
164
|
+
activity_contract = build_activity_contract(
|
|
165
|
+
state,
|
|
166
|
+
assessment,
|
|
167
|
+
feature_path,
|
|
168
|
+
carried_changed_files(feature_dir, state.currentActivity),
|
|
169
|
+
)
|
|
170
|
+
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
171
|
+
fail(f"Impossible de generer le contrat : {exc}")
|
|
172
|
+
raise typer.Exit(1) from exc
|
|
173
|
+
output_file = feature_dir / "activity-contract.json"
|
|
174
|
+
write_activity_contract(output_file, activity_contract)
|
|
175
|
+
|
|
176
|
+
if json_output:
|
|
177
|
+
console.print_json(data=activity_contract.to_dict())
|
|
178
|
+
elif prompt:
|
|
179
|
+
console.print(render_contract_prompt(activity_contract), markup=False)
|
|
180
|
+
else:
|
|
181
|
+
ok(f"Contrat genere pour '{activity_contract.activity}'.")
|
|
182
|
+
console.print(f" Contrat : {output_file}")
|
|
183
|
+
console.print(f" ID : {activity_contract.contractId}")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@app.command("run")
|
|
187
|
+
def run_adaptive(
|
|
188
|
+
feature: str = typer.Argument(..., help="ID ou dossier de la feature"),
|
|
189
|
+
agent: str = typer.Option("codex", help="Backend agent structure"),
|
|
190
|
+
timeout: int = typer.Option(1800, min=30, help="Timeout de l'activite en secondes"),
|
|
191
|
+
permission_mode: Literal["auto", "acceptEdits", "manual"] = typer.Option(
|
|
192
|
+
"auto", help="Mode de permissions transmis a l'agent"
|
|
193
|
+
),
|
|
194
|
+
) -> None:
|
|
195
|
+
"""Execute une activite agentique ou la verification locale."""
|
|
196
|
+
feature_dir = _feature(feature)
|
|
197
|
+
project_root = project_root_for_feature(feature_dir)
|
|
198
|
+
feature_path = feature_dir.relative_to(project_root).as_posix()
|
|
199
|
+
with workflow_lock(feature_dir):
|
|
200
|
+
current_state = _state(feature_dir)
|
|
201
|
+
if current_state.currentActivity == "verify":
|
|
202
|
+
_execute_local_verification(feature_dir, project_root)
|
|
203
|
+
return
|
|
204
|
+
if current_state.currentActivity not in {
|
|
205
|
+
"prepare", "risk-assessment", "spec", "research", "contracts", "plan",
|
|
206
|
+
"tasks", "implement", "review", "rollback-check",
|
|
207
|
+
}:
|
|
208
|
+
fail(
|
|
209
|
+
f"L'activite '{current_state.currentActivity}' ne necessite pas d'agent."
|
|
210
|
+
)
|
|
211
|
+
raise typer.Exit(1)
|
|
212
|
+
try:
|
|
213
|
+
adapter = get_agent_adapter(agent)
|
|
214
|
+
except ValueError as exc:
|
|
215
|
+
fail(str(exc))
|
|
216
|
+
raise typer.Exit(1) from exc
|
|
217
|
+
if adapter.name != "codex":
|
|
218
|
+
fail("Seul l'adaptateur codex supporte actuellement les contrats structures.")
|
|
219
|
+
raise typer.Exit(1)
|
|
220
|
+
|
|
221
|
+
def execute(activity_contract) -> AgentActivityExecution:
|
|
222
|
+
prompt_text = render_contract_prompt(activity_contract)
|
|
223
|
+
try:
|
|
224
|
+
before = snapshot_worktree(project_root)
|
|
225
|
+
with tempfile.TemporaryDirectory(prefix="devflow-adaptive-") as temp_dir:
|
|
226
|
+
temp_path = Path(temp_dir)
|
|
227
|
+
schema_file = temp_path / "output-schema.json"
|
|
228
|
+
output_file = temp_path / "last-message.json"
|
|
229
|
+
schema_file.write_text(
|
|
230
|
+
json.dumps(activity_contract.expectedOutput, ensure_ascii=False),
|
|
231
|
+
encoding="utf-8",
|
|
232
|
+
)
|
|
233
|
+
execution = adapter.execute_structured(
|
|
234
|
+
prompt_text,
|
|
235
|
+
project_root,
|
|
236
|
+
timeout,
|
|
237
|
+
schema_file,
|
|
238
|
+
output_file,
|
|
239
|
+
permission_mode,
|
|
240
|
+
"workspace-write"
|
|
241
|
+
if "write" in activity_contract.allowedActions
|
|
242
|
+
else "read-only",
|
|
243
|
+
)
|
|
244
|
+
output = (
|
|
245
|
+
output_file.read_text(encoding="utf-8")
|
|
246
|
+
if output_file.is_file()
|
|
247
|
+
else ""
|
|
248
|
+
)
|
|
249
|
+
after = snapshot_worktree(project_root)
|
|
250
|
+
observed = tuple(changed_paths(before, after))
|
|
251
|
+
except (OSError, ValueError) as exc:
|
|
252
|
+
return AgentActivityExecution(1, stderr=str(exc))
|
|
253
|
+
return AgentActivityExecution(
|
|
254
|
+
execution.returncode,
|
|
255
|
+
output,
|
|
256
|
+
execution.stderr,
|
|
257
|
+
observed,
|
|
258
|
+
execution.timed_out,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
outcome = run_current_activity(feature_dir, feature_path, execute)
|
|
262
|
+
if outcome.advanced:
|
|
263
|
+
ok(
|
|
264
|
+
f"Activite '{outcome.activity}' validee. "
|
|
265
|
+
f"Prochaine : {outcome.nextActivity}"
|
|
266
|
+
)
|
|
267
|
+
return
|
|
268
|
+
fail(
|
|
269
|
+
f"Activite '{outcome.activity}' non validee : {outcome.reason}"
|
|
270
|
+
+ (f" ({outcome.detail})" if outcome.detail else "")
|
|
271
|
+
)
|
|
272
|
+
raise typer.Exit(1)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@app.command("verify")
|
|
276
|
+
def verify_adaptive(
|
|
277
|
+
feature: str = typer.Argument(..., help="ID ou dossier de la feature"),
|
|
278
|
+
) -> None:
|
|
279
|
+
"""Genere localement les preuves requises par le profil actif."""
|
|
280
|
+
feature_dir = _feature(feature)
|
|
281
|
+
_execute_local_verification(feature_dir, project_root_for_feature(feature_dir))
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@app.command("waive")
|
|
285
|
+
def waive_validation(
|
|
286
|
+
feature: str = typer.Argument(..., help="ID ou dossier de la feature"),
|
|
287
|
+
validation_id: str = typer.Argument(..., help="Identifiant du controle echoue"),
|
|
288
|
+
reason: str = typer.Option(..., "--reason", help="Justification obligatoire"),
|
|
289
|
+
author: Optional[str] = typer.Option(None, "--author", help="Auteur de la derogation"),
|
|
290
|
+
) -> None:
|
|
291
|
+
"""Rend non bloquant un controle planifie echoue, avec audit durable."""
|
|
292
|
+
feature_dir = _feature(feature)
|
|
293
|
+
with workflow_lock(feature_dir):
|
|
294
|
+
try:
|
|
295
|
+
waiver = create_validation_waiver(
|
|
296
|
+
feature_dir, validation_id, reason, author=author
|
|
297
|
+
)
|
|
298
|
+
except ValueError as exc:
|
|
299
|
+
fail(str(exc))
|
|
300
|
+
raise typer.Exit(1) from exc
|
|
301
|
+
safe_refresh_worklog(feature_dir)
|
|
302
|
+
ok(
|
|
303
|
+
f"Derogation '{waiver['id']}' enregistree pour "
|
|
304
|
+
f"{waiver['commandId']}."
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@app.command("reopen")
|
|
309
|
+
def reopen(
|
|
310
|
+
feature: str = typer.Argument(..., help="ID ou dossier de la feature"),
|
|
311
|
+
activity: str = typer.Argument(..., help="Activite anterieure a reprendre"),
|
|
312
|
+
reason: str = typer.Option(..., "--reason", help="Motif obligatoire"),
|
|
313
|
+
author: Optional[str] = typer.Option(None, "--author", help="Auteur du retour"),
|
|
314
|
+
) -> None:
|
|
315
|
+
"""Revient a une activite anterieure sans effacer l'historique."""
|
|
316
|
+
feature_dir = _feature(feature)
|
|
317
|
+
with workflow_lock(feature_dir):
|
|
318
|
+
state = _state(feature_dir)
|
|
319
|
+
try:
|
|
320
|
+
entry = reopen_activity(
|
|
321
|
+
feature_dir, state, activity, reason, author=author
|
|
322
|
+
)
|
|
323
|
+
except (OSError, ValueError) as exc:
|
|
324
|
+
fail(str(exc))
|
|
325
|
+
raise typer.Exit(1) from exc
|
|
326
|
+
write_adaptive_state(feature_dir / "workflow-state.json", state)
|
|
327
|
+
safe_refresh_worklog(feature_dir)
|
|
328
|
+
ok(
|
|
329
|
+
f"Workflow rouvert sur '{activity}' (audit #{entry['sequence']}, "
|
|
330
|
+
f"archive : {entry['archivePath']})."
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _execute_local_verification(feature_dir: Path, project_root: Path) -> None:
|
|
335
|
+
outcome = run_automatic_verification(feature_dir, project_root)
|
|
336
|
+
if outcome.passed:
|
|
337
|
+
ok(f"Preuves generees et validees. Prochaine : {outcome.nextActivity}")
|
|
338
|
+
return
|
|
339
|
+
fail(
|
|
340
|
+
f"Verification automatique refusee : {outcome.reason}"
|
|
341
|
+
+ (f" ({outcome.detail})" if outcome.detail else "")
|
|
342
|
+
)
|
|
343
|
+
raise typer.Exit(1)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@app.command("worklog")
|
|
347
|
+
def worklog(
|
|
348
|
+
feature: str = typer.Argument(..., help="ID ou dossier de la feature"),
|
|
349
|
+
) -> None:
|
|
350
|
+
"""Regenere le journal compact depuis les artefacts du workflow."""
|
|
351
|
+
feature_dir = _feature(feature)
|
|
352
|
+
try:
|
|
353
|
+
output = refresh_worklog(feature_dir)
|
|
354
|
+
except ValueError as exc:
|
|
355
|
+
fail(str(exc))
|
|
356
|
+
raise typer.Exit(1) from exc
|
|
357
|
+
ok(f"Worklog actualise : {output}")
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""devflow assess — Recommande un profil sans modifier le pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from devflow_cli.core.assessment import AssessmentSignals, assess_feature
|
|
14
|
+
from devflow_cli.core.adaptive_workflow import workflow_lock
|
|
15
|
+
from devflow_cli.core.state import read_state
|
|
16
|
+
from devflow_cli.utils.console import console, fail, ok
|
|
17
|
+
from devflow_cli.utils.paths import resolve_feature_dir
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def assess(
|
|
21
|
+
feature: str = typer.Argument(..., help="ID ou dossier de la feature"),
|
|
22
|
+
scope: Literal["local", "multiple", "transversal"] = typer.Option(
|
|
23
|
+
"local", help="Surface du changement"
|
|
24
|
+
),
|
|
25
|
+
ambiguity: Literal["low", "medium", "high"] = typer.Option(
|
|
26
|
+
"medium", help="Niveau d'ambiguite"
|
|
27
|
+
),
|
|
28
|
+
risk: Literal["low", "medium", "high"] = typer.Option(
|
|
29
|
+
"medium", help="Risque d'impact"
|
|
30
|
+
),
|
|
31
|
+
contract_change: bool = typer.Option(
|
|
32
|
+
False, "--contract-change", help="Le changement modifie un contrat"
|
|
33
|
+
),
|
|
34
|
+
data_security: bool = typer.Option(
|
|
35
|
+
False, "--data-security", help="Le changement touche aux donnees ou a la securite"
|
|
36
|
+
),
|
|
37
|
+
summary: str = typer.Option("", help="Resume court de la demande"),
|
|
38
|
+
profile: Literal["quick", "standard", "deep", "critical"] | None = typer.Option(
|
|
39
|
+
None, help="Force un profil au lieu de la recommandation"
|
|
40
|
+
),
|
|
41
|
+
json_output: bool = typer.Option(False, "--json", help="Affiche le resultat en JSON"),
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Evalue une feature et ecrit assessment.json sans changer state.json."""
|
|
44
|
+
feature_dir = resolve_feature_dir(feature)
|
|
45
|
+
if feature_dir is None:
|
|
46
|
+
fail(f"Feature '{feature}' introuvable.")
|
|
47
|
+
raise typer.Exit(1)
|
|
48
|
+
|
|
49
|
+
state_file = feature_dir / "state.json"
|
|
50
|
+
state = read_state(state_file)
|
|
51
|
+
if state is None:
|
|
52
|
+
fail(f"state.json invalide dans {feature_dir.name}")
|
|
53
|
+
raise typer.Exit(1)
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
assessment = assess_feature(
|
|
57
|
+
state.issueId,
|
|
58
|
+
AssessmentSignals(
|
|
59
|
+
scope=scope,
|
|
60
|
+
ambiguity=ambiguity,
|
|
61
|
+
risk=risk,
|
|
62
|
+
contractChange=contract_change,
|
|
63
|
+
dataOrSecurity=data_security,
|
|
64
|
+
),
|
|
65
|
+
summary=summary,
|
|
66
|
+
profile_override=profile,
|
|
67
|
+
)
|
|
68
|
+
except ValueError as exc:
|
|
69
|
+
fail(str(exc))
|
|
70
|
+
raise typer.Exit(1) from exc
|
|
71
|
+
payload = assessment.to_dict()
|
|
72
|
+
output_file = feature_dir / "assessment.json"
|
|
73
|
+
with workflow_lock(feature_dir):
|
|
74
|
+
if (feature_dir / "workflow-state.json").exists():
|
|
75
|
+
fail("Un workflow adaptatif existe deja ; assessment.json ne sera pas ecrase.")
|
|
76
|
+
raise typer.Exit(1)
|
|
77
|
+
_write_json_atomic(output_file, payload)
|
|
78
|
+
|
|
79
|
+
if json_output:
|
|
80
|
+
console.print_json(data=payload)
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
ok(f"Profil selectionne : {assessment.selectedProfile}")
|
|
84
|
+
if assessment.selectedProfile != assessment.recommendedProfile:
|
|
85
|
+
console.print(f" Recommande : {assessment.recommendedProfile}")
|
|
86
|
+
console.print(f" Confiance : {assessment.confidence}")
|
|
87
|
+
for reason in assessment.reasons:
|
|
88
|
+
console.print(f" - {reason}")
|
|
89
|
+
console.print(f" Activites : {' -> '.join(assessment.activities)}")
|
|
90
|
+
console.print(f" Preuves : {', '.join(assessment.requiredEvidence)}")
|
|
91
|
+
console.print(f" Rapport : {output_file}")
|
|
92
|
+
console.print(" Pipeline : inchange")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _write_json_atomic(target: Path, payload: dict) -> None:
|
|
96
|
+
"""Ecrit le rapport sans laisser de JSON partiel en cas d'interruption."""
|
|
97
|
+
temporary: Path | None = None
|
|
98
|
+
try:
|
|
99
|
+
with tempfile.NamedTemporaryFile(
|
|
100
|
+
mode="w",
|
|
101
|
+
encoding="utf-8",
|
|
102
|
+
dir=target.parent,
|
|
103
|
+
prefix=f".{target.name}.",
|
|
104
|
+
suffix=".tmp",
|
|
105
|
+
delete=False,
|
|
106
|
+
) as handle:
|
|
107
|
+
temporary = Path(handle.name)
|
|
108
|
+
handle.write(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
|
|
109
|
+
handle.flush()
|
|
110
|
+
os.fsync(handle.fileno())
|
|
111
|
+
os.replace(temporary, target)
|
|
112
|
+
temporary = None
|
|
113
|
+
finally:
|
|
114
|
+
if temporary is not None:
|
|
115
|
+
temporary.unlink(missing_ok=True)
|
devflow_cli/commands/check.py
CHANGED
|
@@ -23,12 +23,20 @@ def _count_files(directory: Path, pattern: str) -> int:
|
|
|
23
23
|
return len(list(directory.glob(pattern)))
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
def check(
|
|
26
|
+
def check(
|
|
27
|
+
ai: str = typer.Option(
|
|
28
|
+
"claude-code", "--ai", help="Agent a verifier : claude-code ou codex"
|
|
29
|
+
),
|
|
30
|
+
) -> None:
|
|
27
31
|
"""Verifie les prerequis (outils, fichiers, config)."""
|
|
28
32
|
ok_count = 0
|
|
29
33
|
warn_count = 0
|
|
30
34
|
fail_count = 0
|
|
31
35
|
|
|
36
|
+
if ai not in {"claude-code", "codex"}:
|
|
37
|
+
fail("Agent non supporte pour check : utilisez claude-code ou codex")
|
|
38
|
+
raise typer.Exit(1)
|
|
39
|
+
|
|
32
40
|
claude_dir = get_claude_dir()
|
|
33
41
|
|
|
34
42
|
console.print("Verification des prerequis devflow...")
|
|
@@ -47,6 +55,41 @@ def check() -> None:
|
|
|
47
55
|
fail("git -- non installe")
|
|
48
56
|
fail_count += 1
|
|
49
57
|
|
|
58
|
+
if ai == "codex":
|
|
59
|
+
codex_path = _cmd_exists("codex")
|
|
60
|
+
if codex_path:
|
|
61
|
+
result = subprocess.run(
|
|
62
|
+
["codex", "--version"], capture_output=True, text=True
|
|
63
|
+
)
|
|
64
|
+
version = (result.stdout or result.stderr).strip()
|
|
65
|
+
if result.returncode == 0:
|
|
66
|
+
ok(f"codex CLI -- {codex_path} ({version})")
|
|
67
|
+
ok_count += 1
|
|
68
|
+
else:
|
|
69
|
+
fail(f"codex CLI -- execution impossible ({version})")
|
|
70
|
+
fail_count += 1
|
|
71
|
+
else:
|
|
72
|
+
fail("codex CLI -- non installee")
|
|
73
|
+
fail_count += 1
|
|
74
|
+
|
|
75
|
+
specs_dir = Path(".specify/specs")
|
|
76
|
+
if specs_dir.is_dir():
|
|
77
|
+
ok(".specify/specs/ -- present dans le projet courant")
|
|
78
|
+
ok_count += 1
|
|
79
|
+
else:
|
|
80
|
+
warn(".specify/specs/ -- absent; lancez devflow init --ai codex")
|
|
81
|
+
warn_count += 1
|
|
82
|
+
|
|
83
|
+
console.print()
|
|
84
|
+
total = ok_count + warn_count + fail_count
|
|
85
|
+
console.print(
|
|
86
|
+
f"Resume : {ok_count} OK, {warn_count} WARN, "
|
|
87
|
+
f"{fail_count} FAIL ({total} checks)"
|
|
88
|
+
)
|
|
89
|
+
if fail_count > 0:
|
|
90
|
+
raise typer.Exit(1)
|
|
91
|
+
return
|
|
92
|
+
|
|
50
93
|
# claude CLI
|
|
51
94
|
claude_path = _cmd_exists("claude")
|
|
52
95
|
if claude_path:
|