Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# Outcome Engineering

A multi-tenant web app for Outcome Graphs: vision, strategy, ICPs, outcomes,
opportunities, solutions, assumption tests, PRDs, and a flywheel — stored in
Postgres and served through one FastAPI service with a SvelteKit UI, an `oe`
CLI, and a read-only MCP server.
A multi-tenant web app for Outcome Graphs: vision, strategy, ICPs, jobs,
outcomes, opportunities, solutions, assumption tests, PRDs, and a flywheel —
stored in Postgres and served through one FastAPI service with a SvelteKit
UI, an `oe` CLI, and a read-only MCP server.

Layout:

Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,24 @@ The format follows Keep a Changelog, and this project uses semantic versioning.

## [Unreleased]

### Added

- Added `job` as a root-level node kind capturing jobs-to-be-done: the
progress a customer is trying to make in a specific circumstance. Outcomes
and opportunities reference jobs many-to-many (mirroring ICP references,
with inheritance down the trace), jobs reference the ICPs who have them,
and `oe context` / the context endpoint surface related jobs and their
content.
- Added a `jobs` best-practices reference (circumstance, functional/
emotional/social progress, forces of progress, competing alternatives) and
job guidance to the `oe-cli`, `oe-grill`, `oe-graph-audit`, and
`oe-validate` skills.
- Added validation severities: errors break validity as before, while new
advisory warnings flag top-level opportunities not connected to a job and
jobs without an ICP.
- Added jobs to the graph canvas: a jobs band beside ICPs, job reference
edges, and job pickers on outcome and opportunity editors.

## [0.1.3] - 2026-07-03

### Added
Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@

Outcome Engineering turns messy product thinking into an **Outcome Graph** that
humans and agents can challenge, trace, and update: vision, strategy, ideal
customer profiles (ICPs), outcomes, opportunities, solutions, assumption
tests, PRDs — plus a flywheel describing the causal loop that compounds.
customer profiles (ICPs), customer jobs, outcomes, opportunities, solutions,
assumption tests, PRDs — plus a flywheel describing the causal loop that
compounds. Jobs (jobs-to-be-done) capture the progress a customer seeks in a
specific circumstance; outcomes and opportunities reference them alongside
ICPs.

It is a multi-tenant web application:

Expand Down Expand Up @@ -32,7 +35,7 @@ open http://localhost:3000

The stack runs in **simulation auth**: sign in with any token (e.g. `dev`) —
each distinct token is its own dev user. Create a graph, add a vision,
strategy, ICPs and outcomes, drill into opportunities → solutions →
strategy, ICPs, jobs and outcomes, drill into opportunities → solutions →
assumption tests / PRDs, add a flywheel, and share a public read-only link
from the graph's settings page (works in an incognito window).

Expand Down
29 changes: 24 additions & 5 deletions api/src/oe_api/routes/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,34 @@
from oe_api.db import get_store
from oe_api.schemas import NodeCreate, NodePatch
from oe_core import rules, selector
from oe_core.errors import IcpReferenceError, StrategyDateError
from oe_core.errors import DomainError, IcpReferenceError, JobReferenceError, StrategyDateError
from oe_core.model import GraphSnapshot, Node
from oe_store.store import GraphStore

router = APIRouter()


def _resolve_icp_ids(snapshot: GraphSnapshot, icp_selectors: list[str]) -> tuple[str, ...]:
def _resolve_ref_ids(
snapshot: GraphSnapshot, selectors: list[str], *, kind: str, error: type[DomainError]
) -> tuple[str, ...]:
ids = []
for sel in icp_selectors:
for sel in selectors:
try:
node = selector.resolve(snapshot, sel)
except Exception as error:
raise IcpReferenceError(f"icp reference {sel!r} does not resolve to a node in this graph") from error
except Exception as exc:
raise error(f"{kind} reference {sel!r} does not resolve to a node in this graph") from exc
ids.append(node.id)
return tuple(ids)


def _resolve_icp_ids(snapshot: GraphSnapshot, icp_selectors: list[str]) -> tuple[str, ...]:
return _resolve_ref_ids(snapshot, icp_selectors, kind="icp", error=IcpReferenceError)


def _resolve_job_ids(snapshot: GraphSnapshot, job_selectors: list[str]) -> tuple[str, ...]:
return _resolve_ref_ids(snapshot, job_selectors, kind="job", error=JobReferenceError)


@router.get("/graphs/{graph_ref}/nodes")
def list_nodes(
kind: str | None = Query(default=None),
Expand All @@ -45,6 +55,7 @@ def create_node(
if body.under is not None:
parent = selector.resolve(snapshot, body.under)
icp_ids = _resolve_icp_ids(snapshot, body.icps)
job_ids = _resolve_job_ids(snapshot, body.jobs)
rules.check_create(
snapshot,
kind=body.kind,
Expand All @@ -53,6 +64,7 @@ def create_node(
starts=body.starts,
ends=body.ends,
icp_ref_ids=icp_ids,
job_ref_ids=job_ids,
)
title = body.title or body.slug.replace("-", " ").title()
content = body.content or f"# {title}\n"
Expand All @@ -67,6 +79,7 @@ def create_node(
starts=body.starts,
ends=body.ends,
icp_ref_ids=icp_ids,
job_ref_ids=job_ids,
)
store.session.commit()
snapshot = store.load_snapshot(access.graph.id)
Expand Down Expand Up @@ -108,6 +121,11 @@ def patch_node(
icp_ids = _resolve_icp_ids(snapshot, body.icps)
rules.check_icp_refs(snapshot, kind=node.kind, icp_ref_ids=icp_ids)

job_ids = None
if body.jobs is not None:
job_ids = _resolve_job_ids(snapshot, body.jobs)
rules.check_job_refs(snapshot, kind=node.kind, job_ref_ids=job_ids)

row = store.get_node_row(node.id)
store.update_node(
row,
Expand All @@ -119,6 +137,7 @@ def patch_node(
ends=ends,
set_dates=set_dates,
icp_ref_ids=icp_ids,
job_ref_ids=job_ids,
)
store.session.commit()
snapshot = store.load_snapshot(access.graph.id)
Expand Down
2 changes: 2 additions & 0 deletions api/src/oe_api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class NodeCreate(BaseModel):
starts: date | None = None
ends: date | None = None
icps: list[str] = [] # icp selectors
jobs: list[str] = [] # job selectors


class NodePatch(BaseModel):
Expand All @@ -40,6 +41,7 @@ class NodePatch(BaseModel):
starts: date | None = None
ends: date | None = None
icps: list[str] | None = None
jobs: list[str] | None = None


class FlywheelPut(BaseModel):
Expand Down
16 changes: 14 additions & 2 deletions api/src/oe_api/serialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ def node_payload(snapshot: GraphSnapshot, node: Node) -> dict:
"children": [child.ref for child in snapshot.children(node)],
"icps": [by_id[i].ref for i in node.icp_ref_ids if i in by_id],
"icpIds": list(node.icp_ref_ids),
"jobs": [by_id[i].ref for i in node.job_ref_ids if i in by_id],
"jobIds": list(node.job_ref_ids),
"position": node.position,
"version": node.version,
"starts": node.starts.isoformat() if node.starts else None,
Expand Down Expand Up @@ -63,11 +65,13 @@ def trace_payload(snapshot: GraphSnapshot, node: Node) -> dict:
def context_payload(snapshot: GraphSnapshot, node: Node) -> dict:
ancestors = snapshot.ancestors(node)
icps = snapshot.related_icps(node)
jobs = snapshot.related_jobs(node)
return {
"node": node_payload(snapshot, node),
"trace": [node_summary(snapshot, n) for n in [*ancestors, node]],
"ancestors": [node_payload(snapshot, n) for n in ancestors],
"icps": [node_payload(snapshot, icp) for icp in icps],
"jobs": [node_payload(snapshot, job) for job in jobs],
"children": [node_summary(snapshot, child) for child in snapshot.children(node)],
"flywheelContext": flywheel_markdown(snapshot.flywheel) if snapshot.flywheel else "",
"markdown": context_markdown(snapshot, node),
Expand All @@ -77,8 +81,10 @@ def context_payload(snapshot: GraphSnapshot, node: Node) -> dict:
def validation_payload(snapshot: GraphSnapshot) -> dict:
issues = validate_graph(snapshot)
return {
"valid": not issues,
"issues": [{"ref": issue.ref, "message": issue.message} for issue in issues],
"valid": not any(issue.severity == "error" for issue in issues),
"issues": [
{"ref": issue.ref, "message": issue.message, "severity": issue.severity} for issue in issues
],
}


Expand Down Expand Up @@ -115,6 +121,7 @@ def graph_overview_payload(snapshot: GraphSnapshot, *, read_only: bool) -> dict:
nodes = []
edges = []
icp_served_by: dict[str, list[str]] = {}
job_served_by: dict[str, list[str]] = {}
for node in snapshot.nodes:
payload = node_payload(snapshot, node)
payload["deletable"] = not read_only
Expand All @@ -124,9 +131,14 @@ def graph_overview_payload(snapshot: GraphSnapshot, *, read_only: bool) -> dict:
for icp_ref in payload["icps"]:
edges.append({"source": node.ref, "target": icp_ref, "type": "icp"})
icp_served_by.setdefault(icp_ref, []).append(node.ref)
for job_ref in payload["jobs"]:
edges.append({"source": node.ref, "target": job_ref, "type": "job"})
job_served_by.setdefault(job_ref, []).append(node.ref)
for payload in nodes:
if payload["kind"] == "icp":
payload["servedBy"] = icp_served_by.get(payload["ref"], [])
elif payload["kind"] == "job":
payload["servedBy"] = job_served_by.get(payload["ref"], [])

vision = snapshot.vision()
strategy = snapshot.current_strategy()
Expand Down
8 changes: 7 additions & 1 deletion api/src/oe_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,12 +179,18 @@ def validate(graph: str = GRAPH_OPTION, as_json: bool = _json_option()) -> None:
if as_json:
typer.echo(json.dumps(payload, indent=2))
return
warnings = [issue for issue in payload["issues"] if issue.get("severity") == "warning"]
errors = [issue for issue in payload["issues"] if issue.get("severity") != "warning"]
if payload["valid"]:
typer.echo(f"OK: {graph} is a valid Outcome Graph")
for issue in warnings:
typer.echo(f"- warning {issue['ref']}: {issue['message']}")
return
typer.echo(f"Invalid Outcome Graph: {graph}")
for issue in payload["issues"]:
for issue in errors:
typer.echo(f"- {issue['ref']}: {issue['message']}")
for issue in warnings:
typer.echo(f"- warning {issue['ref']}: {issue['message']}")
raise typer.Exit(code=1)


Expand Down
3 changes: 2 additions & 1 deletion api/src/oe_cli/skills/oe-best-practices/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: oe-best-practices
description: Use this skill when creating, editing, reviewing, or classifying Outcome Graph content, especially when deciding what belongs in a vision, strategy, ICP, outcome, opportunity, solution, assumption test, or PRD.
description: Use this skill when creating, editing, reviewing, or classifying Outcome Graph content, especially when deciding what belongs in a vision, strategy, ICP, job, outcome, opportunity, solution, assumption test, or PRD.
---

# OE Best Practices
Expand All @@ -17,6 +17,7 @@ Use this skill to keep Outcome Graph content semantically clean.
## References

- Outcomes: read `references/outcomes.md` when working with outcome content.
- Jobs: read `references/jobs.md` when working with job content.
- Opportunities: read `references/opportunities.md` when working with opportunity content.
- PRDs: read `references/prds.md` when working with product requirement document content.
- Assumption tests: read `references/assumption-tests.md` when working with assumption test content.
Expand Down
2 changes: 1 addition & 1 deletion api/src/oe_cli/skills/oe-best-practices/references/icps.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

An ICP is the specific kind of customer, user, buyer, team, or practitioner the product is choosing to serve.

ICPs describe who the graph is for. Outcomes and opportunities use ICPs to stay grounded in a real audience.
ICPs describe who the graph is for. Jobs describe the progress those people are trying to make; keep the "who" in the ICP and the progress in its jobs. Outcomes and opportunities use ICPs to stay grounded in a real audience.

Use these five rules when writing or reviewing ICPs:

Expand Down
36 changes: 36 additions & 0 deletions api/src/oe_cli/skills/oe-best-practices/references/jobs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Jobs

A job is the progress a customer is trying to make in a specific circumstance.

ICPs describe who the product serves. Jobs describe the progress those people seek and the circumstance that creates the struggle. Opportunities describe specific unmet needs, pains, or desires within a job. Customers "hire" a product when it helps them make that progress better than what they use today.

Jobs live at the graph root beside ICPs: they are durable customer context, not part of the outcome → opportunity → solution trace chain. Outcomes and opportunities reference the jobs they serve; jobs reference the ICPs who have them.

Use these seven rules when writing or reviewing jobs:

1. State the progress, not the task or the product.
Write the change in the customer's situation they are trying to achieve. "Send an email" is a task; "help me communicate important information so others know what action to take" is progress.

2. Anchor it in a circumstance.
Say when and where the struggle arises — the situation, trigger, or moment that creates demand. A job without a circumstance is an aspiration ("be successful") and cannot guide discovery.

3. Cover functional, emotional, and social progress.
Say what the customer wants to get done, how they want to feel, and how they want to be seen by others. Most switching decisions hinge on the emotional and social dimensions.

4. Name the forces of progress.
Capture what pushes the customer away from the current situation, what pulls them toward a new way, what anxieties block the switch, and what habits keep them where they are. Anxieties and habits are usually the riskiest assumptions behind any solution.

5. Record what the customer hires today.
List the competing alternatives — including workarounds, manual processes, non-obvious competitors, and doing nothing. A job with no alternatives listed has not been studied yet.

6. Keep jobs few, durable, and solution-agnostic.
A job should survive strategy changes and product iterations. If it names a feature or only makes sense for one solution, it is an opportunity or a solution in disguise.

7. Reference the ICPs who have the job.
A job nobody identifiable has cannot be validated. Different ICPs can share a job, and one ICP can have several jobs.

Title shape: a short progress statement the customer would recognize, such as "Get through the week with meals I feel good about."

Body shape: circumstance, the progress sought (functional, emotional, social), the forces of progress (push, pull, anxiety, habit), and what the customer hires today.

Smells: task-shaped titles, aspiration-shaped titles with no circumstance, feature names, jobs that duplicate an opportunity one level down, and more jobs than ICPs can plausibly carry.
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

An opportunity is a customer need, pain point, desire, or want that affects a parent outcome.

Outcomes describe what changes if we succeed. Opportunities describe the customer needs, pains, desires, or wants that explain where to intervene.
Outcomes describe what changes if we succeed. Jobs describe the progress the customer is trying to make. Opportunities describe the specific needs, pains, desires, or wants within that progress that explain where to intervene.

Use these six rules when writing or reviewing opportunities:
Use these seven rules when writing or reviewing opportunities:

1. Keep it customer-shaped.
Write the opportunity as something the customer could say or recognize. Do not write company goals disguised as customer needs.
Expand All @@ -22,4 +22,7 @@ Use these six rules when writing or reviewing opportunities:
The opportunity should explain how addressing this customer need could drive the parent outcome.

6. Structure top-level opportunities around moments in the experience.
For broad opportunity spaces, prefer top-level opportunities that map to distinct moments where the customer encounters the product, problem, or workflow.
For broad opportunity spaces, prefer top-level opportunities that map to distinct moments where the customer encounters the product, problem, or workflow. When the parent outcome references a job, use the job's steps and circumstance as those moments.

7. Connect it to a job.
Reference the job whose progress this need blocks (directly, or inherited from a parent outcome or opportunity). A list of pains without a job is fragmented; the job gives the opportunity its strategic meaning.
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Use these six rules when writing or reviewing outcomes:
Say who changes: customers, users, practitioners, teams, buyers, agents, maintainers, or another specific actor.

3. Connect customer value and business value.
The outcome should matter to the customer or user and plausibly matter to the product or business.
The outcome should matter to the customer or user and plausibly matter to the product or business. Referencing the jobs the outcome serves keeps the customer side concrete.

4. Make it measurable without making the title a metric.
Keep the title human-readable. Put proof in the measures, and measure impact rather than activity. Prefer both quantitative and qualitative measures: quantitative evidence shows scale or frequency, while qualitative evidence explains meaning and causality.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Use these five rules when writing or reviewing strategy content:
Respond to a real obstacle, constraint, market condition, customer behavior, or adoption problem. Avoid generic ambition without diagnosis.

3. Define the wedge.
Say who the strategy serves first, where the product will play, and why that focus gives the product a better chance of progress.
Say who the strategy serves first, where the product will play, and why that focus gives the product a better chance of progress. The sharpest wedge names an ICP and the job the product chooses to win first.

4. Explain how this can win.
Include the logic of advantage: why this approach could create customer value, business value, learning speed, differentiation, or compounding leverage.
Expand Down
16 changes: 12 additions & 4 deletions api/src/oe_cli/skills/oe-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,25 @@ server. The Python package is `outcome-engineering`. The command is `oe`.
Graphs live in a server database (not in the repo). A graph is a tree of
nodes plus an ICP collection and an optional flywheel:

- Kinds: `vision`, `strategy`, `icp`, `outcome`, `opportunity`, `solution`,
`assumption-test`, `prd`.
- Kinds: `vision`, `strategy`, `icp`, `job`, `outcome`, `opportunity`,
`solution`, `assumption-test`, `prd`.
- Placement rules: the graph root holds vision (max one), strategies, ICPs,
and outcomes; outcome → opportunity; opportunity → opportunity | solution;
solution → assumption-test | prd. Nothing else nests.
jobs, and outcomes; outcome → opportunity; opportunity → opportunity |
solution; solution → assumption-test | prd. Nothing else nests.
- Strategies declare `starts`/`ends` dates; periods must not overlap and
status is derived from the dates.
- ICPs (ideal customer profiles) are the "who". They are not part of the
outcome → opportunity → solution trace chain; outcomes and opportunities
reference them many-to-many, and `oe context` surfaces a node's own plus
inherited ICPs.
- Jobs are the progress a customer is trying to make in a specific
circumstance (jobs-to-be-done). Like ICPs they are durable root-level
context outside the trace chain: outcomes and opportunities reference them
many-to-many, jobs reference the ICPs who have them, and `oe context`
surfaces a node's own plus inherited jobs.
- `oe validate` reports errors (structure violations, graph invalid) and
warnings (advisory: opportunities not connected to a job, jobs without an
ICP). Warnings do not make the graph invalid.
- Assumption tests are the unified concept for the assumptions a solution
depends on and the work to test them. They live under solutions only.
- Node ids ("refs") are `<kind>.<slug>`. Selectors accept a ref, a bare slug
Expand Down
Loading
Loading