fde-workflow-cli 0.1.0__tar.gz → 0.2.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.
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/PKG-INFO +10 -4
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/README.md +9 -3
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/pyproject.toml +1 -1
- fde_workflow_cli-0.2.0/src/fde_workflow_cli/commands/hook.py +107 -0
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/commands/kpi.py +33 -1
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/commands/northstar.py +21 -4
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/commands/path.py +3 -0
- fde_workflow_cli-0.2.0/src/fde_workflow_cli/commands/portfolio.py +72 -0
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/commands/project.py +13 -1
- fde_workflow_cli-0.2.0/src/fde_workflow_cli/commands/report.py +65 -0
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/commands/stage.py +15 -4
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/detect.py +9 -0
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/doctor.py +3 -2
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/main.py +14 -1
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/model.py +1 -0
- fde_workflow_cli-0.2.0/src/fde_workflow_cli/registry.py +70 -0
- fde_workflow_cli-0.2.0/src/fde_workflow_cli/report.py +176 -0
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/rules.py +45 -1
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/store.py +42 -3
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/summary.py +3 -0
- fde_workflow_cli-0.1.0/src/fde_workflow_cli/commands/hook.py +0 -52
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/LICENSE +0 -0
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/__init__.py +0 -0
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/__main__.py +0 -0
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/clock.py +0 -0
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/commands/__init__.py +0 -0
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/commands/common.py +0 -0
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/commands/decision.py +0 -0
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/errors.py +0 -0
- {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/recipes.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fde-workflow-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
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
|
|
@@ -35,14 +35,20 @@ fde-workflow doctor # checks that everything is ready
|
|
|
35
35
|
|---|---|
|
|
36
36
|
| `init`, `detect` | Set up `.fde/`; inspect a repository's methodology and artifacts |
|
|
37
37
|
| `status`, `context`, `validate` | Summaries for people and for Claude; rule checks |
|
|
38
|
+
| `drift` | Specs, plans or other artifacts that no path links |
|
|
39
|
+
| `report status\|close` | Markdown reports in `.fde/reports/` for the sponsor |
|
|
40
|
+
| `portfolio`, `portfolio forget` | Every repository on this machine that uses the framework |
|
|
38
41
|
| `project set` | The project's name, customer and sponsor |
|
|
39
42
|
| `northstar set\|confirm` | The North Star |
|
|
40
|
-
| `kpi add\|update\|measure` | KPIs, their recipes and
|
|
43
|
+
| `kpi add\|update\|measure\|due` | KPIs, their recipes, measurements and the ones due |
|
|
41
44
|
| `path add\|update\|move\|link\|confirm` | Paths and their links to execution work |
|
|
42
45
|
| `stage set\|confirm\|criteria\|decide` | The project stage and its gates |
|
|
43
46
|
| `decision add` | Other direction decisions |
|
|
44
|
-
| `hook session-start` | Entry
|
|
47
|
+
| `hook session-start\|post-tool-use` | Entry points of the plugin's hooks |
|
|
45
48
|
|
|
46
|
-
Run `fde-workflow <command> --help` for the options.
|
|
49
|
+
Run `fde-workflow <command> --help` for the options. `init`, `status`, `validate`, `context` and
|
|
50
|
+
the SessionStart hook add the repository to the portfolio registry,
|
|
51
|
+
`$XDG_CONFIG_HOME/fde-workflow/projects.json` (default `~/.config/fde-workflow/projects.json`),
|
|
52
|
+
which holds repository paths only. Requires Python 3.13 or newer. Source and
|
|
47
53
|
plugin: https://github.com/mjfura/fde-workflow
|
|
48
54
|
|
|
@@ -13,13 +13,19 @@ fde-workflow doctor # checks that everything is ready
|
|
|
13
13
|
|---|---|
|
|
14
14
|
| `init`, `detect` | Set up `.fde/`; inspect a repository's methodology and artifacts |
|
|
15
15
|
| `status`, `context`, `validate` | Summaries for people and for Claude; rule checks |
|
|
16
|
+
| `drift` | Specs, plans or other artifacts that no path links |
|
|
17
|
+
| `report status\|close` | Markdown reports in `.fde/reports/` for the sponsor |
|
|
18
|
+
| `portfolio`, `portfolio forget` | Every repository on this machine that uses the framework |
|
|
16
19
|
| `project set` | The project's name, customer and sponsor |
|
|
17
20
|
| `northstar set\|confirm` | The North Star |
|
|
18
|
-
| `kpi add\|update\|measure` | KPIs, their recipes and
|
|
21
|
+
| `kpi add\|update\|measure\|due` | KPIs, their recipes, measurements and the ones due |
|
|
19
22
|
| `path add\|update\|move\|link\|confirm` | Paths and their links to execution work |
|
|
20
23
|
| `stage set\|confirm\|criteria\|decide` | The project stage and its gates |
|
|
21
24
|
| `decision add` | Other direction decisions |
|
|
22
|
-
| `hook session-start` | Entry
|
|
25
|
+
| `hook session-start\|post-tool-use` | Entry points of the plugin's hooks |
|
|
23
26
|
|
|
24
|
-
Run `fde-workflow <command> --help` for the options.
|
|
27
|
+
Run `fde-workflow <command> --help` for the options. `init`, `status`, `validate`, `context` and
|
|
28
|
+
the SessionStart hook add the repository to the portfolio registry,
|
|
29
|
+
`$XDG_CONFIG_HOME/fde-workflow/projects.json` (default `~/.config/fde-workflow/projects.json`),
|
|
30
|
+
which holds repository paths only. Requires Python 3.13 or newer. Source and
|
|
25
31
|
plugin: https://github.com/mjfura/fde-workflow
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fde-workflow-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
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"
|
|
@@ -0,0 +1,107 @@
|
|
|
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 detect, registry, 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 parse(payload: str) -> dict[str, object]:
|
|
18
|
+
"""The hook's JSON input, or an empty mapping when it is missing or malformed."""
|
|
19
|
+
try:
|
|
20
|
+
data = json.loads(payload) if payload.strip() else {}
|
|
21
|
+
except json.JSONDecodeError:
|
|
22
|
+
return {}
|
|
23
|
+
return data if isinstance(data, dict) else {}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def store_for(data: dict[str, object]) -> Store:
|
|
27
|
+
cwd = data.get("cwd")
|
|
28
|
+
start = Path(cwd) if isinstance(cwd, str) else Path(os.environ.get("CLAUDE_PROJECT_DIR", "."))
|
|
29
|
+
return Store(locate(start))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def session_context(payload: str) -> str | None:
|
|
33
|
+
"""The context for a new session, or None when the repository has no .fde/."""
|
|
34
|
+
store = store_for(parse(payload))
|
|
35
|
+
if not store.exists():
|
|
36
|
+
return None
|
|
37
|
+
try:
|
|
38
|
+
direction = store.load()
|
|
39
|
+
measurements = store.measurements()
|
|
40
|
+
except FdeError as error:
|
|
41
|
+
return (
|
|
42
|
+
f"{summary.CONTEXT_HEADER}\n\n.fde/ cannot be read: {error}\n"
|
|
43
|
+
"Run `fde-workflow validate`."
|
|
44
|
+
)
|
|
45
|
+
issues = rules.check(direction, measurements, store.dir)
|
|
46
|
+
registry.register(store.root)
|
|
47
|
+
return summary.context(direction, measurements, issues, brief=True)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def post_tool_use_context(payload: str) -> str | None:
|
|
51
|
+
"""A nudge when Write created an execution artifact that no path links (rule L1)."""
|
|
52
|
+
data = parse(payload)
|
|
53
|
+
tool_input = data.get("tool_input")
|
|
54
|
+
if data.get("tool_name") != "Write" or not isinstance(tool_input, dict):
|
|
55
|
+
return None
|
|
56
|
+
file_path = tool_input.get("file_path")
|
|
57
|
+
if not isinstance(file_path, str):
|
|
58
|
+
return None
|
|
59
|
+
written = Path(file_path)
|
|
60
|
+
if not written.is_absolute():
|
|
61
|
+
written = store_for(data).root / written
|
|
62
|
+
store = Store(locate(written.parent))
|
|
63
|
+
if not store.exists():
|
|
64
|
+
return None
|
|
65
|
+
if not written.resolve().is_relative_to(store.root.resolve()):
|
|
66
|
+
return None
|
|
67
|
+
relative = written.resolve().relative_to(store.root.resolve()).as_posix()
|
|
68
|
+
try:
|
|
69
|
+
direction = store.load()
|
|
70
|
+
except FdeError:
|
|
71
|
+
return None
|
|
72
|
+
if relative not in detect.drift(store.root, direction):
|
|
73
|
+
return None
|
|
74
|
+
return (
|
|
75
|
+
f"fde-workflow: {relative} is an execution artifact that no path links. Ask the user "
|
|
76
|
+
"which path it serves and link it (`fde-workflow path link <path-id> "
|
|
77
|
+
f"{relative}`), or say plainly that it is outside the direction (drift). The path skill "
|
|
78
|
+
"covers this."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.command("post-tool-use")
|
|
83
|
+
def post_tool_use() -> None:
|
|
84
|
+
"""Nudge about an unlinked execution artifact (reads the hook's JSON on stdin)."""
|
|
85
|
+
try:
|
|
86
|
+
text = post_tool_use_context(sys.stdin.read())
|
|
87
|
+
except Exception as error: # a hook must never interrupt the session
|
|
88
|
+
typer.echo(f"fde-workflow hook: {error}", err=True)
|
|
89
|
+
return
|
|
90
|
+
if text:
|
|
91
|
+
output = {"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": text}}
|
|
92
|
+
typer.echo(json.dumps(output))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command("session-start")
|
|
96
|
+
def session_start() -> None:
|
|
97
|
+
"""Print the direction as SessionStart context (reads the hook's JSON on stdin)."""
|
|
98
|
+
try:
|
|
99
|
+
text = session_context(sys.stdin.read())
|
|
100
|
+
except Exception as error: # a hook must never interrupt the session
|
|
101
|
+
typer.echo(f"fde-workflow hook: {error}", err=True)
|
|
102
|
+
return
|
|
103
|
+
if text:
|
|
104
|
+
output = {
|
|
105
|
+
"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": text}
|
|
106
|
+
}
|
|
107
|
+
typer.echo(json.dumps(output))
|
|
@@ -130,6 +130,18 @@ def update(
|
|
|
130
130
|
typer.echo(f"Updated KPI {kpi_id}.")
|
|
131
131
|
|
|
132
132
|
|
|
133
|
+
@app.command()
|
|
134
|
+
def due(root: RootOption = None) -> None:
|
|
135
|
+
"""List the KPIs that are due: last measured a full window ago or more."""
|
|
136
|
+
with user_errors():
|
|
137
|
+
store = open_store(root)
|
|
138
|
+
found = rules.due_kpis(store.load(), store.measurements())
|
|
139
|
+
if not found:
|
|
140
|
+
typer.echo("No KPI is due.")
|
|
141
|
+
for kpi, last in found:
|
|
142
|
+
typer.echo(f"{kpi.id}: last measured on {last.taken} (window {kpi.window})")
|
|
143
|
+
|
|
144
|
+
|
|
133
145
|
@app.command()
|
|
134
146
|
def measure(
|
|
135
147
|
kpi_id: Annotated[str, typer.Argument()],
|
|
@@ -148,6 +160,10 @@ def measure(
|
|
|
148
160
|
run: Annotated[
|
|
149
161
|
bool, typer.Option("--run", help="Run a script recipe and record its output.")
|
|
150
162
|
] = False,
|
|
163
|
+
reason: Annotated[
|
|
164
|
+
str | None,
|
|
165
|
+
typer.Option(help="Why a new baseline replaces one measured with the same recipe."),
|
|
166
|
+
] = None,
|
|
151
167
|
root: RootOption = None,
|
|
152
168
|
) -> None:
|
|
153
169
|
"""Record a measurement; without --value or --run, show how to take it."""
|
|
@@ -163,6 +179,15 @@ def measure(
|
|
|
163
179
|
fingerprint = recipes.fingerprint(store.dir, kpi.recipe)
|
|
164
180
|
if fingerprint is None:
|
|
165
181
|
raise FdeError(f"Recipe .fde/{kpi.recipe.file} does not exist: write it first.")
|
|
182
|
+
previous = rules.latest_baseline(store.measurements(), kpi.id)
|
|
183
|
+
rebaseline_reason: str | None = None
|
|
184
|
+
if baseline is not None and previous is not None:
|
|
185
|
+
if previous.recipe_sha256 == fingerprint:
|
|
186
|
+
rebaseline_reason = require(
|
|
187
|
+
reason, "--reason", "to replace a baseline measured with the same recipe"
|
|
188
|
+
)
|
|
189
|
+
else:
|
|
190
|
+
rebaseline_reason = reason or "The recipe changed after the previous baseline."
|
|
166
191
|
if kpi.recipe.type is RecipeType.SCRIPT:
|
|
167
192
|
if value is not None:
|
|
168
193
|
raise FdeError("Script recipes are run, not typed: use --run.")
|
|
@@ -185,7 +210,6 @@ def measure(
|
|
|
185
210
|
)
|
|
186
211
|
return
|
|
187
212
|
source = require(source, "--source", "to record a measurement")
|
|
188
|
-
previous = rules.latest_baseline(store.measurements(), kpi.id)
|
|
189
213
|
store.append_measurement(
|
|
190
214
|
Measurement(
|
|
191
215
|
kpi=kpi.id,
|
|
@@ -199,6 +223,14 @@ def measure(
|
|
|
199
223
|
recipe_sha256=fingerprint,
|
|
200
224
|
)
|
|
201
225
|
)
|
|
226
|
+
if rebaseline_reason is not None:
|
|
227
|
+
record(
|
|
228
|
+
store,
|
|
229
|
+
DecisionType.BASELINE_CHANGE,
|
|
230
|
+
f"New {baseline} baseline of {kpi.id}: {number(value)}",
|
|
231
|
+
rebaseline_reason,
|
|
232
|
+
refs=[kpi.id],
|
|
233
|
+
)
|
|
202
234
|
unit = f" {kpi.unit}" if kpi.unit else ""
|
|
203
235
|
label = f"{baseline} baseline" if baseline else "measurement"
|
|
204
236
|
typer.echo(f"Recorded {label} {kpi.id} = {number(value)}{unit} (window {start} to {end}).")
|
{fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/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(
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""`fde-workflow portfolio`: every repository on this machine that uses the framework."""
|
|
2
|
+
|
|
3
|
+
import shlex
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from fde_workflow_cli import registry, rules, summary
|
|
10
|
+
from fde_workflow_cli.errors import FdeError
|
|
11
|
+
from fde_workflow_cli.store import Store
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(
|
|
14
|
+
help="Every repository on this machine that uses the framework.",
|
|
15
|
+
invoke_without_command=True,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def project_line(root: Path) -> str:
|
|
20
|
+
store = Store(root)
|
|
21
|
+
if not store.exists():
|
|
22
|
+
command = f"fde-workflow portfolio forget {shlex.quote(str(root))}"
|
|
23
|
+
return f"{root} · no .fde/ any more: `{command}`"
|
|
24
|
+
try:
|
|
25
|
+
direction = store.load()
|
|
26
|
+
measurements = store.measurements()
|
|
27
|
+
except FdeError as error:
|
|
28
|
+
first_line = next(iter(str(error).splitlines()), "")
|
|
29
|
+
return f"{root} · .fde/ cannot be read: {first_line}"
|
|
30
|
+
issues = rules.check(direction, measurements, store.dir)
|
|
31
|
+
errors = sum(issue.level == "error" for issue in issues)
|
|
32
|
+
primary_id = direction.north_star.primary_kpi if direction.north_star else None
|
|
33
|
+
primary = direction.kpi(primary_id) if primary_id else None
|
|
34
|
+
kpi = summary.kpi_line(primary, measurements) if primary else "no primary KPI"
|
|
35
|
+
due = len(rules.due_kpis(direction, measurements))
|
|
36
|
+
stage = str(direction.stage.current) + (" (on hold)" if direction.stage.on_hold else "")
|
|
37
|
+
return (
|
|
38
|
+
f"{summary.clean(direction.project.name)} · {stage} · {kpi} · {due} due · "
|
|
39
|
+
f"{errors} error(s), {len(issues) - errors} warning(s) · {root}"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.callback()
|
|
44
|
+
def show(ctx: typer.Context) -> None:
|
|
45
|
+
"""One line per project: stage, primary KPI, due KPIs, errors and warnings."""
|
|
46
|
+
if ctx.invoked_subcommand:
|
|
47
|
+
return
|
|
48
|
+
try:
|
|
49
|
+
projects = registry.registered()
|
|
50
|
+
except registry.RegistryError as error:
|
|
51
|
+
typer.echo(f"The portfolio registry {error}")
|
|
52
|
+
return
|
|
53
|
+
if not projects:
|
|
54
|
+
typer.echo("No projects registered yet: run `fde-workflow init` in a repository.")
|
|
55
|
+
for root in projects:
|
|
56
|
+
typer.echo(project_line(root))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.command()
|
|
60
|
+
def forget(
|
|
61
|
+
path: Annotated[Path, typer.Argument(help="The repository to remove from the portfolio.")],
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Remove a repository from the portfolio (its .fde/ is not touched)."""
|
|
64
|
+
try:
|
|
65
|
+
forgotten = registry.forget(path)
|
|
66
|
+
except registry.RegistryError as error:
|
|
67
|
+
typer.echo(f"Error: the portfolio registry {error}", err=True)
|
|
68
|
+
raise typer.Exit(1) from error
|
|
69
|
+
if forgotten:
|
|
70
|
+
typer.echo(f"Forgot {path.resolve()}.")
|
|
71
|
+
else:
|
|
72
|
+
typer.echo(f"{path.resolve()} was not in the portfolio.")
|
|
@@ -6,7 +6,7 @@ from typing import Annotated
|
|
|
6
6
|
|
|
7
7
|
import typer
|
|
8
8
|
|
|
9
|
-
from fde_workflow_cli import clock, detect, rules, summary
|
|
9
|
+
from fde_workflow_cli import clock, detect, registry, rules, summary
|
|
10
10
|
from fde_workflow_cli.commands.common import RootOption, open_store, user_errors
|
|
11
11
|
from fde_workflow_cli.errors import FdeError
|
|
12
12
|
from fde_workflow_cli.model import Artifacts, Direction, Project, StageState
|
|
@@ -47,6 +47,7 @@ def init(
|
|
|
47
47
|
artifacts=Artifacts(globs=globs),
|
|
48
48
|
)
|
|
49
49
|
)
|
|
50
|
+
registry.register(store.root)
|
|
50
51
|
typer.echo(
|
|
51
52
|
f"Created {store.dir} (stage discovery; artifact globs: {', '.join(globs) or 'none'})."
|
|
52
53
|
)
|
|
@@ -60,11 +61,20 @@ def detect_command(root: RootOption = None) -> None:
|
|
|
60
61
|
typer.echo(json.dumps(detect.report(store.root, direction), indent=2))
|
|
61
62
|
|
|
62
63
|
|
|
64
|
+
def drift(root: RootOption = None) -> None:
|
|
65
|
+
"""List execution artifacts that no path links and that are not pre-framework (rule L1)."""
|
|
66
|
+
with user_errors():
|
|
67
|
+
store = open_store(root)
|
|
68
|
+
found = detect.drift(store.root, store.load())
|
|
69
|
+
typer.echo("\n".join(found) if found else "No drift.")
|
|
70
|
+
|
|
71
|
+
|
|
63
72
|
def validate(root: RootOption = None) -> None:
|
|
64
73
|
"""Check .fde/ against the framework's rules; exit 1 when a rule is broken."""
|
|
65
74
|
with user_errors():
|
|
66
75
|
store = open_store(root)
|
|
67
76
|
issues = rules.check(store.load(), store.measurements(), store.dir)
|
|
77
|
+
registry.register(store.root)
|
|
68
78
|
for issue in issues:
|
|
69
79
|
typer.echo(f"{'✗' if issue.level == 'error' else '!'} [{issue.rule}] {issue.message}")
|
|
70
80
|
errors = sum(issue.level == "error" for issue in issues)
|
|
@@ -82,6 +92,7 @@ def status(root: RootOption = None) -> None:
|
|
|
82
92
|
direction = store.load()
|
|
83
93
|
measurements = store.measurements()
|
|
84
94
|
issues = rules.check(direction, measurements, store.dir)
|
|
95
|
+
registry.register(store.root)
|
|
85
96
|
typer.echo(summary.render(direction, measurements, issues))
|
|
86
97
|
|
|
87
98
|
|
|
@@ -95,6 +106,7 @@ def context(
|
|
|
95
106
|
direction = store.load()
|
|
96
107
|
measurements = store.measurements()
|
|
97
108
|
issues = rules.check(direction, measurements, store.dir)
|
|
109
|
+
registry.register(store.root)
|
|
98
110
|
typer.echo(summary.context(direction, measurements, issues, brief=brief))
|
|
99
111
|
|
|
100
112
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""`fde-workflow report`: write a status or closing report to `.fde/reports/`."""
|
|
2
|
+
|
|
3
|
+
import datetime as dt
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from fde_workflow_cli import clock, detect, report, rules
|
|
11
|
+
from fde_workflow_cli.commands.common import RootOption, open_store, parse_date, user_errors
|
|
12
|
+
from fde_workflow_cli.report import ReportKind
|
|
13
|
+
|
|
14
|
+
STATUS_FILE = re.compile(r"^(\d{4}-\d{2}-\d{2})-status(?:-\d+)?\.md$")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def previous_status_date(reports: Path, today: dt.date) -> dt.date | None:
|
|
18
|
+
"""Date of the latest status report before today: regenerating one keeps its period."""
|
|
19
|
+
dates = [
|
|
20
|
+
date
|
|
21
|
+
for path in reports.glob("*.md")
|
|
22
|
+
if (match := STATUS_FILE.match(path.name))
|
|
23
|
+
and (date := dt.date.fromisoformat(match.group(1))) < today
|
|
24
|
+
]
|
|
25
|
+
return max(dates, default=None)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def free_path(reports: Path, stem: str) -> Path:
|
|
29
|
+
path, number = reports / f"{stem}.md", 2
|
|
30
|
+
while path.exists():
|
|
31
|
+
path, number = reports / f"{stem}-{number}.md", number + 1
|
|
32
|
+
return path
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def report_command(
|
|
36
|
+
kind: Annotated[ReportKind, typer.Argument(help="status (for the sponsor) or close.")],
|
|
37
|
+
since: Annotated[
|
|
38
|
+
str | None,
|
|
39
|
+
typer.Option(help="Decisions from this date (status default: the previous report)."),
|
|
40
|
+
] = None,
|
|
41
|
+
root: RootOption = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Write a Markdown report to .fde/reports/ and print its path."""
|
|
44
|
+
with user_errors():
|
|
45
|
+
store = open_store(root)
|
|
46
|
+
direction = store.load()
|
|
47
|
+
measurements = store.measurements()
|
|
48
|
+
reports = store.dir / "reports"
|
|
49
|
+
reports.mkdir(exist_ok=True)
|
|
50
|
+
start = parse_date(since, "--since") if since else None
|
|
51
|
+
if start is None and kind is ReportKind.STATUS:
|
|
52
|
+
start = previous_status_date(reports, clock.today())
|
|
53
|
+
text = report.render(
|
|
54
|
+
kind,
|
|
55
|
+
direction,
|
|
56
|
+
measurements,
|
|
57
|
+
store.decisions(),
|
|
58
|
+
rules.check(direction, measurements, store.dir),
|
|
59
|
+
detect.drift(store.root, direction),
|
|
60
|
+
start,
|
|
61
|
+
rules.stale_baselines(direction, measurements, store.dir),
|
|
62
|
+
)
|
|
63
|
+
path = free_path(reports, f"{clock.today()}-{kind}")
|
|
64
|
+
path.write_text(text, encoding="utf-8")
|
|
65
|
+
typer.echo(str(path))
|
|
@@ -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()
|
|
@@ -102,6 +102,15 @@ def linked_to(direction: Direction | None, relative: str) -> str | None:
|
|
|
102
102
|
return None
|
|
103
103
|
|
|
104
104
|
|
|
105
|
+
def drift(root: Path, direction: Direction) -> list[str]:
|
|
106
|
+
"""L1: artifacts matching the globs that no path links and that are not pre-framework."""
|
|
107
|
+
return [
|
|
108
|
+
relative
|
|
109
|
+
for relative in artifact_files(root, direction.artifacts.globs)
|
|
110
|
+
if linked_to(direction, relative) is None
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
|
|
105
114
|
def report(root: Path, direction: Direction | None) -> dict[str, object]:
|
|
106
115
|
methodology = detect_methodology(root)
|
|
107
116
|
globs = direction.artifacts.globs if direction else PRESET_GLOBS[methodology]
|
|
@@ -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
|
|
|
@@ -6,7 +6,17 @@ from typing import Annotated
|
|
|
6
6
|
import typer
|
|
7
7
|
|
|
8
8
|
from fde_workflow_cli import doctor as checks
|
|
9
|
-
from fde_workflow_cli.commands import
|
|
9
|
+
from fde_workflow_cli.commands import (
|
|
10
|
+
decision,
|
|
11
|
+
hook,
|
|
12
|
+
kpi,
|
|
13
|
+
northstar,
|
|
14
|
+
path,
|
|
15
|
+
portfolio,
|
|
16
|
+
project,
|
|
17
|
+
report,
|
|
18
|
+
stage,
|
|
19
|
+
)
|
|
10
20
|
|
|
11
21
|
COMMAND = "fde-workflow"
|
|
12
22
|
DIST_NAME = "fde-workflow-cli"
|
|
@@ -50,9 +60,11 @@ def doctor() -> None:
|
|
|
50
60
|
|
|
51
61
|
app.command()(project.init)
|
|
52
62
|
app.command("detect")(project.detect_command)
|
|
63
|
+
app.command()(project.drift)
|
|
53
64
|
app.command()(project.validate)
|
|
54
65
|
app.command()(project.status)
|
|
55
66
|
app.command()(project.context)
|
|
67
|
+
app.command("report")(report.report_command)
|
|
56
68
|
app.add_typer(project.app, name="project")
|
|
57
69
|
app.add_typer(kpi.app, name="kpi")
|
|
58
70
|
app.add_typer(northstar.app, name="northstar")
|
|
@@ -60,3 +72,4 @@ app.add_typer(path.app, name="path")
|
|
|
60
72
|
app.add_typer(stage.app, name="stage")
|
|
61
73
|
app.add_typer(decision.app, name="decision")
|
|
62
74
|
app.add_typer(hook.app, name="hook")
|
|
75
|
+
app.add_typer(portfolio.app, name="portfolio")
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""The portfolio registry: the repositories on this machine that use the framework.
|
|
2
|
+
|
|
3
|
+
It lives outside every repository, in $XDG_CONFIG_HOME/fde-workflow/projects.json, and holds
|
|
4
|
+
paths only. Registering must never make a command fail, so write errors are ignored.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RegistryError(Exception):
|
|
14
|
+
"""The registry file exists but cannot be read; it is never overwritten then."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def registry_file() -> Path:
|
|
18
|
+
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
|
19
|
+
return Path(base) / "fde-workflow" / "projects.json"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def registered() -> list[Path]:
|
|
23
|
+
"""The registered roots; raises RegistryError when the file exists but is unreadable."""
|
|
24
|
+
path = registry_file()
|
|
25
|
+
if not path.exists():
|
|
26
|
+
return []
|
|
27
|
+
try:
|
|
28
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
29
|
+
except (OSError, ValueError) as error:
|
|
30
|
+
raise RegistryError(f"{path} cannot be read ({error}): fix or delete it.") from error
|
|
31
|
+
projects = data.get("projects") if isinstance(data, dict) else None
|
|
32
|
+
if not isinstance(projects, list):
|
|
33
|
+
raise RegistryError(f"{path} cannot be read (no projects list): fix or delete it.")
|
|
34
|
+
return [Path(item) for item in projects if isinstance(item, str)]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _write(projects: list[Path]) -> None:
|
|
38
|
+
path = registry_file()
|
|
39
|
+
text = json.dumps({"projects": [str(project) for project in projects]}, indent=2) + "\n"
|
|
40
|
+
try:
|
|
41
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
with tempfile.NamedTemporaryFile(
|
|
43
|
+
"w", encoding="utf-8", dir=path.parent, suffix=".tmp", delete=False
|
|
44
|
+
) as handle:
|
|
45
|
+
handle.write(text)
|
|
46
|
+
except OSError:
|
|
47
|
+
return
|
|
48
|
+
try:
|
|
49
|
+
os.replace(handle.name, path)
|
|
50
|
+
except OSError:
|
|
51
|
+
Path(handle.name).unlink(missing_ok=True)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def register(root: Path) -> None:
|
|
55
|
+
root = root.resolve()
|
|
56
|
+
try:
|
|
57
|
+
projects = registered()
|
|
58
|
+
except RegistryError:
|
|
59
|
+
return
|
|
60
|
+
if root not in projects:
|
|
61
|
+
_write([*projects, root])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def forget(root: Path) -> bool:
|
|
65
|
+
root = root.resolve()
|
|
66
|
+
projects = registered()
|
|
67
|
+
if root not in projects:
|
|
68
|
+
return False
|
|
69
|
+
_write([project for project in projects if project != root])
|
|
70
|
+
return True
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Markdown reports of a direction: a status report for the sponsor and the closing value report."""
|
|
2
|
+
|
|
3
|
+
import datetime as dt
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
|
|
6
|
+
from fde_workflow_cli import clock
|
|
7
|
+
from fde_workflow_cli.model import BaselineType, Better, Decision, Direction, Kpi, Measurement
|
|
8
|
+
from fde_workflow_cli.rules import Issue, latest_baseline
|
|
9
|
+
from fde_workflow_cli.summary import number
|
|
10
|
+
|
|
11
|
+
LIMITATION = (
|
|
12
|
+
"Before/after comparisons do not prove causation: the external factors recorded with each "
|
|
13
|
+
"measurement are listed above."
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ReportKind(StrEnum):
|
|
18
|
+
STATUS = "status"
|
|
19
|
+
CLOSE = "close"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
TITLES = {ReportKind.STATUS: "Status report", ReportKind.CLOSE: "Closing report"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def cell(text: str) -> str:
|
|
26
|
+
"""Whole text on one line, safe inside a Markdown table cell or list item."""
|
|
27
|
+
line = "".join(char for char in " ".join(text.split()) if char.isprintable())
|
|
28
|
+
return line.replace("|", "\\|")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _window(measurement: Measurement) -> str:
|
|
32
|
+
return f"{measurement.window_start} to {measurement.window_end}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _latest(measurements: list[Measurement], kpi_id: str) -> Measurement | None:
|
|
36
|
+
return next((m for m in reversed(measurements) if m.kpi == kpi_id and m.baseline is None), None)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _kpi_row(kpi: Kpi, measurements: list[Measurement], stale: bool) -> str:
|
|
40
|
+
unit = f" {cell(kpi.unit)}" if kpi.unit else ""
|
|
41
|
+
baseline = latest_baseline(measurements, kpi.id)
|
|
42
|
+
latest = _latest(measurements, kpi.id)
|
|
43
|
+
sign = "≤" if kpi.direction is Better.DECREASE else "≥"
|
|
44
|
+
columns = [
|
|
45
|
+
kpi.id,
|
|
46
|
+
str(kpi.kind),
|
|
47
|
+
f"{number(baseline.value)}{unit} ({baseline.baseline}, {_window(baseline)})"
|
|
48
|
+
if baseline
|
|
49
|
+
else "—",
|
|
50
|
+
f"{number(latest.value)}{unit} ({_window(latest)})" if latest else "—",
|
|
51
|
+
f"{sign} {number(kpi.target)}{unit}",
|
|
52
|
+
]
|
|
53
|
+
if stale:
|
|
54
|
+
columns.append("not comparable: recipe changed")
|
|
55
|
+
elif baseline and latest:
|
|
56
|
+
delta = latest.value - baseline.value
|
|
57
|
+
change = f"{delta:+g}{unit}"
|
|
58
|
+
if baseline.value:
|
|
59
|
+
change += f" ({delta / abs(baseline.value):+.1%})"
|
|
60
|
+
columns.append(change)
|
|
61
|
+
else:
|
|
62
|
+
columns.append("—")
|
|
63
|
+
if latest and not stale:
|
|
64
|
+
met = (
|
|
65
|
+
latest.value <= kpi.target
|
|
66
|
+
if kpi.direction is Better.DECREASE
|
|
67
|
+
else latest.value >= kpi.target
|
|
68
|
+
)
|
|
69
|
+
columns.append("yes" if met else "no")
|
|
70
|
+
else:
|
|
71
|
+
columns.append("—")
|
|
72
|
+
return "| " + " | ".join(columns) + " |"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def render(
|
|
76
|
+
kind: ReportKind,
|
|
77
|
+
direction: Direction,
|
|
78
|
+
measurements: list[Measurement],
|
|
79
|
+
decisions: list[Decision],
|
|
80
|
+
issues: list[Issue],
|
|
81
|
+
drift: list[str],
|
|
82
|
+
since: dt.date | None,
|
|
83
|
+
stale: set[str] | None = None,
|
|
84
|
+
) -> str:
|
|
85
|
+
"""The report; `stale` names KPIs whose recipe changed after their baseline (rule K4)."""
|
|
86
|
+
project = direction.project
|
|
87
|
+
lines = [f"# {TITLES[kind]}: {cell(project.name)} — {clock.today()}", ""]
|
|
88
|
+
if project.customer:
|
|
89
|
+
lines.append(f"- Customer: {cell(project.customer)}")
|
|
90
|
+
if project.sponsor:
|
|
91
|
+
lines.append(f"- Sponsor: {cell(project.sponsor)}")
|
|
92
|
+
|
|
93
|
+
lines += ["", "## North Star", ""]
|
|
94
|
+
north_star = direction.north_star
|
|
95
|
+
if north_star is None:
|
|
96
|
+
lines.append("Not defined yet.")
|
|
97
|
+
else:
|
|
98
|
+
inferred = "" if north_star.confirmed else " (inferred)"
|
|
99
|
+
lines.append(f"Statement: {cell(north_star.statement)}{inferred}")
|
|
100
|
+
lines.append("")
|
|
101
|
+
if north_star.beneficiary:
|
|
102
|
+
lines.append(f"- Beneficiary: {cell(north_star.beneficiary)}")
|
|
103
|
+
if north_star.problem:
|
|
104
|
+
lines.append(f"- Problem: {cell(north_star.problem)}")
|
|
105
|
+
lines += [f"- Non-goal: {cell(goal)}" for goal in north_star.non_goals]
|
|
106
|
+
lines.append(f"- Primary KPI: {north_star.primary_kpi or 'not set'}")
|
|
107
|
+
|
|
108
|
+
stage = direction.stage
|
|
109
|
+
lines += ["", "## Stage", "", f"{stage.current} since {stage.entered}"]
|
|
110
|
+
if stage.outcome:
|
|
111
|
+
lines[-1] += f" ({stage.outcome})"
|
|
112
|
+
if stage.on_hold:
|
|
113
|
+
lines[-1] += " (on hold)"
|
|
114
|
+
if stage.gate_criteria:
|
|
115
|
+
lines += ["", "Gate criteria:"] + [f"- {cell(item)}" for item in stage.gate_criteria]
|
|
116
|
+
|
|
117
|
+
lines += ["", "## KPIs", ""]
|
|
118
|
+
if direction.kpis:
|
|
119
|
+
lines += [
|
|
120
|
+
"| KPI | Kind | Baseline | Latest | Target | Change | Target met |",
|
|
121
|
+
"|---|---|---|---|---|---|---|",
|
|
122
|
+
]
|
|
123
|
+
lines += [_kpi_row(kpi, measurements, kpi.id in (stale or set())) for kpi in direction.kpis]
|
|
124
|
+
factors = [m for m in measurements if m.external_factors]
|
|
125
|
+
if factors:
|
|
126
|
+
lines += ["", "External factors:"]
|
|
127
|
+
lines += [f"- {m.kpi}, {m.taken}: {cell(m.external_factors)}" for m in factors]
|
|
128
|
+
else:
|
|
129
|
+
lines.append("None defined yet.")
|
|
130
|
+
|
|
131
|
+
lines += ["", "## Paths", ""]
|
|
132
|
+
if direction.paths:
|
|
133
|
+
lines += [
|
|
134
|
+
"| Path | Status | Hypothesis | Expects | Links | Learning |",
|
|
135
|
+
"|---|---|---|---|---|---|",
|
|
136
|
+
]
|
|
137
|
+
for path in direction.paths:
|
|
138
|
+
expects = ", ".join(f"{k} → {number(v)}" for k, v in path.expects.items())
|
|
139
|
+
links = "<br>".join(cell(link) for link in path.links) or "—"
|
|
140
|
+
lines.append(
|
|
141
|
+
f"| {path.id} | {path.status} | {cell(path.hypothesis)} | {expects} "
|
|
142
|
+
f"| {links} | {cell(path.learning or '')} |"
|
|
143
|
+
)
|
|
144
|
+
else:
|
|
145
|
+
lines.append("None yet.")
|
|
146
|
+
|
|
147
|
+
lines += ["", "## Drift", ""]
|
|
148
|
+
lines += [f"- {item}" for item in drift] if drift else ["None."]
|
|
149
|
+
if direction.artifacts.pre_framework:
|
|
150
|
+
lines += ["", "## Before the framework", ""]
|
|
151
|
+
lines += [f"- {cell(item)}" for item in direction.artifacts.pre_framework]
|
|
152
|
+
|
|
153
|
+
shown = [d for d in decisions if since is None or d.date >= since]
|
|
154
|
+
lines += ["", f"## Decisions since {since}" if since else "## Decisions", ""]
|
|
155
|
+
for item in shown:
|
|
156
|
+
approved = f" (approved by {cell(item.approved_by)})" if item.approved_by else ""
|
|
157
|
+
lines.append(
|
|
158
|
+
f"- {item.date} · {item.type} · {cell(item.summary)} — {cell(item.reason)}{approved}"
|
|
159
|
+
)
|
|
160
|
+
if not shown:
|
|
161
|
+
lines.append("None.")
|
|
162
|
+
|
|
163
|
+
if issues:
|
|
164
|
+
lines += ["", "## Open issues", ""]
|
|
165
|
+
lines += [f"- [{issue.rule}] {cell(issue.message)}" for issue in issues]
|
|
166
|
+
|
|
167
|
+
lines += ["", "## Notes", ""]
|
|
168
|
+
for kpi in direction.kpis:
|
|
169
|
+
baseline = latest_baseline(measurements, kpi.id)
|
|
170
|
+
if baseline and baseline.baseline is BaselineType.LATE:
|
|
171
|
+
lines.append(
|
|
172
|
+
f"- {kpi.id}: the baseline is late (measured on {baseline.taken}, after the work "
|
|
173
|
+
"started), so impact achieved before it is not captured."
|
|
174
|
+
)
|
|
175
|
+
lines.append(f"- {LIMITATION}")
|
|
176
|
+
return "\n".join(lines) + "\n"
|
|
@@ -5,11 +5,12 @@ from dataclasses import dataclass
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
from typing import Literal
|
|
7
7
|
|
|
8
|
-
from fde_workflow_cli import recipes
|
|
8
|
+
from fde_workflow_cli import clock, recipes
|
|
9
9
|
from fde_workflow_cli.errors import FdeError
|
|
10
10
|
from fde_workflow_cli.model import (
|
|
11
11
|
Direction,
|
|
12
12
|
DirectionPath,
|
|
13
|
+
Kpi,
|
|
13
14
|
KpiKind,
|
|
14
15
|
Measurement,
|
|
15
16
|
PathStatus,
|
|
@@ -33,6 +34,14 @@ def check(direction: Direction, measurements: list[Measurement], fde_dir: Path)
|
|
|
33
34
|
*_kpis(direction, measurements, fde_dir),
|
|
34
35
|
*_paths(direction, measurements),
|
|
35
36
|
*_stage(direction, measurements),
|
|
37
|
+
*(
|
|
38
|
+
Issue(
|
|
39
|
+
"warning",
|
|
40
|
+
"K6",
|
|
41
|
+
f"KPI {kpi.id} is due: last measured on {last.taken} (window {kpi.window}).",
|
|
42
|
+
)
|
|
43
|
+
for kpi, last in due_kpis(direction, measurements)
|
|
44
|
+
),
|
|
36
45
|
]
|
|
37
46
|
return sorted(issues, key=lambda issue: issue.level != "error")
|
|
38
47
|
|
|
@@ -43,6 +52,41 @@ def latest_baseline(measurements: list[Measurement], kpi_id: str) -> Measurement
|
|
|
43
52
|
return baselines[-1] if baselines else None
|
|
44
53
|
|
|
45
54
|
|
|
55
|
+
def stale_baselines(
|
|
56
|
+
direction: Direction, measurements: list[Measurement], fde_dir: Path
|
|
57
|
+
) -> set[str]:
|
|
58
|
+
"""K4: KPIs whose recipe changed after their latest baseline."""
|
|
59
|
+
stale = set()
|
|
60
|
+
for kpi in direction.kpis:
|
|
61
|
+
baseline = latest_baseline(measurements, kpi.id)
|
|
62
|
+
try:
|
|
63
|
+
current = recipes.fingerprint(fde_dir, kpi.recipe)
|
|
64
|
+
except FdeError:
|
|
65
|
+
continue
|
|
66
|
+
if baseline is not None and current is not None and baseline.recipe_sha256 != current:
|
|
67
|
+
stale.add(kpi.id)
|
|
68
|
+
return stale
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def due_kpis(
|
|
72
|
+
direction: Direction, measurements: list[Measurement]
|
|
73
|
+
) -> list[tuple[Kpi, Measurement]]:
|
|
74
|
+
"""K6: KPIs with a baseline whose last measurement was taken a full window ago or more."""
|
|
75
|
+
if direction.stage.current is StageName.CLOSED:
|
|
76
|
+
return []
|
|
77
|
+
today = clock.today()
|
|
78
|
+
due = []
|
|
79
|
+
for kpi in direction.kpis:
|
|
80
|
+
taken = [m for m in measurements if m.kpi == kpi.id]
|
|
81
|
+
if latest_baseline(taken, kpi.id) is None:
|
|
82
|
+
continue
|
|
83
|
+
last = max(taken, key=lambda m: m.taken)
|
|
84
|
+
start, _ = kpi.window_ending(today)
|
|
85
|
+
if last.taken < start:
|
|
86
|
+
due.append((kpi, last))
|
|
87
|
+
return due
|
|
88
|
+
|
|
89
|
+
|
|
46
90
|
def has_evidence(path: DirectionPath, measurements: list[Measurement]) -> bool:
|
|
47
91
|
"""P2: a non-baseline measurement of a targeted KPI whose whole window follows activation."""
|
|
48
92
|
activated = path.activated
|
|
@@ -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 []
|
|
@@ -86,6 +86,9 @@ def render(
|
|
|
86
86
|
expects = ", ".join(f"{kpi_id} → {number(v)}" for kpi_id, v in path.expects.items())
|
|
87
87
|
lines.append(f" {path.id} [{path.status}]{inferred}: {clean(path.hypothesis)} ({expects})")
|
|
88
88
|
|
|
89
|
+
if brief:
|
|
90
|
+
# Due KPIs stay visible: they are the reminder that replaces scheduled measurements.
|
|
91
|
+
issues = sorted(issues, key=lambda issue: (issue.level != "error", issue.rule != "K6"))
|
|
89
92
|
shown = issues[:BRIEF_ISSUES] if brief else issues
|
|
90
93
|
if shown:
|
|
91
94
|
lines.append("Issues:")
|
|
@@ -1,52 +0,0 @@
|
|
|
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))
|
|
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
|