fde-workflow-cli 0.1.0__py3-none-any.whl
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/__init__.py +1 -0
- fde_workflow_cli/__main__.py +5 -0
- fde_workflow_cli/clock.py +7 -0
- fde_workflow_cli/commands/__init__.py +1 -0
- fde_workflow_cli/commands/common.py +87 -0
- fde_workflow_cli/commands/decision.py +31 -0
- fde_workflow_cli/commands/hook.py +52 -0
- fde_workflow_cli/commands/kpi.py +210 -0
- fde_workflow_cli/commands/northstar.py +90 -0
- fde_workflow_cli/commands/path.py +257 -0
- fde_workflow_cli/commands/project.py +123 -0
- fde_workflow_cli/commands/stage.py +155 -0
- fde_workflow_cli/detect.py +123 -0
- fde_workflow_cli/doctor.py +45 -0
- fde_workflow_cli/errors.py +5 -0
- fde_workflow_cli/main.py +62 -0
- fde_workflow_cli/model.py +226 -0
- fde_workflow_cli/recipes.py +79 -0
- fde_workflow_cli/rules.py +168 -0
- fde_workflow_cli/store.py +142 -0
- fde_workflow_cli/summary.py +104 -0
- fde_workflow_cli-0.1.0.dist-info/METADATA +48 -0
- fde_workflow_cli-0.1.0.dist-info/RECORD +26 -0
- fde_workflow_cli-0.1.0.dist-info/WHEEL +4 -0
- fde_workflow_cli-0.1.0.dist-info/entry_points.txt +3 -0
- fde_workflow_cli-0.1.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""`fde-workflow path`: open paths, move them through their life cycle, link execution work."""
|
|
2
|
+
|
|
3
|
+
import datetime as dt
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from fde_workflow_cli import clock
|
|
10
|
+
from fde_workflow_cli.commands.common import (
|
|
11
|
+
RootOption,
|
|
12
|
+
find_path,
|
|
13
|
+
open_store,
|
|
14
|
+
parse_date,
|
|
15
|
+
record,
|
|
16
|
+
require,
|
|
17
|
+
user_errors,
|
|
18
|
+
)
|
|
19
|
+
from fde_workflow_cli.errors import FdeError
|
|
20
|
+
from fde_workflow_cli.model import DecisionType, DirectionPath, PathStatus, StageName
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(help="Manage paths: hypotheses that move the KPIs.", no_args_is_help=True)
|
|
23
|
+
|
|
24
|
+
TRANSITIONS: dict[PathStatus, set[PathStatus]] = {
|
|
25
|
+
PathStatus.PROPOSED: {PathStatus.ACTIVE, PathStatus.DISCARDED},
|
|
26
|
+
PathStatus.ACTIVE: {PathStatus.MEASURING, PathStatus.DISCARDED},
|
|
27
|
+
PathStatus.MEASURING: {PathStatus.VALIDATED, PathStatus.DISCARDED, PathStatus.ACTIVE},
|
|
28
|
+
PathStatus.VALIDATED: set(),
|
|
29
|
+
PathStatus.DISCARDED: set(),
|
|
30
|
+
}
|
|
31
|
+
STATUSES_AT_ADD = (PathStatus.PROPOSED, PathStatus.ACTIVE, PathStatus.MEASURING)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_expects(items: list[str]) -> dict[str, float]:
|
|
35
|
+
expects: dict[str, float] = {}
|
|
36
|
+
for item in items:
|
|
37
|
+
kpi_id, separator, value = item.partition("=")
|
|
38
|
+
if not separator:
|
|
39
|
+
raise FdeError(f"--expects {item!r} must look like KPI_ID=VALUE.")
|
|
40
|
+
try:
|
|
41
|
+
expects[kpi_id.strip()] = float(value)
|
|
42
|
+
except ValueError as error:
|
|
43
|
+
raise FdeError(f"--expects {item!r}: {value!r} is not a number.") from error
|
|
44
|
+
return expects
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def normalise_ref(root: Path, ref: str) -> str:
|
|
48
|
+
"""URLs stay as they are; files become POSIX paths relative to the repository root."""
|
|
49
|
+
if "://" in ref:
|
|
50
|
+
return ref
|
|
51
|
+
path = Path(ref)
|
|
52
|
+
absolute = (path if path.is_absolute() else Path.cwd() / path).resolve()
|
|
53
|
+
if absolute.is_relative_to(root.resolve()):
|
|
54
|
+
return absolute.relative_to(root.resolve()).as_posix()
|
|
55
|
+
return path.as_posix()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.command()
|
|
59
|
+
def add(
|
|
60
|
+
path_id: Annotated[str, typer.Argument(help="Kebab-case id, e.g. auto-triage.")],
|
|
61
|
+
hypothesis: Annotated[
|
|
62
|
+
str, typer.Option(help='"If we do X, we expect KPI Y to go from A to B."')
|
|
63
|
+
],
|
|
64
|
+
expects: Annotated[
|
|
65
|
+
list[str], typer.Option("--expects", help="KPI_ID=VALUE expected; repeat per KPI.")
|
|
66
|
+
],
|
|
67
|
+
reason: Annotated[
|
|
68
|
+
str | None, typer.Option(help="Why the path opens (not needed with --inferred).")
|
|
69
|
+
] = None,
|
|
70
|
+
status: Annotated[
|
|
71
|
+
PathStatus, typer.Option(help="Initial status: proposed, active or measuring.")
|
|
72
|
+
] = PathStatus.PROPOSED,
|
|
73
|
+
activated: Annotated[
|
|
74
|
+
str | None, typer.Option(help="When work started, YYYY-MM-DD (default: today).")
|
|
75
|
+
] = None,
|
|
76
|
+
maturity: Annotated[
|
|
77
|
+
StageName | None, typer.Option(help="The path's own stage, when it differs.")
|
|
78
|
+
] = None,
|
|
79
|
+
link: Annotated[
|
|
80
|
+
list[str] | None, typer.Option("--link", help="Execution artifact; repeat it.")
|
|
81
|
+
] = None,
|
|
82
|
+
inferred: Annotated[
|
|
83
|
+
bool, typer.Option("--inferred", help="Inferred from existing work; needs confirmation.")
|
|
84
|
+
] = False,
|
|
85
|
+
root: RootOption = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Open a path; records a path-open decision unless it is --inferred."""
|
|
88
|
+
with user_errors():
|
|
89
|
+
store = open_store(root)
|
|
90
|
+
direction = store.load()
|
|
91
|
+
if direction.path(path_id):
|
|
92
|
+
raise FdeError(f"Path {path_id} already exists: use `fde-workflow path update`.")
|
|
93
|
+
if status not in STATUSES_AT_ADD:
|
|
94
|
+
raise FdeError("A path starts as proposed, active or measuring; use `path move` later.")
|
|
95
|
+
if not inferred:
|
|
96
|
+
reason = require(reason, "--reason", "to open a path (or use --inferred)")
|
|
97
|
+
started: dt.date | None = None
|
|
98
|
+
if activated:
|
|
99
|
+
started = parse_date(activated, "--activated")
|
|
100
|
+
elif status is not PathStatus.PROPOSED:
|
|
101
|
+
started = clock.today()
|
|
102
|
+
direction.paths.append(
|
|
103
|
+
DirectionPath(
|
|
104
|
+
id=path_id,
|
|
105
|
+
hypothesis=hypothesis,
|
|
106
|
+
expects=parse_expects(expects),
|
|
107
|
+
status=status,
|
|
108
|
+
activated=started,
|
|
109
|
+
maturity=maturity,
|
|
110
|
+
confirmed=not inferred,
|
|
111
|
+
links=[normalise_ref(store.root, ref) for ref in link or []],
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
store.save(direction)
|
|
115
|
+
if not inferred:
|
|
116
|
+
record(
|
|
117
|
+
store,
|
|
118
|
+
DecisionType.PATH_OPEN,
|
|
119
|
+
f"Open path {path_id}: {hypothesis}",
|
|
120
|
+
reason or "",
|
|
121
|
+
refs=[path_id],
|
|
122
|
+
)
|
|
123
|
+
typer.echo(f"Added path {path_id} ({status}{', inferred' if inferred else ''}).")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@app.command()
|
|
127
|
+
def update(
|
|
128
|
+
path_id: Annotated[str, typer.Argument()],
|
|
129
|
+
hypothesis: Annotated[str | None, typer.Option()] = None,
|
|
130
|
+
expects: Annotated[
|
|
131
|
+
list[str] | None, typer.Option("--expects", help="Replaces every expectation.")
|
|
132
|
+
] = None,
|
|
133
|
+
maturity: Annotated[StageName | None, typer.Option()] = None,
|
|
134
|
+
learning: Annotated[str | None, typer.Option()] = None,
|
|
135
|
+
root: RootOption = None,
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Change a path's hypothesis, expectations, maturity or learning."""
|
|
138
|
+
with user_errors():
|
|
139
|
+
store = open_store(root)
|
|
140
|
+
direction = store.load()
|
|
141
|
+
path = find_path(direction, path_id)
|
|
142
|
+
if hypothesis is not None:
|
|
143
|
+
path.hypothesis = hypothesis
|
|
144
|
+
if expects is not None:
|
|
145
|
+
path.expects = parse_expects(expects)
|
|
146
|
+
if maturity is not None:
|
|
147
|
+
path.maturity = maturity
|
|
148
|
+
if learning is not None:
|
|
149
|
+
path.learning = learning
|
|
150
|
+
store.save(direction)
|
|
151
|
+
typer.echo(f"Updated path {path_id}.")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@app.command()
|
|
155
|
+
def move(
|
|
156
|
+
path_id: Annotated[str, typer.Argument()],
|
|
157
|
+
status: Annotated[PathStatus, typer.Argument(help="The new status.")],
|
|
158
|
+
reason: Annotated[str, typer.Option(help="Why the status changes.")],
|
|
159
|
+
learning: Annotated[
|
|
160
|
+
str | None, typer.Option(help="What the path taught us (required to discard).")
|
|
161
|
+
] = None,
|
|
162
|
+
approved_by: Annotated[str | None, typer.Option("--approved-by")] = None,
|
|
163
|
+
root: RootOption = None,
|
|
164
|
+
) -> None:
|
|
165
|
+
"""Move a path: proposed → active → measuring → validated | discarded."""
|
|
166
|
+
with user_errors():
|
|
167
|
+
store = open_store(root)
|
|
168
|
+
direction = store.load()
|
|
169
|
+
path = find_path(direction, path_id)
|
|
170
|
+
if status not in TRANSITIONS[path.status]:
|
|
171
|
+
allowed = ", ".join(sorted(TRANSITIONS[path.status])) or "none, it is final"
|
|
172
|
+
raise FdeError(
|
|
173
|
+
f"Path {path_id} cannot go from {path.status} to {status} (allowed: {allowed})."
|
|
174
|
+
)
|
|
175
|
+
if status is PathStatus.DISCARDED and not learning:
|
|
176
|
+
raise FdeError("--learning is required to discard a path: what did it teach us?")
|
|
177
|
+
path.status = status
|
|
178
|
+
if status is PathStatus.ACTIVE and path.activated is None:
|
|
179
|
+
path.activated = clock.today()
|
|
180
|
+
if learning:
|
|
181
|
+
path.learning = learning
|
|
182
|
+
store.save(direction)
|
|
183
|
+
if status is PathStatus.VALIDATED:
|
|
184
|
+
record(
|
|
185
|
+
store,
|
|
186
|
+
DecisionType.PATH_VALIDATE,
|
|
187
|
+
f"Validate path {path_id}",
|
|
188
|
+
reason,
|
|
189
|
+
approved_by or "",
|
|
190
|
+
[path_id],
|
|
191
|
+
)
|
|
192
|
+
if status is PathStatus.DISCARDED:
|
|
193
|
+
record(
|
|
194
|
+
store,
|
|
195
|
+
DecisionType.PATH_DISCARD,
|
|
196
|
+
f"Discard path {path_id}",
|
|
197
|
+
reason,
|
|
198
|
+
approved_by or "",
|
|
199
|
+
[path_id],
|
|
200
|
+
)
|
|
201
|
+
typer.echo(f"Path {path_id} is now {status}.")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@app.command()
|
|
205
|
+
def link(
|
|
206
|
+
refs: Annotated[
|
|
207
|
+
list[str], typer.Argument(help="PATH_ID REF..., or REF... with --pre-framework.")
|
|
208
|
+
],
|
|
209
|
+
pre_framework: Annotated[
|
|
210
|
+
bool, typer.Option("--pre-framework", help="Mark artifacts as pre-framework instead.")
|
|
211
|
+
] = False,
|
|
212
|
+
root: RootOption = None,
|
|
213
|
+
) -> None:
|
|
214
|
+
"""Link execution artifacts (files or URLs) to a path, or mark them pre-framework."""
|
|
215
|
+
with user_errors():
|
|
216
|
+
store = open_store(root)
|
|
217
|
+
direction = store.load()
|
|
218
|
+
known = direction.artifacts.pre_framework
|
|
219
|
+
if pre_framework:
|
|
220
|
+
targets = [normalise_ref(store.root, ref) for ref in refs]
|
|
221
|
+
known.extend(ref for ref in dict.fromkeys(targets) if ref not in known)
|
|
222
|
+
message = f"Marked {len(targets)} artifact(s) as pre-framework."
|
|
223
|
+
else:
|
|
224
|
+
if len(refs) < 2:
|
|
225
|
+
raise FdeError("Usage: fde-workflow path link PATH_ID REF... (or --pre-framework).")
|
|
226
|
+
path = find_path(direction, refs[0])
|
|
227
|
+
targets = [normalise_ref(store.root, ref) for ref in refs[1:]]
|
|
228
|
+
path.links.extend(ref for ref in dict.fromkeys(targets) if ref not in path.links)
|
|
229
|
+
direction.artifacts.pre_framework = [ref for ref in known if ref not in targets]
|
|
230
|
+
message = f"Linked {len(targets)} artifact(s) to {path.id}."
|
|
231
|
+
store.save(direction)
|
|
232
|
+
typer.echo(message)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@app.command()
|
|
236
|
+
def confirm(
|
|
237
|
+
path_id: Annotated[str, typer.Argument()],
|
|
238
|
+
reason: Annotated[
|
|
239
|
+
str, typer.Option(help="Why it is confirmed.")
|
|
240
|
+
] = "Confirmed a path inferred from existing work.",
|
|
241
|
+
root: RootOption = None,
|
|
242
|
+
) -> None:
|
|
243
|
+
"""Mark an inferred path as confirmed; records a path-open decision."""
|
|
244
|
+
with user_errors():
|
|
245
|
+
store = open_store(root)
|
|
246
|
+
direction = store.load()
|
|
247
|
+
path = find_path(direction, path_id)
|
|
248
|
+
path.confirmed = True
|
|
249
|
+
store.save(direction)
|
|
250
|
+
record(
|
|
251
|
+
store,
|
|
252
|
+
DecisionType.PATH_OPEN,
|
|
253
|
+
f"Confirm path {path_id}: {path.hypothesis}",
|
|
254
|
+
reason,
|
|
255
|
+
refs=[path_id],
|
|
256
|
+
)
|
|
257
|
+
typer.echo(f"Path {path_id} confirmed.")
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Commands on the whole `.fde/`: init, detect, validate, status and context."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from fde_workflow_cli import clock, detect, rules, summary
|
|
10
|
+
from fde_workflow_cli.commands.common import RootOption, open_store, user_errors
|
|
11
|
+
from fde_workflow_cli.errors import FdeError
|
|
12
|
+
from fde_workflow_cli.model import Artifacts, Direction, Project, StageState
|
|
13
|
+
from fde_workflow_cli.store import Store, locate
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(help="Name the project, its customer and its sponsor.", no_args_is_help=True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def init(
|
|
19
|
+
root: RootOption = None,
|
|
20
|
+
name: Annotated[
|
|
21
|
+
str | None, typer.Option(help="Project name (default: the directory's name).")
|
|
22
|
+
] = None,
|
|
23
|
+
preset: Annotated[
|
|
24
|
+
detect.Preset, typer.Option(help="Where execution artifacts live (auto detects it).")
|
|
25
|
+
] = detect.Preset.AUTO,
|
|
26
|
+
glob: Annotated[
|
|
27
|
+
list[str] | None,
|
|
28
|
+
typer.Option(
|
|
29
|
+
"--glob", help="Where execution artifacts live; repeat it; overrides --preset."
|
|
30
|
+
),
|
|
31
|
+
] = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Create .fde/ with an empty direction in stage discovery."""
|
|
34
|
+
with user_errors():
|
|
35
|
+
directory = (root or Path.cwd()).resolve()
|
|
36
|
+
if root is None and locate(directory) != directory:
|
|
37
|
+
raise FdeError(
|
|
38
|
+
f"{locate(directory)} already has .fde/: run the commands from there, "
|
|
39
|
+
"or pass --root to create a separate direction on purpose."
|
|
40
|
+
)
|
|
41
|
+
store = Store(directory)
|
|
42
|
+
globs = glob or detect.globs_for(preset, directory)
|
|
43
|
+
store.create(
|
|
44
|
+
Direction(
|
|
45
|
+
project=Project(name=name or directory.name),
|
|
46
|
+
stage=StageState(entered=clock.today()),
|
|
47
|
+
artifacts=Artifacts(globs=globs),
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
typer.echo(
|
|
51
|
+
f"Created {store.dir} (stage discovery; artifact globs: {', '.join(globs) or 'none'})."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def detect_command(root: RootOption = None) -> None:
|
|
56
|
+
"""Print the repository's methodology, artifacts and deployment signals as JSON."""
|
|
57
|
+
with user_errors():
|
|
58
|
+
store = open_store(root)
|
|
59
|
+
direction = store.load() if store.exists() else None
|
|
60
|
+
typer.echo(json.dumps(detect.report(store.root, direction), indent=2))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def validate(root: RootOption = None) -> None:
|
|
64
|
+
"""Check .fde/ against the framework's rules; exit 1 when a rule is broken."""
|
|
65
|
+
with user_errors():
|
|
66
|
+
store = open_store(root)
|
|
67
|
+
issues = rules.check(store.load(), store.measurements(), store.dir)
|
|
68
|
+
for issue in issues:
|
|
69
|
+
typer.echo(f"{'✗' if issue.level == 'error' else '!'} [{issue.rule}] {issue.message}")
|
|
70
|
+
errors = sum(issue.level == "error" for issue in issues)
|
|
71
|
+
warnings = len(issues) - errors
|
|
72
|
+
if errors:
|
|
73
|
+
typer.echo(f"Invalid: {errors} error(s), {warnings} warning(s).")
|
|
74
|
+
raise typer.Exit(1)
|
|
75
|
+
typer.echo(f"Valid with {warnings} warning(s)." if warnings else "Valid.")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def status(root: RootOption = None) -> None:
|
|
79
|
+
"""Summarise the direction for people."""
|
|
80
|
+
with user_errors():
|
|
81
|
+
store = open_store(root)
|
|
82
|
+
direction = store.load()
|
|
83
|
+
measurements = store.measurements()
|
|
84
|
+
issues = rules.check(direction, measurements, store.dir)
|
|
85
|
+
typer.echo(summary.render(direction, measurements, issues))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def context(
|
|
89
|
+
root: RootOption = None,
|
|
90
|
+
brief: Annotated[bool, typer.Option("--brief", help="Only what every session needs.")] = False,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Summarise the direction for Claude."""
|
|
93
|
+
with user_errors():
|
|
94
|
+
store = open_store(root)
|
|
95
|
+
direction = store.load()
|
|
96
|
+
measurements = store.measurements()
|
|
97
|
+
issues = rules.check(direction, measurements, store.dir)
|
|
98
|
+
typer.echo(summary.context(direction, measurements, issues, brief=brief))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@app.command("set")
|
|
102
|
+
def set_project(
|
|
103
|
+
name: Annotated[str | None, typer.Option(help="Project name.")] = None,
|
|
104
|
+
customer: Annotated[str | None, typer.Option(help="The customer.")] = None,
|
|
105
|
+
sponsor: Annotated[
|
|
106
|
+
str | None, typer.Option(help="Who approves direction changes and gate decisions.")
|
|
107
|
+
] = None,
|
|
108
|
+
root: RootOption = None,
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Set the project's name, customer or sponsor."""
|
|
111
|
+
with user_errors():
|
|
112
|
+
store = open_store(root)
|
|
113
|
+
direction = store.load()
|
|
114
|
+
changes = {
|
|
115
|
+
k: v
|
|
116
|
+
for k, v in {"name": name, "customer": customer, "sponsor": sponsor}.items()
|
|
117
|
+
if v is not None
|
|
118
|
+
}
|
|
119
|
+
if not changes:
|
|
120
|
+
raise FdeError("Nothing to change: pass --name, --customer or --sponsor.")
|
|
121
|
+
direction.project = direction.project.model_copy(update=changes)
|
|
122
|
+
store.save(direction)
|
|
123
|
+
typer.echo("Project updated.")
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""`fde-workflow stage`: set the project stage, its gate criteria and gate decisions."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from fde_workflow_cli import clock
|
|
8
|
+
from fde_workflow_cli.commands.common import (
|
|
9
|
+
RootOption,
|
|
10
|
+
open_store,
|
|
11
|
+
parse_date,
|
|
12
|
+
record,
|
|
13
|
+
require,
|
|
14
|
+
user_errors,
|
|
15
|
+
)
|
|
16
|
+
from fde_workflow_cli.errors import FdeError
|
|
17
|
+
from fde_workflow_cli.model import (
|
|
18
|
+
STAGE_ORDER,
|
|
19
|
+
DecisionType,
|
|
20
|
+
GateDecision,
|
|
21
|
+
Outcome,
|
|
22
|
+
StageName,
|
|
23
|
+
StageState,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
app = typer.Typer(help="Set the project stage and record gate decisions.", no_args_is_help=True)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.command("set")
|
|
30
|
+
def set_(
|
|
31
|
+
stage: Annotated[StageName, typer.Argument(help="The stage the project is in.")],
|
|
32
|
+
reason: Annotated[str, typer.Option(help="Why the stage is set without a gate.")],
|
|
33
|
+
outcome: Annotated[
|
|
34
|
+
Outcome | None, typer.Option(help="Required when the stage is closed.")
|
|
35
|
+
] = None,
|
|
36
|
+
entered: Annotated[
|
|
37
|
+
str | None, typer.Option(help="When the project got there, YYYY-MM-DD (default: today).")
|
|
38
|
+
] = None,
|
|
39
|
+
inferred: Annotated[
|
|
40
|
+
bool, typer.Option("--inferred", help="Inferred from evidence; needs confirmation.")
|
|
41
|
+
] = False,
|
|
42
|
+
approved_by: Annotated[str | None, typer.Option("--approved-by")] = None,
|
|
43
|
+
root: RootOption = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Set the stage directly, without a gate (e.g. when adopting an existing project)."""
|
|
46
|
+
with user_errors():
|
|
47
|
+
store = open_store(root)
|
|
48
|
+
direction = store.load()
|
|
49
|
+
direction.stage = StageState(
|
|
50
|
+
current=stage,
|
|
51
|
+
entered=parse_date(entered, "--entered") if entered else clock.today(),
|
|
52
|
+
outcome=outcome,
|
|
53
|
+
confirmed=not inferred,
|
|
54
|
+
)
|
|
55
|
+
store.save(direction)
|
|
56
|
+
suffix = " (inferred)" if inferred else ""
|
|
57
|
+
record(
|
|
58
|
+
store,
|
|
59
|
+
DecisionType.STAGE_SET,
|
|
60
|
+
f"Stage set to {stage}{suffix}",
|
|
61
|
+
reason,
|
|
62
|
+
approved_by or "",
|
|
63
|
+
)
|
|
64
|
+
typer.echo(f"Stage set to {stage}{suffix}.")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.command()
|
|
68
|
+
def confirm(root: RootOption = None) -> None:
|
|
69
|
+
"""Mark an inferred stage as confirmed."""
|
|
70
|
+
with user_errors():
|
|
71
|
+
store = open_store(root)
|
|
72
|
+
direction = store.load()
|
|
73
|
+
direction.stage.confirmed = True
|
|
74
|
+
store.save(direction)
|
|
75
|
+
typer.echo(f"Stage {direction.stage.current} confirmed.")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.command()
|
|
79
|
+
def criteria(
|
|
80
|
+
items: Annotated[list[str], typer.Argument(help="One argument per gate criterion.")],
|
|
81
|
+
reason: Annotated[str | None, typer.Option(help="Why agreed criteria change.")] = None,
|
|
82
|
+
approved_by: Annotated[
|
|
83
|
+
str | None, typer.Option("--approved-by", help="Who approves the change.")
|
|
84
|
+
] = None,
|
|
85
|
+
root: RootOption = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Set the current stage's gate criteria; replacing agreed criteria records a decision."""
|
|
88
|
+
with user_errors():
|
|
89
|
+
store = open_store(root)
|
|
90
|
+
direction = store.load()
|
|
91
|
+
stage = direction.stage
|
|
92
|
+
replacing = bool(stage.gate_criteria) and stage.gate_criteria != items
|
|
93
|
+
if replacing:
|
|
94
|
+
reason = require(reason, "--reason", "to change agreed gate criteria")
|
|
95
|
+
approved_by = require(approved_by, "--approved-by", "to change agreed gate criteria")
|
|
96
|
+
stage.gate_criteria = list(items)
|
|
97
|
+
store.save(direction)
|
|
98
|
+
if replacing:
|
|
99
|
+
record(
|
|
100
|
+
store,
|
|
101
|
+
DecisionType.STAGE_GATE,
|
|
102
|
+
f"Gate criteria of {stage.current} changed",
|
|
103
|
+
reason or "",
|
|
104
|
+
approved_by or "",
|
|
105
|
+
)
|
|
106
|
+
typer.echo(f"Gate criteria of {stage.current}: {len(items)}.")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@app.command()
|
|
110
|
+
def decide(
|
|
111
|
+
gate: Annotated[GateDecision, typer.Argument(help="go, recycle, hold or kill.")],
|
|
112
|
+
reason: Annotated[str, typer.Option(help="The evidence behind the decision.")],
|
|
113
|
+
approved_by: Annotated[str, typer.Option("--approved-by", help="The sponsor.")],
|
|
114
|
+
to: Annotated[
|
|
115
|
+
StageName | None, typer.Option("--to", help="With go: skip to a later stage.")
|
|
116
|
+
] = None,
|
|
117
|
+
root: RootOption = None,
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Record a gate decision and apply it to the stage."""
|
|
120
|
+
with user_errors():
|
|
121
|
+
store = open_store(root)
|
|
122
|
+
direction = store.load()
|
|
123
|
+
before = direction.stage
|
|
124
|
+
if before.current is StageName.CLOSED:
|
|
125
|
+
raise FdeError("The project is closed.")
|
|
126
|
+
if to is not None and gate is not GateDecision.GO:
|
|
127
|
+
raise FdeError("--to only applies to go.")
|
|
128
|
+
today = clock.today()
|
|
129
|
+
if gate is GateDecision.GO:
|
|
130
|
+
position = STAGE_ORDER.index(before.current)
|
|
131
|
+
target = to or STAGE_ORDER[position + 1]
|
|
132
|
+
if STAGE_ORDER.index(target) <= position:
|
|
133
|
+
raise FdeError(f"--to must be a stage after {before.current}.")
|
|
134
|
+
if target is StageName.CLOSED and before.current is not StageName.PRODUCTION:
|
|
135
|
+
raise FdeError("Only go from production closes a project; otherwise use kill.")
|
|
136
|
+
outcome = Outcome.COMPLETED if target is StageName.CLOSED else None
|
|
137
|
+
direction.stage = StageState(current=target, entered=today, outcome=outcome)
|
|
138
|
+
elif gate is GateDecision.KILL:
|
|
139
|
+
direction.stage = StageState(
|
|
140
|
+
current=StageName.CLOSED, entered=today, outcome=Outcome.DISCONTINUED
|
|
141
|
+
)
|
|
142
|
+
else:
|
|
143
|
+
before.on_hold = gate is GateDecision.HOLD
|
|
144
|
+
store.save(direction)
|
|
145
|
+
after = direction.stage.current
|
|
146
|
+
record(
|
|
147
|
+
store,
|
|
148
|
+
DecisionType.STAGE_GATE,
|
|
149
|
+
f"{gate} at {before.current}" + (f" → {after}" if after != before.current else ""),
|
|
150
|
+
reason,
|
|
151
|
+
approved_by,
|
|
152
|
+
)
|
|
153
|
+
typer.echo(
|
|
154
|
+
f"Recorded {gate}; stage is {after}{' (on hold)' if direction.stage.on_hold else ''}."
|
|
155
|
+
)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Find the execution methodology and its artifacts in a repository. Read-only."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from fde_workflow_cli.model import Direction
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Preset(StrEnum):
|
|
11
|
+
AUTO = "auto"
|
|
12
|
+
SUPERPOWERS = "superpowers"
|
|
13
|
+
SPEC_KIT = "spec-kit"
|
|
14
|
+
NONE = "none"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
PRESET_GLOBS: dict[Preset, list[str]] = {
|
|
18
|
+
Preset.SUPERPOWERS: ["docs/superpowers/specs/**", "docs/superpowers/plans/**"],
|
|
19
|
+
Preset.SPEC_KIT: ["specs/**"],
|
|
20
|
+
Preset.NONE: [],
|
|
21
|
+
}
|
|
22
|
+
DEPLOYMENT_MARKERS = (
|
|
23
|
+
"Dockerfile",
|
|
24
|
+
"docker-compose.yml",
|
|
25
|
+
"compose.yaml",
|
|
26
|
+
"Procfile",
|
|
27
|
+
"fly.toml",
|
|
28
|
+
"vercel.json",
|
|
29
|
+
"netlify.toml",
|
|
30
|
+
"render.yaml",
|
|
31
|
+
"app.yaml",
|
|
32
|
+
"serverless.yml",
|
|
33
|
+
"k8s",
|
|
34
|
+
"helm",
|
|
35
|
+
"terraform",
|
|
36
|
+
)
|
|
37
|
+
DEPLOYMENT_WORKFLOW_WORDS = ("deploy", "release", "publish")
|
|
38
|
+
READMES = ("README.md", "README.rst", "README.txt", "README")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def detect_methodology(root: Path) -> Preset:
|
|
42
|
+
if (root / "docs" / "superpowers").is_dir():
|
|
43
|
+
return Preset.SUPERPOWERS
|
|
44
|
+
if (root / ".specify").is_dir() or (root / "specs").is_dir():
|
|
45
|
+
return Preset.SPEC_KIT
|
|
46
|
+
return Preset.NONE
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def globs_for(preset: Preset, root: Path) -> list[str]:
|
|
50
|
+
resolved = detect_methodology(root) if preset is Preset.AUTO else preset
|
|
51
|
+
return list(PRESET_GLOBS[resolved])
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def artifact_files(root: Path, globs: list[str]) -> list[str]:
|
|
55
|
+
"""Files under `root` matching any glob, as sorted POSIX paths relative to `root`."""
|
|
56
|
+
found: set[str] = set()
|
|
57
|
+
for pattern in globs:
|
|
58
|
+
for path in root.glob(pattern):
|
|
59
|
+
if path.is_file():
|
|
60
|
+
found.add(path.relative_to(root).as_posix())
|
|
61
|
+
return sorted(found)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def first_commit_date(root: Path, relative: str) -> str | None:
|
|
65
|
+
"""Date (YYYY-MM-DD) of the commit that added the file, or None outside git history."""
|
|
66
|
+
try:
|
|
67
|
+
result = subprocess.run( # noqa: S603 - fixed read-only git query
|
|
68
|
+
["git", "log", "--diff-filter=A", "--format=%cs", "--", relative], # noqa: S607
|
|
69
|
+
cwd=root,
|
|
70
|
+
capture_output=True,
|
|
71
|
+
text=True,
|
|
72
|
+
timeout=30,
|
|
73
|
+
check=False,
|
|
74
|
+
)
|
|
75
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
76
|
+
return None
|
|
77
|
+
dates = result.stdout.split()
|
|
78
|
+
return dates[-1] if result.returncode == 0 and dates else None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def deployment_signals(root: Path) -> list[str]:
|
|
82
|
+
signals = [name for name in DEPLOYMENT_MARKERS if (root / name).exists()]
|
|
83
|
+
workflows = root / ".github" / "workflows"
|
|
84
|
+
if workflows.is_dir():
|
|
85
|
+
signals += sorted(
|
|
86
|
+
f".github/workflows/{path.name}"
|
|
87
|
+
for path in workflows.iterdir()
|
|
88
|
+
if any(word in path.name.lower() for word in DEPLOYMENT_WORKFLOW_WORDS)
|
|
89
|
+
)
|
|
90
|
+
return signals
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def linked_to(direction: Direction | None, relative: str) -> str | None:
|
|
94
|
+
"""The path id that links the artifact, "pre-framework", or None."""
|
|
95
|
+
if direction is None:
|
|
96
|
+
return None
|
|
97
|
+
for path in direction.paths:
|
|
98
|
+
if relative in path.links:
|
|
99
|
+
return path.id
|
|
100
|
+
if relative in direction.artifacts.pre_framework:
|
|
101
|
+
return "pre-framework"
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def report(root: Path, direction: Direction | None) -> dict[str, object]:
|
|
106
|
+
methodology = detect_methodology(root)
|
|
107
|
+
globs = direction.artifacts.globs if direction else PRESET_GLOBS[methodology]
|
|
108
|
+
return {
|
|
109
|
+
"root": str(root),
|
|
110
|
+
"fde_initialised": direction is not None,
|
|
111
|
+
"methodology": methodology.value,
|
|
112
|
+
"globs": globs,
|
|
113
|
+
"readme": next((name for name in READMES if (root / name).is_file()), None),
|
|
114
|
+
"deployment_signals": deployment_signals(root),
|
|
115
|
+
"artifacts": [
|
|
116
|
+
{
|
|
117
|
+
"path": relative,
|
|
118
|
+
"first_commit": first_commit_date(root, relative),
|
|
119
|
+
"linked_to": linked_to(direction, relative),
|
|
120
|
+
}
|
|
121
|
+
for relative in artifact_files(root, globs)
|
|
122
|
+
],
|
|
123
|
+
}
|