outcome-engineering 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.
- outcome_engineering/__init__.py +4 -0
- outcome_engineering/cli.py +310 -0
- outcome_engineering/example.py +231 -0
- outcome_engineering/graph.py +277 -0
- outcome_engineering/model.py +77 -0
- outcome_engineering/skill_installer.py +75 -0
- outcome_engineering/skills/oe-cli/SKILL.md +117 -0
- outcome_engineering-0.1.0.dist-info/METADATA +158 -0
- outcome_engineering-0.1.0.dist-info/RECORD +11 -0
- outcome_engineering-0.1.0.dist-info/WHEEL +4 -0
- outcome_engineering-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from outcome_engineering.example import create_example
|
|
8
|
+
from outcome_engineering.graph import (
|
|
9
|
+
create_node,
|
|
10
|
+
discover_nodes,
|
|
11
|
+
find_node,
|
|
12
|
+
find_nodes_by_kind,
|
|
13
|
+
marker_content,
|
|
14
|
+
node_ancestors,
|
|
15
|
+
supporting_files,
|
|
16
|
+
validate as validate_graph,
|
|
17
|
+
)
|
|
18
|
+
from outcome_engineering.model import KIND_TO_RELATIONSHIP
|
|
19
|
+
from outcome_engineering.skill_installer import install_project_skill, install_skill, install_skill_for_agent
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(help="Outcome Engineering product graph tooling.")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.command(
|
|
25
|
+
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
|
|
26
|
+
)
|
|
27
|
+
def install(
|
|
28
|
+
ctx: typer.Context,
|
|
29
|
+
force: bool = typer.Option(False, "--force", help="Replace the target skill directory if it already exists."),
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Install bundled assets, including the oe-cli skill."""
|
|
32
|
+
try:
|
|
33
|
+
skill_value = parse_skills_option(ctx.args)
|
|
34
|
+
installed_at = install_project_skill(skill_value, force=force)
|
|
35
|
+
except ValueError as error:
|
|
36
|
+
typer.echo(str(error))
|
|
37
|
+
raise typer.Exit(code=1) from error
|
|
38
|
+
except FileExistsError as error:
|
|
39
|
+
typer.echo(str(error))
|
|
40
|
+
raise typer.Exit(code=1) from error
|
|
41
|
+
|
|
42
|
+
typer.echo(f"Installed oe-cli skill at {installed_at}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.command()
|
|
46
|
+
def validate(
|
|
47
|
+
path: Path = typer.Argument(Path("product"), help="Product graph root to validate."),
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Validate a product graph directory."""
|
|
50
|
+
issues = validate_graph(path)
|
|
51
|
+
if not issues:
|
|
52
|
+
typer.echo(f"OK: {path} is a valid product graph")
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
typer.echo(f"Invalid product graph: {path}")
|
|
56
|
+
for issue in issues:
|
|
57
|
+
typer.echo(f"- {issue.path}: {issue.message}")
|
|
58
|
+
raise typer.Exit(code=1)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.command("tree")
|
|
62
|
+
def tree_command(
|
|
63
|
+
path: Path = typer.Argument(Path("product"), help="Product graph root to print."),
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Print the product graph tree."""
|
|
66
|
+
issues = validate_graph(path)
|
|
67
|
+
if issues:
|
|
68
|
+
typer.echo(f"Invalid product graph: {path}")
|
|
69
|
+
for issue in issues:
|
|
70
|
+
typer.echo(f"- {issue.path}: {issue.message}")
|
|
71
|
+
raise typer.Exit(code=1)
|
|
72
|
+
|
|
73
|
+
root = path.resolve()
|
|
74
|
+
typer.echo(str(path))
|
|
75
|
+
top_level = [node for node in discover_nodes(root) if node.parent is None and node.kind not in {"vision", "strategy"}]
|
|
76
|
+
for index, node in enumerate(top_level):
|
|
77
|
+
print_node(node, prefix="", is_last=index == len(top_level) - 1)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.command("create-example")
|
|
81
|
+
def create_example_command(
|
|
82
|
+
output: Path = typer.Option(
|
|
83
|
+
Path("examples/delegation-product-graph"),
|
|
84
|
+
"--output",
|
|
85
|
+
"-o",
|
|
86
|
+
help="Directory to create.",
|
|
87
|
+
),
|
|
88
|
+
force: bool = typer.Option(False, "--force", help="Replace output directory if it already exists."),
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Create an example product graph."""
|
|
91
|
+
try:
|
|
92
|
+
create_example(output, force=force)
|
|
93
|
+
except FileExistsError as error:
|
|
94
|
+
typer.echo(str(error))
|
|
95
|
+
raise typer.Exit(code=1) from error
|
|
96
|
+
typer.echo(f"Created example product graph at {output}")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.command("install-skill")
|
|
100
|
+
def install_skill_command(
|
|
101
|
+
agent: str = typer.Option("codex", "--agent", "-a", help="Agent tool target: codex, claude, or all."),
|
|
102
|
+
target: Path | None = typer.Option(None, "--target", "-t", help="Exact skill install directory. Overrides --agent."),
|
|
103
|
+
force: bool = typer.Option(False, "--force", help="Replace the target skill directory if it already exists."),
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Install the bundled oe-cli agent skill."""
|
|
106
|
+
try:
|
|
107
|
+
installed_paths = [install_skill(target=target, force=force)] if target is not None else install_skill_for_agent(agent, force=force)
|
|
108
|
+
except (FileExistsError, ValueError) as error:
|
|
109
|
+
typer.echo(str(error))
|
|
110
|
+
raise typer.Exit(code=1) from error
|
|
111
|
+
for installed_at in installed_paths:
|
|
112
|
+
typer.echo(f"Installed oe-cli skill at {installed_at}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@app.command()
|
|
116
|
+
def trace(
|
|
117
|
+
selector: str = typer.Argument(..., help="Node id, slug, node directory, or marker file path."),
|
|
118
|
+
root: Path = typer.Option(Path("product"), "--root", "-r", help="Product graph root."),
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Show where a node sits in the product graph."""
|
|
121
|
+
issues = validate_graph(root)
|
|
122
|
+
if issues:
|
|
123
|
+
typer.echo(f"Invalid product graph: {root}")
|
|
124
|
+
for issue in issues:
|
|
125
|
+
typer.echo(f"- {issue.path}: {issue.message}")
|
|
126
|
+
raise typer.Exit(code=1)
|
|
127
|
+
|
|
128
|
+
node = find_node(root, selector)
|
|
129
|
+
if node is None:
|
|
130
|
+
typer.echo(f"Node not found or ambiguous: {selector}")
|
|
131
|
+
raise typer.Exit(code=1)
|
|
132
|
+
|
|
133
|
+
typer.echo(f"{node.kind}: {node.slug}")
|
|
134
|
+
typer.echo(f"id: {node.id}")
|
|
135
|
+
typer.echo(f"path: {node.path}")
|
|
136
|
+
typer.echo(f"marker: {node.marker_file}")
|
|
137
|
+
if node.parent is not None:
|
|
138
|
+
typer.echo(f"parent: {node.parent.id}")
|
|
139
|
+
typer.echo(f"relationship: {node.relationship}")
|
|
140
|
+
else:
|
|
141
|
+
typer.echo("parent: <root>")
|
|
142
|
+
|
|
143
|
+
ancestors = node_ancestors(node)
|
|
144
|
+
if ancestors:
|
|
145
|
+
typer.echo("")
|
|
146
|
+
typer.echo("Trace:")
|
|
147
|
+
for ancestor in ancestors:
|
|
148
|
+
typer.echo(f"- {ancestor.kind}: {ancestor.slug}")
|
|
149
|
+
typer.echo(f"- {node.kind}: {node.slug}")
|
|
150
|
+
|
|
151
|
+
if node.children:
|
|
152
|
+
typer.echo("")
|
|
153
|
+
typer.echo("Children:")
|
|
154
|
+
for child in node.children:
|
|
155
|
+
typer.echo(f"- {child.kind}: {child.slug}")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@app.command("list")
|
|
159
|
+
def list_command(
|
|
160
|
+
kind: str | None = typer.Argument(None, help="Optional node kind to list."),
|
|
161
|
+
root: Path = typer.Option(Path("product"), "--root", "-r", help="Product graph root."),
|
|
162
|
+
) -> None:
|
|
163
|
+
"""List graph nodes."""
|
|
164
|
+
issues = validate_graph(root)
|
|
165
|
+
if issues:
|
|
166
|
+
typer.echo(f"Invalid product graph: {root}")
|
|
167
|
+
for issue in issues:
|
|
168
|
+
typer.echo(f"- {issue.path}: {issue.message}")
|
|
169
|
+
raise typer.Exit(code=1)
|
|
170
|
+
|
|
171
|
+
if kind is not None and kind.endswith("s"):
|
|
172
|
+
kind = kind[:-1]
|
|
173
|
+
if kind is not None and kind not in KIND_TO_RELATIONSHIP:
|
|
174
|
+
supported = ", ".join(sorted(KIND_TO_RELATIONSHIP))
|
|
175
|
+
typer.echo(f"unsupported node kind {kind!r}; expected one of: {supported}")
|
|
176
|
+
raise typer.Exit(code=1)
|
|
177
|
+
|
|
178
|
+
nodes = find_nodes_by_kind(root, kind)
|
|
179
|
+
for node in nodes:
|
|
180
|
+
typer.echo(f"{node.id}\t{node.path}")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@app.command()
|
|
184
|
+
def show(
|
|
185
|
+
selector: str = typer.Argument(..., help="Node id, slug, node directory, or marker file path."),
|
|
186
|
+
root: Path = typer.Option(Path("product"), "--root", "-r", help="Product graph root."),
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Print a node's marker file."""
|
|
189
|
+
node = load_valid_node(root, selector)
|
|
190
|
+
typer.echo(marker_content(node).rstrip())
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@app.command()
|
|
194
|
+
def context(
|
|
195
|
+
selector: str = typer.Argument(..., help="Node id, slug, node directory, or marker file path."),
|
|
196
|
+
root: Path = typer.Option(Path("product"), "--root", "-r", help="Product graph root."),
|
|
197
|
+
) -> None:
|
|
198
|
+
"""Print deterministic context around a node for an agent."""
|
|
199
|
+
node = load_valid_node(root, selector)
|
|
200
|
+
ancestors = node_ancestors(node)
|
|
201
|
+
|
|
202
|
+
typer.echo(f"# Context: {node.id}")
|
|
203
|
+
typer.echo("")
|
|
204
|
+
typer.echo("## Trace")
|
|
205
|
+
for ancestor in ancestors:
|
|
206
|
+
typer.echo(f"- {ancestor.id} ({ancestor.marker_file})")
|
|
207
|
+
typer.echo(f"- {node.id} ({node.marker_file})")
|
|
208
|
+
|
|
209
|
+
if node.children:
|
|
210
|
+
typer.echo("")
|
|
211
|
+
typer.echo("## Children")
|
|
212
|
+
for child in node.children:
|
|
213
|
+
typer.echo(f"- {child.id} ({child.marker_file})")
|
|
214
|
+
|
|
215
|
+
files = supporting_files(node)
|
|
216
|
+
if files:
|
|
217
|
+
typer.echo("")
|
|
218
|
+
typer.echo("## Supporting Files")
|
|
219
|
+
for path in files:
|
|
220
|
+
typer.echo(f"- {path}")
|
|
221
|
+
|
|
222
|
+
if ancestors:
|
|
223
|
+
typer.echo("")
|
|
224
|
+
typer.echo("## Ancestor Content")
|
|
225
|
+
for ancestor in ancestors:
|
|
226
|
+
typer.echo("")
|
|
227
|
+
typer.echo(f"### {ancestor.id}")
|
|
228
|
+
typer.echo("")
|
|
229
|
+
typer.echo(marker_content(ancestor).rstrip())
|
|
230
|
+
|
|
231
|
+
typer.echo("")
|
|
232
|
+
typer.echo("## Node Content")
|
|
233
|
+
typer.echo("")
|
|
234
|
+
typer.echo(marker_content(node).rstrip())
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@app.command("new")
|
|
238
|
+
def new_command(
|
|
239
|
+
kind: str = typer.Argument(..., help=f"Node kind: {', '.join(sorted(KIND_TO_RELATIONSHIP))}."),
|
|
240
|
+
slug: str = typer.Argument(..., help="Filesystem slug for the node."),
|
|
241
|
+
root: Path = typer.Option(Path("product"), "--root", "-r", help="Product graph root."),
|
|
242
|
+
under: str | None = typer.Option(None, "--under", "-u", help="Parent node id, slug, path, or marker file."),
|
|
243
|
+
title: str | None = typer.Option(None, "--title", "-t", help="Human-readable title."),
|
|
244
|
+
) -> None:
|
|
245
|
+
"""Create a product graph node in the valid location."""
|
|
246
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
247
|
+
issues = validate_graph(root)
|
|
248
|
+
if issues:
|
|
249
|
+
typer.echo(f"Invalid product graph: {root}")
|
|
250
|
+
for issue in issues:
|
|
251
|
+
typer.echo(f"- {issue.path}: {issue.message}")
|
|
252
|
+
raise typer.Exit(code=1)
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
node = create_node(root, kind=kind, slug=slug, title=title, under=under)
|
|
256
|
+
except (ValueError, FileExistsError) as error:
|
|
257
|
+
typer.echo(str(error))
|
|
258
|
+
raise typer.Exit(code=1) from error
|
|
259
|
+
|
|
260
|
+
typer.echo(f"Created {node.id}")
|
|
261
|
+
typer.echo(f"path: {node.path}")
|
|
262
|
+
typer.echo(f"marker: {node.marker_file}")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def load_valid_node(root: Path, selector: str):
|
|
266
|
+
issues = validate_graph(root)
|
|
267
|
+
if issues:
|
|
268
|
+
typer.echo(f"Invalid product graph: {root}")
|
|
269
|
+
for issue in issues:
|
|
270
|
+
typer.echo(f"- {issue.path}: {issue.message}")
|
|
271
|
+
raise typer.Exit(code=1)
|
|
272
|
+
|
|
273
|
+
node = find_node(root, selector)
|
|
274
|
+
if node is None:
|
|
275
|
+
typer.echo(f"Node not found or ambiguous: {selector}")
|
|
276
|
+
raise typer.Exit(code=1)
|
|
277
|
+
return node
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def parse_skills_option(args: list[str]) -> str:
|
|
281
|
+
if not args:
|
|
282
|
+
raise ValueError("nothing to install; use --skills or --skills=agents")
|
|
283
|
+
|
|
284
|
+
value: str | None = None
|
|
285
|
+
index = 0
|
|
286
|
+
while index < len(args):
|
|
287
|
+
arg = args[index]
|
|
288
|
+
if arg == "--skills":
|
|
289
|
+
value = "claude"
|
|
290
|
+
elif arg.startswith("--skills="):
|
|
291
|
+
value = arg.split("=", 1)[1] or "claude"
|
|
292
|
+
else:
|
|
293
|
+
raise ValueError(f"unknown install option: {arg}")
|
|
294
|
+
index += 1
|
|
295
|
+
|
|
296
|
+
if value is None:
|
|
297
|
+
raise ValueError("nothing to install; use --skills or --skills=agents")
|
|
298
|
+
return value
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def print_node(node, prefix: str, is_last: bool) -> None:
|
|
302
|
+
branch = "`-- " if is_last else "|-- "
|
|
303
|
+
typer.echo(f"{prefix}{branch}{node.kind}: {node.slug}")
|
|
304
|
+
child_prefix = prefix + (" " if is_last else "| ")
|
|
305
|
+
for index, child in enumerate(node.children):
|
|
306
|
+
print_node(child, child_prefix, index == len(node.children) - 1)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
if __name__ == "__main__":
|
|
310
|
+
app()
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class ExampleNode:
|
|
10
|
+
slug: str
|
|
11
|
+
kind: str
|
|
12
|
+
title: str
|
|
13
|
+
body: str
|
|
14
|
+
children: dict[str, list["ExampleNode"]] = field(default_factory=dict)
|
|
15
|
+
files: dict[str, str] = field(default_factory=dict)
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def marker_file(self) -> str:
|
|
19
|
+
return f"{self.kind.upper()}.md"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def write_file(path: Path, content: str) -> None:
|
|
23
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
path.write_text(content.rstrip() + "\n", encoding="utf-8")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def render_node(node: ExampleNode) -> str:
|
|
28
|
+
return f"""# {node.title}
|
|
29
|
+
|
|
30
|
+
```yaml
|
|
31
|
+
type: {node.kind}
|
|
32
|
+
id: {node.kind}.{node.slug}
|
|
33
|
+
status: example
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
{node.body}
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def write_node(base: Path, node: ExampleNode) -> None:
|
|
41
|
+
node_dir = base / node.slug
|
|
42
|
+
write_file(node_dir / node.marker_file, render_node(node))
|
|
43
|
+
|
|
44
|
+
for relative_path, content in node.files.items():
|
|
45
|
+
write_file(node_dir / relative_path, content)
|
|
46
|
+
|
|
47
|
+
for relationship, children in node.children.items():
|
|
48
|
+
for child in children:
|
|
49
|
+
write_node(node_dir / relationship, child)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def example_graph() -> ExampleNode:
|
|
53
|
+
return ExampleNode(
|
|
54
|
+
slug="delegation-confidence",
|
|
55
|
+
kind="outcome",
|
|
56
|
+
title="Delegation Confidence",
|
|
57
|
+
body="""Knowledge workers trust delegated agent work enough to use it for real recurring tasks.
|
|
58
|
+
|
|
59
|
+
## Measures
|
|
60
|
+
|
|
61
|
+
- A user delegates at least one recurring task to an agent.
|
|
62
|
+
- The delegated task completes with reviewable output.
|
|
63
|
+
- The user would choose to delegate the same task again.
|
|
64
|
+
|
|
65
|
+
## Known / Unknown
|
|
66
|
+
|
|
67
|
+
- Known: users want help turning messy work into agent-usable instructions.
|
|
68
|
+
- Unknown: whether safe tool access or better task framing is the larger blocker.""",
|
|
69
|
+
files={
|
|
70
|
+
"notes.md": """# Notes
|
|
71
|
+
|
|
72
|
+
This outcome is intentionally broad enough to contain multiple opportunity branches.
|
|
73
|
+
""",
|
|
74
|
+
},
|
|
75
|
+
children={
|
|
76
|
+
"opportunities": [
|
|
77
|
+
ExampleNode(
|
|
78
|
+
slug="users-do-not-know-what-to-delegate",
|
|
79
|
+
kind="opportunity",
|
|
80
|
+
title="Users Do Not Know What To Delegate",
|
|
81
|
+
body="""Users can sense that agents could help, but they struggle to identify which parts of their work are good delegation candidates.
|
|
82
|
+
|
|
83
|
+
## Evidence
|
|
84
|
+
|
|
85
|
+
- Workshop participants often start with vague work categories instead of concrete recurring tasks.
|
|
86
|
+
- Delegation candidates become clearer after an interview about recent work.""",
|
|
87
|
+
files={
|
|
88
|
+
"evidence/interview-patterns.md": """# Interview Patterns
|
|
89
|
+
|
|
90
|
+
Example evidence placeholder. In a real product graph this would link to interview notes, clips, transcripts, or synthesis.
|
|
91
|
+
""",
|
|
92
|
+
},
|
|
93
|
+
children={
|
|
94
|
+
"opportunities": [
|
|
95
|
+
ExampleNode(
|
|
96
|
+
slug="recurring-work-is-hard-to-describe",
|
|
97
|
+
kind="opportunity",
|
|
98
|
+
title="Recurring Work Is Hard To Describe",
|
|
99
|
+
body="""Users often know how to do a task themselves, but cannot easily describe the process, edge cases, inputs, and quality bar for an agent.""",
|
|
100
|
+
children={
|
|
101
|
+
"solutions": [
|
|
102
|
+
ExampleNode(
|
|
103
|
+
slug="delegation-interview",
|
|
104
|
+
kind="solution",
|
|
105
|
+
title="Delegation Interview",
|
|
106
|
+
body="""An agent-guided interview helps the user describe one concrete task, then expands outward to map related work.""",
|
|
107
|
+
children={
|
|
108
|
+
"assumptions": [
|
|
109
|
+
ExampleNode(
|
|
110
|
+
slug="interview-produces-better-task-descriptions",
|
|
111
|
+
kind="assumption",
|
|
112
|
+
title="Interview Produces Better Task Descriptions",
|
|
113
|
+
body="""If an agent interviews the user about a concrete recent task, the resulting task description will be more actionable than a blank-form prompt.""",
|
|
114
|
+
children={
|
|
115
|
+
"experiments": [
|
|
116
|
+
ExampleNode(
|
|
117
|
+
slug="compare-blank-form-vs-interview",
|
|
118
|
+
kind="experiment",
|
|
119
|
+
title="Compare Blank Form Vs Interview",
|
|
120
|
+
body="""Ask users to describe the same delegation candidate through a blank form and through an interview. Compare completeness, confidence, and agent execution quality.""",
|
|
121
|
+
)
|
|
122
|
+
]
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
],
|
|
126
|
+
"prds": [
|
|
127
|
+
ExampleNode(
|
|
128
|
+
slug="delegation-interview-mvp",
|
|
129
|
+
kind="prd",
|
|
130
|
+
title="Delegation Interview MVP",
|
|
131
|
+
body="""Build the smallest flow that interviews a user about one recurring task and turns the answers into a reusable agent instruction artifact.
|
|
132
|
+
|
|
133
|
+
## Acceptance Criteria
|
|
134
|
+
|
|
135
|
+
- The interview anchors on one concrete recent task.
|
|
136
|
+
- The output includes inputs, steps, edge cases, quality bar, and stop-and-ask conditions.
|
|
137
|
+
- The output can be used by an agent in a fresh context.""",
|
|
138
|
+
)
|
|
139
|
+
],
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
]
|
|
143
|
+
},
|
|
144
|
+
)
|
|
145
|
+
]
|
|
146
|
+
},
|
|
147
|
+
),
|
|
148
|
+
ExampleNode(
|
|
149
|
+
slug="agents-lack-safe-access-to-tools",
|
|
150
|
+
kind="opportunity",
|
|
151
|
+
title="Agents Lack Safe Access To Tools",
|
|
152
|
+
body="""Even when a user knows what to delegate, the agent may lack safe, discoverable, permissioned access to the systems needed to do the work.""",
|
|
153
|
+
children={
|
|
154
|
+
"solutions": [
|
|
155
|
+
ExampleNode(
|
|
156
|
+
slug="agent-central",
|
|
157
|
+
kind="solution",
|
|
158
|
+
title="Agent Central",
|
|
159
|
+
body="""A capability platform where administrators configure and publish safe operations that agents can discover and execute through a small MCP surface.""",
|
|
160
|
+
children={
|
|
161
|
+
"assumptions": [
|
|
162
|
+
ExampleNode(
|
|
163
|
+
slug="operation-discovery-reduces-tool-overload",
|
|
164
|
+
kind="assumption",
|
|
165
|
+
title="Operation Discovery Reduces Tool Overload",
|
|
166
|
+
body="""Agents will perform better when they can search and describe operations instead of receiving a very large static tool list.""",
|
|
167
|
+
children={
|
|
168
|
+
"experiments": [
|
|
169
|
+
ExampleNode(
|
|
170
|
+
slug="fake-connector-prototype",
|
|
171
|
+
kind="experiment",
|
|
172
|
+
title="Fake Connector Prototype",
|
|
173
|
+
body="""Build an in-memory connector prototype and observe whether agents can discover, describe, and execute the right operation without needing one MCP tool per capability.""",
|
|
174
|
+
)
|
|
175
|
+
]
|
|
176
|
+
},
|
|
177
|
+
),
|
|
178
|
+
ExampleNode(
|
|
179
|
+
slug="admins-will-curate-agent-capabilities",
|
|
180
|
+
kind="assumption",
|
|
181
|
+
title="Admins Will Curate Agent Capabilities",
|
|
182
|
+
body="""Organizations will assign someone to configure, publish, and govern the operations made available to agents.""",
|
|
183
|
+
),
|
|
184
|
+
],
|
|
185
|
+
"prds": [
|
|
186
|
+
ExampleNode(
|
|
187
|
+
slug="agent-central-mvp",
|
|
188
|
+
kind="prd",
|
|
189
|
+
title="Agent Central MVP",
|
|
190
|
+
body="""Validate the smallest useful loop for low-context agent operation discovery and execution.
|
|
191
|
+
|
|
192
|
+
## Acceptance Criteria
|
|
193
|
+
|
|
194
|
+
- Agents can search operations.
|
|
195
|
+
- Agents can describe a selected operation.
|
|
196
|
+
- Agents can execute a valid GraphQL operation.
|
|
197
|
+
- Runtime permission checks allow or deny execution.""",
|
|
198
|
+
)
|
|
199
|
+
],
|
|
200
|
+
},
|
|
201
|
+
)
|
|
202
|
+
]
|
|
203
|
+
},
|
|
204
|
+
),
|
|
205
|
+
]
|
|
206
|
+
},
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def create_example(root: Path, force: bool) -> None:
|
|
211
|
+
if root.exists():
|
|
212
|
+
if not force:
|
|
213
|
+
raise FileExistsError(f"{root} already exists. Re-run with --force to replace it.")
|
|
214
|
+
shutil.rmtree(root)
|
|
215
|
+
|
|
216
|
+
write_file(
|
|
217
|
+
root / "VISION.md",
|
|
218
|
+
"""# Vision
|
|
219
|
+
|
|
220
|
+
Enable knowledge workers to securely and effectively delegate work to AI agents.
|
|
221
|
+
""",
|
|
222
|
+
)
|
|
223
|
+
write_file(
|
|
224
|
+
root / "STRATEGY.md",
|
|
225
|
+
"""# Strategy
|
|
226
|
+
|
|
227
|
+
Focus on recurring knowledge work where better task framing and safer tool access can make delegation feel trustworthy.
|
|
228
|
+
""",
|
|
229
|
+
)
|
|
230
|
+
write_node(root / "outcomes", example_graph())
|
|
231
|
+
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from outcome_engineering.model import (
|
|
6
|
+
ALLOWED_CHILD_RELATIONSHIPS,
|
|
7
|
+
KIND_TO_MARKER_FILE,
|
|
8
|
+
KIND_TO_RELATIONSHIP,
|
|
9
|
+
MARKER_FILES,
|
|
10
|
+
PARENT_KIND_TO_CHILD_KIND,
|
|
11
|
+
RELATIONSHIP_TO_CHILD_KIND,
|
|
12
|
+
ProductNode,
|
|
13
|
+
ValidationIssue,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def marker_files_in(path: Path) -> list[Path]:
|
|
18
|
+
return sorted(child for child in path.iterdir() if child.is_file() and child.name in MARKER_FILES)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def discover_nodes(root: Path) -> list[ProductNode]:
|
|
22
|
+
root = root.resolve()
|
|
23
|
+
nodes: list[ProductNode] = []
|
|
24
|
+
|
|
25
|
+
def visit_node_dir(path: Path, parent: ProductNode | None, relationship: str | None) -> ProductNode | None:
|
|
26
|
+
markers = marker_files_in(path)
|
|
27
|
+
if len(markers) != 1:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
marker = markers[0]
|
|
31
|
+
node = ProductNode(
|
|
32
|
+
path=path,
|
|
33
|
+
kind=MARKER_FILES[marker.name],
|
|
34
|
+
marker_file=marker,
|
|
35
|
+
slug=path.name,
|
|
36
|
+
parent=parent,
|
|
37
|
+
relationship=relationship,
|
|
38
|
+
children=[],
|
|
39
|
+
)
|
|
40
|
+
nodes.append(node)
|
|
41
|
+
|
|
42
|
+
child_nodes: list[ProductNode] = []
|
|
43
|
+
for rel_dir in relationship_dirs(path):
|
|
44
|
+
for child_dir in sorted(child for child in rel_dir.iterdir() if child.is_dir()):
|
|
45
|
+
child = visit_node_dir(child_dir, node, rel_dir.name)
|
|
46
|
+
if child is not None:
|
|
47
|
+
child_nodes.append(child)
|
|
48
|
+
|
|
49
|
+
node.children.extend(child_nodes)
|
|
50
|
+
return node
|
|
51
|
+
|
|
52
|
+
for child in sorted(root.iterdir()):
|
|
53
|
+
if child.is_dir() and child.name in RELATIONSHIP_TO_CHILD_KIND:
|
|
54
|
+
for node_dir in sorted(grandchild for grandchild in child.iterdir() if grandchild.is_dir()):
|
|
55
|
+
visit_node_dir(node_dir, None, child.name)
|
|
56
|
+
|
|
57
|
+
for marker in marker_files_in(root):
|
|
58
|
+
nodes.append(
|
|
59
|
+
ProductNode(
|
|
60
|
+
path=root,
|
|
61
|
+
kind=MARKER_FILES[marker.name],
|
|
62
|
+
marker_file=marker,
|
|
63
|
+
slug=root.name,
|
|
64
|
+
parent=None,
|
|
65
|
+
relationship=None,
|
|
66
|
+
children=[],
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return nodes
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def find_node(root: Path, selector: str) -> ProductNode | None:
|
|
74
|
+
root = root.resolve()
|
|
75
|
+
selector_path = Path(selector)
|
|
76
|
+
if selector_path.exists():
|
|
77
|
+
resolved_selector = selector_path.resolve()
|
|
78
|
+
for node in discover_nodes(root):
|
|
79
|
+
if node.path == resolved_selector or node.marker_file == resolved_selector:
|
|
80
|
+
return node
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
matches = [node for node in discover_nodes(root) if node.id == selector or node.slug == selector]
|
|
84
|
+
if len(matches) == 1:
|
|
85
|
+
return matches[0]
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def find_nodes_by_kind(root: Path, kind: str | None = None) -> list[ProductNode]:
|
|
90
|
+
nodes = [node for node in discover_nodes(root) if node.kind not in {"vision", "strategy"}]
|
|
91
|
+
if kind is None:
|
|
92
|
+
return sorted(nodes, key=lambda node: (node.kind, node.slug, str(node.path)))
|
|
93
|
+
return sorted((node for node in nodes if node.kind == kind), key=lambda node: (node.slug, str(node.path)))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def node_ancestors(node: ProductNode) -> list[ProductNode]:
|
|
97
|
+
ancestors: list[ProductNode] = []
|
|
98
|
+
current = node.parent
|
|
99
|
+
while current is not None:
|
|
100
|
+
ancestors.append(current)
|
|
101
|
+
current = current.parent
|
|
102
|
+
return list(reversed(ancestors))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def create_node(root: Path, kind: str, slug: str, title: str | None, under: str | None) -> ProductNode:
|
|
106
|
+
root = root.resolve()
|
|
107
|
+
if kind not in KIND_TO_RELATIONSHIP:
|
|
108
|
+
supported = ", ".join(sorted(KIND_TO_RELATIONSHIP))
|
|
109
|
+
raise ValueError(f"unsupported node kind {kind!r}; expected one of: {supported}")
|
|
110
|
+
|
|
111
|
+
parent: ProductNode | None = None
|
|
112
|
+
parent_kind = "root"
|
|
113
|
+
parent_path = root
|
|
114
|
+
if kind != "outcome":
|
|
115
|
+
if under is None:
|
|
116
|
+
raise ValueError(f"{kind} requires --under")
|
|
117
|
+
parent = find_node(root, under)
|
|
118
|
+
if parent is None:
|
|
119
|
+
raise ValueError(f"parent not found: {under}")
|
|
120
|
+
parent_kind = parent.kind
|
|
121
|
+
parent_path = parent.path
|
|
122
|
+
elif under is not None:
|
|
123
|
+
raise ValueError("outcome cannot use --under; outcomes live under the product graph root")
|
|
124
|
+
|
|
125
|
+
allowed_child_kinds = PARENT_KIND_TO_CHILD_KIND.get(parent_kind, set())
|
|
126
|
+
if kind not in allowed_child_kinds:
|
|
127
|
+
allowed = ", ".join(sorted(allowed_child_kinds)) or "none"
|
|
128
|
+
raise ValueError(f"cannot create {kind} under {parent_kind}; allowed child kinds: {allowed}")
|
|
129
|
+
|
|
130
|
+
relationship = KIND_TO_RELATIONSHIP[kind]
|
|
131
|
+
node_path = parent_path / relationship / slug
|
|
132
|
+
if node_path.exists():
|
|
133
|
+
raise FileExistsError(f"{node_path} already exists")
|
|
134
|
+
|
|
135
|
+
marker = KIND_TO_MARKER_FILE[kind]
|
|
136
|
+
node_path.mkdir(parents=True)
|
|
137
|
+
marker_path = node_path / marker
|
|
138
|
+
marker_path.write_text(render_template(kind, slug, title or title_from_slug(slug)), encoding="utf-8")
|
|
139
|
+
|
|
140
|
+
return ProductNode(
|
|
141
|
+
path=node_path,
|
|
142
|
+
kind=kind,
|
|
143
|
+
marker_file=marker_path,
|
|
144
|
+
slug=slug,
|
|
145
|
+
parent=parent,
|
|
146
|
+
relationship=relationship,
|
|
147
|
+
children=[],
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def render_template(kind: str, slug: str, title: str) -> str:
|
|
152
|
+
sections = {
|
|
153
|
+
"outcome": "## Measures\n\n- TODO\n\n## Known / Unknown\n\n- Known: TODO\n- Unknown: TODO\n",
|
|
154
|
+
"opportunity": "## Evidence\n\n- TODO\n\n## Known / Unknown\n\n- Known: TODO\n- Unknown: TODO\n",
|
|
155
|
+
"solution": "## Product Risks\n\n- Value: TODO\n- Usability: TODO\n- Feasibility: TODO\n- Viability: TODO\n\n## Assumptions\n\n- TODO\n",
|
|
156
|
+
"assumption": "## Risk Type\n\nTODO\n\n## How This Could Be False\n\nTODO\n",
|
|
157
|
+
"experiment": "## Tests Assumption\n\nTODO\n\n## Success / Failure Signal\n\nTODO\n\n## Decision It Informs\n\nTODO\n",
|
|
158
|
+
"prd": "## Problem\n\nTODO\n\n## User Stories\n\n- TODO\n\n## Acceptance Criteria\n\n- TODO\n",
|
|
159
|
+
}
|
|
160
|
+
body = sections[kind]
|
|
161
|
+
return f"""# {title}
|
|
162
|
+
|
|
163
|
+
```yaml
|
|
164
|
+
type: {kind}
|
|
165
|
+
id: {kind}.{slug}
|
|
166
|
+
status: draft
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
{body}
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def title_from_slug(slug: str) -> str:
|
|
174
|
+
return " ".join(part.capitalize() for part in slug.replace("_", "-").split("-") if part)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def marker_content(node: ProductNode) -> str:
|
|
178
|
+
return node.marker_file.read_text(encoding="utf-8")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def supporting_files(node: ProductNode) -> list[Path]:
|
|
182
|
+
relationship_names = set(RELATIONSHIP_TO_CHILD_KIND)
|
|
183
|
+
return sorted(
|
|
184
|
+
path
|
|
185
|
+
for path in node.path.rglob("*")
|
|
186
|
+
if path.is_file()
|
|
187
|
+
and path != node.marker_file
|
|
188
|
+
and not any(part in relationship_names for part in path.relative_to(node.path).parts[:-1])
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def relationship_dirs(path: Path) -> list[Path]:
|
|
193
|
+
return sorted(child for child in path.iterdir() if child.is_dir() and child.name in RELATIONSHIP_TO_CHILD_KIND)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def validate(root: Path) -> list[ValidationIssue]:
|
|
197
|
+
root = root.resolve()
|
|
198
|
+
issues: list[ValidationIssue] = []
|
|
199
|
+
|
|
200
|
+
if not root.exists():
|
|
201
|
+
return [ValidationIssue(root, "path does not exist")]
|
|
202
|
+
if not root.is_dir():
|
|
203
|
+
return [ValidationIssue(root, "path is not a directory")]
|
|
204
|
+
|
|
205
|
+
seen_ids: dict[str, Path] = {}
|
|
206
|
+
|
|
207
|
+
for path in sorted([root, *[p for p in root.rglob("*") if p.is_dir()]]):
|
|
208
|
+
markers = marker_files_in(path)
|
|
209
|
+
if path == root:
|
|
210
|
+
root_marker_names = {marker.name for marker in markers}
|
|
211
|
+
unexpected_root_markers = root_marker_names - {"VISION.md", "STRATEGY.md"}
|
|
212
|
+
if unexpected_root_markers:
|
|
213
|
+
issues.append(ValidationIssue(path, f"root has invalid marker files: {', '.join(sorted(unexpected_root_markers))}"))
|
|
214
|
+
continue
|
|
215
|
+
if len(markers) > 1:
|
|
216
|
+
issues.append(ValidationIssue(path, f"node directory has multiple marker files: {', '.join(m.name for m in markers)}"))
|
|
217
|
+
continue
|
|
218
|
+
if len(markers) == 1:
|
|
219
|
+
marker = markers[0]
|
|
220
|
+
kind = MARKER_FILES[marker.name]
|
|
221
|
+
node_id = f"{kind}.{path.name}"
|
|
222
|
+
if node_id in seen_ids:
|
|
223
|
+
issues.append(ValidationIssue(path, f"duplicate node id {node_id}; first seen at {seen_ids[node_id]}"))
|
|
224
|
+
else:
|
|
225
|
+
seen_ids[node_id] = path
|
|
226
|
+
|
|
227
|
+
parent_info = parent_node_and_relationship(root, path)
|
|
228
|
+
if path == root:
|
|
229
|
+
continue
|
|
230
|
+
if parent_info is None:
|
|
231
|
+
issues.append(ValidationIssue(path, "node is not inside a valid relationship directory"))
|
|
232
|
+
continue
|
|
233
|
+
parent_path, relationship = parent_info
|
|
234
|
+
expected_kinds = RELATIONSHIP_TO_CHILD_KIND[relationship]
|
|
235
|
+
if kind not in expected_kinds:
|
|
236
|
+
issues.append(
|
|
237
|
+
ValidationIssue(
|
|
238
|
+
path,
|
|
239
|
+
f"{marker.name} is not valid under {relationship}/; expected {', '.join(sorted(expected_kinds))}",
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
if relationship == "experiments":
|
|
243
|
+
parent_markers = marker_files_in(parent_path)
|
|
244
|
+
parent_kind = MARKER_FILES[parent_markers[0].name] if len(parent_markers) == 1 else "unknown"
|
|
245
|
+
if parent_kind != "assumption":
|
|
246
|
+
issues.append(ValidationIssue(path, "experiments can only live under an assumption"))
|
|
247
|
+
|
|
248
|
+
for path in sorted([root, *[p for p in root.rglob("*") if p.is_dir()]]):
|
|
249
|
+
markers = marker_files_in(path)
|
|
250
|
+
current_kind = "root"
|
|
251
|
+
if len(markers) == 1:
|
|
252
|
+
current_kind = MARKER_FILES[markers[0].name]
|
|
253
|
+
|
|
254
|
+
for rel_dir in relationship_dirs(path):
|
|
255
|
+
if rel_dir.name not in ALLOWED_CHILD_RELATIONSHIPS[current_kind]:
|
|
256
|
+
issues.append(ValidationIssue(rel_dir, f"{rel_dir.name}/ is not allowed under {current_kind}"))
|
|
257
|
+
for child_dir in sorted(child for child in rel_dir.iterdir() if child.is_dir()):
|
|
258
|
+
child_markers = marker_files_in(child_dir)
|
|
259
|
+
if len(child_markers) == 0:
|
|
260
|
+
issues.append(ValidationIssue(child_dir, f"missing marker file for child under {rel_dir.name}/"))
|
|
261
|
+
|
|
262
|
+
return issues
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def parent_node_and_relationship(root: Path, path: Path) -> tuple[Path, str] | None:
|
|
266
|
+
parent = path.parent
|
|
267
|
+
if parent == root:
|
|
268
|
+
return None
|
|
269
|
+
relationship = parent.name
|
|
270
|
+
if relationship not in RELATIONSHIP_TO_CHILD_KIND:
|
|
271
|
+
return None
|
|
272
|
+
node_parent = parent.parent
|
|
273
|
+
if node_parent == root:
|
|
274
|
+
return node_parent, relationship
|
|
275
|
+
if len(marker_files_in(node_parent)) != 1:
|
|
276
|
+
return None
|
|
277
|
+
return node_parent, relationship
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
MARKER_FILES = {
|
|
8
|
+
"VISION.md": "vision",
|
|
9
|
+
"STRATEGY.md": "strategy",
|
|
10
|
+
"OUTCOME.md": "outcome",
|
|
11
|
+
"OPPORTUNITY.md": "opportunity",
|
|
12
|
+
"SOLUTION.md": "solution",
|
|
13
|
+
"ASSUMPTION.md": "assumption",
|
|
14
|
+
"EXPERIMENT.md": "experiment",
|
|
15
|
+
"PRD.md": "prd",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
RELATIONSHIP_TO_CHILD_KIND = {
|
|
19
|
+
"outcomes": {"outcome"},
|
|
20
|
+
"opportunities": {"opportunity"},
|
|
21
|
+
"solutions": {"solution"},
|
|
22
|
+
"assumptions": {"assumption"},
|
|
23
|
+
"experiments": {"experiment"},
|
|
24
|
+
"prds": {"prd"},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
KIND_TO_MARKER_FILE = {kind: marker for marker, kind in MARKER_FILES.items()}
|
|
28
|
+
|
|
29
|
+
KIND_TO_RELATIONSHIP = {
|
|
30
|
+
"outcome": "outcomes",
|
|
31
|
+
"opportunity": "opportunities",
|
|
32
|
+
"solution": "solutions",
|
|
33
|
+
"assumption": "assumptions",
|
|
34
|
+
"experiment": "experiments",
|
|
35
|
+
"prd": "prds",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
PARENT_KIND_TO_CHILD_KIND = {
|
|
39
|
+
"root": {"outcome"},
|
|
40
|
+
"outcome": {"opportunity"},
|
|
41
|
+
"opportunity": {"opportunity", "solution"},
|
|
42
|
+
"solution": {"assumption", "prd"},
|
|
43
|
+
"assumption": {"experiment"},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
ALLOWED_CHILD_RELATIONSHIPS = {
|
|
47
|
+
"root": {"outcomes"},
|
|
48
|
+
"vision": set(),
|
|
49
|
+
"strategy": set(),
|
|
50
|
+
"outcome": {"opportunities"},
|
|
51
|
+
"opportunity": {"opportunities", "solutions"},
|
|
52
|
+
"solution": {"assumptions", "prds"},
|
|
53
|
+
"assumption": {"experiments"},
|
|
54
|
+
"experiment": set(),
|
|
55
|
+
"prd": set(),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class ProductNode:
|
|
61
|
+
path: Path
|
|
62
|
+
kind: str
|
|
63
|
+
marker_file: Path
|
|
64
|
+
slug: str
|
|
65
|
+
parent: "ProductNode | None" = None
|
|
66
|
+
relationship: str | None = None
|
|
67
|
+
children: list["ProductNode"] = field(default_factory=list)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def id(self) -> str:
|
|
71
|
+
return f"{self.kind}.{self.slug}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class ValidationIssue:
|
|
76
|
+
path: Path
|
|
77
|
+
message: str
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
from importlib import resources
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
SKILL_NAME = "oe-cli"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def skill_target(agent: str) -> Path:
|
|
13
|
+
normalized = agent.lower()
|
|
14
|
+
if normalized == "codex":
|
|
15
|
+
return codex_skill_target()
|
|
16
|
+
if normalized == "claude":
|
|
17
|
+
return claude_skill_target()
|
|
18
|
+
raise ValueError(f"unsupported agent {agent!r}; expected codex, claude, or all")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def project_skill_target(kind: str, cwd: Path | None = None) -> Path:
|
|
22
|
+
root = (cwd or Path.cwd()).resolve()
|
|
23
|
+
normalized = kind.lower()
|
|
24
|
+
if normalized in {"", "claude"}:
|
|
25
|
+
return root / ".claude" / "skills" / SKILL_NAME
|
|
26
|
+
if normalized == "agents":
|
|
27
|
+
return root / ".agents" / "skills" / SKILL_NAME
|
|
28
|
+
raise ValueError("unsupported --skills value; use --skills, --skills=claude, or --skills=agents")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def codex_skill_target() -> Path:
|
|
32
|
+
codex_home = os.environ.get("CODEX_HOME")
|
|
33
|
+
if codex_home:
|
|
34
|
+
return Path(codex_home) / "skills" / SKILL_NAME
|
|
35
|
+
return Path.home() / ".codex" / "skills" / SKILL_NAME
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def claude_skill_target() -> Path:
|
|
39
|
+
claude_home = os.environ.get("CLAUDE_HOME")
|
|
40
|
+
if claude_home:
|
|
41
|
+
return Path(claude_home) / "skills" / SKILL_NAME
|
|
42
|
+
return Path.home() / ".claude" / "skills" / SKILL_NAME
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def install_skill(target: Path | None = None, force: bool = False) -> Path:
|
|
46
|
+
destination = (target or codex_skill_target()).expanduser()
|
|
47
|
+
return copy_skill(destination, force=force)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def install_skill_for_agent(agent: str, force: bool = False) -> list[Path]:
|
|
51
|
+
normalized = agent.lower()
|
|
52
|
+
if normalized == "all":
|
|
53
|
+
return [copy_skill(skill_target(name), force=force) for name in ("codex", "claude")]
|
|
54
|
+
return [copy_skill(skill_target(normalized), force=force)]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def install_project_skill(kind: str = "claude", cwd: Path | None = None, force: bool = False) -> Path:
|
|
58
|
+
return copy_skill(project_skill_target(kind, cwd=cwd), force=force)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def copy_skill(destination: Path, force: bool = False) -> Path:
|
|
62
|
+
destination = destination.expanduser()
|
|
63
|
+
source = resources.files("outcome_engineering") / "skills" / SKILL_NAME
|
|
64
|
+
|
|
65
|
+
if destination.exists():
|
|
66
|
+
if not force:
|
|
67
|
+
raise FileExistsError(f"{destination} already exists. Re-run with --force to replace it.")
|
|
68
|
+
if destination.is_dir():
|
|
69
|
+
shutil.rmtree(destination)
|
|
70
|
+
else:
|
|
71
|
+
destination.unlink()
|
|
72
|
+
|
|
73
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
shutil.copytree(source, destination)
|
|
75
|
+
return destination
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: oe-cli
|
|
3
|
+
description: Use this skill whenever a repository has an Outcome Engineering product graph, the `oe` CLI, the `outcome-engineering` Python package, or the user asks about product vision, outcomes, opportunities, solutions, assumptions, experiments, PRDs, product discovery, or implementing work that should trace to product intent. This skill tells agents how to use `oe` to inspect, create, validate, and contextualize product graph structure before doing product or delivery work.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Outcome Engineering
|
|
7
|
+
|
|
8
|
+
Use the `oe` CLI to work with repo-native product graphs.
|
|
9
|
+
|
|
10
|
+
The Python package is `outcome-engineering`. The command is `oe`.
|
|
11
|
+
|
|
12
|
+
Install the skill for agent tools:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
uvx outcome-engineering install --skills
|
|
16
|
+
uvx outcome-engineering install --skills=agents
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Outcome Engineering stores product intent as a filesystem graph rooted at:
|
|
20
|
+
|
|
21
|
+
```text
|
|
22
|
+
product/
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The graph convention is:
|
|
26
|
+
|
|
27
|
+
```text
|
|
28
|
+
product/
|
|
29
|
+
VISION.md
|
|
30
|
+
STRATEGY.md
|
|
31
|
+
outcomes/
|
|
32
|
+
<outcome>/
|
|
33
|
+
OUTCOME.md
|
|
34
|
+
opportunities/
|
|
35
|
+
<opportunity>/
|
|
36
|
+
OPPORTUNITY.md
|
|
37
|
+
opportunities/
|
|
38
|
+
<sub-opportunity>/
|
|
39
|
+
solutions/
|
|
40
|
+
<solution>/
|
|
41
|
+
SOLUTION.md
|
|
42
|
+
assumptions/
|
|
43
|
+
<assumption>/
|
|
44
|
+
ASSUMPTION.md
|
|
45
|
+
experiments/
|
|
46
|
+
<experiment>/
|
|
47
|
+
EXPERIMENT.md
|
|
48
|
+
prds/
|
|
49
|
+
<prd>/
|
|
50
|
+
PRD.md
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Experiments belong under assumptions. Do not create experiments directly under solutions.
|
|
54
|
+
|
|
55
|
+
## When To Use
|
|
56
|
+
|
|
57
|
+
Use this skill when the user asks to:
|
|
58
|
+
|
|
59
|
+
- create or refine product vision, strategy, outcomes, opportunities, solutions, assumptions, experiments, or PRDs
|
|
60
|
+
- decide what to build next
|
|
61
|
+
- turn product thinking into a PRD or delivery work
|
|
62
|
+
- implement a feature that should trace back to product intent
|
|
63
|
+
- validate or inspect an existing product graph
|
|
64
|
+
- add discovery evidence, assumptions, or experiments
|
|
65
|
+
|
|
66
|
+
Skip it for isolated implementation chores that do not affect product behavior, such as small refactors, formatting, dependency bumps, or mechanical bug fixes with no product artifact.
|
|
67
|
+
|
|
68
|
+
## Default Workflow
|
|
69
|
+
|
|
70
|
+
Start by checking whether the repo has a product graph:
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
test -d product && uv run oe validate product
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
If the graph exists, inspect it before making product or delivery changes:
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
uv run oe tree product
|
|
80
|
+
uv run oe list outcomes --root product
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Before working on a specific product artifact, trace it:
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
uv run oe trace <node-id-or-slug> --root product
|
|
87
|
+
uv run oe context <node-id-or-slug> --root product
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Use `oe context` before writing a PRD, editing a solution, or implementing code from a PRD. It gives deterministic context; it does not replace reading the linked files.
|
|
91
|
+
|
|
92
|
+
After changing graph structure, validate:
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
uv run oe validate product
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Creating Nodes
|
|
99
|
+
|
|
100
|
+
Create graph nodes through `oe new` instead of hand-rolling directories.
|
|
101
|
+
|
|
102
|
+
```sh
|
|
103
|
+
uv run oe new outcome <slug> --root product
|
|
104
|
+
uv run oe new opportunity <slug> --root product --under outcome.<slug>
|
|
105
|
+
uv run oe new solution <slug> --root product --under opportunity.<slug>
|
|
106
|
+
uv run oe new assumption <slug> --root product --under solution.<slug>
|
|
107
|
+
uv run oe new experiment <slug> --root product --under assumption.<slug>
|
|
108
|
+
uv run oe new prd <slug> --root product --under solution.<slug>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
If a slug is ambiguous, use the full node id or marker file path.
|
|
112
|
+
|
|
113
|
+
## Important Boundary
|
|
114
|
+
|
|
115
|
+
`oe` currently manages deterministic structure. It does not make product judgment.
|
|
116
|
+
|
|
117
|
+
Do not pretend that `oe validate` means the product thinking is good. It only means the graph structure is valid. Human judgment and user discovery remain required.
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: outcome-engineering
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Repo-native product graph tooling for Outcome Engineering.
|
|
5
|
+
Requires-Dist: typer>=0.16.0
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
# Outcome Engineering
|
|
10
|
+
|
|
11
|
+
Outcome Engineering is a framework for helping humans continuously challenge product thinking and invent valuable solutions that users and customers actually want, use, and pay for, in ways that work for the business.
|
|
12
|
+
|
|
13
|
+
The core claim:
|
|
14
|
+
|
|
15
|
+
> Product development should be modeled as a living intent graph where every product bet can be traced upward to its strategic purpose, downward to implementation, and sideways to the evidence and learning that shaped it.
|
|
16
|
+
|
|
17
|
+
Traceability is the mechanism. Better product judgment and better product outcomes are the point.
|
|
18
|
+
|
|
19
|
+
The graph provides structure for product work and gives agents a way to understand, organize, and challenge what is happening.
|
|
20
|
+
|
|
21
|
+
The framework connects product vision, strategy, OKRs, outcomes, opportunity solution trees, assumptions, experiments, PRDs, user stories, acceptance criteria, code, tests, and evidence into one coherent system.
|
|
22
|
+
|
|
23
|
+
It is not a replacement for human product judgment. Humans still set direction, talk to users, interpret nuance, and make decisions. Agentic engineering can help maintain structure, reframe opportunities, analyze assumptions, challenge output-thinking, expose what is known and unknown, generate options, implement code, run tests, and preserve traceability across the system.
|
|
24
|
+
|
|
25
|
+
Agents can generate plausible opportunities, solutions, assumptions, stories, and analyses, but plausibility is not grounding. Synthetic artifacts remain hypotheses until supported by real customer, user, market, business, or technical evidence.
|
|
26
|
+
|
|
27
|
+
## The Problem
|
|
28
|
+
|
|
29
|
+
Most organizations lose intent as work moves through the product development process.
|
|
30
|
+
|
|
31
|
+
A compelling product vision becomes a strategy deck. Strategy becomes OKRs. OKRs become roadmap items. Roadmap items become tickets. Tickets become code. Code ships. Somewhere along the way, the original customer need, business intent, assumptions, and evidence are often lost.
|
|
32
|
+
|
|
33
|
+
The result is delivery without memory:
|
|
34
|
+
|
|
35
|
+
- Teams ship outputs without knowing which outcome they serve.
|
|
36
|
+
- Discovery evidence lives separately from delivery work.
|
|
37
|
+
- User stories lose their connection to customer opportunities.
|
|
38
|
+
- Code becomes hard to explain in product terms.
|
|
39
|
+
- Learning arrives too late, or never updates the underlying product model.
|
|
40
|
+
|
|
41
|
+
Outcome Engineering treats this as a structural problem.
|
|
42
|
+
|
|
43
|
+
## The Model
|
|
44
|
+
|
|
45
|
+
At the highest level:
|
|
46
|
+
|
|
47
|
+
```text
|
|
48
|
+
Vision
|
|
49
|
+
-> Strategy
|
|
50
|
+
-> OKRs
|
|
51
|
+
-> Outcomes
|
|
52
|
+
-> Opportunity Solution Trees
|
|
53
|
+
-> Opportunities
|
|
54
|
+
-> Solutions
|
|
55
|
+
-> Assumptions
|
|
56
|
+
-> Experiments
|
|
57
|
+
-> Decisions
|
|
58
|
+
-> PRDs
|
|
59
|
+
-> User Stories
|
|
60
|
+
-> Acceptance Criteria
|
|
61
|
+
-> Code
|
|
62
|
+
-> Tests
|
|
63
|
+
-> Released Product
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
But this is not a one-way waterfall.
|
|
67
|
+
|
|
68
|
+
Evidence and learning can happen throughout the graph:
|
|
69
|
+
|
|
70
|
+
```text
|
|
71
|
+
Any product belief
|
|
72
|
+
-> Evidence
|
|
73
|
+
-> Learning
|
|
74
|
+
-> Update the graph
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Evidence can come from user interviews, customer conversations, sales calls, support conversations, analytics, prototype tests, assumption tests, usability sessions, technical spikes, experiments, production telemetry, and market observation.
|
|
78
|
+
|
|
79
|
+
Quantitative data can show what is happening. Human discovery is needed to understand why it is happening.
|
|
80
|
+
|
|
81
|
+
## Repository Structure
|
|
82
|
+
|
|
83
|
+
- [docs/framework.md](docs/framework.md) defines the framework.
|
|
84
|
+
- [docs/graph.md](docs/graph.md) describes the concept graph.
|
|
85
|
+
- [docs/glossary.md](docs/glossary.md) defines the core terms.
|
|
86
|
+
- [docs/example-structure.md](docs/example-structure.md) explains the example filesystem graph.
|
|
87
|
+
|
|
88
|
+
## CLI
|
|
89
|
+
|
|
90
|
+
The Python package is `outcome-engineering`. The command is `oe`.
|
|
91
|
+
|
|
92
|
+
Real product repositories should store the graph at:
|
|
93
|
+
|
|
94
|
+
```text
|
|
95
|
+
product/
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Install the bundled agent skill with Playwright-style project-local commands:
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
uv run oe install --skills --force
|
|
102
|
+
uv run oe install --skills=agents --force
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Inspect a product graph:
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
uv run oe validate product
|
|
109
|
+
uv run oe tree product
|
|
110
|
+
uv run oe list outcomes --root product
|
|
111
|
+
uv run oe list opportunities --root product
|
|
112
|
+
uv run oe list solutions --root product
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Trace product intent before editing a product artifact or implementing from a PRD:
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
uv run oe trace solution.agent-central --root product
|
|
119
|
+
uv run oe context solution.agent-central --root product
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Read a node's canonical marker file:
|
|
123
|
+
|
|
124
|
+
```sh
|
|
125
|
+
uv run oe show outcome.delegation-confidence --root product
|
|
126
|
+
uv run oe show opportunity.agents-lack-safe-access --root product
|
|
127
|
+
uv run oe show prd.agent-central-mvp --root product
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Create nodes deterministically:
|
|
131
|
+
|
|
132
|
+
```sh
|
|
133
|
+
uv run oe new outcome delegation-confidence --root product
|
|
134
|
+
uv run oe new opportunity agents-lack-safe-access --root product --under outcome.delegation-confidence
|
|
135
|
+
uv run oe new solution agent-central --root product --under opportunity.agents-lack-safe-access
|
|
136
|
+
uv run oe new assumption operation-discovery-reduces-tool-overload --root product --under solution.agent-central
|
|
137
|
+
uv run oe new experiment fake-connector-prototype --root product --under assumption.operation-discovery-reduces-tool-overload
|
|
138
|
+
uv run oe new prd agent-central-mvp --root product --under solution.agent-central
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Experiments can only live under assumptions.
|
|
142
|
+
|
|
143
|
+
Try the example graph:
|
|
144
|
+
|
|
145
|
+
```sh
|
|
146
|
+
uv run oe create-example --force
|
|
147
|
+
uv run oe validate examples/delegation-product-graph
|
|
148
|
+
uv run oe tree examples/delegation-product-graph
|
|
149
|
+
uv run oe context solution.agent-central --root examples/delegation-product-graph
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Install the skill into explicit global agent-tool locations:
|
|
153
|
+
|
|
154
|
+
```sh
|
|
155
|
+
uv run oe install-skill --agent codex --force
|
|
156
|
+
uv run oe install-skill --agent claude --force
|
|
157
|
+
uv run oe install-skill --agent all --force
|
|
158
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
outcome_engineering/__init__.py,sha256=EegsRgUFfo3HhgqBaD44uYXUxrm2MfPqXUxcAu0ggEo,63
|
|
2
|
+
outcome_engineering/cli.py,sha256=xQ-Z_7LuCUpU93cwFdR9qAzZ4PARt12_IAfkCNMMYJM,10624
|
|
3
|
+
outcome_engineering/example.py,sha256=7GdFrnjsEgnpyvHxW2_75XbzZK-CH9kKCDyjAVtn5lo,11098
|
|
4
|
+
outcome_engineering/graph.py,sha256=i4NhTDnUbAR_r51HaJQzI9qg2rcPp7sj7G_Oc4XuobI,10466
|
|
5
|
+
outcome_engineering/model.py,sha256=A3f9X_-UnFfgoWa_0Zv54fzh5ePEH8G6PEq2f1QJOfY,1809
|
|
6
|
+
outcome_engineering/skill_installer.py,sha256=sUK5C-ns2uARNWuF6dBaR5vF7cOo0iMzDZMMaJyRKTA,2534
|
|
7
|
+
outcome_engineering/skills/oe-cli/SKILL.md,sha256=kXlKtePOrGMI4nFjjumWWS9OWj0cCLFhKtjcM4f5IVM,3696
|
|
8
|
+
outcome_engineering-0.1.0.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
|
|
9
|
+
outcome_engineering-0.1.0.dist-info/entry_points.txt,sha256=IIdrnuxEgUt0GSH5aSiH8N85JJHBUEjNIl7XL4nyd-A,52
|
|
10
|
+
outcome_engineering-0.1.0.dist-info/METADATA,sha256=O5p80I2-PlO-9H2WOblgGprz3ha3BzlGZ-sqY0GZuUw,6014
|
|
11
|
+
outcome_engineering-0.1.0.dist-info/RECORD,,
|