devflow-cli 1.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.
@@ -0,0 +1,137 @@
1
+ """devflow extension — Gestion des extensions devflow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+ from devflow_cli.core.catalog import (
10
+ load_all_catalogs,
11
+ find_extension,
12
+ is_installed,
13
+ Extension,
14
+ )
15
+ from devflow_cli.core.installer import install_extension, uninstall_extension
16
+ from devflow_cli.utils.console import console, ok, warn, fail, make_table
17
+
18
+ app = typer.Typer(help="Gestion des extensions devflow")
19
+
20
+
21
+ @app.command("list")
22
+ def list_extensions() -> None:
23
+ """Liste les extensions avec statut d'installation."""
24
+ catalogs = load_all_catalogs()
25
+
26
+ if not catalogs:
27
+ fail("Aucun catalogue trouve.")
28
+ raise typer.Exit(1)
29
+
30
+ table = make_table("Statut", "Source", "Nom", "Fichiers", "Description", title="Extensions devflow")
31
+
32
+ for catalog in catalogs:
33
+ for ext in catalog.extensions:
34
+ status = "[green]installe[/green]" if is_installed(ext) else "[dim]non installe[/dim]"
35
+ source = f"[yellow]{ext.source}[/yellow]" if ext.source != "principal" else ext.source
36
+ table.add_row(
37
+ status,
38
+ source,
39
+ ext.name,
40
+ str(len(ext.includes)),
41
+ ext.description,
42
+ )
43
+
44
+ console.print(table)
45
+
46
+
47
+ @app.command()
48
+ def search(term: str = typer.Argument(..., help="Terme de recherche")) -> None:
49
+ """Recherche dans le catalogue (principal + communautaire)."""
50
+ catalogs = load_all_catalogs()
51
+ term_lower = term.lower()
52
+
53
+ console.print(f"Recherche : '{term}'")
54
+ console.print()
55
+
56
+ found = False
57
+ for catalog in catalogs:
58
+ for ext in catalog.extensions:
59
+ if term_lower in ext.name.lower() or term_lower in ext.description.lower():
60
+ source_label = f" [yellow][{ext.source}][/yellow]" if ext.source != "principal" else ""
61
+ console.print(f" {ext.name}{source_label} — {ext.description}")
62
+ found = True
63
+
64
+ if not found:
65
+ console.print(" Aucun resultat.")
66
+
67
+
68
+ @app.command()
69
+ def add(name: str = typer.Argument(..., help="Nom de l'extension a installer")) -> None:
70
+ """Installe une extension."""
71
+ catalogs = load_all_catalogs()
72
+ ext = find_extension(name, catalogs)
73
+
74
+ if ext is None:
75
+ fail(f"Extension '{name}' non trouvee dans aucun catalogue.")
76
+ console.print("Utilisez 'devflow extension catalog' pour voir les extensions disponibles.")
77
+ raise typer.Exit(1)
78
+
79
+ source_label = f" [{ext.source}]" if ext.source != "principal" else ""
80
+ console.print(f"Installation de '{name}'{source_label}...")
81
+ console.print()
82
+
83
+ result = install_extension(ext)
84
+
85
+ for inc in ext.includes:
86
+ filename = inc.file.split("/")[-1]
87
+ console.print(f" [green][ok][/green] {inc.target}/{filename}")
88
+
89
+ console.print()
90
+ console.print(f"Termine : {result.copied} copies, {result.skipped} ignores")
91
+
92
+
93
+ @app.command()
94
+ def remove(name: str = typer.Argument(..., help="Nom de l'extension a desinstaller")) -> None:
95
+ """Desinstalle une extension."""
96
+ catalogs = load_all_catalogs()
97
+ ext = find_extension(name, catalogs)
98
+
99
+ if ext is None:
100
+ fail(f"Extension '{name}' non trouvee dans le catalogue.")
101
+ raise typer.Exit(1)
102
+
103
+ console.print(f"Suppression de '{name}'...")
104
+ console.print()
105
+
106
+ removed = uninstall_extension(ext)
107
+
108
+ for inc in ext.includes:
109
+ filename = inc.file.split("/")[-1]
110
+ console.print(f" [green][ok][/green] supprime {inc.target}/{filename}")
111
+
112
+ console.print()
113
+ console.print(f"Termine : {removed} fichiers supprimes")
114
+
115
+
116
+ @app.command()
117
+ def catalog() -> None:
118
+ """Affiche le catalogue complet."""
119
+ catalogs = load_all_catalogs()
120
+
121
+ if not catalogs:
122
+ fail("Aucun catalogue trouve.")
123
+ raise typer.Exit(1)
124
+
125
+ for cat in catalogs:
126
+ label = cat.source.capitalize()
127
+ console.rule(f"[bold]Catalogue devflow — {label}[/bold]")
128
+ console.print()
129
+
130
+ for ext in cat.extensions:
131
+ console.print(f"Extension : [bold]{ext.name}[/bold] v{ext.version}")
132
+ console.print(f" Type : {ext.type}")
133
+ console.print(f" Desc : {ext.description}")
134
+ console.print(f" Fichiers :")
135
+ for inc in ext.includes:
136
+ console.print(f" - {inc.type}: {inc.file}")
137
+ console.print()
@@ -0,0 +1,102 @@
1
+ """devflow feature — Initialise une feature (branche + state.json)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import typer
10
+
11
+ from devflow_cli.core.git import branch_exists, create_branch, checkout_branch, GitNotFoundError
12
+ from devflow_cli.core.state import create_state
13
+ from devflow_cli.utils.console import console, ok, info
14
+ from devflow_cli.utils.paths import get_specs_root, next_feature_number
15
+
16
+
17
+ def _clean_short_name(name: str) -> str:
18
+ """Nettoie un short-name pour usage comme nom de repertoire."""
19
+ cleaned = name.lower()
20
+ cleaned = re.sub(r"[^a-z0-9]", "-", cleaned)
21
+ cleaned = re.sub(r"-+", "-", cleaned)
22
+ return cleaned.strip("-")
23
+
24
+
25
+ def _validate_issue_id(issue_id: str) -> None:
26
+ """Valide que l'issue_id contient au moins un caractere alphanumerique."""
27
+ if not issue_id or not issue_id.strip() or not re.search(r"[a-zA-Z0-9]", issue_id):
28
+ console.print("[red]Erreur : identifiant de feature invalide (vide ou sans caractère alphanumérique).[/red]")
29
+ raise typer.Exit(1)
30
+
31
+
32
+ def _ensure_git() -> None:
33
+ """Verifie que git est disponible, sinon affiche erreur et quitte."""
34
+ try:
35
+ from devflow_cli.core.git import ensure_git_available
36
+ ensure_git_available()
37
+ except GitNotFoundError as e:
38
+ console.print(f"[red]Erreur : {e}[/red]")
39
+ raise typer.Exit(1)
40
+
41
+
42
+ def _init_feature_files(feature_dir: Path, issue_id: str) -> None:
43
+ """Cree state.json et review-log.md dans le dossier feature."""
44
+ state_file = feature_dir / "state.json"
45
+ if state_file.is_file():
46
+ console.print(" state.json existe deja, pas d'ecrasement.")
47
+ else:
48
+ create_state(state_file, issue_id)
49
+ ok("state.json initialise")
50
+
51
+ review_log = feature_dir / "review-log.md"
52
+ if review_log.is_file():
53
+ console.print(" review-log.md existe deja.")
54
+ else:
55
+ review_log.write_text(
56
+ f"# Review Log — {issue_id}\n\n"
57
+ f"> Journal des reviews de la feature {issue_id}\n\n---\n",
58
+ encoding="utf-8",
59
+ )
60
+ ok("review-log.md cree")
61
+
62
+
63
+ def feature(
64
+ issue_id: str = typer.Argument(..., help="Identifiant de l'issue (ex: KS-123)"),
65
+ short_name: Optional[str] = typer.Option(None, "--short-name", help="Nom court pour le repertoire (ex: user-auth)"),
66
+ ) -> None:
67
+ """Initialise une feature : branche git + state.json + review-log.md."""
68
+ _validate_issue_id(issue_id)
69
+ _ensure_git()
70
+
71
+ name = _clean_short_name(short_name) if short_name else _clean_short_name(issue_id)
72
+ num = next_feature_number()
73
+ feature_name = f"{num:03d}-{name}"
74
+ branch = feature_name
75
+ feature_dir = get_specs_root() / feature_name
76
+
77
+ # Branche
78
+ if branch_exists(branch):
79
+ info(f"La branche {branch} existe deja, switch dessus.")
80
+ checkout_branch(branch)
81
+ else:
82
+ info(f"Creation de la branche {branch}...")
83
+ if not create_branch(branch):
84
+ console.print(f"[red]Erreur lors de la creation de la branche {branch}[/red]")
85
+ raise typer.Exit(1)
86
+
87
+ # Dossier feature
88
+ if feature_dir.is_dir():
89
+ console.print(f" Le dossier {feature_dir} existe deja.")
90
+ else:
91
+ feature_dir.mkdir(parents=True)
92
+ ok(f"Dossier {feature_dir} cree")
93
+
94
+ _init_feature_files(feature_dir, issue_id)
95
+
96
+ console.print()
97
+ console.print(f"Feature [bold]{issue_id}[/bold] initialisee :")
98
+ console.print(f" Branche : {branch}")
99
+ console.print(f" Dossier : {feature_dir}/")
100
+ console.print(f" Etat : spec (etape initiale)")
101
+ console.print()
102
+ console.print(f"Prochaine etape : /devflow {issue_id}")
@@ -0,0 +1,94 @@
1
+ """devflow init — Installe devflow dans ~/.claude/ et initialise le projet."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from pathlib import Path
7
+ from typing import Literal, Optional
8
+
9
+ import typer
10
+
11
+ from devflow_cli import SUPPORTED_AGENTS
12
+ from devflow_cli.core.installer import install_devflow
13
+ from devflow_cli.core.git import is_git_repo
14
+ from devflow_cli.utils.console import console, ok, fail, warn, header
15
+ from devflow_cli.core.manifest import compute_source_manifest, save_manifest
16
+ from devflow_cli.utils.paths import get_devflow_root, get_claude_dir
17
+
18
+
19
+ def init(
20
+ path: Optional[Path] = typer.Argument(None, help="Projet cible"),
21
+ force: bool = typer.Option(False, "--force", help="Ecraser les fichiers existants"),
22
+ with_constitution: bool = typer.Option(False, "--with-constitution", help="Creer une constitution starter"),
23
+ here: bool = typer.Option(False, "--here", help="Initialiser dans le repertoire courant"),
24
+ script: Optional[Literal["bash"]] = typer.Option(None, "--script", help="Type de scripts (bash)"),
25
+ ai: str = typer.Option("claude-code", "--ai", help="Agent IA cible"),
26
+ ) -> None:
27
+ """Installe devflow (commandes, agents, templates, scripts)."""
28
+ # Validation --here vs path
29
+ if here and path is not None:
30
+ fail("Les options --here et un chemin explicite sont mutuellement exclusifs.")
31
+ raise typer.Exit(1)
32
+
33
+ if here or path is None:
34
+ path = Path(".")
35
+
36
+ target = path.resolve()
37
+
38
+ if not is_git_repo(target):
39
+ fail(f"{target} n'est pas un depot git.")
40
+ raise typer.Exit(1)
41
+
42
+ # Validation --ai
43
+ if ai not in SUPPORTED_AGENTS:
44
+ warn(f"Agent '{ai}' non supporte. Agents reconnus : {', '.join(SUPPORTED_AGENTS)}. Installation avec defaults claude-code.")
45
+
46
+ header(f"devflow init — {target}")
47
+ console.print()
48
+
49
+ results = install_devflow(force=force, script_type=script)
50
+
51
+ for category, result in results.items():
52
+ label = category.ljust(10)
53
+ console.print(f" {label}: {result.copied} copies, {result.skipped} ignores")
54
+
55
+ # Resume des options
56
+ if script:
57
+ console.print(f" Scripts : {script} uniquement")
58
+ if ai != "claude-code":
59
+ console.print(f" Agent : {ai} (defaults claude-code appliques)")
60
+
61
+ # Structure projet
62
+ specs_dir = target / ".specify" / "specs"
63
+ if not specs_dir.exists():
64
+ specs_dir.mkdir(parents=True)
65
+ ok(".specify/specs/ cree")
66
+ else:
67
+ console.print(" Structure : .specify/specs/ existe deja")
68
+
69
+ # Constitution optionnelle
70
+ if with_constitution:
71
+ const_dest = target / ".specify" / "memory" / "constitution.md"
72
+ const_src = get_devflow_root() / "templates" / "constitution-template.md"
73
+ if force or not const_dest.exists():
74
+ if const_src.is_file():
75
+ const_dest.parent.mkdir(parents=True, exist_ok=True)
76
+ shutil.copy2(const_src, const_dest)
77
+ ok("Constitution creee dans .specify/memory/constitution.md")
78
+ else:
79
+ fail("Template constitution non trouve")
80
+ else:
81
+ console.print(" Constitution : .specify/memory/constitution.md existe deja (--force pour ecraser)")
82
+
83
+ # Manifeste d'installation
84
+ try:
85
+ devflow_root = get_devflow_root()
86
+ manifest_data = compute_source_manifest(devflow_root)
87
+ manifest_data["installed_at"] = manifest_data.pop("computed_at")
88
+ save_manifest(get_claude_dir(), manifest_data)
89
+ ok("Manifeste d'installation cree")
90
+ except Exception:
91
+ warn("Impossible de creer le manifeste d'installation")
92
+
93
+ console.print()
94
+ console.print("[green]Installation terminee.[/green]")
@@ -0,0 +1,106 @@
1
+ """devflow migrate — Migration interactive des features legacy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+ import typer
9
+
10
+ from devflow_cli.core.state import read_state
11
+ from devflow_cli.utils.console import console, ok, info, warn, fail, header
12
+ from devflow_cli.utils.paths import get_specs_root, next_feature_number
13
+
14
+
15
+ def _clean_short_name(name: str) -> str:
16
+ """Nettoie un short-name pour usage comme nom de repertoire."""
17
+ import re
18
+ cleaned = name.lower()
19
+ cleaned = re.sub(r"[^a-z0-9]", "-", cleaned)
20
+ cleaned = re.sub(r"-+", "-", cleaned)
21
+ return cleaned.strip("-")
22
+
23
+
24
+ def migrate(
25
+ dry_run: bool = typer.Option(False, "--dry-run", help="Affiche le mapping prevu sans deplacer"),
26
+ ) -> None:
27
+ """Migration interactive des features de docs/features/ vers .specify/specs/."""
28
+ legacy_dir = Path("docs/features")
29
+
30
+ if not legacy_dir.is_dir():
31
+ info("Rien a migrer : docs/features/ n'existe pas.")
32
+ return
33
+
34
+ # Scanner les features legacy
35
+ features = []
36
+ for d in sorted(legacy_dir.iterdir()):
37
+ state_file = d / "state.json"
38
+ if d.is_dir() and state_file.is_file():
39
+ state = read_state(state_file)
40
+ features.append((d, state))
41
+
42
+ if not features:
43
+ info("Rien a migrer : aucune feature avec state.json dans docs/features/.")
44
+ return
45
+
46
+ header(f"devflow migrate — {len(features)} feature(s) a migrer")
47
+ console.print()
48
+
49
+ specs_root = get_specs_root()
50
+ specs_root.mkdir(parents=True, exist_ok=True)
51
+
52
+ migrated = 0
53
+ skipped = 0
54
+ conflicts = 0
55
+
56
+ for legacy_path, state in features:
57
+ issue_id = state.issueId
58
+ step = state.currentStep
59
+ artifact_count = sum(1 for f in legacy_path.iterdir() if f.is_file())
60
+
61
+ console.print(f" [bold]{issue_id}[/bold] — etape: {step}, {artifact_count} artefacts")
62
+
63
+ if dry_run:
64
+ console.print(f" [dim](dry-run) Serait migre vers .specify/specs/[/dim]")
65
+ migrated += 1
66
+ continue
67
+
68
+ # Demander un short-name
69
+ try:
70
+ default_name = _clean_short_name(issue_id)
71
+ short_name = typer.prompt(
72
+ f" Short-name pour {issue_id}",
73
+ default=default_name,
74
+ )
75
+ except (KeyboardInterrupt, typer.Abort):
76
+ console.print(f" [yellow]Ignore (annule)[/yellow]")
77
+ skipped += 1
78
+ continue
79
+
80
+ clean_name = _clean_short_name(short_name)
81
+ num = next_feature_number()
82
+ target_name = f"{num:03d}-{clean_name}"
83
+ target_path = specs_root / target_name
84
+
85
+ # Verifier les conflits
86
+ if target_path.exists():
87
+ warn(f" Conflit : {target_path} existe deja. Feature ignoree.")
88
+ conflicts += 1
89
+ continue
90
+
91
+ # Deplacer
92
+ shutil.move(str(legacy_path), str(target_path))
93
+ ok(f" {issue_id} -> .specify/specs/{target_name}/")
94
+ migrated += 1
95
+
96
+ # Recapitulatif
97
+ console.print()
98
+ console.rule("Recapitulatif")
99
+ console.print(f" Migrees : {migrated}")
100
+ if skipped:
101
+ console.print(f" Ignorees : {skipped}")
102
+ if conflicts:
103
+ console.print(f" Conflits : {conflicts}")
104
+ if dry_run:
105
+ console.print()
106
+ console.print("[dim]Mode dry-run : aucun fichier deplace.[/dim]")
@@ -0,0 +1,93 @@
1
+ """devflow regen — Detection et regeneration en cascade des artefacts desuets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import typer
10
+
11
+ from devflow_cli.core.git import current_branch
12
+ from devflow_cli.core.staleness import get_staleness, get_stale_artifacts, REGEN_COMMANDS
13
+ from devflow_cli.utils.console import console, ok, info, header
14
+ from devflow_cli.utils.paths import resolve_feature_dir
15
+
16
+
17
+ def regen(
18
+ only: Optional[str] = typer.Option(None, "--only", help="Regenerer uniquement cet artefact (plan, tasks)"),
19
+ json_output: bool = typer.Option(False, "--json", help="Sortie JSON pour integration Claude Code"),
20
+ ) -> None:
21
+ """Detecte et propose la regeneration des artefacts desuets."""
22
+ branch = current_branch()
23
+ feature_dir = resolve_feature_dir(branch) if branch else None
24
+ if feature_dir is None:
25
+ if json_output:
26
+ print(json.dumps({"error": "Aucune feature trouvee"}))
27
+ else:
28
+ console.print("Aucune feature trouvee dans specs/ ou docs/features/.")
29
+ raise typer.Exit(1)
30
+
31
+ constitution_path = Path(".specify/memory/constitution.md")
32
+ const_path = constitution_path if constitution_path.is_file() else None
33
+
34
+ staleness = get_staleness(feature_dir, const_path)
35
+ stale_list = get_stale_artifacts(feature_dir, const_path)
36
+
37
+ # Filtrage --only
38
+ if only:
39
+ target = f"{only}.md" if not only.endswith(".md") else only
40
+ stale_list = [s for s in stale_list if s.artifact == target]
41
+
42
+ if json_output:
43
+ output = {
44
+ "feature_dir": str(feature_dir),
45
+ "stale": [
46
+ {
47
+ "artifact": s.artifact,
48
+ "reason": s.reason,
49
+ "command": REGEN_COMMANDS.get(s.artifact, ""),
50
+ }
51
+ for s in stale_list
52
+ ],
53
+ "fresh": [
54
+ name for name, info in staleness.items()
55
+ if info.state == "fresh"
56
+ ],
57
+ "missing": [
58
+ name for name, info in staleness.items()
59
+ if info.state == "missing"
60
+ ],
61
+ }
62
+ print(json.dumps(output, indent=2))
63
+ return
64
+
65
+ header(f"devflow regen — {feature_dir.name}")
66
+ console.print()
67
+
68
+ if not stale_list:
69
+ ok("Tout est synchronise. Aucune regeneration necessaire.")
70
+ return
71
+
72
+ console.print("Artefacts desuets detectes :")
73
+ for s in stale_list:
74
+ console.print(f" [yellow]{s.artifact}[/yellow] — {s.reason}")
75
+
76
+ console.print()
77
+ console.print("Commandes a executer :")
78
+ for i, s in enumerate(stale_list, 1):
79
+ cmd = REGEN_COMMANDS.get(s.artifact, "N/A")
80
+ console.print(f" {i}. {cmd}")
81
+
82
+ console.print()
83
+ if typer.confirm("Regenerer ?", default=False):
84
+ console.print()
85
+ info("Lancez les commandes suivantes dans l'ordre :")
86
+ for s in stale_list:
87
+ cmd = REGEN_COMMANDS.get(s.artifact)
88
+ if cmd:
89
+ console.print(f" → {cmd}")
90
+ console.print()
91
+ info("Ou utilisez /devflow.regen pour execution automatique.")
92
+ else:
93
+ console.print("Annule.")
@@ -0,0 +1,118 @@
1
+ """devflow status — Affiche le statut des features."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import typer
9
+
10
+ from devflow_cli import STEPS, ARTIFACTS
11
+ from devflow_cli.core.state import read_state
12
+ from devflow_cli.core.staleness import get_staleness
13
+ from devflow_cli.utils.console import console, make_table
14
+ from devflow_cli.utils.paths import get_specs_root, resolve_feature_dir
15
+
16
+
17
+ def status(
18
+ issue_id: Optional[str] = typer.Argument(None, help="ID de la feature (optionnel)"),
19
+ ) -> None:
20
+ """Lecture rapide du statut d'une feature ou liste toutes les features."""
21
+ if issue_id is None:
22
+ _list_features()
23
+ else:
24
+ _show_feature(issue_id)
25
+
26
+
27
+ def _list_features() -> None:
28
+ """Liste toutes les features en scannant trois repertoires."""
29
+ scan_dirs = [
30
+ (Path(".specify/specs"), False),
31
+ (Path("specs"), True),
32
+ (Path("docs/features"), True),
33
+ ]
34
+
35
+ table = make_table("ID", "Etape", "Completees", "Location", title="Features")
36
+
37
+ found = False
38
+ has_legacy = False
39
+
40
+ for scan_root, is_legacy in scan_dirs:
41
+ if not scan_root.is_dir():
42
+ continue
43
+ for d in sorted(scan_root.iterdir()):
44
+ state_file = d / "state.json"
45
+ if not state_file.is_file():
46
+ continue
47
+ found = True
48
+ state = read_state(state_file)
49
+ location = "[yellow][legacy][/yellow]" if is_legacy else ""
50
+ if is_legacy:
51
+ has_legacy = True
52
+ table.add_row(
53
+ state.issueId,
54
+ state.currentStep,
55
+ f"{state.completed_count}/{state.total_steps}",
56
+ location,
57
+ )
58
+
59
+ if found:
60
+ console.print(table)
61
+ if has_legacy:
62
+ console.print(
63
+ "\n[yellow]Astuce: utilisez 'devflow migrate' pour deplacer "
64
+ "les features legacy vers .specify/specs/[/yellow]"
65
+ )
66
+ else:
67
+ console.print("Aucune feature initialisee.")
68
+
69
+
70
+ def _show_feature(issue_id: str) -> None:
71
+ """Affiche le detail d'une feature."""
72
+ feature_dir = resolve_feature_dir(issue_id)
73
+
74
+ if feature_dir is None:
75
+ console.print(f"Feature {issue_id} non initialisee.")
76
+ console.print(f"Lancer : devflow feature {issue_id}")
77
+ raise typer.Exit(1)
78
+
79
+ state_file = feature_dir / "state.json"
80
+
81
+ if not state_file.is_file():
82
+ console.print(f"Feature {issue_id} non initialisee.")
83
+ console.print(f"Lancer : devflow feature {issue_id}")
84
+ raise typer.Exit(1)
85
+
86
+ state = read_state(state_file)
87
+
88
+ console.rule(f"[bold]devflow — {issue_id}[/bold]")
89
+ console.print()
90
+ console.print(f"Etape courante : [bold]{state.currentStep}[/bold]")
91
+ console.print(f"Cree le : {state.createdAt}")
92
+ console.print(f"Mis a jour : {state.updatedAt}")
93
+ console.print()
94
+
95
+ # Staleness detection
96
+ constitution_path = Path(".specify/memory/constitution.md")
97
+ staleness = get_staleness(feature_dir, constitution_path if constitution_path.is_file() else None)
98
+
99
+ console.print("Artefacts :")
100
+ for artifact in ARTIFACTS:
101
+ path = feature_dir / artifact
102
+ stale_info = staleness.get(artifact)
103
+
104
+ if artifact.endswith("/"):
105
+ # Directory artifact (e.g. contracts/)
106
+ if path.is_dir() and any(path.iterdir()):
107
+ console.print(f" [green][OK][/green] {artifact}")
108
+ elif (feature_dir / artifact.rstrip("/")).with_suffix(".md").is_file():
109
+ console.print(f" [green][OK][/green] {artifact.rstrip('/')}.md")
110
+ else:
111
+ console.print(f" [dim][ ][/dim] {artifact}")
112
+ elif path.is_file():
113
+ if stale_info and stale_info.state == "stale":
114
+ console.print(f" [yellow][STALE][/yellow] {artifact} — {stale_info.reason}")
115
+ else:
116
+ console.print(f" [green][OK][/green] {artifact}")
117
+ else:
118
+ console.print(f" [dim][ ][/dim] {artifact}")