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 @@
1
+ """The fde-workflow command line tool."""
@@ -0,0 +1,5 @@
1
+ """Allow `python -m fde_workflow_cli`."""
2
+
3
+ from fde_workflow_cli.main import app
4
+
5
+ app(prog_name="fde-workflow")
@@ -0,0 +1,7 @@
1
+ """Today's date, in one place so tests can fix it."""
2
+
3
+ import datetime as dt
4
+
5
+
6
+ def today() -> dt.date:
7
+ return dt.date.today()
@@ -0,0 +1 @@
1
+ """The CLI's command groups."""
@@ -0,0 +1,87 @@
1
+ """Helpers shared by the commands."""
2
+
3
+ import datetime as dt
4
+ from collections.abc import Iterator
5
+ from contextlib import contextmanager
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import typer
10
+ from pydantic import ValidationError
11
+
12
+ from fde_workflow_cli import clock
13
+ from fde_workflow_cli.errors import FdeError
14
+ from fde_workflow_cli.model import Decision, DecisionType, Direction, DirectionPath, Kpi
15
+ from fde_workflow_cli.store import Store, describe_validation_error, locate
16
+
17
+ RootOption = Annotated[
18
+ Path | None,
19
+ typer.Option(
20
+ "--root",
21
+ help="Repository with .fde/ (default: the closest one from the current directory).",
22
+ ),
23
+ ]
24
+
25
+
26
+ def open_store(root: Path | None) -> Store:
27
+ return Store(root.resolve() if root else locate(Path.cwd()))
28
+
29
+
30
+ @contextmanager
31
+ def user_errors() -> Iterator[None]:
32
+ """Turn problems the user can fix into a one-line error and exit code 1."""
33
+ try:
34
+ yield
35
+ except FdeError as error:
36
+ typer.echo(f"Error: {error}", err=True)
37
+ raise typer.Exit(1) from error
38
+ except ValidationError as error:
39
+ typer.echo(f"Error: {describe_validation_error('The input', error)}", err=True)
40
+ raise typer.Exit(1) from error
41
+
42
+
43
+ def require(value: str | None, option: str, why: str) -> str:
44
+ if not value:
45
+ raise FdeError(f"{option} is required {why}.")
46
+ return value
47
+
48
+
49
+ def parse_date(text: str, option: str) -> dt.date:
50
+ try:
51
+ return dt.date.fromisoformat(text)
52
+ except ValueError as error:
53
+ raise FdeError(f"{option} must be a date like 2026-10-12, not {text!r}.") from error
54
+
55
+
56
+ def find_kpi(direction: Direction, kpi_id: str) -> Kpi:
57
+ kpi = direction.kpi(kpi_id)
58
+ if kpi is None:
59
+ raise FdeError(f"There is no KPI {kpi_id}.")
60
+ return kpi
61
+
62
+
63
+ def find_path(direction: Direction, path_id: str) -> DirectionPath:
64
+ path = direction.path(path_id)
65
+ if path is None:
66
+ raise FdeError(f"There is no path {path_id}.")
67
+ return path
68
+
69
+
70
+ def record(
71
+ store: Store,
72
+ decision_type: DecisionType,
73
+ summary: str,
74
+ reason: str,
75
+ approved_by: str = "",
76
+ refs: list[str] | None = None,
77
+ ) -> None:
78
+ store.append_decision(
79
+ Decision(
80
+ date=clock.today(),
81
+ type=decision_type,
82
+ summary=summary,
83
+ reason=reason,
84
+ approved_by=approved_by,
85
+ refs=refs or [],
86
+ )
87
+ )
@@ -0,0 +1,31 @@
1
+ """`fde-workflow decision`: record a direction decision no other command records."""
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+
7
+ from fde_workflow_cli.commands.common import RootOption, open_store, record, user_errors
8
+ from fde_workflow_cli.model import DecisionType
9
+
10
+ app = typer.Typer(help="Record direction decisions.", no_args_is_help=True)
11
+
12
+
13
+ @app.command()
14
+ def add(
15
+ summary: Annotated[str, typer.Option(help="What was decided.")],
16
+ reason: Annotated[str, typer.Option(help="Why.")],
17
+ decision_type: Annotated[
18
+ DecisionType, typer.Option("--type", help="Kind of decision.")
19
+ ] = DecisionType.OTHER,
20
+ approved_by: Annotated[str, typer.Option("--approved-by", help="Who approved it.")] = "",
21
+ ref: Annotated[
22
+ list[str] | None, typer.Option("--ref", help="Related id or URL; repeat it.")
23
+ ] = None,
24
+ root: RootOption = None,
25
+ ) -> None:
26
+ """Record a decision, e.g. a scope change."""
27
+ with user_errors():
28
+ store = open_store(root)
29
+ store.load()
30
+ record(store, decision_type, summary, reason, approved_by, ref or [])
31
+ typer.echo("Decision recorded.")
@@ -0,0 +1,52 @@
1
+ """`fde-workflow hook`: entry points of the plugin's hooks. A hook never fails a session."""
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import typer
9
+
10
+ from fde_workflow_cli import rules, summary
11
+ from fde_workflow_cli.errors import FdeError
12
+ from fde_workflow_cli.store import Store, locate
13
+
14
+ app = typer.Typer(help="Entry points of the Claude Code plugin hooks.", no_args_is_help=True)
15
+
16
+
17
+ def session_context(payload: str) -> str | None:
18
+ """The context for a new session, or None when the repository has no .fde/."""
19
+ try:
20
+ data = json.loads(payload) if payload.strip() else {}
21
+ except json.JSONDecodeError:
22
+ data = {}
23
+ cwd = data.get("cwd") if isinstance(data, dict) else None
24
+ start = Path(cwd) if isinstance(cwd, str) else Path(os.environ.get("CLAUDE_PROJECT_DIR", "."))
25
+ store = Store(locate(start))
26
+ if not store.exists():
27
+ return None
28
+ try:
29
+ direction = store.load()
30
+ measurements = store.measurements()
31
+ except FdeError as error:
32
+ return (
33
+ f"{summary.CONTEXT_HEADER}\n\n.fde/ cannot be read: {error}\n"
34
+ "Run `fde-workflow validate`."
35
+ )
36
+ issues = rules.check(direction, measurements, store.dir)
37
+ return summary.context(direction, measurements, issues, brief=True)
38
+
39
+
40
+ @app.command("session-start")
41
+ def session_start() -> None:
42
+ """Print the direction as SessionStart context (reads the hook's JSON on stdin)."""
43
+ try:
44
+ text = session_context(sys.stdin.read())
45
+ except Exception as error: # a hook must never interrupt the session
46
+ typer.echo(f"fde-workflow hook: {error}", err=True)
47
+ return
48
+ if text:
49
+ output = {
50
+ "hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": text}
51
+ }
52
+ typer.echo(json.dumps(output))
@@ -0,0 +1,210 @@
1
+ """`fde-workflow kpi`: define KPIs and record their measurements."""
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+
7
+ from fde_workflow_cli import clock, recipes, rules
8
+ from fde_workflow_cli.commands.common import (
9
+ RootOption,
10
+ find_kpi,
11
+ open_store,
12
+ parse_date,
13
+ record,
14
+ require,
15
+ user_errors,
16
+ )
17
+ from fde_workflow_cli.errors import FdeError
18
+ from fde_workflow_cli.model import (
19
+ BaselineType,
20
+ Better,
21
+ DecisionType,
22
+ Kpi,
23
+ KpiKind,
24
+ Measurement,
25
+ Recipe,
26
+ RecipeType,
27
+ )
28
+ from fde_workflow_cli.summary import number
29
+
30
+ app = typer.Typer(help="Define KPIs and record their measurements.", no_args_is_help=True)
31
+
32
+
33
+ @app.command()
34
+ def add(
35
+ kpi_id: Annotated[str, typer.Argument(help="Kebab-case id, e.g. resolution-time.")],
36
+ name: Annotated[str, typer.Option(help="Human-readable name.")],
37
+ kind: Annotated[KpiKind, typer.Option(help="primary, secondary or guardrail.")],
38
+ definition: Annotated[str, typer.Option(help="What it measures and its formula.")],
39
+ source: Annotated[str, typer.Option(help="Where the data comes from.")],
40
+ better: Annotated[Better, typer.Option("--direction", help="Which way is better.")],
41
+ target: Annotated[
42
+ float, typer.Option(help="Numeric target; for a guardrail, the worst acceptable value.")
43
+ ],
44
+ recipe_type: Annotated[RecipeType, typer.Option("--recipe-type", help="How to measure it.")],
45
+ recipe_file: Annotated[
46
+ str, typer.Option("--recipe-file", help="Inside .fde/, e.g. measures/resolution-time.sql.")
47
+ ],
48
+ unit: Annotated[str, typer.Option(help="Unit, e.g. min or %.")] = "",
49
+ window: Annotated[str, typer.Option(help="Measurement window in days, e.g. 28d.")] = "28d",
50
+ root: RootOption = None,
51
+ ) -> None:
52
+ """Define a KPI; its recipe file must already exist."""
53
+ with user_errors():
54
+ store = open_store(root)
55
+ direction = store.load()
56
+ if direction.kpi(kpi_id):
57
+ raise FdeError(f"KPI {kpi_id} already exists: use `fde-workflow kpi update`.")
58
+ direction.kpis.append(
59
+ Kpi(
60
+ id=kpi_id,
61
+ name=name,
62
+ kind=kind,
63
+ definition=definition,
64
+ source=source,
65
+ unit=unit,
66
+ direction=better,
67
+ target=target,
68
+ window=window,
69
+ recipe=Recipe(type=recipe_type, file=recipe_file),
70
+ )
71
+ )
72
+ store.save(direction)
73
+ typer.echo(f"Added KPI {kpi_id}.")
74
+
75
+
76
+ @app.command()
77
+ def update(
78
+ kpi_id: Annotated[str, typer.Argument()],
79
+ name: Annotated[str | None, typer.Option()] = None,
80
+ kind: Annotated[KpiKind | None, typer.Option()] = None,
81
+ definition: Annotated[str | None, typer.Option()] = None,
82
+ source: Annotated[str | None, typer.Option()] = None,
83
+ better: Annotated[Better | None, typer.Option("--direction")] = None,
84
+ target: Annotated[float | None, typer.Option()] = None,
85
+ unit: Annotated[str | None, typer.Option()] = None,
86
+ window: Annotated[str | None, typer.Option()] = None,
87
+ recipe_type: Annotated[RecipeType | None, typer.Option("--recipe-type")] = None,
88
+ recipe_file: Annotated[str | None, typer.Option("--recipe-file")] = None,
89
+ reason: Annotated[str | None, typer.Option(help="Why the target changes.")] = None,
90
+ approved_by: Annotated[
91
+ str | None, typer.Option("--approved-by", help="Who approves a target change.")
92
+ ] = None,
93
+ root: RootOption = None,
94
+ ) -> None:
95
+ """Change a KPI; changing its target records a decision."""
96
+ with user_errors():
97
+ store = open_store(root)
98
+ direction = store.load()
99
+ kpi = find_kpi(direction, kpi_id)
100
+ fields = {
101
+ "name": name,
102
+ "kind": kind,
103
+ "definition": definition,
104
+ "source": source,
105
+ "direction": better,
106
+ "target": target,
107
+ "unit": unit,
108
+ "window": window,
109
+ }
110
+ changes: dict[str, object] = {k: v for k, v in fields.items() if v is not None}
111
+ if recipe_type or recipe_file:
112
+ changes["recipe"] = Recipe(
113
+ type=recipe_type or kpi.recipe.type, file=recipe_file or kpi.recipe.file
114
+ )
115
+ target_changed = target is not None and target != kpi.target
116
+ if target_changed:
117
+ reason = require(reason, "--reason", "to change a target")
118
+ approved_by = require(approved_by, "--approved-by", "to change a target")
119
+ direction.kpis[direction.kpis.index(kpi)] = kpi.model_copy(update=changes)
120
+ store.save(direction)
121
+ if target_changed and target is not None:
122
+ record(
123
+ store,
124
+ DecisionType.TARGET_CHANGE,
125
+ f"Target of {kpi_id}: {number(kpi.target)} → {number(target)}",
126
+ reason or "",
127
+ approved_by or "",
128
+ [kpi_id],
129
+ )
130
+ typer.echo(f"Updated KPI {kpi_id}.")
131
+
132
+
133
+ @app.command()
134
+ def measure(
135
+ kpi_id: Annotated[str, typer.Argument()],
136
+ baseline: Annotated[
137
+ BaselineType | None, typer.Option(help="Record it as the KPI's baseline.")
138
+ ] = None,
139
+ window_end: Annotated[
140
+ str | None,
141
+ typer.Option("--window-end", help="Last day measured, YYYY-MM-DD (default: today)."),
142
+ ] = None,
143
+ value: Annotated[float | None, typer.Option(help="The measured value.")] = None,
144
+ source: Annotated[str | None, typer.Option(help="Where the value came from.")] = None,
145
+ factors: Annotated[
146
+ str, typer.Option(help="External factors that may have moved the value.")
147
+ ] = "",
148
+ run: Annotated[
149
+ bool, typer.Option("--run", help="Run a script recipe and record its output.")
150
+ ] = False,
151
+ root: RootOption = None,
152
+ ) -> None:
153
+ """Record a measurement; without --value or --run, show how to take it."""
154
+ with user_errors():
155
+ store = open_store(root)
156
+ direction = store.load()
157
+ kpi = find_kpi(direction, kpi_id)
158
+ start, end = kpi.window_ending(
159
+ parse_date(window_end, "--window-end") if window_end else clock.today()
160
+ )
161
+ if end > clock.today():
162
+ raise FdeError(f"--window-end {end} is in the future.")
163
+ fingerprint = recipes.fingerprint(store.dir, kpi.recipe)
164
+ if fingerprint is None:
165
+ raise FdeError(f"Recipe .fde/{kpi.recipe.file} does not exist: write it first.")
166
+ if kpi.recipe.type is RecipeType.SCRIPT:
167
+ if value is not None:
168
+ raise FdeError("Script recipes are run, not typed: use --run.")
169
+ if not run:
170
+ typer.echo(
171
+ f"Would run .fde/{kpi.recipe.file} with FDE_WINDOW_START={start} "
172
+ f"FDE_WINDOW_END={end}.\nAdd --run to execute it and record the result."
173
+ )
174
+ return
175
+ value = recipes.run_script(store.dir, kpi.recipe, start, end)
176
+ source = source or f"script .fde/{kpi.recipe.file}"
177
+ else:
178
+ if run:
179
+ raise FdeError(f"Only script recipes run; {kpi.id} has a {kpi.recipe.type} recipe.")
180
+ if value is None:
181
+ text = recipes.render(store.dir, kpi.recipe, start, end)
182
+ typer.echo(
183
+ f"{kpi.recipe.type} recipe of {kpi.id}, window {start} to {end}:\n\n{text}\n"
184
+ "Take the measurement, then record it with --value N --source TEXT."
185
+ )
186
+ return
187
+ source = require(source, "--source", "to record a measurement")
188
+ previous = rules.latest_baseline(store.measurements(), kpi.id)
189
+ store.append_measurement(
190
+ Measurement(
191
+ kpi=kpi.id,
192
+ taken=clock.today(),
193
+ window_start=start,
194
+ window_end=end,
195
+ value=value,
196
+ baseline=baseline,
197
+ source=source,
198
+ external_factors=factors,
199
+ recipe_sha256=fingerprint,
200
+ )
201
+ )
202
+ unit = f" {kpi.unit}" if kpi.unit else ""
203
+ label = f"{baseline} baseline" if baseline else "measurement"
204
+ typer.echo(f"Recorded {label} {kpi.id} = {number(value)}{unit} (window {start} to {end}).")
205
+ if baseline is None and previous is None:
206
+ typer.echo(f"! {kpi.id} has no baseline yet: this measurement is not one.")
207
+ elif baseline is None and previous is not None and previous.recipe_sha256 != fingerprint:
208
+ typer.echo(
209
+ "! [K4] The recipe changed after the baseline: measure it again with --baseline."
210
+ )
@@ -0,0 +1,90 @@
1
+ """`fde-workflow northstar`: define, change and confirm the North Star."""
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+
7
+ from fde_workflow_cli.commands.common import RootOption, open_store, record, require, user_errors
8
+ from fde_workflow_cli.errors import FdeError
9
+ from fde_workflow_cli.model import DecisionType, NorthStar
10
+
11
+ app = typer.Typer(help="Define, change and confirm the North Star.", no_args_is_help=True)
12
+
13
+
14
+ @app.command("set")
15
+ def set_(
16
+ statement: Annotated[
17
+ str | None, typer.Option(help="The outcome, for someone; never a solution.")
18
+ ] = None,
19
+ beneficiary: Annotated[str | None, typer.Option(help="Who gets the outcome.")] = None,
20
+ problem: Annotated[str | None, typer.Option(help="The problem it solves.")] = None,
21
+ non_goal: Annotated[
22
+ list[str] | None,
23
+ typer.Option("--non-goal", help="Out of scope; repeat it, the list is replaced."),
24
+ ] = None,
25
+ primary_kpi: Annotated[
26
+ str | None, typer.Option("--primary-kpi", help="Id of the primary KPI.")
27
+ ] = None,
28
+ inferred: Annotated[
29
+ bool, typer.Option("--inferred", help="Inferred from existing work; needs confirmation.")
30
+ ] = False,
31
+ reason: Annotated[str | None, typer.Option(help="Why a confirmed North Star changes.")] = None,
32
+ approved_by: Annotated[
33
+ str | None, typer.Option("--approved-by", help="Who approves the change (the sponsor).")
34
+ ] = None,
35
+ root: RootOption = None,
36
+ ) -> None:
37
+ """Create the North Star, or change it; changing a confirmed one records a decision."""
38
+ with user_errors():
39
+ store = open_store(root)
40
+ direction = store.load()
41
+ current = direction.north_star
42
+ if current is None:
43
+ new = NorthStar(
44
+ statement=require(statement, "--statement", "to create the North Star"),
45
+ beneficiary=beneficiary or "",
46
+ problem=problem or "",
47
+ non_goals=non_goal or [],
48
+ primary_kpi=primary_kpi,
49
+ confirmed=not inferred,
50
+ )
51
+ else:
52
+ fields = {
53
+ "statement": statement,
54
+ "beneficiary": beneficiary,
55
+ "problem": problem,
56
+ "non_goals": non_goal,
57
+ "primary_kpi": primary_kpi,
58
+ }
59
+ changes: dict[str, object] = {k: v for k, v in fields.items() if v is not None}
60
+ if inferred:
61
+ changes["confirmed"] = False
62
+ new = current.model_copy(update=changes)
63
+ is_change = current is not None and current.confirmed and new != current
64
+ if is_change:
65
+ reason = require(reason, "--reason", "to change a confirmed North Star")
66
+ approved_by = require(approved_by, "--approved-by", "to change a confirmed North Star")
67
+ direction.north_star = new
68
+ store.save(direction)
69
+ if is_change:
70
+ record(
71
+ store,
72
+ DecisionType.NORTH_STAR_CHANGE,
73
+ f"North Star: {new.statement}",
74
+ reason or "",
75
+ approved_by or "",
76
+ )
77
+ typer.echo("North Star changed." if is_change else "North Star saved.")
78
+
79
+
80
+ @app.command()
81
+ def confirm(root: RootOption = None) -> None:
82
+ """Mark an inferred North Star as confirmed."""
83
+ with user_errors():
84
+ store = open_store(root)
85
+ direction = store.load()
86
+ if direction.north_star is None:
87
+ raise FdeError("There is no North Star to confirm.")
88
+ direction.north_star.confirmed = True
89
+ store.save(direction)
90
+ typer.echo("North Star confirmed.")