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
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ Before starting new work, review [docs/concepts.md](docs/concepts.md) for canoni
## Key Files

- config — JSON5 file with spaces registered
- `schemas/` — Bundled default schema files (json-schema with metadata extension, JSON5 format). Files starting with `_` are "partials" (fragments for `$ref`) and are loaded automatically. Local partials in a schema's directory **must** have unique `$id`s.
- `schemas/` — Bundled default schema files (JSON5) using the ost-tools schema dialect and top-level `$metadata`. Files starting with `_` are "partials" (fragments for `$ref`) and are loaded automatically. Local partials in a schema's directory **must** have unique `$id`s.
- `src/metadata-contract.ts` — Single source of truth for the `$metadata` contract (TS `as const` + inferred types)
- `schemas/generated/_ost_tools_schema_meta.json` — Generated metaschema artifact (regenerate with `bun run generate:schema-meta`)

## Testing

Expand All @@ -37,4 +39,4 @@ Before starting new work, review [docs/concepts.md](docs/concepts.md) for canoni
- `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.
A Stop hook runs linting, autoformatting and unit tests. If it reports issues related to change you made, address them.
36 changes: 34 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ See [docs/concepts.md](docs/concepts.md) for the full terminology reference, inc
2. `~/.config/ost-tools/config.json` (or `$XDG_CONFIG_HOME/ost-tools/config.json`)
3. `./config.json` in the current working directory

See `config.example.json` for the full structure. The config maps space namees to paths, with optional Miro integration fields and global defaults. Paths in config files are resolved relative to the config file.
See `config.example.json` for the full structure. The config maps space names to paths, with optional Miro integration fields and global defaults. Paths in config files are resolved relative to the config file.

**Including spaces from other configs:** Use `includeSpacesFrom` to import space definitions from other config files. This is useful for aggregating spaces from multiple projects into a central config, reducing the need to specify `--config` on CLI commands. Duplicate space names are not allowed.

Expand All @@ -54,7 +54,30 @@ Schemas define the structure and rules for the entities in a space, allowing cus

Two schemas (`general` and `strict_ost`) are included. The general schema combines a basic vision/mission/goals hierarchy with a hierarchy loosely based on Opportunity Solution Trees. It is intentionally flexible to support rapid initial adoption. The strict OST schema has a narrower scope, and reflects Teresa Torres' specific recommendations for Opportunity Solution Trees more closely.

Schema hierarchy levels support DAG (multi-parent) relationships via configurable edge fields. Each level in `_metadata.hierarchy` can be a plain type name string (defaults to `parent` field on child nodes) or an object:
ost-tools schemas use a Draft-07-based metaschema that adds a top-level `$metadata` block:

```json5
"$metadata": {
"hierarchy": {
"levels": ["outcome", { "type": "opportunity", "selfRef": true }, "solution", "assumption_test"],
"allowSkipLevels": false
},
"aliases": { "experiment": "assumption_test" },
"rules": [
{
"id": "active-outcome-count",
"category": "workflow",
"description": "Only one outcome should be active at a time",
"scope": "global",
"check": "$count(nodes[resolvedType='outcome' and status='active']) <= 1"
}
]
}
```

Rules are a flat array (`rules[]`) with per-rule `category`.

Schema hierarchy levels support DAG (multi-parent) relationships via configurable edge fields. Each entry in `$metadata.hierarchy.levels` can be a plain type name string (defaults to `parent` field on child nodes) or an object:

```json
{ "type": "opportunity", "selfRef": true }
Expand All @@ -70,8 +93,17 @@ Schema hierarchy levels support DAG (multi-parent) relationships via configurabl
| `multiple` | `false` | Whether the field is an array of wikilinks (enables multi-parent DAG) |
| `selfRef` | `false` | Whether a node of this type may reference a same-type parent |

Metadata is composable across `$ref` graphs:
- zero or one metadata provider may define `hierarchy`
- `aliases` are shallow-merged (later wins)
- `rules` merge by `id`; conflicts error unless the later rule sets `override: true`
- `$metadata.rules` supports `$ref` imports for reusable rule packs

If no provider defines `hierarchy`, hierarchy-specific checks are skipped. Reading a `space_on_a_page` file still requires `hierarchy.levels`.

**Customizing Schemas:**
- **Partial schemas**: Files starting with an underscore (like `_ost_tools_base.json`) are loaded and used to resolve references (using `$ref`).
- **No-metadata partials**: If a partial has no `$metadata`, prefer `$schema: "http://json-schema.org/draft-07/schema#"` so it validates standalone as plain JSON Schema.
- **Loading priority**: Partial schemas are loaded from both the default schema directory and the directory of your specified target schema.
- **Transitive resolution**: `$ref` chains are resolved recursively across files/schemas (including nested `allOf` usage in partials).
- **Unique IDs**: To encourage clean namespacing, local partial schemas **must** have unique `$id`s that do not collide with the default schemas. If a collision is detected, validation will fail with an error.
Expand Down
7 changes: 7 additions & 0 deletions bun.lock

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

9 changes: 5 additions & 4 deletions docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,9 @@ Typed pages are distinct from `space on a page` files: a typed page *is* a `spac

A **schema** defines the valid structure for nodes in a `space`: the fields, types, constraints, and descriptive `rules` for each entity type. A space uses the default schema unless a custom one is declared in its config.

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.
The schema handles structural validation. Cross-node and workflow checks are handled by executable `rules` defined in `$metadata.rules`.

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).)*
Schemas are composable: structural definitions and metadata can be sourced across `$ref` graphs, then merged deterministically (root metadata applied last, single hierarchy provider, aliases merged, rules merged by `id` with explicit override semantics).

### Rules

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

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

See [docs/rules.md](rules.md) for the rules reference, including JSONata expression syntax and the full `_metadata` field reference.
See [docs/rules.md](rules.md) for the rules reference, including JSONata expression syntax and the full `$metadata` field reference.

---

Expand All @@ -142,7 +142,7 @@ See [docs/rules.md](rules.md) for the rules reference, including JSONata express

## Hierarchy

The **hierarchy** is the ordered list of node types in a space, from root to leaf. It is defined in the schema's `_metadata.hierarchy` array and drives depth-based type inference (for `space on a page`), tree rendering, and hierarchy validation. The root type has no parent; every other type has parents in the level immediately above (unless `allowSkipLevels` is set).
The **hierarchy** is the ordered list of node types in a space, from root to leaf. It is defined in the schema's `$metadata.hierarchy.levels` array and drives depth-based type inference (for `space on a page`), tree rendering, and hierarchy validation. The root type has no parent; every other type has parents in the level immediately above (unless `$metadata.hierarchy.allowSkipLevels` is set).

Relationships between levels are modelled as a layered DAG: a non-root node may have zero parents (orphaned), one parent, or multiple parents. The `show` command renders this as an indented tree, marking repeated nodes with `(*)` where the subtree is already shown elsewhere.

Expand All @@ -155,6 +155,7 @@ A **hierarchy edge** is a directional link connecting a child node to one or mor
| `field` | `"parent"` | The frontmatter field that holds the wikilink(s) |
| `fieldOn` | `"child"` | `"parent"` means the field is on the **parent** node and points to children (reversed direction) |
| `multiple` | `false` | When `true`, the field holds an **array** of wikilinks rather than a single one |
| `selfRef` | `false` | When `true`, a node may have a parent of the same resolved type |

Dangling wikilinks — edge field values that do not resolve to any known node — are reported as reference errors during validation.

Expand Down
145 changes: 86 additions & 59 deletions docs/rules.md
Original file line number Diff line number Diff line change
@@ -1,93 +1,120 @@
# 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.
Rules are JSONata expressions in schema metadata (`$metadata.rules`). They run after structural JSON Schema validation and let you enforce cross-node checks and workflow constraints.

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

## Rule Categories
## Rule shape

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.
Rules are a flat array.

| 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
```json5
"rules": [
{
"id": "active-outcome-count",
"category": "workflow",
"description": "Only one outcome should be active at a time",
"scope": "global",
"check": "$count(nodes[resolvedType='outcome' and status='active']) <= 1"
}
]
```

| 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 |
| `id` | yes | Unique rule identifier |
| `category` | yes | `validation` \| `coherence` \| `workflow` \| `best-practice` |
| `description` | yes | Human-readable rule intent |
| `check` | yes | JSONata expression; must evaluate to `true` |
| `type` | no | Restrict rule to nodes of this `resolvedType` |
| `scope` | no | Use `"global"` to evaluate once for the whole space |
| `override` | no | Only for merge conflicts: allows later duplicate `id` to replace earlier |

## Categories

Categories label violations for reporting. They do not change expression execution semantics.

| Category | Typical use |
|---|---|
| `validation` | Hard correctness constraints |
| `coherence` | Cross-node consistency checks |
| `workflow` | Process/operating-discipline checks |
| `best-practice` | Advisory quality checks |

## Evaluation model

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.
- Rules with `scope: "global"` run once.
- Other rules run per applicable node.
- If `type` is set, only nodes with matching `resolvedType` are evaluated.

## JSONata Expression Context
## Expression context

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

| Variable | Description |
|---|---|
| `nodes` | Array of all nodes in the space |
| `current` | The node being evaluated |
| `parent` | First resolved parent node — absent if no parent was resolved. Provided for convenience with single-parent relationships; use `parents` for DAG hierarchies. |
| `parents` | Array of all resolved parent nodes — absent if no parents were resolved |
| `nodes` | All nodes in the space |
| `current` | Current node for this evaluation |
| `parent` | First resolved parent node (if any) |
| `parents` | All resolved parent nodes (if any) |

Nodes include all node properties (title, type, status, parent wikilink, etc.) plus resolved fields: `resolvedType` (canonical type after type alias resolution), `resolvedParentTitle` (first parent title), and `resolvedParentTitles` (array of all parent titles).
Useful resolved fields on nodes:
- `resolvedType`
- `resolvedParentTitle`
- `resolvedParentTitles`

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

### Referencing `current` inside predicates
## Predicate scoping (`$$`)

Inside a predicate (`nodes[...]`), bare names refer to fields on each item. Use `$$` (JSONata root) to reach outer-scope variables:
Inside `nodes[...]`, bare names refer to each candidate node. Use `$$` to reference outer 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`
## Rule imports (`$ref`)

- `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]]`)
`$metadata.rules` can include `$ref` entries that import:
- one rule
- a rule-set object with `rules: []`

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

```jsonata
$exists(parent) = false // true for root nodes
```json5
"rules": [
{ "$ref": "ost-tools://rule-pack#/$defs/workflowRule" },
{ "$ref": "ost-tools://rule-pack#/$defs/coreRuleSet" }
]
```

## Examples
Imported entries are normalized into the same flat runtime list.

## Merge and conflict behavior

When metadata is composed across `$ref`:
- Rules are merged by `id`.
- Different payloads for the same `id` are an error by default.
- A later rule may replace an earlier one only with `override: true`.

Example override:

```json
```json5
{
"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"
}
]
"id": "active-outcome-count",
"override": true,
"category": "workflow",
"description": "Require exactly one active outcome",
"scope": "global",
"check": "$count(nodes[resolvedType='outcome' and status='active']) = 1"
}
```

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.
## Common patterns

```jsonata
$exists(current.metric) = true
$count(current.sources) >= 1
$count(nodes[resolvedType='outcome' and status='active']) <= 1
current.status != 'active' or $exists(parent) = false or parent.status = 'active'
```
Loading
Loading