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
2 changes: 1 addition & 1 deletion .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review --comment'
claude_args: '--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr view:*),Bash(gh pr diff:*)"'
claude_args: '--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr checkout:*),Bash(git log:*),Bash(bun run test:*),Bash(bun run lint:*),Bash(bun test:*),Bash(npx tsc:*),Bash(bun run tsc:*),Bash(gh pr checks:*),Bash(npx biome check:*),Bash(git fetch:*),Bash(gh issue list:*),Bash(gh issue view:*)"'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options

4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,9 @@ Before starting new work, review [docs/concepts.md](docs/concepts.md) for canoni
- `bun run test` — unit tests (fixtures in `tests/`)
- `bun run test:smoke` — smoke tests that run `validate` against every space in `config.json` (`smoke/`)

## Debugging

- `bun run src/index.ts dump <path>` — Output parsed node data with resolved parents, useful for debugging rule violations

## Hooks
A Stop hook runs linting, autoformatting and unit tests. If it reports issues related to change you made, address them.
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,6 @@ Key properties:
- Heading levels must not skip — each level must be exactly one deeper than its parent.
- A horizontal rule (`---`) terminates parsing; headings below it are ignored.

> The name "OST on a page" may be revised as the tooling moves toward space-centric terminology — see [GitHub issue #22](https://github.com/mindsocket/ost-tools/issues/22).

#### Preamble

**Preamble** is content in a `space on a page` document that appears before the first heading. It is parsed but discarded — not associated with any node.
Expand All @@ -64,6 +62,8 @@ A `typed page` may contain embedded nodes in its body. Those nodes become full m

A **type alias** is an alternative name accepted in the `type` field for a given `space node` type. Aliases allow teams to use their own vocabulary while still receiving schema validation. For example, a schema might accept `outcome` as an alias for `goal`.

A `space node`'s resolved type (`resolvedType`) is its canonical type after alias resolution. Prefer resolvedType over the raw type field for all comparisons in rules and hierarchy checks.

---

## Typed page
Expand All @@ -80,7 +80,7 @@ A **schema** defines the valid structure for nodes in a `space`: the fields, typ

The schema handles structural validation. It does not encode qualitative or cross-node checks — those are handled by `rules`, which may be embedded within the schema or applied separately.

Schemas are designed to be composable: shared building blocks (common field sets, scoring models, constraint overlays) can be referenced across schema files, letting teams tailor a schema without forking their foundations. *(Schema composability is under active development — see [GitHub issues #13](https://github.com/mindsocket/ost-tools/issues/13), [#17](https://github.com/mindsocket/ost-tools/issues/17).)*
Schemas are designed to be composable: shared building blocks (common field sets, scoring models, constraint overlays) can be referenced across schema files, letting teams tailor a schema without forking their foundations. *(Schema composability is under development — see [GitHub issue #17](https://github.com/mindsocket/ost-tools/issues/17).)*

### Rules

Expand All @@ -98,7 +98,7 @@ Rules may be:

Rules are distinct from schema validation: the schema checks structure; rules check meaning and quality.

*(Rules support is planned — see [GitHub issue #16](https://github.com/mindsocket/ost-tools/issues/16).)*
See [docs/rules.md](rules.md) for the rules reference, including JSONata expression syntax and the full `_metadata` field reference.

---

Expand Down
92 changes: 92 additions & 0 deletions docs/rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Executable Rules

Rules are JSONata expressions embedded in a schema's `_metadata.rules` block. Each rule is evaluated against applicable nodes at validation time and must return `true` to pass. Rules encode checks that JSON Schema structural validation cannot express — cross-node consistency, quantitative thresholds, and qualitative best practices.

For how rules fit into the broader schema metadata, see [docs/schemas.md](schemas.md).

## Rule Categories

Rules are grouped into categories under `_metadata.rules`. Categories are informational — they determine how violations are labelled and grouped in output, but do not affect how the rule is evaluated. Use `scope` to control evaluation mode.

| Category | Purpose |
|---|---|
| `validation` | Structural correctness — a violation means the node is incorrect and should be fixed |
| `coherence` | Cross-node checks — for flagging conflicts or contradictions between nodes |
| `workflow` | Process discipline checks — for keeping the tree in an operational working state (active counts, status consistency) |
| `bestPractice` | Advisory guidance — signals the space may benefit from additional work |

## Rule Object Structure

| Field | Required | Description |
|---|---|---|
| `id` | yes | Unique identifier (kebab-case) |
| `description` | yes | Human-readable description of what the rule checks |
| `check` | yes | JSONata expression that must evaluate to `true` to pass |
| `type` | no | If set, only applies to nodes of this resolved type |
| `scope` | no | Set to `'global'` to evaluate the rule once against the full node set |

Rules without `scope: 'global'` are evaluated once per applicable node (all nodes, or only those matching `type`). A global rule is evaluated once and produces at most one violation for the space — use this for aggregate checks, like counts, across all nodes.

## JSONata Expression Context

Each expression is evaluated once per applicable node with the following input:

| Variable | Description |
|---|---|
| `nodes` | Array of all nodes in the space |
| `current` | The node being evaluated |
| `parent` | The resolved parent node — absent if no parent was resolved |

Nodes include all node properties (title, type, status, parent wikilink, etc.) plus two resolved fields: `resolvedType` (canonical type after alias resolution) and `resolvedParentTitle` (parent title after resolving any links).

Prefer `resolvedType` over `type` for type comparisons. When aliases are in use, `type` reflects the raw frontmatter value and may not match canonical names.

### Referencing `current` inside predicates

Inside a predicate (`nodes[...]`), bare names refer to fields on each item. Use `$$` (JSONata root) to reach outer-scope variables:

```jsonata
// Count solutions whose parent title matches the current node's title
$count(nodes[resolvedParentTitle=$$.current.title and resolvedType='solution'])
```

### `parent` vs `current.parent`

- `parent` — the resolved parent **node object**; absent if the parent was not found in the space
- `current.parent` — the raw wikilink string from frontmatter (e.g. `[[My Outcome]]`)

Use `$exists(parent)` to test whether the current node has a resolved parent:

```jsonata
$exists(parent) = false // true for root nodes
```

## Examples

```json
{
"workflow": [
{
"id": "active-outcome-count",
"description": "Only one outcome should be active at a time",
"scope": "global",
"check": "$count(nodes[resolvedType='outcome' and status='active']) <= 1"
},
{
"id": "active-node-parent-active",
"description": "An active node's parent should also be active",
"check": "current.status != 'active' or $exists(parent) = false or parent.status = 'active'"
}
],
"bestPractice": [
{
"id": "solution-quantity",
"description": "Explore multiple candidate solutions (aim for at least three) for the target opportunity",
"type": "opportunity",
"check": "(current.status != 'exploring' and current.status != 'active') or $count(nodes[resolvedParentTitle=$$.current.title and resolvedType='solution']) >= 3"
}
]
}
```

The first workflow rule uses `scope: 'global'` — evaluated once against the whole space, producing at most one violation. The second runs per-node with no `type` filter, checking every node. The best-practice rule only runs against `opportunity` nodes where status is `exploring` or `active`, using `resolvedParentTitle` to count child solutions.
61 changes: 47 additions & 14 deletions docs/schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ A flexible, opinionated schema supporting a multi-level strategy hierarchy along
**Features:**
- Allows `vision`, `mission`, `goal` hierarchy for strategic planning
- Optional numeric assessment fields (1-5 scale) for opportunities and solutions
- Type aliases: `goal` and `outcome` are accepted for the same entity type
- Type aliases: alternative terms accepted for some types
- `additionalProperties: true` allows extensibility

**Use when:**
Expand All @@ -58,18 +58,12 @@ A schema following the canonical 4-level Opportunity Solution Tree structure, ba
- `outcome` — Root-level outcome (product metric, no parent)
- `opportunity` — Customer pain points, desires, and needs (can be nested)
- `solution` — Solutions to explore for target opportunities
- `experiment`|`assumption_test`|`test` — Assumption tests for solutions

**Structure:**
- `outcome` cannot have a parent (it's the root)
- `opportunity` can have `outcome` or another `opportunity` as parent (nested hierarchy)
- `solution` must have an `opportunity` parent
- `experiment`|`assumption_test`|`test` must have a `solution` parent
- `assumption_test` — Assumption tests for solutions

**Fields:**
- `outcome` includes optional `metric` field for the product metric
- `opportunity` includes optional `source` field to track research origin
- `experiment` includes required `assumption` field and optional `category` enum
- `outcome` requires a `metric` field for the product metric
- `opportunity` requires a `source` field to track research origin
- `assumption_test` requires an `assumption` field and accepts an optional `category`

**Use when:**
- You want to follow Teresa Torres' OST methodology strictly
Expand All @@ -93,9 +87,46 @@ Common definitions used across multiple schemas:

Shared definitions specific to the strict OST schema:

- `OutcomeProps` — Outcome-specific properties (metric)
- `outcomeProps` — Outcome-specific properties (metric)
- `opportunityProps` — Opportunity properties (source)
- `experimentProps` — Experiment properties (assumption, category)
- `assumptionTestProps` — Assumption test properties (assumption, category)
- `_metadata` — Hierarchy, type aliases, and executable rules for strict OST validation

## Schema Metadata

The `_metadata` block in `$defs` carries non-structural validation configuration. It is not a JSON Schema construct — the tooling reads it separately from the schema validator.

```jsonc
{
"$defs": {
"_metadata": {
"hierarchy": ["outcome", "opportunity", "solution", "assumption_test"],
"aliases": { "test": "assumption_test" },
"allowSelfRef": ["opportunity"],
"allowSkipLevels": false,
"rules": { ... }
}
}
}
```

| Field | Type | Description |
|---|---|---|
| `hierarchy` | `string[]` | Ordered list of canonical types from root to leaf |
| `aliases` | `Record<string, string>` | Maps alternative type names to canonical types |
| `allowSelfRef` | `string[]` | Types that may have a parent of the same type (e.g. nested opportunities) |
| `allowSkipLevels` | `boolean` | When `true`, a node may have any ancestor type above it, not just the immediate parent |
| `rules` | `object` | Executable validation rules — see [docs/rules.md](rules.md) |

### Hierarchy validation

The validator checks every node type and its parent type against the hierarchy order, with violations flagged.

`allowSelfRef` and `allowSkipLevels` modify the strictness. For example, `"allowSelfRef": ["opportunity"]` permits nested opportunity trees.

### Type aliases

`aliases` maps alternative type names to canonical types. A node with `type: outcome` and `"aliases": { "outcome": "goal" }` will have `resolvedType: goal` and be treated as a `goal` everywhere — in hierarchy checks, rule type filters, and output.

## Schema Composability

Expand All @@ -105,6 +136,8 @@ Schemas are designed to be composable. You can create custom schemas by:
2. Using `$ref` to reference shared definitions from `_shared.json` or other schemas
3. Defining your own node types and constraints

Referencing another schema file merges its `$defs` into the compiled schema, including any `_metadata` block. If multiple referenced files each define `_metadata`, only the last one merged is used — `rules` arrays are not combined across sources.

Example of referencing shared definitions:

```jsonc
Expand Down Expand Up @@ -134,6 +167,6 @@ Schema files support JSONC (JSON with Comments) format, allowing inline document

## Further Reading

- [ Teresa Torres' work on Opportunity Solution Trees](https://producttalk.org/2021/02/using-opportunity-solution-trees/)
- [Teresa Torres' work on Opportunity Solution Trees](https://producttalk.org/2021/02/using-opportunity-solution-trees/)
- "Continuous Discovery Habits" (2021) by Teresa Torres
- [JSON Schema specification](https://json-schema.org/)
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"glob": "^13.0.6",
"gray-matter": "^4.0.3",
"js-yaml": "^4.1.1",
"jsonata": "^2.1.0",
"jsonc-parser": "^3.3.1",
"mdast-util-to-string": "^4.0.0",
"remark-gfm": "^4.0.1",
Expand Down
47 changes: 28 additions & 19 deletions schemas/_strict.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,35 @@
// "Continuous Discovery Habits" (2021) and at producttalk.org.
"$defs": {
"_metadata": {
"hierarchy": ["outcome", "opportunity", "solution", "experiment"],
"hierarchy": ["outcome", "opportunity", "solution", "assumption_test"],
"allowSelfRef": ["opportunity"],
"rules": {
"general": [
"Only one outcome should be active at a time",
"Only one target opportunity should be active at a time"
"workflow": [
{
"id": "active-outcome-count",
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't coherence. Add a workflow category for both of these rules.

Coherence is more for things like "the goal is to make $10M" when "the opportunity is to rebuild the design system". Coherence rules are likely to be more qualitative.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also add a workflow check that an active (status) node should have an active (status) parent.

Ideally this goes into _shared but we don't have a way to merge rules yet - now we have a reason to want #17 (comment)

"description": "Only one outcome should be active at a time",
"scope": "global",
"check": "$count(nodes[resolvedType='outcome' and status='active']) <= 1"
},
{
"id": "active-opportunity-count",
"description": "Only one target opportunity should be active at a time",
"scope": "global",
"check": "$count(nodes[resolvedType='opportunity' and status='active']) <= 1"
},
{
"id": "active-node-parent-active",
"description": "An active node's parent should also be active",
"check": "current.status != 'active' or $exists(parent) = false or parent.status = 'active'"
}
],
"outcome": ["Should be a specific product metric with a directional component (e.g., 'increase X')"],
"opportunity": [
"Frame in the customer's voice — something a customer would actually say",
"Must be in the problem space (addressable by more than one solution)",
"Must be grounded in customer research (use the 'source' field to track origin)"
],
"solution": [
"Parent must be an opportunity (not another solution)",
"Explore multiple candidate solutions (aim for at least three) for the target opportunity before committing to one"
],
"experiment": [
"Parent must be a solution",
"Tests a single assumption — not the whole idea",
"Run tests on the riskiest assumption for each solution candidate"
"bestPractice": [
{
"id": "solution-quantity",
"description": "Explore multiple candidate solutions (aim for at least three) for the target opportunity",
"type": "opportunity",
"check": "(current.status != 'exploring' and current.status != 'active') or $count(nodes[resolvedParentTitle=$$.current.title and resolvedType='solution']) >= 3"
}
]
}
},
Expand All @@ -51,7 +60,7 @@
},
"required": ["source"]
},
"experimentProps": {
"assumptionTestProps": {
"type": "object",
"description": "Properties for an Assumption Test (the fourth level of the OST)",
"properties": {
Expand Down
8 changes: 7 additions & 1 deletion schemas/general.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@
"description": "Validates frontmatter for space node files",
"$defs": {
"_metadata": {
"hierarchy": ["vision", "mission", "goal", "opportunity", "solution", "experiment"]
"hierarchy": ["vision", "mission", "goal", "opportunity", "solution", "experiment"],
"aliases": {
"outcome": "goal",
"assumption_test": "experiment",
"test": "experiment"
},
"allowSelfRef": ["goal", "opportunity", "solution"]
}
},
"oneOf": [
Expand Down
16 changes: 2 additions & 14 deletions schemas/strict_ost.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,6 @@
// and at producttalk.org.
// Structure: outcome → opportunity → solution → assumption test
"description": "Validates frontmatter for space node files following the canonical 4-level OST structure: outcome → opportunity → solution → assumption test",
"$defs": {
"_metadata": {
"hierarchy": ["outcome", "opportunity", "solution", "experiment"]
}
},
"oneOf": [
{
"type": "object",
Expand Down Expand Up @@ -86,10 +81,10 @@
"allOf": [
{ "$ref": "ost-tools://_shared#/$defs/baseNodeProps" },
{ "$ref": "ost-tools://_shared#/$defs/ostEntityProps" },
{ "$ref": "ost-tools://_strict#/$defs/experimentProps" }
{ "$ref": "ost-tools://_strict#/$defs/assumptionTestProps" }
],
"properties": {
"type": { "enum": ["experiment", "assumption_test", "test"] },
"type": { "const": "assumption_test" },
"parent": {
"$ref": "ost-tools://_shared#/$defs/wikilink",
"description": "Parent solution (wikilink)"
Expand All @@ -104,13 +99,6 @@
"parent": "[[Simplify Signup Flow]]",
"assumption": "Users can complete the simplified signup in under 2 minutes",
"category": "usability"
},
{
"type": "test",
"status": "active",
"parent": "[[Prototype Landing Page]]",
"assumption": "Users will understand the value proposition within 5 seconds",
"category": "desirability"
}
]
}
Expand Down
Loading
Loading