fde-workflow-cli 0.1.1__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.1 → fde_workflow_cli-0.2.0}/PKG-INFO +10 -4
- {fde_workflow_cli-0.1.1 → fde_workflow_cli-0.2.0}/README.md +9 -3
- {fde_workflow_cli-0.1.1 → 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.1 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/commands/kpi.py +12 -0
- fde_workflow_cli-0.2.0/src/fde_workflow_cli/commands/portfolio.py +72 -0
- {fde_workflow_cli-0.1.1 → 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.1 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/detect.py +9 -0
- {fde_workflow_cli-0.1.1 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/main.py +14 -1
- 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.1 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/rules.py +45 -1
- {fde_workflow_cli-0.1.1 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/summary.py +3 -0
- fde_workflow_cli-0.1.1/src/fde_workflow_cli/commands/hook.py +0 -52
- {fde_workflow_cli-0.1.1 → fde_workflow_cli-0.2.0}/LICENSE +0 -0
- {fde_workflow_cli-0.1.1 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/__init__.py +0 -0
- {fde_workflow_cli-0.1.1 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/__main__.py +0 -0
- {fde_workflow_cli-0.1.1 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/clock.py +0 -0
- {fde_workflow_cli-0.1.1 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/commands/__init__.py +0 -0
- {fde_workflow_cli-0.1.1 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/commands/common.py +0 -0
- {fde_workflow_cli-0.1.1 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/commands/decision.py +0 -0
- {fde_workflow_cli-0.1.1 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/commands/northstar.py +0 -0
- {fde_workflow_cli-0.1.1 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/commands/path.py +0 -0
- {fde_workflow_cli-0.1.1 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/commands/stage.py +0 -0
- {fde_workflow_cli-0.1.1 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/doctor.py +0 -0
- {fde_workflow_cli-0.1.1 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/errors.py +0 -0
- {fde_workflow_cli-0.1.1 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/model.py +0 -0
- {fde_workflow_cli-0.1.1 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/recipes.py +0 -0
- {fde_workflow_cli-0.1.1 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/store.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()],
|
|
@@ -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))
|
|
@@ -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]
|
|
@@ -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
|
|
@@ -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
|
{fde_workflow_cli-0.1.1 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/commands/northstar.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|