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.
Files changed (30) hide show
  1. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/PKG-INFO +10 -4
  2. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/README.md +9 -3
  3. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/pyproject.toml +1 -1
  4. fde_workflow_cli-0.2.0/src/fde_workflow_cli/commands/hook.py +107 -0
  5. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/commands/kpi.py +33 -1
  6. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/commands/northstar.py +21 -4
  7. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/commands/path.py +3 -0
  8. fde_workflow_cli-0.2.0/src/fde_workflow_cli/commands/portfolio.py +72 -0
  9. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/commands/project.py +13 -1
  10. fde_workflow_cli-0.2.0/src/fde_workflow_cli/commands/report.py +65 -0
  11. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/commands/stage.py +15 -4
  12. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/detect.py +9 -0
  13. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/doctor.py +3 -2
  14. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/main.py +14 -1
  15. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/model.py +1 -0
  16. fde_workflow_cli-0.2.0/src/fde_workflow_cli/registry.py +70 -0
  17. fde_workflow_cli-0.2.0/src/fde_workflow_cli/report.py +176 -0
  18. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/rules.py +45 -1
  19. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/store.py +42 -3
  20. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/summary.py +3 -0
  21. fde_workflow_cli-0.1.0/src/fde_workflow_cli/commands/hook.py +0 -52
  22. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/LICENSE +0 -0
  23. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/__init__.py +0 -0
  24. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/__main__.py +0 -0
  25. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/clock.py +0 -0
  26. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/commands/__init__.py +0 -0
  27. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/commands/common.py +0 -0
  28. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/commands/decision.py +0 -0
  29. {fde_workflow_cli-0.1.0 → fde_workflow_cli-0.2.0}/src/fde_workflow_cli/errors.py +0 -0
  30. {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.1.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 measurements |
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 point of the plugin's SessionStart hook |
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. Requires Python 3.13 or newer. Source and
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 measurements |
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 point of the plugin's SessionStart hook |
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. Requires Python 3.13 or newer. Source and
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.1.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}).")
@@ -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(
@@ -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(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()
@@ -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
- 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
 
@@ -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 decision, hook, kpi, northstar, path, project, stage
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")
@@ -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
 
@@ -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(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 []
@@ -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))