fde-workflow-cli 0.1.0__tar.gz

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.
Files changed (25) hide show
  1. fde_workflow_cli-0.1.0/LICENSE +22 -0
  2. fde_workflow_cli-0.1.0/PKG-INFO +48 -0
  3. fde_workflow_cli-0.1.0/README.md +25 -0
  4. fde_workflow_cli-0.1.0/pyproject.toml +62 -0
  5. fde_workflow_cli-0.1.0/src/fde_workflow_cli/__init__.py +1 -0
  6. fde_workflow_cli-0.1.0/src/fde_workflow_cli/__main__.py +5 -0
  7. fde_workflow_cli-0.1.0/src/fde_workflow_cli/clock.py +7 -0
  8. fde_workflow_cli-0.1.0/src/fde_workflow_cli/commands/__init__.py +1 -0
  9. fde_workflow_cli-0.1.0/src/fde_workflow_cli/commands/common.py +87 -0
  10. fde_workflow_cli-0.1.0/src/fde_workflow_cli/commands/decision.py +31 -0
  11. fde_workflow_cli-0.1.0/src/fde_workflow_cli/commands/hook.py +52 -0
  12. fde_workflow_cli-0.1.0/src/fde_workflow_cli/commands/kpi.py +210 -0
  13. fde_workflow_cli-0.1.0/src/fde_workflow_cli/commands/northstar.py +90 -0
  14. fde_workflow_cli-0.1.0/src/fde_workflow_cli/commands/path.py +257 -0
  15. fde_workflow_cli-0.1.0/src/fde_workflow_cli/commands/project.py +123 -0
  16. fde_workflow_cli-0.1.0/src/fde_workflow_cli/commands/stage.py +155 -0
  17. fde_workflow_cli-0.1.0/src/fde_workflow_cli/detect.py +123 -0
  18. fde_workflow_cli-0.1.0/src/fde_workflow_cli/doctor.py +45 -0
  19. fde_workflow_cli-0.1.0/src/fde_workflow_cli/errors.py +5 -0
  20. fde_workflow_cli-0.1.0/src/fde_workflow_cli/main.py +62 -0
  21. fde_workflow_cli-0.1.0/src/fde_workflow_cli/model.py +226 -0
  22. fde_workflow_cli-0.1.0/src/fde_workflow_cli/recipes.py +79 -0
  23. fde_workflow_cli-0.1.0/src/fde_workflow_cli/rules.py +168 -0
  24. fde_workflow_cli-0.1.0/src/fde_workflow_cli/store.py +142 -0
  25. fde_workflow_cli-0.1.0/src/fde_workflow_cli/summary.py +104 -0
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) Marco Fura Mendoza
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
@@ -0,0 +1,48 @@
1
+ Metadata-Version: 2.4
2
+ Name: fde-workflow-cli
3
+ Version: 0.1.0
4
+ Summary: Direction framework for FDE customer projects: North Star, KPIs with baselines, paths, stages and decisions, on top of any execution methodology.
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Keywords: claude-code,cli
8
+ Author: Marco Fura Mendoza
9
+ Requires-Python: >=3.13,<4.0
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Requires-Dist: pydantic (>=2.14,<3)
16
+ Requires-Dist: pyyaml (>=6.0.3,<7)
17
+ Requires-Dist: typer (>=0.27,<1.0)
18
+ Project-URL: Homepage, https://github.com/mjfura/fde-workflow
19
+ Project-URL: Issues, https://github.com/mjfura/fde-workflow/issues
20
+ Project-URL: Repository, https://github.com/mjfura/fde-workflow
21
+ Description-Content-Type: text/markdown
22
+
23
+ # fde-workflow-cli
24
+
25
+ Command line tool of the `fde-workflow` Claude Code plugin: it reads and writes a repository's
26
+ FDE direction in `.fde/` and enforces the framework's rules.
27
+
28
+ ```sh
29
+ uv tool install fde-workflow-cli # or: pipx install fde-workflow-cli
30
+ fde-workflow --version
31
+ fde-workflow doctor # checks that everything is ready
32
+ ```
33
+
34
+ | Command | Purpose |
35
+ |---|---|
36
+ | `init`, `detect` | Set up `.fde/`; inspect a repository's methodology and artifacts |
37
+ | `status`, `context`, `validate` | Summaries for people and for Claude; rule checks |
38
+ | `project set` | The project's name, customer and sponsor |
39
+ | `northstar set\|confirm` | The North Star |
40
+ | `kpi add\|update\|measure` | KPIs, their recipes and measurements |
41
+ | `path add\|update\|move\|link\|confirm` | Paths and their links to execution work |
42
+ | `stage set\|confirm\|criteria\|decide` | The project stage and its gates |
43
+ | `decision add` | Other direction decisions |
44
+ | `hook session-start` | Entry point of the plugin's SessionStart hook |
45
+
46
+ Run `fde-workflow <command> --help` for the options. Requires Python 3.13 or newer. Source and
47
+ plugin: https://github.com/mjfura/fde-workflow
48
+
@@ -0,0 +1,25 @@
1
+ # fde-workflow-cli
2
+
3
+ Command line tool of the `fde-workflow` Claude Code plugin: it reads and writes a repository's
4
+ FDE direction in `.fde/` and enforces the framework's rules.
5
+
6
+ ```sh
7
+ uv tool install fde-workflow-cli # or: pipx install fde-workflow-cli
8
+ fde-workflow --version
9
+ fde-workflow doctor # checks that everything is ready
10
+ ```
11
+
12
+ | Command | Purpose |
13
+ |---|---|
14
+ | `init`, `detect` | Set up `.fde/`; inspect a repository's methodology and artifacts |
15
+ | `status`, `context`, `validate` | Summaries for people and for Claude; rule checks |
16
+ | `project set` | The project's name, customer and sponsor |
17
+ | `northstar set\|confirm` | The North Star |
18
+ | `kpi add\|update\|measure` | KPIs, their recipes and measurements |
19
+ | `path add\|update\|move\|link\|confirm` | Paths and their links to execution work |
20
+ | `stage set\|confirm\|criteria\|decide` | The project stage and its gates |
21
+ | `decision add` | Other direction decisions |
22
+ | `hook session-start` | Entry point of the plugin's SessionStart hook |
23
+
24
+ Run `fde-workflow <command> --help` for the options. Requires Python 3.13 or newer. Source and
25
+ plugin: https://github.com/mjfura/fde-workflow
@@ -0,0 +1,62 @@
1
+ [project]
2
+ name = "fde-workflow-cli"
3
+ version = "0.1.0"
4
+ description = "Direction framework for FDE customer projects: North Star, KPIs with baselines, paths, stages and decisions, on top of any execution methodology."
5
+ authors = [{ name = "Marco Fura Mendoza" }]
6
+ readme = "README.md"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ keywords = ["claude-code", "cli"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Environment :: Console",
13
+ "Operating System :: OS Independent",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.13",
16
+ ]
17
+ requires-python = ">=3.13,<4.0"
18
+ dependencies = [
19
+ "typer (>=0.27,<1.0)",
20
+ "pydantic (>=2.14,<3)",
21
+ "pyyaml (>=6.0.3,<7)",
22
+ ]
23
+
24
+ [project.scripts]
25
+ fde-workflow = "fde_workflow_cli.main:app"
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/mjfura/fde-workflow"
29
+ Repository = "https://github.com/mjfura/fde-workflow"
30
+ Issues = "https://github.com/mjfura/fde-workflow/issues"
31
+
32
+ [tool.poetry]
33
+ packages = [{ include = "fde_workflow_cli", from = "src" }]
34
+
35
+ [tool.poetry.group.dev.dependencies]
36
+ pytest = "^9.1"
37
+ ruff = "^0.16"
38
+ mypy = "^2.3"
39
+ types-pyyaml = "^6.0.12.20260906"
40
+
41
+ [build-system]
42
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
43
+ build-backend = "poetry.core.masonry.api"
44
+
45
+ [tool.ruff]
46
+ line-length = 100
47
+ target-version = "py313"
48
+ src = ["src", "tests"]
49
+
50
+ [tool.ruff.lint]
51
+ select = ["E", "W", "F", "I", "B", "UP", "SIM", "RUF", "S", "PT"]
52
+
53
+ [tool.ruff.lint.per-file-ignores]
54
+ "tests/**" = ["S101"]
55
+
56
+ [tool.mypy]
57
+ python_version = "3.13"
58
+ strict = true
59
+ files = ["src", "tests"]
60
+
61
+ [tool.pytest.ini_options]
62
+ testpaths = ["tests"]
@@ -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.")