fde-workflow-cli 0.1.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,45 @@
1
+ """Readiness checks behind `fde-workflow doctor`. Add one function per check to CHECKS."""
2
+
3
+ import sys
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from fde_workflow_cli.errors import FdeError
9
+ from fde_workflow_cli.model import SCHEMA_VERSION
10
+ from fde_workflow_cli.store import Store, locate
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class CheckResult:
15
+ ok: bool
16
+ message: str
17
+
18
+
19
+ MINIMUM_PYTHON = (3, 13)
20
+
21
+
22
+ def check_python() -> CheckResult:
23
+ found = ".".join(str(part) for part in sys.version_info[:3])
24
+ if sys.version_info >= MINIMUM_PYTHON:
25
+ return CheckResult(ok=True, message=f"Python {found}")
26
+ return CheckResult(ok=False, message=f"Python {found} is too old: install Python 3.13 or newer")
27
+
28
+
29
+ def check_direction() -> CheckResult:
30
+ store = Store(locate(Path.cwd()))
31
+ if not store.exists():
32
+ return CheckResult(ok=True, message="No .fde/ here yet: the adopt skill sets one up")
33
+ try:
34
+ store.load()
35
+ except FdeError as error:
36
+ first_line = str(error).splitlines()[0]
37
+ return CheckResult(ok=False, message=f".fde/ cannot be read: {first_line}")
38
+ return CheckResult(ok=True, message=f".fde/ uses schema {SCHEMA_VERSION}: {store.dir}")
39
+
40
+
41
+ CHECKS: list[Callable[[], CheckResult]] = [check_python, check_direction]
42
+
43
+
44
+ def run_checks() -> list[CheckResult]:
45
+ return [check() for check in CHECKS]
@@ -0,0 +1,5 @@
1
+ """The error type for problems the user can fix."""
2
+
3
+
4
+ class FdeError(Exception):
5
+ """A problem the user can fix; the message says how."""
@@ -0,0 +1,62 @@
1
+ """Commands of the fde-workflow command line tool."""
2
+
3
+ from importlib.metadata import version
4
+ from typing import Annotated
5
+
6
+ import typer
7
+
8
+ from fde_workflow_cli import doctor as checks
9
+ from fde_workflow_cli.commands import decision, hook, kpi, northstar, path, project, stage
10
+
11
+ COMMAND = "fde-workflow"
12
+ DIST_NAME = "fde-workflow-cli"
13
+
14
+ app = typer.Typer(no_args_is_help=True, add_completion=False)
15
+
16
+
17
+ def _print_version(value: bool) -> None:
18
+ if value:
19
+ typer.echo(f"{COMMAND} {version(DIST_NAME)}")
20
+ raise typer.Exit()
21
+
22
+
23
+ @app.callback()
24
+ def main(
25
+ show_version: Annotated[
26
+ bool,
27
+ typer.Option(
28
+ "--version",
29
+ callback=_print_version,
30
+ is_eager=True,
31
+ help="Show the version and exit.",
32
+ ),
33
+ ] = False,
34
+ ) -> None:
35
+ """Command line tool of the fde-workflow Claude Code plugin."""
36
+
37
+
38
+ @app.command()
39
+ def doctor() -> None:
40
+ """Check that fde-workflow is ready to use."""
41
+ results = checks.run_checks()
42
+ for result in results:
43
+ typer.echo(f"{'✓' if result.ok else '✗'} {result.message}")
44
+ problems = sum(not result.ok for result in results)
45
+ if problems:
46
+ typer.echo(f"Not ready: {problems} problem(s).")
47
+ raise typer.Exit(1)
48
+ typer.echo("Ready.")
49
+
50
+
51
+ app.command()(project.init)
52
+ app.command("detect")(project.detect_command)
53
+ app.command()(project.validate)
54
+ app.command()(project.status)
55
+ app.command()(project.context)
56
+ app.add_typer(project.app, name="project")
57
+ app.add_typer(kpi.app, name="kpi")
58
+ app.add_typer(northstar.app, name="northstar")
59
+ app.add_typer(path.app, name="path")
60
+ app.add_typer(stage.app, name="stage")
61
+ app.add_typer(decision.app, name="decision")
62
+ app.add_typer(hook.app, name="hook")
@@ -0,0 +1,226 @@
1
+ """The direction framework's data model: what the files in `.fde/` contain."""
2
+
3
+ import datetime as dt
4
+ from enum import StrEnum
5
+ from pathlib import PurePosixPath
6
+ from typing import Annotated, Self
7
+
8
+ from pydantic import (
9
+ BaseModel,
10
+ ConfigDict,
11
+ Field,
12
+ FiniteFloat,
13
+ StringConstraints,
14
+ field_validator,
15
+ model_validator,
16
+ )
17
+
18
+ SCHEMA_VERSION = 1
19
+
20
+ Identifier = Annotated[str, StringConstraints(pattern=r"^[a-z0-9]+(-[a-z0-9]+)*$")]
21
+ Window = Annotated[str, StringConstraints(pattern=r"^[1-9][0-9]*d$")]
22
+
23
+
24
+ class StageName(StrEnum):
25
+ DISCOVERY = "discovery"
26
+ POC = "poc"
27
+ MVP = "mvp"
28
+ EARLY_ACCESS = "early-access"
29
+ PRODUCTION = "production"
30
+ CLOSED = "closed"
31
+
32
+
33
+ STAGE_ORDER: list[StageName] = list(StageName)
34
+
35
+
36
+ class Outcome(StrEnum):
37
+ COMPLETED = "completed"
38
+ DISCONTINUED = "discontinued"
39
+
40
+
41
+ class GateDecision(StrEnum):
42
+ GO = "go"
43
+ RECYCLE = "recycle"
44
+ HOLD = "hold"
45
+ KILL = "kill"
46
+
47
+
48
+ class KpiKind(StrEnum):
49
+ PRIMARY = "primary"
50
+ SECONDARY = "secondary"
51
+ GUARDRAIL = "guardrail"
52
+
53
+
54
+ class Better(StrEnum):
55
+ INCREASE = "increase"
56
+ DECREASE = "decrease"
57
+
58
+
59
+ class RecipeType(StrEnum):
60
+ SCRIPT = "script"
61
+ QUERY = "query"
62
+ MANUAL = "manual"
63
+
64
+
65
+ class PathStatus(StrEnum):
66
+ PROPOSED = "proposed"
67
+ ACTIVE = "active"
68
+ MEASURING = "measuring"
69
+ VALIDATED = "validated"
70
+ DISCARDED = "discarded"
71
+
72
+
73
+ class BaselineType(StrEnum):
74
+ HISTORICAL = "historical"
75
+ LATE = "late"
76
+
77
+
78
+ class DecisionType(StrEnum):
79
+ NORTH_STAR_CHANGE = "north-star-change"
80
+ TARGET_CHANGE = "target-change"
81
+ PATH_OPEN = "path-open"
82
+ PATH_VALIDATE = "path-validate"
83
+ PATH_DISCARD = "path-discard"
84
+ STAGE_GATE = "stage-gate"
85
+ STAGE_SET = "stage-set"
86
+ SCOPE_CHANGE = "scope-change"
87
+ OTHER = "other"
88
+
89
+
90
+ class Record(BaseModel):
91
+ """Base of every model: unknown keys are errors, so typos in hand-edited files surface."""
92
+
93
+ model_config = ConfigDict(extra="forbid")
94
+
95
+
96
+ class Project(Record):
97
+ name: str = Field(min_length=1)
98
+ customer: str = ""
99
+ sponsor: str = ""
100
+
101
+
102
+ class NorthStar(Record):
103
+ statement: str = Field(min_length=1)
104
+ beneficiary: str = ""
105
+ problem: str = ""
106
+ non_goals: list[str] = Field(default_factory=list)
107
+ primary_kpi: Identifier | None = None
108
+ confirmed: bool = True
109
+
110
+
111
+ class StageState(Record):
112
+ current: StageName = StageName.DISCOVERY
113
+ entered: dt.date
114
+ outcome: Outcome | None = None
115
+ on_hold: bool = False
116
+ confirmed: bool = True
117
+ gate_criteria: list[str] = Field(default_factory=list)
118
+
119
+ @model_validator(mode="after")
120
+ def _outcome_only_when_closed(self) -> Self:
121
+ if (self.current is StageName.CLOSED) != (self.outcome is not None):
122
+ raise ValueError("outcome must be set when, and only when, the stage is closed")
123
+ return self
124
+
125
+
126
+ class Recipe(Record):
127
+ type: RecipeType
128
+ file: str = Field(min_length=1)
129
+
130
+
131
+ class Kpi(Record):
132
+ id: Identifier
133
+ name: str = Field(min_length=1)
134
+ kind: KpiKind
135
+ definition: str = Field(min_length=1)
136
+ source: str = Field(min_length=1)
137
+ unit: str = ""
138
+ direction: Better
139
+ target: FiniteFloat
140
+ window: Window = "28d"
141
+ recipe: Recipe
142
+
143
+ def window_ending(self, end: dt.date) -> tuple[dt.date, dt.date]:
144
+ """The measurement window (first day, last day) that ends on `end`."""
145
+ days = int(self.window.removesuffix("d"))
146
+ return end - dt.timedelta(days=days - 1), end
147
+
148
+
149
+ class DirectionPath(Record):
150
+ id: Identifier
151
+ hypothesis: str = Field(min_length=1)
152
+ expects: dict[Identifier, FiniteFloat]
153
+ status: PathStatus = PathStatus.PROPOSED
154
+ activated: dt.date | None = None
155
+ maturity: StageName | None = None
156
+ confirmed: bool = True
157
+ links: list[str] = Field(default_factory=list)
158
+ learning: str | None = None
159
+
160
+
161
+ class Artifacts(Record):
162
+ globs: list[str] = Field(default_factory=list)
163
+ pre_framework: list[str] = Field(default_factory=list)
164
+
165
+ @field_validator("globs")
166
+ @classmethod
167
+ def _globs_stay_inside_the_repository(cls, globs: list[str]) -> list[str]:
168
+ for pattern in globs:
169
+ path = PurePosixPath(pattern)
170
+ if not pattern or path.is_absolute() or ".." in path.parts:
171
+ raise ValueError(f"{pattern!r} must be a relative pattern inside the repository")
172
+ return globs
173
+
174
+
175
+ class Direction(Record):
176
+ schema_version: int = SCHEMA_VERSION
177
+ project: Project
178
+ north_star: NorthStar | None = None
179
+ stage: StageState
180
+ kpis: list[Kpi] = Field(default_factory=list)
181
+ paths: list[DirectionPath] = Field(default_factory=list)
182
+ artifacts: Artifacts = Field(default_factory=Artifacts)
183
+
184
+ @model_validator(mode="after")
185
+ def _ids_are_unique(self) -> Self:
186
+ for label, ids in (
187
+ ("KPI", [k.id for k in self.kpis]),
188
+ ("path", [p.id for p in self.paths]),
189
+ ):
190
+ duplicates = sorted({i for i in ids if ids.count(i) > 1})
191
+ if duplicates:
192
+ raise ValueError(f"duplicate {label} id(s): {', '.join(duplicates)}")
193
+ return self
194
+
195
+ def kpi(self, kpi_id: str) -> Kpi | None:
196
+ return next((k for k in self.kpis if k.id == kpi_id), None)
197
+
198
+ def path(self, path_id: str) -> DirectionPath | None:
199
+ return next((p for p in self.paths if p.id == path_id), None)
200
+
201
+
202
+ class Measurement(Record):
203
+ kpi: Identifier
204
+ taken: dt.date
205
+ window_start: dt.date
206
+ window_end: dt.date
207
+ value: FiniteFloat
208
+ baseline: BaselineType | None = None
209
+ source: str = Field(min_length=1)
210
+ external_factors: str = ""
211
+ recipe_sha256: str
212
+
213
+ @model_validator(mode="after")
214
+ def _window_is_ordered(self) -> Self:
215
+ if self.window_start > self.window_end:
216
+ raise ValueError("window_start must not be after window_end")
217
+ return self
218
+
219
+
220
+ class Decision(Record):
221
+ date: dt.date
222
+ type: DecisionType
223
+ summary: str = Field(min_length=1)
224
+ reason: str = Field(min_length=1)
225
+ approved_by: str = ""
226
+ refs: list[str] = Field(default_factory=list)
@@ -0,0 +1,79 @@
1
+ """Measurement recipes: where they live, their fingerprint, and running script recipes."""
2
+
3
+ import datetime as dt
4
+ import hashlib
5
+ import math
6
+ import os
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ from fde_workflow_cli.errors import FdeError
11
+ from fde_workflow_cli.model import Recipe
12
+
13
+ SCRIPT_TIMEOUT_SECONDS = 600
14
+
15
+
16
+ def recipe_path(fde_dir: Path, recipe: Recipe) -> Path:
17
+ """Return the recipe's file, which must stay inside `.fde/`."""
18
+ path = (fde_dir / recipe.file).resolve()
19
+ if not path.is_relative_to(fde_dir.resolve()):
20
+ raise FdeError(f"Recipe {recipe.file!r} must be inside .fde/ (e.g. measures/<kpi>.sql).")
21
+ return path
22
+
23
+
24
+ def fingerprint(fde_dir: Path, recipe: Recipe) -> str | None:
25
+ """SHA-256 of the recipe file, or None when the file does not exist."""
26
+ path = recipe_path(fde_dir, recipe)
27
+ if not path.is_file():
28
+ return None
29
+ return hashlib.sha256(path.read_bytes()).hexdigest()
30
+
31
+
32
+ def render(fde_dir: Path, recipe: Recipe, start: dt.date, end: dt.date) -> str:
33
+ """The recipe's text with its window placeholders filled in."""
34
+ text = recipe_path(fde_dir, recipe).read_text(encoding="utf-8")
35
+ return text.replace("{{window_start}}", start.isoformat()).replace(
36
+ "{{window_end}}", end.isoformat()
37
+ )
38
+
39
+
40
+ def run_script(fde_dir: Path, recipe: Recipe, start: dt.date, end: dt.date) -> float:
41
+ """Run a script recipe from the repository root and return the number it prints."""
42
+ path = recipe_path(fde_dir, recipe)
43
+ if not os.access(path, os.X_OK):
44
+ raise FdeError(f"{path} is not executable: run `chmod +x {path}`.")
45
+ env = {**os.environ, "FDE_WINDOW_START": start.isoformat(), "FDE_WINDOW_END": end.isoformat()}
46
+ try:
47
+ result = subprocess.run( # noqa: S603 - the project's own recipe, run only with --run
48
+ [str(path)],
49
+ cwd=fde_dir.parent,
50
+ env=env,
51
+ capture_output=True,
52
+ text=True,
53
+ timeout=SCRIPT_TIMEOUT_SECONDS,
54
+ check=False,
55
+ )
56
+ except OSError as error:
57
+ raise FdeError(
58
+ f"Recipe {recipe.file} cannot be executed ({error.strerror}): start it with a "
59
+ "shebang such as #!/bin/sh."
60
+ ) from error
61
+ except subprocess.TimeoutExpired as error:
62
+ raise FdeError(
63
+ f"Recipe {recipe.file} did not finish in {SCRIPT_TIMEOUT_SECONDS} s."
64
+ ) from error
65
+ if result.returncode != 0:
66
+ raise FdeError(
67
+ f"Recipe {recipe.file} failed with exit code {result.returncode}:\n"
68
+ f"{result.stderr.strip()}"
69
+ )
70
+ output = result.stdout.strip()
71
+ try:
72
+ value = float(output)
73
+ except ValueError as error:
74
+ raise FdeError(
75
+ f"Recipe {recipe.file} must print one number; it printed {output!r}."
76
+ ) from error
77
+ if not math.isfinite(value):
78
+ raise FdeError(f"Recipe {recipe.file} must print a finite number; it printed {output!r}.")
79
+ return value
@@ -0,0 +1,168 @@
1
+ """The framework's rules (spec §2), checked over a whole `.fde/`."""
2
+
3
+ from collections.abc import Iterator
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Literal
7
+
8
+ from fde_workflow_cli import recipes
9
+ from fde_workflow_cli.errors import FdeError
10
+ from fde_workflow_cli.model import (
11
+ Direction,
12
+ DirectionPath,
13
+ KpiKind,
14
+ Measurement,
15
+ PathStatus,
16
+ StageName,
17
+ )
18
+
19
+ STAGES_THAT_MEASURE = (StageName.MVP, StageName.EARLY_ACCESS, StageName.PRODUCTION)
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Issue:
24
+ level: Literal["error", "warning"]
25
+ rule: str
26
+ message: str
27
+
28
+
29
+ def check(direction: Direction, measurements: list[Measurement], fde_dir: Path) -> list[Issue]:
30
+ """Every broken rule, errors first. Errors block writes; warnings are reported."""
31
+ issues = [
32
+ *_north_star(direction),
33
+ *_kpis(direction, measurements, fde_dir),
34
+ *_paths(direction, measurements),
35
+ *_stage(direction, measurements),
36
+ ]
37
+ return sorted(issues, key=lambda issue: issue.level != "error")
38
+
39
+
40
+ def latest_baseline(measurements: list[Measurement], kpi_id: str) -> Measurement | None:
41
+ """The KPI's most recently recorded baseline (files keep recording order)."""
42
+ baselines = [m for m in measurements if m.kpi == kpi_id and m.baseline is not None]
43
+ return baselines[-1] if baselines else None
44
+
45
+
46
+ def has_evidence(path: DirectionPath, measurements: list[Measurement]) -> bool:
47
+ """P2: a non-baseline measurement of a targeted KPI whose whole window follows activation."""
48
+ activated = path.activated
49
+ if activated is None:
50
+ return False
51
+ return any(
52
+ m.kpi in path.expects and m.baseline is None and m.window_start > activated
53
+ for m in measurements
54
+ )
55
+
56
+
57
+ def _north_star(direction: Direction) -> Iterator[Issue]:
58
+ north_star = direction.north_star
59
+ if north_star is None:
60
+ yield Issue("warning", "N1", "No North Star yet: define it with the discovery skill.")
61
+ return
62
+ if not north_star.confirmed:
63
+ yield Issue(
64
+ "warning",
65
+ "C1",
66
+ "The North Star was inferred and is not confirmed: "
67
+ "`fde-workflow northstar confirm` once the FDE and the sponsor agree.",
68
+ )
69
+ if north_star.primary_kpi is None:
70
+ yield Issue("warning", "N1", "The North Star has no primary KPI yet.")
71
+ return
72
+ kpi = direction.kpi(north_star.primary_kpi)
73
+ if kpi is None:
74
+ yield Issue(
75
+ "error", "N1", f"The North Star's primary KPI {north_star.primary_kpi} does not exist."
76
+ )
77
+ elif kpi.kind is not KpiKind.PRIMARY:
78
+ yield Issue(
79
+ "error",
80
+ "K2",
81
+ f"The North Star's primary KPI {kpi.id} has kind {kpi.kind}, not primary.",
82
+ )
83
+
84
+
85
+ def _kpis(direction: Direction, measurements: list[Measurement], fde_dir: Path) -> Iterator[Issue]:
86
+ primaries = [kpi.id for kpi in direction.kpis if kpi.kind is KpiKind.PRIMARY]
87
+ if len(primaries) > 1:
88
+ yield Issue("error", "K2", f"Only one KPI can be primary; found {', '.join(primaries)}.")
89
+ for kpi in direction.kpis:
90
+ try:
91
+ current = recipes.fingerprint(fde_dir, kpi.recipe)
92
+ except FdeError as error:
93
+ yield Issue("error", "M1", f"KPI {kpi.id}: {error}")
94
+ continue
95
+ if current is None:
96
+ yield Issue(
97
+ "error",
98
+ "M1",
99
+ f"KPI {kpi.id}: recipe .fde/{kpi.recipe.file} does not exist; write it first.",
100
+ )
101
+ continue
102
+ baseline = latest_baseline(measurements, kpi.id)
103
+ if baseline is None:
104
+ yield Issue(
105
+ "warning",
106
+ "K3",
107
+ f"KPI {kpi.id} has no baseline: measure it before work that should move it starts.",
108
+ )
109
+ elif baseline.recipe_sha256 != current:
110
+ yield Issue(
111
+ "warning",
112
+ "K4",
113
+ f"KPI {kpi.id}: the recipe changed after the baseline, so comparisons are not "
114
+ "valid; measure the baseline again with --baseline.",
115
+ )
116
+
117
+
118
+ def _paths(direction: Direction, measurements: list[Measurement]) -> Iterator[Issue]:
119
+ for path in direction.paths:
120
+ if not path.expects:
121
+ yield Issue("error", "P1", f"Path {path.id} targets no KPI.")
122
+ for kpi_id in path.expects:
123
+ if direction.kpi(kpi_id) is None:
124
+ yield Issue("error", "P1", f"Path {path.id} targets unknown KPI {kpi_id}.")
125
+ if path.status is PathStatus.VALIDATED and not has_evidence(path, measurements):
126
+ yield Issue(
127
+ "error",
128
+ "P2",
129
+ f"Path {path.id} cannot be validated without evidence: it needs a measurement of "
130
+ f"{', '.join(path.expects)} whose window starts after it became active.",
131
+ )
132
+ if not path.confirmed:
133
+ yield Issue(
134
+ "warning",
135
+ "C1",
136
+ f"Path {path.id} was inferred and is not confirmed: `fde-workflow path confirm`.",
137
+ )
138
+
139
+
140
+ def _stage(direction: Direction, measurements: list[Measurement]) -> Iterator[Issue]:
141
+ stage = direction.stage
142
+ if not stage.confirmed:
143
+ yield Issue(
144
+ "warning",
145
+ "C1",
146
+ "The stage was inferred and is not confirmed: `fde-workflow stage confirm`.",
147
+ )
148
+ if stage.current is StageName.CLOSED:
149
+ return
150
+ if not stage.gate_criteria:
151
+ yield Issue(
152
+ "warning",
153
+ "S1",
154
+ f"Stage {stage.current} has no gate criteria: agree them with the sponsor now, "
155
+ "not when leaving the stage.",
156
+ )
157
+ primary = direction.north_star.primary_kpi if direction.north_star else None
158
+ if (
159
+ stage.current in STAGES_THAT_MEASURE
160
+ and primary is not None
161
+ and not any(m.kpi == primary and m.window_end >= stage.entered for m in measurements)
162
+ ):
163
+ yield Issue(
164
+ "warning",
165
+ "S3",
166
+ f"Primary KPI {primary} has no measurement since entering {stage.current} "
167
+ f"on {stage.entered}.",
168
+ )