fde-workflow-cli 0.1.0__tar.gz → 0.1.1__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.
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/PKG-INFO +1 -1
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/pyproject.toml +1 -1
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/commands/kpi.py +21 -1
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/commands/northstar.py +21 -4
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/commands/path.py +3 -0
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/commands/stage.py +15 -4
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/doctor.py +3 -2
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/model.py +1 -0
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/store.py +42 -3
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/LICENSE +0 -0
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/README.md +0 -0
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/__init__.py +0 -0
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/__main__.py +0 -0
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/clock.py +0 -0
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/commands/__init__.py +0 -0
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/commands/common.py +0 -0
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/commands/decision.py +0 -0
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/commands/hook.py +0 -0
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/commands/project.py +0 -0
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/detect.py +0 -0
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/errors.py +0 -0
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/main.py +0 -0
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/recipes.py +0 -0
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/rules.py +0 -0
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/summary.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fde-workflow-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: Direction framework for FDE customer projects: North Star, KPIs with baselines, paths, stages and decisions, on top of any execution methodology.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fde-workflow-cli"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.1"
|
|
4
4
|
description = "Direction framework for FDE customer projects: North Star, KPIs with baselines, paths, stages and decisions, on top of any execution methodology."
|
|
5
5
|
authors = [{ name = "Marco Fura Mendoza" }]
|
|
6
6
|
readme = "README.md"
|
|
@@ -148,6 +148,10 @@ def measure(
|
|
|
148
148
|
run: Annotated[
|
|
149
149
|
bool, typer.Option("--run", help="Run a script recipe and record its output.")
|
|
150
150
|
] = False,
|
|
151
|
+
reason: Annotated[
|
|
152
|
+
str | None,
|
|
153
|
+
typer.Option(help="Why a new baseline replaces one measured with the same recipe."),
|
|
154
|
+
] = None,
|
|
151
155
|
root: RootOption = None,
|
|
152
156
|
) -> None:
|
|
153
157
|
"""Record a measurement; without --value or --run, show how to take it."""
|
|
@@ -163,6 +167,15 @@ def measure(
|
|
|
163
167
|
fingerprint = recipes.fingerprint(store.dir, kpi.recipe)
|
|
164
168
|
if fingerprint is None:
|
|
165
169
|
raise FdeError(f"Recipe .fde/{kpi.recipe.file} does not exist: write it first.")
|
|
170
|
+
previous = rules.latest_baseline(store.measurements(), kpi.id)
|
|
171
|
+
rebaseline_reason: str | None = None
|
|
172
|
+
if baseline is not None and previous is not None:
|
|
173
|
+
if previous.recipe_sha256 == fingerprint:
|
|
174
|
+
rebaseline_reason = require(
|
|
175
|
+
reason, "--reason", "to replace a baseline measured with the same recipe"
|
|
176
|
+
)
|
|
177
|
+
else:
|
|
178
|
+
rebaseline_reason = reason or "The recipe changed after the previous baseline."
|
|
166
179
|
if kpi.recipe.type is RecipeType.SCRIPT:
|
|
167
180
|
if value is not None:
|
|
168
181
|
raise FdeError("Script recipes are run, not typed: use --run.")
|
|
@@ -185,7 +198,6 @@ def measure(
|
|
|
185
198
|
)
|
|
186
199
|
return
|
|
187
200
|
source = require(source, "--source", "to record a measurement")
|
|
188
|
-
previous = rules.latest_baseline(store.measurements(), kpi.id)
|
|
189
201
|
store.append_measurement(
|
|
190
202
|
Measurement(
|
|
191
203
|
kpi=kpi.id,
|
|
@@ -199,6 +211,14 @@ def measure(
|
|
|
199
211
|
recipe_sha256=fingerprint,
|
|
200
212
|
)
|
|
201
213
|
)
|
|
214
|
+
if rebaseline_reason is not None:
|
|
215
|
+
record(
|
|
216
|
+
store,
|
|
217
|
+
DecisionType.BASELINE_CHANGE,
|
|
218
|
+
f"New {baseline} baseline of {kpi.id}: {number(value)}",
|
|
219
|
+
rebaseline_reason,
|
|
220
|
+
refs=[kpi.id],
|
|
221
|
+
)
|
|
202
222
|
unit = f" {kpi.unit}" if kpi.unit else ""
|
|
203
223
|
label = f"{baseline} baseline" if baseline else "measurement"
|
|
204
224
|
typer.echo(f"Recorded {label} {kpi.id} = {number(value)}{unit} (window {start} to {end}).")
|
{fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/commands/northstar.py
RENAMED
|
@@ -78,13 +78,30 @@ def set_(
|
|
|
78
78
|
|
|
79
79
|
|
|
80
80
|
@app.command()
|
|
81
|
-
def confirm(
|
|
82
|
-
|
|
81
|
+
def confirm(
|
|
82
|
+
reason: Annotated[
|
|
83
|
+
str, typer.Option(help="Why it is confirmed.")
|
|
84
|
+
] = "Confirmed a North Star inferred from existing work.",
|
|
85
|
+
approved_by: Annotated[str, typer.Option("--approved-by", help="Who agreed it.")] = "",
|
|
86
|
+
root: RootOption = None,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Mark an inferred North Star as confirmed; records a decision."""
|
|
83
89
|
with user_errors():
|
|
84
90
|
store = open_store(root)
|
|
85
91
|
direction = store.load()
|
|
86
|
-
|
|
92
|
+
north_star = direction.north_star
|
|
93
|
+
if north_star is None:
|
|
87
94
|
raise FdeError("There is no North Star to confirm.")
|
|
88
|
-
|
|
95
|
+
if north_star.confirmed:
|
|
96
|
+
typer.echo("North Star already confirmed.")
|
|
97
|
+
return
|
|
98
|
+
north_star.confirmed = True
|
|
89
99
|
store.save(direction)
|
|
100
|
+
record(
|
|
101
|
+
store,
|
|
102
|
+
DecisionType.NORTH_STAR_CHANGE,
|
|
103
|
+
f"Confirm North Star: {north_star.statement}",
|
|
104
|
+
reason,
|
|
105
|
+
approved_by,
|
|
106
|
+
)
|
|
90
107
|
typer.echo("North Star confirmed.")
|
|
@@ -245,6 +245,9 @@ def confirm(
|
|
|
245
245
|
store = open_store(root)
|
|
246
246
|
direction = store.load()
|
|
247
247
|
path = find_path(direction, path_id)
|
|
248
|
+
if path.confirmed:
|
|
249
|
+
typer.echo(f"Path {path_id} already confirmed.")
|
|
250
|
+
return
|
|
248
251
|
path.confirmed = True
|
|
249
252
|
store.save(direction)
|
|
250
253
|
record(
|
|
@@ -65,14 +65,25 @@ def set_(
|
|
|
65
65
|
|
|
66
66
|
|
|
67
67
|
@app.command()
|
|
68
|
-
def confirm(
|
|
69
|
-
|
|
68
|
+
def confirm(
|
|
69
|
+
reason: Annotated[
|
|
70
|
+
str, typer.Option(help="Why it is confirmed.")
|
|
71
|
+
] = "Confirmed a stage inferred from evidence.",
|
|
72
|
+
approved_by: Annotated[str, typer.Option("--approved-by", help="Who agreed it.")] = "",
|
|
73
|
+
root: RootOption = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Mark an inferred stage as confirmed; records a decision."""
|
|
70
76
|
with user_errors():
|
|
71
77
|
store = open_store(root)
|
|
72
78
|
direction = store.load()
|
|
73
|
-
|
|
79
|
+
stage = direction.stage
|
|
80
|
+
if stage.confirmed:
|
|
81
|
+
typer.echo(f"Stage {stage.current} already confirmed.")
|
|
82
|
+
return
|
|
83
|
+
stage.confirmed = True
|
|
74
84
|
store.save(direction)
|
|
75
|
-
|
|
85
|
+
record(store, DecisionType.STAGE_SET, f"Confirm stage {stage.current}", reason, approved_by)
|
|
86
|
+
typer.echo(f"Stage {stage.current} confirmed.")
|
|
76
87
|
|
|
77
88
|
|
|
78
89
|
@app.command()
|
|
@@ -32,9 +32,10 @@ def check_direction() -> CheckResult:
|
|
|
32
32
|
return CheckResult(ok=True, message="No .fde/ here yet: the adopt skill sets one up")
|
|
33
33
|
try:
|
|
34
34
|
store.load()
|
|
35
|
+
store.measurements()
|
|
36
|
+
store.decisions()
|
|
35
37
|
except FdeError as error:
|
|
36
|
-
|
|
37
|
-
return CheckResult(ok=False, message=f".fde/ cannot be read: {first_line}")
|
|
38
|
+
return CheckResult(ok=False, message=f".fde/ cannot be read: {error}")
|
|
38
39
|
return CheckResult(ok=True, message=f".fde/ uses schema {SCHEMA_VERSION}: {store.dir}")
|
|
39
40
|
|
|
40
41
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Read and write `.fde/`. direction.yaml is validated, rules included, before every write."""
|
|
2
2
|
|
|
3
|
+
import hashlib
|
|
3
4
|
import os
|
|
4
5
|
import tempfile
|
|
5
6
|
from pathlib import Path
|
|
@@ -53,6 +54,7 @@ class Store:
|
|
|
53
54
|
self.root = root
|
|
54
55
|
self.dir = root / FDE_DIR
|
|
55
56
|
self.direction_file = self.dir / DIRECTION_FILE
|
|
57
|
+
self._loaded_digest: str | None = None
|
|
56
58
|
|
|
57
59
|
def exists(self) -> bool:
|
|
58
60
|
return self.direction_file.is_file()
|
|
@@ -71,17 +73,29 @@ class Store:
|
|
|
71
73
|
raise FdeError(
|
|
72
74
|
f"No .fde/ in {self.root}: run `fde-workflow init` (or the adopt skill) first."
|
|
73
75
|
)
|
|
76
|
+
text = read_text(self.direction_file)
|
|
77
|
+
self._loaded_digest = _digest(text)
|
|
74
78
|
try:
|
|
75
|
-
data = yaml.safe_load(
|
|
79
|
+
data = yaml.safe_load(text)
|
|
76
80
|
except yaml.YAMLError as error:
|
|
77
81
|
raise FdeError(f"{self.direction_file} is not valid YAML: {error}") from error
|
|
78
82
|
if not isinstance(data, dict):
|
|
79
83
|
raise FdeError(f"{self.direction_file} must contain a YAML mapping.")
|
|
80
84
|
version = data.get("schema_version")
|
|
85
|
+
if version is None:
|
|
86
|
+
raise FdeError(
|
|
87
|
+
f"{self.direction_file} has no schema_version: it is not an fde-workflow "
|
|
88
|
+
"direction file, or it was damaged."
|
|
89
|
+
)
|
|
90
|
+
if isinstance(version, int) and version > SCHEMA_VERSION:
|
|
91
|
+
raise FdeError(
|
|
92
|
+
f"{self.direction_file} has schema_version {version}; this fde-workflow "
|
|
93
|
+
f"supports {SCHEMA_VERSION}. Upgrade it: uv tool upgrade fde-workflow-cli."
|
|
94
|
+
)
|
|
81
95
|
if version != SCHEMA_VERSION:
|
|
82
96
|
raise FdeError(
|
|
83
97
|
f"{self.direction_file} has schema_version {version!r}; this fde-workflow "
|
|
84
|
-
f"
|
|
98
|
+
f"reads only schema_version {SCHEMA_VERSION}."
|
|
85
99
|
)
|
|
86
100
|
try:
|
|
87
101
|
return Direction.model_validate(data)
|
|
@@ -103,12 +117,27 @@ class Store:
|
|
|
103
117
|
if errors:
|
|
104
118
|
listed = "\n".join(f" [{issue.rule}] {issue.message}" for issue in errors)
|
|
105
119
|
raise FdeError(f"Not saved: the change breaks the framework's rules:\n{listed}")
|
|
120
|
+
if (
|
|
121
|
+
self._loaded_digest is not None
|
|
122
|
+
and self.direction_file.is_file()
|
|
123
|
+
and _digest(read_text(self.direction_file)) != self._loaded_digest
|
|
124
|
+
):
|
|
125
|
+
raise FdeError(
|
|
126
|
+
f"{self.direction_file} changed since it was read (another fde-workflow "
|
|
127
|
+
"command?): run the command again."
|
|
128
|
+
)
|
|
106
129
|
text = yaml.safe_dump(data, sort_keys=False, allow_unicode=True)
|
|
107
130
|
with tempfile.NamedTemporaryFile(
|
|
108
131
|
"w", encoding="utf-8", dir=self.dir, suffix=".tmp", delete=False
|
|
109
132
|
) as handle:
|
|
110
133
|
handle.write(text)
|
|
111
|
-
|
|
134
|
+
try:
|
|
135
|
+
os.chmod(handle.name, 0o666 & ~_umask())
|
|
136
|
+
os.replace(handle.name, self.direction_file)
|
|
137
|
+
except OSError as error:
|
|
138
|
+
Path(handle.name).unlink(missing_ok=True)
|
|
139
|
+
raise FdeError(f"Cannot write {self.direction_file}: {error.strerror}.") from error
|
|
140
|
+
self._loaded_digest = _digest(text)
|
|
112
141
|
|
|
113
142
|
def measurements(self) -> list[Measurement]:
|
|
114
143
|
return _read_jsonl(self.dir / MEASUREMENTS_FILE, Measurement)
|
|
@@ -123,6 +152,16 @@ class Store:
|
|
|
123
152
|
_append_jsonl(self.dir / DECISIONS_FILE, decision)
|
|
124
153
|
|
|
125
154
|
|
|
155
|
+
def _digest(text: str) -> str:
|
|
156
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _umask() -> int:
|
|
160
|
+
mask = os.umask(0)
|
|
161
|
+
os.umask(mask)
|
|
162
|
+
return mask
|
|
163
|
+
|
|
164
|
+
|
|
126
165
|
def _read_jsonl[M: BaseModel](path: Path, model: type[M]) -> list[M]:
|
|
127
166
|
if not path.is_file():
|
|
128
167
|
return []
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|