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.
Files changed (25) hide show
  1. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/PKG-INFO +1 -1
  2. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/pyproject.toml +1 -1
  3. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/commands/kpi.py +21 -1
  4. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/commands/northstar.py +21 -4
  5. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/commands/path.py +3 -0
  6. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/commands/stage.py +15 -4
  7. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/doctor.py +3 -2
  8. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/model.py +1 -0
  9. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/store.py +42 -3
  10. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/LICENSE +0 -0
  11. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/README.md +0 -0
  12. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/__init__.py +0 -0
  13. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/__main__.py +0 -0
  14. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/clock.py +0 -0
  15. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/commands/__init__.py +0 -0
  16. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/commands/common.py +0 -0
  17. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/commands/decision.py +0 -0
  18. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/commands/hook.py +0 -0
  19. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/commands/project.py +0 -0
  20. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/detect.py +0 -0
  21. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/errors.py +0 -0
  22. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/main.py +0 -0
  23. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/recipes.py +0 -0
  24. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.1.1}/src/fde_workflow_cli/rules.py +0 -0
  25. {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.0
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.0"
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}).")
@@ -78,13 +78,30 @@ def set_(
78
78
 
79
79
 
80
80
  @app.command()
81
- def confirm(root: RootOption = None) -> None:
82
- """Mark an inferred North Star as confirmed."""
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
- if direction.north_star is None:
92
+ north_star = direction.north_star
93
+ if north_star is None:
87
94
  raise FdeError("There is no North Star to confirm.")
88
- direction.north_star.confirmed = True
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(root: RootOption = None) -> None:
69
- """Mark an inferred stage as confirmed."""
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
- direction.stage.confirmed = True
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
- typer.echo(f"Stage {direction.stage.current} confirmed.")
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
- first_line = str(error).splitlines()[0]
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
 
@@ -83,6 +83,7 @@ class DecisionType(StrEnum):
83
83
  PATH_DISCARD = "path-discard"
84
84
  STAGE_GATE = "stage-gate"
85
85
  STAGE_SET = "stage-set"
86
+ BASELINE_CHANGE = "baseline-change"
86
87
  SCOPE_CHANGE = "scope-change"
87
88
  OTHER = "other"
88
89
 
@@ -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(read_text(self.direction_file))
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"supports {SCHEMA_VERSION}. Upgrade it: uv tool upgrade fde-workflow-cli."
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
- os.replace(handle.name, self.direction_file)
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 []