diff --git a/AGENTS.md b/AGENTS.md index 775d08a..25a858e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index 839cc8e..eda0ea4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index cd1d79a..60f9057 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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). diff --git a/api/src/oe_api/routes/nodes.py b/api/src/oe_api/routes/nodes.py index 4aea1f4..d8cc2b2 100644 --- a/api/src/oe_api/routes/nodes.py +++ b/api/src/oe_api/routes/nodes.py @@ -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), @@ -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, @@ -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" @@ -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) @@ -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, @@ -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) diff --git a/api/src/oe_api/schemas.py b/api/src/oe_api/schemas.py index 30c44cf..a174d89 100644 --- a/api/src/oe_api/schemas.py +++ b/api/src/oe_api/schemas.py @@ -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): @@ -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): diff --git a/api/src/oe_api/serialize.py b/api/src/oe_api/serialize.py index 5682d33..51fa998 100644 --- a/api/src/oe_api/serialize.py +++ b/api/src/oe_api/serialize.py @@ -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, @@ -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), @@ -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 + ], } @@ -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 @@ -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() diff --git a/api/src/oe_cli/main.py b/api/src/oe_cli/main.py index 29540ee..c06d301 100644 --- a/api/src/oe_cli/main.py +++ b/api/src/oe_cli/main.py @@ -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) diff --git a/api/src/oe_cli/skills/oe-best-practices/SKILL.md b/api/src/oe_cli/skills/oe-best-practices/SKILL.md index d497d7b..b144228 100644 --- a/api/src/oe_cli/skills/oe-best-practices/SKILL.md +++ b/api/src/oe_cli/skills/oe-best-practices/SKILL.md @@ -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 @@ -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. diff --git a/api/src/oe_cli/skills/oe-best-practices/references/icps.md b/api/src/oe_cli/skills/oe-best-practices/references/icps.md index 101b4a7..3e31ba8 100644 --- a/api/src/oe_cli/skills/oe-best-practices/references/icps.md +++ b/api/src/oe_cli/skills/oe-best-practices/references/icps.md @@ -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: diff --git a/api/src/oe_cli/skills/oe-best-practices/references/jobs.md b/api/src/oe_cli/skills/oe-best-practices/references/jobs.md new file mode 100644 index 0000000..4a1faad --- /dev/null +++ b/api/src/oe_cli/skills/oe-best-practices/references/jobs.md @@ -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. diff --git a/api/src/oe_cli/skills/oe-best-practices/references/opportunities.md b/api/src/oe_cli/skills/oe-best-practices/references/opportunities.md index 8bd1568..4623471 100644 --- a/api/src/oe_cli/skills/oe-best-practices/references/opportunities.md +++ b/api/src/oe_cli/skills/oe-best-practices/references/opportunities.md @@ -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. @@ -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. diff --git a/api/src/oe_cli/skills/oe-best-practices/references/outcomes.md b/api/src/oe_cli/skills/oe-best-practices/references/outcomes.md index f5a8dda..4bb2c85 100644 --- a/api/src/oe_cli/skills/oe-best-practices/references/outcomes.md +++ b/api/src/oe_cli/skills/oe-best-practices/references/outcomes.md @@ -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. diff --git a/api/src/oe_cli/skills/oe-best-practices/references/strategy.md b/api/src/oe_cli/skills/oe-best-practices/references/strategy.md index f4b3b8c..121ccdc 100644 --- a/api/src/oe_cli/skills/oe-best-practices/references/strategy.md +++ b/api/src/oe_cli/skills/oe-best-practices/references/strategy.md @@ -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. diff --git a/api/src/oe_cli/skills/oe-cli/SKILL.md b/api/src/oe_cli/skills/oe-cli/SKILL.md index 1513b8a..a205ce7 100644 --- a/api/src/oe_cli/skills/oe-cli/SKILL.md +++ b/api/src/oe_cli/skills/oe-cli/SKILL.md @@ -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 `.`. Selectors accept a ref, a bare slug diff --git a/api/src/oe_cli/skills/oe-graph-audit/SKILL.md b/api/src/oe_cli/skills/oe-graph-audit/SKILL.md index 198af70..4341baf 100644 --- a/api/src/oe_cli/skills/oe-graph-audit/SKILL.md +++ b/api/src/oe_cli/skills/oe-graph-audit/SKILL.md @@ -1,6 +1,6 @@ --- name: oe-graph-audit -description: Use this skill when asked to audit, review, critique, or check an Outcome Graph for method quality, including outcome/output separation, opportunity/solution separation, known/unknown discipline, assumption-test placement, ICP fit, narrative coherence, and whether the graph tells a consistent product story. +description: Use this skill when asked to audit, review, critique, or check an Outcome Graph for method quality, including outcome/output separation, opportunity/solution separation, known/unknown discipline, assumption-test placement, ICP fit, job coverage, narrative coherence, and whether the graph tells a consistent product story. --- # OE Graph Audit @@ -19,7 +19,7 @@ oe tree -g oe list -g ``` -2. Read the relevant node content with `oe show -g `. For a full-graph audit, read the vision, the active strategy, ICPs, outcomes, opportunities, solutions, assumption tests, and PRDs. +2. Read the relevant node content with `oe show -g `. For a full-graph audit, read the vision, the active strategy, ICPs, jobs, outcomes, opportunities, solutions, assumption tests, and PRDs. 3. Use `oe context -g ` when auditing a specific node or chain. @@ -30,7 +30,8 @@ oe list -g ## Audit Checks - Apply the relevant `oe-best-practices` reference for each node. -- Check cross-node coherence: vision -> strategy -> ICP -> outcome -> opportunity -> solution -> assumption test / PRD should form a believable product story. +- Check cross-node coherence: vision -> strategy -> ICP/job -> outcome -> opportunity -> solution -> assumption test / PRD should form a believable product story. +- Check job discipline: every job names a circumstance, forces of progress, and what the customer hires today; every job references an ICP; top-level opportunities connect to a job (directly or inherited); jobs are progress statements, not tasks, aspirations, or features. - Flag parent/child mismatches: repetition, level jumps, contradictions, generic wording, or children that do not make the parent more actionable. - Distinguish structural validity from product-method quality. - Do not invent evidence, customers, metrics, or strategic decisions while recommending fixes. diff --git a/api/src/oe_cli/skills/oe-grill/SKILL.md b/api/src/oe_cli/skills/oe-grill/SKILL.md index c8b82bd..4b1046d 100644 --- a/api/src/oe_cli/skills/oe-grill/SKILL.md +++ b/api/src/oe_cli/skills/oe-grill/SKILL.md @@ -40,6 +40,7 @@ Do not force a top-down sequence. Infer the starting node type from the conversa - Vision: the long-term change the product exists to create. - Strategy: a time-bounded focus, choices, constraints, and path to win. - ICP: the kind of customer, user, buyer, or team the product serves. +- Job: the progress a customer is trying to make in a specific circumstance. - Outcome: a measurable change the team wants to drive. - Opportunity: a customer, user, market, or business problem worth addressing. - Solution: a proposed product change or capability. @@ -65,6 +66,7 @@ Be direct and practical. Challenge unclear thinking, but do it to improve the gr When the user's thinking points to a gap, say it plainly: - "This sounds like a solution, but the opportunity behind it is not clear yet." +- "These opportunities are floating; what job's progress do they block?" - "I can model this as an assumption test, but we need the parent solution first." - "This seems strategic enough to update the current strategy, but I would first clarify the tradeoff and time period it implies." - "I would not add this yet; it sounds like a passing concern rather than durable product intent." @@ -75,6 +77,8 @@ When the user is unsure, recommend a path: - "I would capture this as strategy, then add one outcome under it once the desired change is concrete." - "I would add the solution now and mark the risky assumption as the next thing to test." +When grilling around a job, use the jobs-to-be-done questions: What does the customer fire to hire this? What circumstance triggers the struggle? What progress — functional, emotional, social — are they seeking? Which anxiety or habit would kill the switch? What do they hire today, including workarounds and doing nothing? + ## Output Shape For conversational turns, prefer this compact shape: diff --git a/api/src/oe_cli/skills/oe-validate/SKILL.md b/api/src/oe_cli/skills/oe-validate/SKILL.md index 38888f2..6d1e17c 100644 --- a/api/src/oe_cli/skills/oe-validate/SKILL.md +++ b/api/src/oe_cli/skills/oe-validate/SKILL.md @@ -15,7 +15,10 @@ Run deterministic commands first; only reason after seeing real output. oe validate -g ``` -2. If validation passes, report that the graph is structurally valid. +2. If validation passes, report that the graph is structurally valid. Report + any warnings separately (lines prefixed `warning`): they are advisory — + for example an opportunity not connected to a job, or a job without an + ICP — and do not make the graph invalid, but say how to resolve them. 3. If validation fails: - quote the failing command and each reported issue (node ref + message) diff --git a/api/src/oe_core/context.py b/api/src/oe_core/context.py index 9d62576..1276473 100644 --- a/api/src/oe_core/context.py +++ b/api/src/oe_core/context.py @@ -1,6 +1,6 @@ """Agent-facing context assembly for a node: deterministic markdown covering -vision, current strategy, related ICPs, the ancestor chain, the node itself, -its children, and the flywheel.""" +vision, current strategy, related ICPs and jobs, the ancestor chain, the node +itself, its children, and the flywheel.""" from __future__ import annotations @@ -10,6 +10,7 @@ def context_markdown(snapshot: GraphSnapshot, node: Node) -> str: ancestors = snapshot.ancestors(node) icps = snapshot.related_icps(node) + jobs = snapshot.related_jobs(node) children = snapshot.children(node) vision = snapshot.vision() strategy = snapshot.current_strategy() @@ -20,6 +21,7 @@ def context_markdown(snapshot: GraphSnapshot, node: Node) -> str: lines.append(f"- {node.ref}") _append_ref_section(lines, "ICPs", icps) + _append_ref_section(lines, "Jobs", jobs) _append_ref_section(lines, "Children", children) if vision is not None and vision.id != node.id: @@ -32,6 +34,7 @@ def context_markdown(snapshot: GraphSnapshot, node: Node) -> str: _append_content_section(lines, "Ancestor Content", ancestors) _append_content_section(lines, "ICP Content", icps) + _append_content_section(lines, "Job Content", jobs) lines.extend(["", "## Node Content", "", node.content.rstrip()]) return "\n".join(lines) diff --git a/api/src/oe_core/errors.py b/api/src/oe_core/errors.py index 9f5988c..e963b21 100644 --- a/api/src/oe_core/errors.py +++ b/api/src/oe_core/errors.py @@ -23,6 +23,10 @@ class IcpReferenceError(DomainError): """Invalid ICP reference (wrong referrer kind or target not an ICP).""" +class JobReferenceError(DomainError): + """Invalid job reference (wrong referrer kind or target not a job).""" + + class CascadeRequiredError(DomainError): """Delete refused because the node has descendants and cascade was not set.""" diff --git a/api/src/oe_core/model.py b/api/src/oe_core/model.py index 21a50be..394eaf9 100644 --- a/api/src/oe_core/model.py +++ b/api/src/oe_core/model.py @@ -15,6 +15,7 @@ "vision", "strategy", "icp", + "job", "outcome", "opportunity", "solution", @@ -23,14 +24,15 @@ ) # Kinds that live at the graph root (parent_id NULL). -ROOT_KINDS = {"vision", "strategy", "icp", "outcome"} +ROOT_KINDS = {"vision", "strategy", "icp", "job", "outcome"} # parent kind -> allowed child kinds. "root" is the graph root itself. PARENT_KIND_TO_CHILD_KIND: dict[str, set[str]] = { - "root": {"vision", "strategy", "icp", "outcome"}, + "root": {"vision", "strategy", "icp", "job", "outcome"}, "vision": set(), "strategy": set(), "icp": set(), + "job": set(), "outcome": {"opportunity"}, "opportunity": {"opportunity", "solution"}, "solution": {"assumption-test", "prd"}, @@ -39,7 +41,11 @@ } # Kinds allowed to reference ICPs (many-to-many, not part of the trace chain). -ICP_REFERRING_KINDS = {"outcome", "opportunity"} +# Jobs reference ICPs to say who has the job. +ICP_REFERRING_KINDS = {"outcome", "opportunity", "job"} + +# Kinds allowed to reference jobs (many-to-many, not part of the trace chain). +JOB_REFERRING_KINDS = {"outcome", "opportunity"} SLUG_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") @@ -60,6 +66,7 @@ class Node: position: int = 0 version: int = 1 icp_ref_ids: tuple[str, ...] = () # node uuids of referenced ICPs + job_ref_ids: tuple[str, ...] = () # node uuids of referenced jobs starts: date | None = None # strategy only ends: date | None = None # strategy only @@ -102,6 +109,7 @@ def ref(self) -> str: class ValidationIssue: ref: str # human-facing node ref (or "graph" for graph-level issues) message: str + severity: str = "error" # "error" breaks validity; "warning" is advisory @dataclass @@ -162,15 +170,22 @@ def current_strategy(self, today: date | None = None) -> Node | None: def related_icps(self, node: Node) -> list[Node]: """The node's own ICP references plus its ancestors', deduped in order.""" + return self._related_refs(node, "icp", lambda n: n.icp_ref_ids) + + def related_jobs(self, node: Node) -> list[Node]: + """The node's own job references plus its ancestors', deduped in order.""" + return self._related_refs(node, "job", lambda n: n.job_ref_ids) + + def _related_refs(self, node: Node, kind: str, refs_of) -> list[Node]: seen: set[str] = set() - icps: list[Node] = [] + related: list[Node] = [] for candidate in [*self.ancestors(node), node]: - for icp_id in candidate.icp_ref_ids: - icp = self._by_id.get(icp_id) - if icp is not None and icp.kind == "icp" and icp.id not in seen: - seen.add(icp.id) - icps.append(icp) - return icps + for ref_id in refs_of(candidate): + target = self._by_id.get(ref_id) + if target is not None and target.kind == kind and target.id not in seen: + seen.add(target.id) + related.append(target) + return related def allowed_child_kinds(parent_kind: str) -> set[str]: diff --git a/api/src/oe_core/rules.py b/api/src/oe_core/rules.py index a600d7e..e7a1fa8 100644 --- a/api/src/oe_core/rules.py +++ b/api/src/oe_core/rules.py @@ -11,13 +11,16 @@ from oe_core.errors import ( CascadeRequiredError, + DomainError, IcpReferenceError, + JobReferenceError, PlacementError, SlugError, StrategyDateError, ) from oe_core.model import ( ICP_REFERRING_KINDS, + JOB_REFERRING_KINDS, NODE_KINDS, GraphSnapshot, Node, @@ -35,6 +38,7 @@ def check_create( starts: date | None = None, ends: date | None = None, icp_ref_ids: tuple[str, ...] = (), + job_ref_ids: tuple[str, ...] = (), ) -> None: """Raise a DomainError if creating this node would violate the model.""" if kind not in NODE_KINDS: @@ -58,6 +62,7 @@ def check_create( raise StrategyDateError(f"{kind} cannot declare starts/ends; only strategy nodes have a period") check_icp_refs(snapshot, kind=kind, icp_ref_ids=icp_ref_ids) + check_job_refs(snapshot, kind=kind, job_ref_ids=job_ref_ids) def check_slug(snapshot: GraphSnapshot, *, kind: str, slug: str) -> None: @@ -89,17 +94,41 @@ def check_strategy_dates( def check_icp_refs(snapshot: GraphSnapshot, *, kind: str, icp_ref_ids: tuple[str, ...]) -> None: - if not icp_ref_ids: + _check_refs( + snapshot, kind=kind, ref_ids=icp_ref_ids, target_kind="icp", + referring_kinds=ICP_REFERRING_KINDS, error=IcpReferenceError, label="ICPs", article="an icp", + ) + + +def check_job_refs(snapshot: GraphSnapshot, *, kind: str, job_ref_ids: tuple[str, ...]) -> None: + _check_refs( + snapshot, kind=kind, ref_ids=job_ref_ids, target_kind="job", + referring_kinds=JOB_REFERRING_KINDS, error=JobReferenceError, label="jobs", article="a job", + ) + + +def _check_refs( + snapshot: GraphSnapshot, + *, + kind: str, + ref_ids: tuple[str, ...], + target_kind: str, + referring_kinds: set[str], + error: type[DomainError], + label: str, + article: str, +) -> None: + if not ref_ids: return - if kind not in ICP_REFERRING_KINDS: - allowed = ", ".join(sorted(ICP_REFERRING_KINDS)) - raise IcpReferenceError(f"{kind} cannot reference ICPs; only {allowed} may") - for icp_id in icp_ref_ids: - target = snapshot.by_id(icp_id) + if kind not in referring_kinds: + allowed = ", ".join(sorted(referring_kinds)) + raise error(f"{kind} cannot reference {label}; only {allowed} may") + for ref_id in ref_ids: + target = snapshot.by_id(ref_id) if target is None: - raise IcpReferenceError(f"icp reference {icp_id!r} does not resolve to a node in this graph") - if target.kind != "icp": - raise IcpReferenceError(f"icp reference {target.ref} is a {target.kind}, not an icp") + raise error(f"{target_kind} reference {ref_id!r} does not resolve to a node in this graph") + if target.kind != target_kind: + raise error(f"{target_kind} reference {target.ref} is a {target.kind}, not {article}") def check_delete(snapshot: GraphSnapshot, node: Node, *, cascade: bool) -> list[Node]: @@ -111,8 +140,13 @@ def check_delete(snapshot: GraphSnapshot, node: Node, *, cascade: bool) -> list[ raise CascadeRequiredError( f"{node.ref} has {len(descendants)} descendant(s) ({refs}{suffix}); pass cascade=true to delete them too" ) - referrers = [n for n in snapshot.nodes if node.id in n.icp_ref_ids and n not in descendants and n.id != node.id] - if node.kind == "icp" and referrers: - refs = ", ".join(n.ref for n in referrers[:5]) - raise CascadeRequiredError(f"{node.ref} is referenced by {refs}; remove those references first") + if node.kind in ("icp", "job"): + referrers = [ + n + for n in snapshot.nodes + if node.id in (*n.icp_ref_ids, *n.job_ref_ids) and n not in descendants and n.id != node.id + ] + if referrers: + refs = ", ".join(n.ref for n in referrers[:5]) + raise CascadeRequiredError(f"{node.ref} is referenced by {refs}; remove those references first") return descendants diff --git a/api/src/oe_core/validation.py b/api/src/oe_core/validation.py index fc7b4ae..6339b35 100644 --- a/api/src/oe_core/validation.py +++ b/api/src/oe_core/validation.py @@ -5,6 +5,7 @@ from oe_core.model import ( ICP_REFERRING_KINDS, + JOB_REFERRING_KINDS, GraphSnapshot, Node, ValidationIssue, @@ -18,6 +19,8 @@ def validate_graph(snapshot: GraphSnapshot) -> list[ValidationIssue]: _check_placements(snapshot, issues) _check_strategies(snapshot, issues) _check_icp_refs(snapshot, issues) + _check_job_refs(snapshot, issues) + _check_job_guidance(snapshot, issues) _check_flywheel(snapshot, issues) return issues @@ -81,6 +84,40 @@ def _check_icp_refs(snapshot: GraphSnapshot, issues: list[ValidationIssue]) -> N issues.append(ValidationIssue(node.ref, f"icp reference {target.ref} is a {target.kind}, not an icp")) +def _check_job_refs(snapshot: GraphSnapshot, issues: list[ValidationIssue]) -> None: + for node in snapshot.nodes: + if node.job_ref_ids and node.kind not in JOB_REFERRING_KINDS: + allowed = ", ".join(sorted(JOB_REFERRING_KINDS)) + issues.append(ValidationIssue(node.ref, f"{node.kind} cannot reference jobs; only {allowed} may")) + continue + for job_id in node.job_ref_ids: + target = snapshot.by_id(job_id) + if target is None: + issues.append(ValidationIssue(node.ref, f"job reference {job_id!r} does not resolve to a node")) + elif target.kind != "job": + issues.append(ValidationIssue(node.ref, f"job reference {target.ref} is a {target.kind}, not a job")) + + +def _check_job_guidance(snapshot: GraphSnapshot, issues: list[ValidationIssue]) -> None: + """Advisory warnings: jobs and opportunities stay connected without blocking validity.""" + for node in snapshot.of_kind("job"): + if not node.icp_ref_ids: + issues.append( + ValidationIssue(node.ref, "job does not reference any ICP; say who has this job", severity="warning") + ) + for node in snapshot.of_kind("opportunity"): + parent = snapshot.by_id(node.parent_id) if node.parent_id is not None else None + if parent is not None and parent.kind == "outcome" and not snapshot.related_jobs(node): + issues.append( + ValidationIssue( + node.ref, + "opportunity is not connected to a job (own or inherited); " + "reference the customer job whose progress it blocks", + severity="warning", + ) + ) + + def _check_flywheel(snapshot: GraphSnapshot, issues: list[ValidationIssue]) -> None: flywheel = snapshot.flywheel if flywheel is None: diff --git a/api/src/oe_mcp/server.py b/api/src/oe_mcp/server.py index 96dfb8e..dc6a303 100644 --- a/api/src/oe_mcp/server.py +++ b/api/src/oe_mcp/server.py @@ -70,7 +70,7 @@ def get_tree(graph: str) -> dict: @mcp.tool def list_nodes(graph: str, kind: str | None = None) -> dict: """List nodes in a graph, optionally filtered by kind (vision, strategy, - icp, outcome, opportunity, solution, assumption-test, prd).""" + icp, job, outcome, opportunity, solution, assumption-test, prd).""" return _get(f"/api/graphs/{graph}/nodes", params={"kind": kind} if kind else None) @@ -84,8 +84,8 @@ def show_node(graph: str, selector: str) -> dict: @mcp.tool def get_context(graph: str, selector: str) -> str: """Get deterministic agent-facing markdown context for a node: vision, - current strategy, related ICPs, ancestor chain, the node, children, and - flywheel.""" + current strategy, related ICPs and jobs, ancestor chain, the node, + children, and flywheel.""" return _get(f"/api/graphs/{graph}/nodes/{selector}/context")["markdown"] diff --git a/api/src/oe_store/migrations/versions/0002_node_job_refs.py b/api/src/oe_store/migrations/versions/0002_node_job_refs.py new file mode 100644 index 0000000..301caec --- /dev/null +++ b/api/src/oe_store/migrations/versions/0002_node_job_refs.py @@ -0,0 +1,31 @@ +"""Add node_job_refs: outcomes and opportunities reference job nodes +(many-to-many), mirroring node_icp_refs. + +Revision ID: 0002 +Revises: 0001 +""" +import sqlalchemy as sa +from alembic import op + +revision = "0002" +down_revision = "0001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Fresh installs already get the table from 0001, which creates the whole + # ORM metadata; this revision only upgrades databases created before jobs. + if sa.inspect(op.get_bind()).has_table("node_job_refs"): + return + op.create_table( + "node_job_refs", + sa.Column("id", sa.Uuid(), primary_key=True), + sa.Column("node_id", sa.Uuid(), sa.ForeignKey("nodes.id", ondelete="CASCADE"), nullable=False), + sa.Column("job_node_id", sa.Uuid(), sa.ForeignKey("nodes.id", ondelete="CASCADE"), nullable=False), + sa.UniqueConstraint("node_id", "job_node_id"), + ) + + +def downgrade() -> None: + op.drop_table("node_job_refs") diff --git a/api/src/oe_store/models.py b/api/src/oe_store/models.py index 7155f8b..9829a3e 100644 --- a/api/src/oe_store/models.py +++ b/api/src/oe_store/models.py @@ -96,6 +96,11 @@ class NodeRow(Base): cascade="all, delete-orphan", foreign_keys="NodeIcpRef.node_id", ) + job_refs: Mapped[list[NodeJobRef]] = relationship( + back_populates="node", + cascade="all, delete-orphan", + foreign_keys="NodeJobRef.node_id", + ) class NodeIcpRef(Base): @@ -109,6 +114,17 @@ class NodeIcpRef(Base): node: Mapped[NodeRow] = relationship(back_populates="icp_refs", foreign_keys=[node_id]) +class NodeJobRef(Base): + __tablename__ = "node_job_refs" + __table_args__ = (UniqueConstraint("node_id", "job_node_id"),) + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + node_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("nodes.id", ondelete="CASCADE")) + job_node_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("nodes.id", ondelete="CASCADE")) + + node: Mapped[NodeRow] = relationship(back_populates="job_refs", foreign_keys=[node_id]) + + class FlywheelRow(Base): __tablename__ = "flywheels" diff --git a/api/src/oe_store/store.py b/api/src/oe_store/store.py index ea1da30..13449f4 100644 --- a/api/src/oe_store/store.py +++ b/api/src/oe_store/store.py @@ -23,6 +23,7 @@ Graph, GraphMembership, NodeIcpRef, + NodeJobRef, NodeRow, ShareLink, User, @@ -145,7 +146,7 @@ def load_snapshot(self, graph_id: uuid.UUID) -> GraphSnapshot: rows = list( self.session.scalars( select(NodeRow) - .options(selectinload(NodeRow.icp_refs)) + .options(selectinload(NodeRow.icp_refs), selectinload(NodeRow.job_refs)) .where(NodeRow.graph_id == graph_id) ) ) @@ -160,6 +161,7 @@ def load_snapshot(self, graph_id: uuid.UUID) -> GraphSnapshot: position=row.position, version=row.version, icp_ref_ids=tuple(str(ref.icp_node_id) for ref in row.icp_refs), + job_ref_ids=tuple(str(ref.job_node_id) for ref in row.job_refs), starts=row.starts, ends=row.ends, ) @@ -223,6 +225,7 @@ def create_node( starts: date | None = None, ends: date | None = None, icp_ref_ids: tuple[str, ...] = (), + job_ref_ids: tuple[str, ...] = (), ) -> NodeRow: row = NodeRow( graph_id=graph_id, @@ -239,6 +242,8 @@ def create_node( self.session.flush() for icp_id in icp_ref_ids: self.session.add(NodeIcpRef(node_id=row.id, icp_node_id=uuid.UUID(icp_id))) + for job_id in job_ref_ids: + self.session.add(NodeJobRef(node_id=row.id, job_node_id=uuid.UUID(job_id))) self.session.flush() return row @@ -254,6 +259,7 @@ def update_node( ends: date | None = None, set_dates: bool = False, icp_ref_ids: tuple[str, ...] | None = None, + job_ref_ids: tuple[str, ...] | None = None, ) -> NodeRow: if row.version != expected_version: raise VersionConflictError( @@ -271,6 +277,8 @@ def update_node( row.ends = ends if icp_ref_ids is not None: row.icp_refs = [NodeIcpRef(node_id=row.id, icp_node_id=uuid.UUID(i)) for i in icp_ref_ids] + if job_ref_ids is not None: + row.job_refs = [NodeJobRef(node_id=row.id, job_node_id=uuid.UUID(i)) for i in job_ref_ids] row.version += 1 self.session.flush() return row diff --git a/api/tests/test_api.py b/api/tests/test_api.py index 83c2b1b..9546697 100644 --- a/api/tests/test_api.py +++ b/api/tests/test_api.py @@ -90,7 +90,8 @@ def test_graph_delete_owner_only(client, graph): def test_node_lifecycle(client, graph): create_node(client, {"kind": "vision", "slug": "vision", "content": "# Vision\n\nWin."}) create_node(client, {"kind": "icp", "slug": "founders"}) - create_node(client, {"kind": "outcome", "slug": "activation", "icps": ["icp.founders"]}) + create_node(client, {"kind": "job", "slug": "ship-fast", "icps": ["icp.founders"]}) + create_node(client, {"kind": "outcome", "slug": "activation", "icps": ["icp.founders"], "jobs": ["job.ship-fast"]}) create_node(client, {"kind": "opportunity", "slug": "onboarding", "under": "outcome.activation"}) create_node(client, {"kind": "solution", "slug": "wizard", "under": "opportunity.onboarding"}) @@ -103,6 +104,8 @@ def test_node_lifecycle(client, graph): context = client.get("/api/graphs/acme/nodes/wizard/context", headers=OWNER).json() assert "# Context: solution.wizard" in context["markdown"] assert [icp["ref"] for icp in context["icps"]] == ["icp.founders"] + assert [job["ref"] for job in context["jobs"]] == ["job.ship-fast"] + assert "## Job Content" in context["markdown"] tree = client.get("/api/graphs/acme/tree", headers=OWNER).json() outcome = next(r for r in tree["roots"] if r["ref"] == "outcome.activation") @@ -160,6 +163,49 @@ def test_validate_endpoint(client, graph): assert client.get("/api/graphs/acme/validate", headers=VIEWER).json() == {"valid": True, "issues": []} +def test_validate_warnings_do_not_break_validity(client, graph): + create_node(client, {"kind": "outcome", "slug": "o"}) + create_node(client, {"kind": "opportunity", "slug": "floating", "under": "outcome.o"}) + create_node(client, {"kind": "job", "slug": "lonely"}) + payload = client.get("/api/graphs/acme/validate", headers=VIEWER).json() + assert payload["valid"] is True + messages = {issue["ref"]: issue for issue in payload["issues"]} + assert messages["opportunity.floating"]["severity"] == "warning" + assert messages["job.lonely"]["severity"] == "warning" + + +def test_job_refs_via_api(client, graph): + create_node(client, {"kind": "job", "slug": "ship-fast"}) + create_node(client, {"kind": "outcome", "slug": "o"}) + + # jobs are set and cleared via PATCH + node = client.get("/api/graphs/acme/nodes/outcome.o", headers=OWNER).json()["node"] + patched = client.patch( + "/api/graphs/acme/nodes/outcome.o", + json={"version": node["version"], "jobs": ["job.ship-fast"]}, + headers=EDITOR, + ) + assert patched.status_code == 200 and patched.json()["node"]["jobs"] == ["job.ship-fast"] + + # only outcomes and opportunities may reference jobs + response = client.post( + "/api/graphs/acme/nodes", + json={"kind": "icp", "slug": "devs", "jobs": ["job.ship-fast"]}, + headers=OWNER, + ) + assert response.status_code == 400 and "cannot reference jobs" in response.json()["detail"] + + # a referenced job cannot be deleted + response = client.delete("/api/graphs/acme/nodes/job.ship-fast", headers=OWNER) + assert response.status_code == 409 and "referenced by" in response.json()["detail"] + + # overview exposes job edges and servedBy + overview = client.get("/api/graphs/acme/overview", headers=OWNER).json() + assert {"source": "outcome.o", "target": "job.ship-fast", "type": "job"} in overview["edges"] + job_node = next(n for n in overview["nodes"] if n["ref"] == "job.ship-fast") + assert job_node["servedBy"] == ["outcome.o"] + + # --- roles --------------------------------------------------------------------------- def test_viewer_cannot_mutate(client, graph): diff --git a/api/tests/test_core.py b/api/tests/test_core.py index 72e812a..882e7a9 100644 --- a/api/tests/test_core.py +++ b/api/tests/test_core.py @@ -14,6 +14,7 @@ AmbiguousSelectorError, CascadeRequiredError, IcpReferenceError, + JobReferenceError, NotFoundError, PlacementError, SlugError, @@ -39,11 +40,12 @@ def snapshot() -> GraphSnapshot: vision = make_node("vision", "vision") strategy = make_node("strategy", "s1", starts=date(2026, 1, 1), ends=date(2026, 6, 30)) icp = make_node("icp", "founders") - outcome = make_node("outcome", "activation", icp_ref_ids=(icp.id,)) + job = make_node("job", "ship-with-confidence", icp_ref_ids=(icp.id,)) + outcome = make_node("outcome", "activation", icp_ref_ids=(icp.id,), job_ref_ids=(job.id,)) opp = make_node("opportunity", "onboarding", parent_id=outcome.id) sol = make_node("solution", "wizard", parent_id=opp.id) at = make_node("assumption-test", "signup-drop", parent_id=sol.id) - return GraphSnapshot(nodes=[vision, strategy, icp, outcome, opp, sol, at]) + return GraphSnapshot(nodes=[vision, strategy, icp, job, outcome, opp, sol, at]) def get(snapshot: GraphSnapshot, ref: str) -> Node: @@ -53,7 +55,8 @@ def get(snapshot: GraphSnapshot, ref: str) -> Node: # --- placement ------------------------------------------------------------- def test_allowed_child_kinds(): - assert allowed_child_kinds("root") == {"vision", "strategy", "icp", "outcome"} + assert allowed_child_kinds("root") == {"vision", "strategy", "icp", "job", "outcome"} + assert allowed_child_kinds("job") == set() assert allowed_child_kinds("outcome") == {"opportunity"} assert allowed_child_kinds("opportunity") == {"opportunity", "solution"} assert allowed_child_kinds("solution") == {"assumption-test", "prd"} @@ -62,6 +65,7 @@ def test_allowed_child_kinds(): def test_create_valid_placements(snapshot): rules.check_create(snapshot, kind="outcome", slug="retention", parent=None) + rules.check_create(snapshot, kind="job", slug="new-job", parent=None) outcome = get(snapshot, "outcome.activation") rules.check_create(snapshot, kind="opportunity", slug="new-opp", parent=outcome) opp = get(snapshot, "opportunity.onboarding") @@ -74,8 +78,13 @@ def test_create_valid_placements(snapshot): def test_create_rejects_bad_placements(snapshot): outcome = get(snapshot, "outcome.activation") sol = get(snapshot, "solution.wizard") + job = get(snapshot, "job.ship-with-confidence") with pytest.raises(PlacementError): rules.check_create(snapshot, kind="solution", slug="x", parent=outcome) + with pytest.raises(PlacementError): + rules.check_create(snapshot, kind="job", slug="x", parent=outcome) + with pytest.raises(PlacementError): + rules.check_create(snapshot, kind="opportunity", slug="x", parent=job) with pytest.raises(PlacementError): rules.check_create(snapshot, kind="opportunity", slug="x", parent=None) with pytest.raises(PlacementError): @@ -161,11 +170,13 @@ def test_current_strategy(snapshot): # --- ICP references ---------------------------------------------------------- -def test_icp_refs_only_on_outcome_and_opportunity(snapshot): +def test_icp_refs_only_on_referring_kinds(snapshot): icp = get(snapshot, "icp.founders") sol = get(snapshot, "solution.wizard") with pytest.raises(IcpReferenceError, match="cannot reference ICPs"): rules.check_create(snapshot, kind="prd", slug="p", parent=sol, icp_ref_ids=(icp.id,)) + # jobs may reference ICPs: they say who has the job + rules.check_create(snapshot, kind="job", slug="j", parent=None, icp_ref_ids=(icp.id,)) def test_icp_ref_must_target_icp(snapshot): @@ -190,6 +201,39 @@ def test_related_icps_deduped(): assert len(snap.related_icps(opp)) == 1 +# --- job references ------------------------------------------------------------ + +def test_job_refs_only_on_outcome_and_opportunity(snapshot): + job = get(snapshot, "job.ship-with-confidence") + sol = get(snapshot, "solution.wizard") + opp = get(snapshot, "opportunity.onboarding") + with pytest.raises(JobReferenceError, match="cannot reference jobs"): + rules.check_create(snapshot, kind="prd", slug="p", parent=sol, job_ref_ids=(job.id,)) + rules.check_create(snapshot, kind="opportunity", slug="q", parent=opp, job_ref_ids=(job.id,)) + + +def test_job_ref_must_target_job(snapshot): + sol = get(snapshot, "solution.wizard") + with pytest.raises(JobReferenceError, match="not a job"): + rules.check_job_refs(snapshot, kind="outcome", job_ref_ids=(sol.id,)) + with pytest.raises(JobReferenceError, match="does not resolve"): + rules.check_job_refs(snapshot, kind="outcome", job_ref_ids=(str(uuid.uuid4()),)) + + +def test_related_jobs_inherited(snapshot): + sol = get(snapshot, "solution.wizard") + jobs = snapshot.related_jobs(sol) + assert [job.ref for job in jobs] == ["job.ship-with-confidence"] + + +def test_related_jobs_deduped(): + job = make_node("job", "j") + outcome = make_node("outcome", "o", job_ref_ids=(job.id,)) + opp = make_node("opportunity", "p", parent_id=outcome.id, job_ref_ids=(job.id,)) + snap = GraphSnapshot(nodes=[job, outcome, opp]) + assert len(snap.related_jobs(opp)) == 1 + + # --- selectors --------------------------------------------------------------- def test_resolve_by_ref_slug_and_uuid(snapshot): @@ -241,6 +285,18 @@ def test_delete_referenced_icp_refused(snapshot): rules.check_delete(snapshot, icp, cascade=False) +def test_delete_referenced_job_refused(snapshot): + job = get(snapshot, "job.ship-with-confidence") + with pytest.raises(CascadeRequiredError, match="referenced by"): + rules.check_delete(snapshot, job, cascade=False) + + +def test_delete_unreferenced_job_allowed(): + job = make_node("job", "j") + snap = GraphSnapshot(nodes=[job]) + assert rules.check_delete(snap, job, cascade=False) == [] + + # --- ancestors / tree -------------------------------------------------------- def test_ancestors_chain(snapshot): @@ -295,6 +351,44 @@ def test_validate_reports_dangling_icp_ref(): assert any("does not resolve" in i.message for i in issues) +def test_validate_reports_dangling_job_ref(): + outcome = make_node("outcome", "o", job_ref_ids=(str(uuid.uuid4()),)) + issues = validation.validate_graph(GraphSnapshot(nodes=[outcome])) + assert any("job reference" in i.message and "does not resolve" in i.message for i in issues) + assert all(i.severity == "error" for i in issues if "job reference" in i.message) + + +def test_validate_reports_job_ref_on_wrong_kind(): + job = make_node("job", "j", icp_ref_ids=()) + stray = make_node("vision", "v", job_ref_ids=(job.id,)) + issues = validation.validate_graph(GraphSnapshot(nodes=[job, stray])) + assert any("cannot reference jobs" in i.message for i in issues) + + +def test_validate_warns_on_job_without_icp(): + job = make_node("job", "j") + issues = validation.validate_graph(GraphSnapshot(nodes=[job])) + warning = next(i for i in issues if "does not reference any ICP" in i.message) + assert warning.severity == "warning" and warning.ref == "job.j" + + +def test_validate_warns_on_top_level_opportunity_without_job(snapshot): + outcome = make_node("outcome", "o") + opp = make_node("opportunity", "floating", parent_id=outcome.id) + nested = make_node("opportunity", "nested", parent_id=opp.id) + issues = validation.validate_graph(GraphSnapshot(nodes=[outcome, opp, nested])) + warnings = [i for i in issues if "not connected to a job" in i.message] + # only the top-level opportunity warns; nested ones would inherit its job + assert [w.ref for w in warnings] == ["opportunity.floating"] + assert all(w.severity == "warning" for w in warnings) + + +def test_validate_no_job_warning_when_job_inherited(snapshot): + # fixture: outcome references the job, so the opportunity inherits it + issues = validation.validate_graph(snapshot) + assert not any("not connected to a job" in i.message for i in issues) + + def test_validate_flywheel_rules(): n1 = FlywheelNode(id=str(uuid.uuid4()), slug="a", title="A", content="Because it drives B.", status=None, position=0, next_ids=(str(uuid.uuid4()),)) n2 = FlywheelNode(id=str(uuid.uuid4()), slug="b", title="B", content="# heading only", status=None, position=1, next_ids=()) @@ -316,9 +410,11 @@ def test_context_markdown_structure(snapshot): assert "- outcome.activation" in md assert "- solution.wizard" in md assert "## ICPs" in md and "- icp.founders" in md + assert "## Jobs" in md and "- job.ship-with-confidence" in md assert "## Children" in md and "- assumption-test.signup-drop" in md assert "## Vision" in md assert "## Ancestor Content" in md + assert "## Job Content" in md assert "## Node Content" in md assert md.index("## Trace") < md.index("## Node Content") diff --git a/api/tests/test_store.py b/api/tests/test_store.py index 8e6110d..3e0f4bb 100644 --- a/api/tests/test_store.py +++ b/api/tests/test_store.py @@ -63,6 +63,29 @@ def test_snapshot_roundtrip_with_icp_refs(store, graph): assert snap.related_icps(selector.resolve(snap, "opportunity.docs"))[0].ref == "icp.devs" +def test_snapshot_roundtrip_with_job_refs(store, graph): + icp = store.create_node(graph.id, kind="icp", slug="devs", title="Devs", content="c", parent_id=None) + job = store.create_node( + graph.id, kind="job", slug="ship-fast", title="Ship fast", content="c", + parent_id=None, icp_ref_ids=(str(icp.id),), + ) + outcome = store.create_node( + graph.id, kind="outcome", slug="adoption", title="Adoption", content="c", + parent_id=None, job_ref_ids=(str(job.id),), + ) + store.create_node(graph.id, kind="opportunity", slug="docs", title="Docs", content="c", parent_id=str(outcome.id)) + snap = store.load_snapshot(graph.id) + node = selector.resolve(snap, "outcome.adoption") + assert node.job_ref_ids == (str(job.id),) + assert selector.resolve(snap, "job.ship-fast").icp_ref_ids == (str(icp.id),) + assert snap.related_jobs(selector.resolve(snap, "opportunity.docs"))[0].ref == "job.ship-fast" + + row = store.get_node_row(node.id) + store.update_node(row, expected_version=1, job_ref_ids=()) + snap = store.load_snapshot(graph.id) + assert selector.resolve(snap, "outcome.adoption").job_ref_ids == () + + def test_update_node_version_conflict(store, graph): row = store.create_node(graph.id, kind="outcome", slug="o", title="O", content="v1", parent_id=None) store.update_node(row, expected_version=1, content="v2") diff --git a/frontend/src/app.css b/frontend/src/app.css index 39577ec..1a99035 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -12,6 +12,7 @@ --vision: #d96c75; --strategy: #c6a15b; --icp: #9a6bd3; + --job: #d0679d; --outcome: #4f8cf7; --opportunity: #2aa198; --solution: #f08c35; @@ -309,6 +310,12 @@ label.lbl { stroke-width: 1.7; opacity: 0.8; } +.edge.job { + stroke: var(--job); + stroke-dasharray: 7 6; + stroke-width: 1.7; + opacity: 0.8; +} .edge.flywheel { stroke: var(--flywheel-node); stroke-width: 2.1; diff --git a/frontend/src/lib/GraphView.svelte b/frontend/src/lib/GraphView.svelte index 33e5753..3511185 100644 --- a/frontend/src/lib/GraphView.svelte +++ b/frontend/src/lib/GraphView.svelte @@ -17,6 +17,7 @@ vision: '#d96c75', strategy: '#c6a15b', icp: '#b07cf0', + job: '#d0679d', outcome: '#4f8cf7', opportunity: '#2bb3a3', solution: '#f0913a', @@ -47,7 +48,8 @@ let visions: N[] = [], strategies: N[] = [], outcomes: N[] = [], - icps: N[] = []; + icps: N[] = [], + jobs: N[] = []; let flywheelByRef = new Map(); let svg: SVGSVGElement; @@ -72,6 +74,7 @@ strategies = GRAPH.nodes.filter((n: N) => n.kind === 'strategy'); outcomes = GRAPH.nodes.filter((n: N) => n.kind === 'outcome'); icps = GRAPH.nodes.filter((n: N) => n.kind === 'icp'); + jobs = GRAPH.nodes.filter((n: N) => n.kind === 'job'); flywheelByRef = new Map( ((GRAPH.flywheel && GRAPH.flywheel.nodes) || []).map((n: N) => [n.ref, { ...n, kind: 'flywheel-node' }]) ); @@ -183,7 +186,7 @@ const my = (a.y + b.y) / 2; const wobble = sketchOffset(a, b); const d = `M ${a.x} ${a.y + (a.h || NODE_MIN_H) / 2} C ${a.x + wobble.a} ${my + wobble.b}, ${b.x + wobble.c} ${my - wobble.a}, ${b.x} ${b.y - (b.h || NODE_MIN_H) / 2}`; - return el('path', { class: 'edge' + (type === 'icp' ? ' icp' : ''), d }); + return el('path', { class: 'edge' + (type === 'icp' || type === 'job' ? ' ' + type : ''), d }); } function sketchOffset(a: Pos, b: Pos) { const seed = Math.sin(a.x * 12.9898 + a.y * 78.233 + b.x * 37.719 + b.y * 11.131) * 43758.5453; @@ -217,13 +220,16 @@ const centerX = (minX + maxX) / 2; const visionX = spread(visions.length, COL_W * 1.2).map((x) => x + centerX); const strategyX = spread(strategies.length, COL_W * 1.2).map((x) => x + centerX); - const icpX = spread(icps.length, COL_W).map((x) => x + centerX); + const customerRowX = spread(icps.length + jobs.length, COL_W).map((x) => x + centerX); + const icpX = customerRowX.slice(0, icps.length); + const jobX = customerRowX.slice(icps.length); const visionY = 0, strategyY = ROW_H * 0.95, icpY = ROW_H * 1.9; visions.forEach((vision, i) => pos.set(vision.ref, { x: visionX[i], y: visionY, h: nodeHeight(vision) })); strategies.forEach((strategy, i) => pos.set(strategy.ref, { x: strategyX[i], y: strategyY, h: nodeHeight(strategy) })); icps.forEach((icp, i) => pos.set(icp.ref, { x: icpX[i], y: icpY, h: nodeHeight(icp) })); + jobs.forEach((job, i) => pos.set(job.ref, { x: jobX[i], y: icpY, h: nodeHeight(job) })); visions.forEach((vision) => { const targets = strategies.length ? strategies : outcomes; @@ -240,18 +246,22 @@ if (pos.has(e.source) && pos.has(e.target)) viewport.appendChild(edgePath(pos.get(e.source)!, pos.get(e.target)!, 'structural')); }); GRAPH.edges - .filter((e: N) => e.type === 'icp') + // job → ICP references share the customer-context row; the detail panel + // shows them as chips instead of drawing a flat same-row edge. + .filter((e: N) => (e.type === 'icp' || e.type === 'job') && byRef.get(e.source)?.kind !== 'job') .forEach((e: N) => { - if (pos.has(e.source) && pos.has(e.target)) viewport.appendChild(edgePath(pos.get(e.target)!, pos.get(e.source)!, 'icp')); + if (pos.has(e.source) && pos.has(e.target)) viewport.appendChild(edgePath(pos.get(e.target)!, pos.get(e.source)!, e.type)); }); if (visions.length) addBandLabel('vision', visionX, visionY); if (strategies.length) addBandLabel('strategy', strategyX, strategyY); if (icps.length) addBandLabel('ideal customer profiles', icpX, icpY); + if (jobs.length) addBandLabel('jobs', jobX, icpY); if (outcomes.length) addBandLabel('Outcome Graph', [minX], ROW_H * 3); visions.forEach((vision) => drawNode(vision, pos.get(vision.ref)!.x, pos.get(vision.ref)!.y)); strategies.forEach((strategy) => drawNode(strategy, pos.get(strategy.ref)!.x, pos.get(strategy.ref)!.y)); icps.forEach((icp) => drawNode(icp, pos.get(icp.ref)!.x, pos.get(icp.ref)!.y)); + jobs.forEach((job) => drawNode(job, pos.get(job.ref)!.x, pos.get(job.ref)!.y)); outcomes.forEach((o) => drawFullGraphNode(o, pos)); } @@ -451,8 +461,15 @@ addPill(d, n.kind, KIND_COLOR[n.kind]); if (n.kind === 'strategy' && n.starts) addPill(d, `${n.starts} → ${n.ends}`); addTitle(d, n.title, n.ref); - const served = n.kind === 'icp' ? n.servedBy || [] : n.icps || []; - addChips(d, n.kind === 'icp' ? 'Serves' : 'ICPs served', served, byRef, onNodeClick); + if (n.kind === 'icp' || n.kind === 'job') { + addChips(d, 'Serves', n.servedBy || [], byRef, onNodeClick); + } + if (n.kind === 'job') { + addChips(d, 'ICPs', n.icps || [], byRef, onNodeClick); + } else { + addChips(d, 'ICPs served', n.icps || [], byRef, onNodeClick); + addChips(d, 'Jobs', n.jobs || [], byRef, onNodeClick); + } if (!readOnly) { const actions = document.createElement('div'); @@ -534,6 +551,21 @@ parent.appendChild(lbl); } + function refCheckRow(parent: HTMLElement, target: N, selected: string[]): HTMLInputElement { + const row = document.createElement('div'); + row.className = 'check-row'; + const check = document.createElement('input'); + check.type = 'checkbox'; + check.value = target.ref; + check.checked = selected.includes(target.ref); + const lbl = document.createElement('span'); + lbl.textContent = target.title; + row.appendChild(check); + row.appendChild(lbl); + parent.appendChild(row); + return check; + } + function showEditor(n: N) { const d = panelStart(); addTitle(d, 'Edit ' + n.title, n.ref); @@ -564,22 +596,15 @@ } const icpChecks: HTMLInputElement[] = []; - if ((n.kind === 'outcome' || n.kind === 'opportunity') && icps.length) { - labeled(ed, 'ICPs served'); - icps.forEach((icp) => { - const row = document.createElement('div'); - row.className = 'check-row'; - const check = document.createElement('input'); - check.type = 'checkbox'; - check.value = icp.ref; - check.checked = (n.icps || []).includes(icp.ref); - icpChecks.push(check); - const lbl = document.createElement('span'); - lbl.textContent = icp.title; - row.appendChild(check); - row.appendChild(lbl); - ed.appendChild(row); - }); + if ((n.kind === 'outcome' || n.kind === 'opportunity' || n.kind === 'job') && icps.length) { + labeled(ed, n.kind === 'job' ? 'ICPs with this job' : 'ICPs served'); + icps.forEach((icp) => icpChecks.push(refCheckRow(ed, icp, n.icps || []))); + } + + const jobChecks: HTMLInputElement[] = []; + if ((n.kind === 'outcome' || n.kind === 'opportunity') && jobs.length) { + labeled(ed, 'Jobs'); + jobs.forEach((job) => jobChecks.push(refCheckRow(ed, job, n.jobs || []))); } labeled(ed, 'Content (markdown)'); @@ -600,6 +625,7 @@ if (startsInput && startsInput.value) body.starts = startsInput.value; if (endsInput && endsInput.value) body.ends = endsInput.value; if (icpChecks.length) body.icps = icpChecks.filter((c) => c.checked).map((c) => c.value); + if (jobChecks.length) body.jobs = jobChecks.filter((c) => c.checked).map((c) => c.value); const res = await apiCall('PATCH', `/api/graphs/${graphRef}/nodes/${encodeURIComponent(n.ref)}`, body); if (res.ok) { toast('Saved', 'ok'); @@ -965,6 +991,7 @@ {#if !readOnly} + {#if !hasVision} diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index e9f923a..9afa787 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -37,8 +37,8 @@

Outcome Engineering

- Outcome Graphs that humans and agents can trace and update: vision, strategy, ICPs, outcomes, - opportunities, solutions, and the flywheel that connects them. + Outcome Graphs that humans and agents can trace and update: vision, strategy, ICPs, jobs, + outcomes, opportunities, solutions, and the flywheel that connects them.

{#if checking}

Checking session…