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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ The `selfRefField` property enables different fields for regular vs same-type re
| `fieldOn` | `"child"` | `"child"`: child holds a link pointing up. `"parent"`: parent holds an array of child links. |
| `format` | `"page"` | Hint for `template-sync`: `"table"`, `"list"`, or `"heading"` |
| `matchers` | `[]` | Heading text to match for embedded parsing (strings or `/regex/`). Case-insensitive. |
| `multi` | `true` | Whether multiple children are expected |
| `multiple` | `true` | Whether multiple children are expected |
| `embeddedTemplateFields` | `[]` | Field names to include as table columns in templates |

With `fieldOn: "parent"`, embedded child nodes (parsed from a matching heading's list or table) are appended as wikilinks to the parent's `field` array, rather than receiving a `parent` field. This matches schemas where the content model naturally lists children on the parent (e.g. `activity.tasks: ["[[Task A]]"]`).
Expand Down
4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ flowchart LR
| Boundary | Data |
|---|---|
| Space → Read | Raw markdown files / `space_on_a_page` file |
| Read → Nodes | `SpaceNode[]` — schemaData (canonical fields), resolvedType, resolvedParents, linkTargets |
| Schema → Read | Hierarchy levels (type names, edge fields, direction, cardinality), type aliases |
| Read → Nodes | `SpaceNode[]` — schemaData (canonical fields), resolvedType, resolvedParents (`ResolvedParentRef[]`), linkTargets |
| Schema → Read | Hierarchy levels + relationships (type names, edge fields, direction, cardinality), type aliases |
| Schema → Validate | AJV validator, hierarchy rules, JSONata rule expressions |
| Nodes → Output | Validated node set; output commands interpret as needed |
| Config → Output | `fieldMap` (reverse) applied by template-sync for file field names |
Expand Down
75 changes: 46 additions & 29 deletions docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,25 +146,20 @@ 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.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).
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 structural 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.
The hierarchy is 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.

### Edge configuration

A **hierarchy edge** is a directional link connecting a child node to one or more parent nodes. Each non-root level in the hierarchy defines how its edges are expressed in frontmatter. The default is a single `parent` wikilink on the child node, but any field name, direction, and cardinality can be configured.
Each non-root level uses the shared `field`, `fieldOn`, and `multiple` edge options (see [Graph edges](#graph-edges)). Two additional options are hierarchy-specific:

| Option | Default | Meaning |
|---|---|---|
| `field` | `"parent"` | The frontmatter field that holds the wikilink(s) for the regular parent-child relationship |
| `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 (uses `field` for same-type relationships) |
| `selfRefField` | _undefined_ | When set, specifies a different field for same-type parent relationships (always on child-side) |
| `selfRef` | `false` | When `true`, a node may have a parent of the same resolved type, using `field` for both regular and same-type parents |
| `selfRefField` | _undefined_ | When set, specifies a separate field for same-type parent relationships (always on the child). Requires `selfRef: true`. |

**Example: Activities listing Capabilities with sub-capabilities**

```
```json
"levels": [
"Activities",
{
Expand All @@ -177,41 +172,63 @@ A **hierarchy edge** is a directional link connecting a child node to one or mor
]
```

This configuration supports two relationship types:
- **Activities → Capabilities**: Via `capabilities` field on Activity nodes (array of capability wikilinks)
- **Capability → Capability**: Via `parent` field on Capability nodes (single parent capability wikilink)
This defines two edge types for Capabilities:
- **Activities → Capabilities**: Via `capabilities` array field on Activity nodes
- **Capability → Capability**: Via `parent` field on Capability nodes (same-type, child-side)

Without `selfRefField`, a type can only define one relationship field. The `selfRef` flag enables same-type relationships but uses the same `field` for both regular and same-type parents.
---

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

### Resolved parents
A **relationship** is a link between a parent type and a child type that is not part of the primary structural hierarchy. For example, an `opportunity` might have a relationship with `assumption` (multiple) or `problem_statement` (single).

**Resolved parents** (`resolvedParents`) is the set of parent node titles derived from a node's edge field(s) at *parse* time. It is always an array (empty if unresolved or root-level). Tooling uses `resolvedParents` for tree rendering, hierarchy validation, rule evaluation, and diagram/Miro sync.
Relationships are defined in `$metadata.relationships`. Like hierarchy levels, they use the shared `field`, `fieldOn`, and `multiple` edge options (see [Graph edges](#graph-edges)), but carry additional metadata used for parsing and template generation:

### Wikilink
- **`parent`** / **`type`** — the parent and child canonical types (required)
- **`format`** — parsing/generation hint: `"heading"`, `"list"`, `"table"`, or `"page"`
- **`matchers`** — heading text patterns (strings or `/regex/`) used to detect relationship sections during embedded parsing

A heading in a typed page that matches a relationship's `matchers` signals to the parser that following content (single nodes, list items, or table rows) should be typed as that relationship's child type — without requiring explicit inline `[type:: x]` annotations.

---

A **wikilink** is the `[[Title]]` linking syntax (compatible with Obsidian) used to express hierarchy edges between `space nodes`. Any edge field — whether named `parent` or a custom name — holds wikilinks to linked nodes.
## Graph edges

Two forms are supported:
Both `hierarchy.levels` and `relationships` define **edges** in a directed graph over the node set. All edges use the same three configuration options:

| Option | Default | Meaning |
|---|---|---|
| `field` | `"parent"` | The frontmatter field holding 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 |

The `fieldOn: "parent"` pattern is used when the content model lists children on the parent node (e.g. `tasks: ["[[Task A]]", "[[Task B]]"]`). Embedded parsing then appends child wikilinks to the parent's field array rather than setting a `parent` field on each child.

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

### Wikilink

A **wikilink** is the `[[Title]]` linking syntax (compatible with Obsidian) used in edge fields to reference other `space nodes`. Two forms are supported:

| Form | Example | Resolves to |
|---|---|---|
| Plain title | `[[My Goal]]` | The `space node` whose title equals `My Goal` |
| Anchor ref | `[[vision_page#^goal1]]` | The `embedded node` with `anchor` `goal1` inside `vision_page.md` |

### Relationships (Adjacent)

An **adjacent relationship** (or simply **relationship**) is a link between a parent type and a child type that is not part of the primary structural hierarchy. For example, an `opportunity` might have a relationship with `assumption` (multiple) or `problem_statement` (single).
### Resolved parents

Relationships are defined in the schema's `$metadata.relationships` and provide tips for both generation (`template-sync`) and parsing (`parse-embedded`). In particular, a heading matching a relationship name in a typed page acts as a signal to the parser: content (single-node), and list items or table rows (multi-node) below it are typed as that relationship's child type without requiring explicit inline annotations.
**Resolved parents** (`resolvedParents`) is the set of parent references derived from a node's edge fields at *parse* time. It is always an array (empty if unresolved or root-level). Both `hierarchy.levels` and `relationships` edges resolve into this single array — forming a unified labelled directed graph over the node set.

Relationships support two link directions via `field` and `fieldOn`:
Each entry is a `ResolvedParentRef` object:

- **`fieldOn: "child"` (default)** — the child node carries the relationship field (e.g. `parent: "[[Opportunity A]]"`). This is the conventional form inherited from hierarchy edges.
- **`fieldOn: "parent"`** — the parent node carries an array field (e.g. `tasks: ["[[Task A]]", "[[Task B]]"]`). Use this when the content model places the list on the parent rather than on each child. Embedded parsing populates the parent's field array rather than setting `parent` on each child node.
| Field | Type | Description |
|---|---|---|
| `title` | `string` | The parent node's title |
| `field` | `string` | The frontmatter field that held the wikilink |
| `source` | `'hierarchy' \| 'relationship'` | Whether the edge came from a hierarchy level or a relationship |
| `selfRef` | `boolean` | Whether the edge is a same-type (self-referential) parent link |

The `field` property names the frontmatter field (defaults to `"parent"` for child-side; must be explicit for parent-side). Validation checks all wikilinks in the field resolve to nodes of the declared type.
The `source` label lets downstream consumers distinguish edge types without re-inspecting the schema. Validation routes `hierarchy` edges to structural checks (parent-type rules, skip-level detection) and `relationship` edges to field reference checks (type-match, missing-target). Tree rendering and rule evaluation use the full set.

### Anchor

Expand Down
8 changes: 4 additions & 4 deletions docs/schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,13 @@ Top-level metadata shape:
| `hierarchy` | object | Optional per provider; at most one provider may define it after composition |
| `hierarchy.levels` | `(string \| HierarchyLevel)[]` | Ordered root→leaf types |
| `hierarchy.allowSkipLevels` | `boolean` | Optional; allows parent to be any ancestor level |
| `relationships` | `Relationship[]` | Optional; defines adjacent related node links |
| `relationships` | `Relationship[]` | Optional; defines related node links outside the primary hierarchy |
| `aliases` | `Record<string, string>` | Optional type alias map |
| `rules` | `Rule[]` | Optional flat rule array |

### Relationships

Adjacent relationships define how related nodes (not part of the hierarchy) are handled during parsing and template generation.
Relationships define links between node types that are not part of the primary structural hierarchy. They are handled during parsing and template generation.

| Field | Type | Default | Description |
|---|---|---|---|
Expand All @@ -114,7 +114,7 @@ Adjacent relationships define how related nodes (not part of the hierarchy) are
| `format` | `string` | `"page"` | Hint for `template-sync`: `"table"`, `"list"`, or `"heading"` |
| `matchers` | `string[]` | `[]` | Heading text to match (strings or `/regex/`). Case-insensitive. |
| `embeddedTemplateFields` | `string[]` | `[]` | Field names to include in templates when `format` is `"table"` |
| `multi` | `boolean` | `true` | Whether multiple children are expected |
| `multiple` | `boolean` | `true` | Whether multiple children are expected |

**`fieldOn: "child"` (default)** — child node has a field pointing to its parent. Embedded parsing sets this field on each child node; validation checks that it resolves to a node of the declared parent type.

Expand Down Expand Up @@ -145,7 +145,7 @@ Adjacent relationships define how related nodes (not part of the hierarchy) are
"fieldOn": "parent",
"format": "list",
"matchers": ["Tasks"],
"multi": true
"multiple": true
}
]
```
Expand Down
4 changes: 2 additions & 2 deletions schemas/general.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@
"type": "problem_statement",
"format": "heading",
"matchers": ["Problem statement", "What problem are we solving?"],
"multi": false
"multiple": false
},
{
"parent": "opportunity",
"type": "assumption",
"format": "table",
"matchers": ["Assumptions", "^Assumptions?$"],
"embeddedTemplateFields": ["assumption", "status", "confidence"],
"multi": true
"multiple": true
}
]
},
Expand Down
2 changes: 1 addition & 1 deletion schemas/generated/_ost_tools_schema_meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@
"format": {
"enum": ["heading", "list", "table", "page"]
},
"multi": {
"multiple": {
"type": "boolean"
},
"matchers": {
Expand Down
12 changes: 9 additions & 3 deletions skills/ost-tools/references/schema-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ Use object entries to override defaults:
- `multiple: true` for array wikilinks
- `selfRef: true` for same-type parent links

### Adjacent Relationships (`$metadata.relationships`)
### Relationships (`$metadata.relationships`)

Adjacent relationships define how sub-entities (nodes inside other files) are parsed and generated.
Relationships define how sub-entities (nodes inside other files) are parsed and generated.

| Field | Default | Description |
|---|---|---|
Expand All @@ -58,7 +58,7 @@ Adjacent relationships define how sub-entities (nodes inside other files) are pa
| `fieldOn` | `"child"` | `"child"`: child has the field pointing up. `"parent"`: parent has an array field pointing down to children. |
| `format` | | Hint for `template-sync`: `"table"`, `"list"`, or `"heading"` |
| `matchers` | | Heading text to match (strings or `/regex/`). Case-insensitive. |
| `multi` | `true` | Whether multiple children are expected |
| `multiple` | `true` | Whether multiple children are expected |
| `embeddedTemplateFields` | | Field names for table columns |

**`fieldOn: "parent"` pattern** — use when the content model lists children on the parent (e.g. `activity.tasks: ["[[Task A]]"]`). Embedded parsing appends child wikilinks to the parent's `field` array rather than setting a `parent` field on each child. Validation checks each array entry resolves to a node of `type`.
Expand Down Expand Up @@ -146,6 +146,12 @@ Each rule evaluation receives: `nodes`, `current`, `parent`, `parents`.

Use `resolvedType` in comparisons (not raw `type`) so aliases are respected.

Each node also carries convenience fields for common queries:
- `resolvedParentTitle` — title of the first resolved parent (or `undefined`)
- `resolvedParentTitles` — array of all resolved parent titles

`resolvedParents` on the raw node is an array of `ResolvedParentRef` objects (`{ title, field, source, selfRef }`); use the convenience fields for simple title-matching.

```jsonata
$count(nodes[resolvedParentTitle=$$.current.title and resolvedType='solution'])
```
15 changes: 6 additions & 9 deletions src/commands/validate.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import type { ErrorObject } from 'ajv';
import { readSpace } from '../read/read-space';
import { buildTargetIndex } from '../read/wikilink-utils';
import { buildFullRegistry, createValidator, loadMetadata, readRawSchema } from '../schema/schema';
import { validateHierarchyWithFields, validateRelationships } from '../schema/validate-hierarchy';
import { validateGraph } from '../schema/validate-graph';
import { validateRules } from '../schema/validate-rules';
import type { HierarchyViolation, RuleViolation } from '../types';
import type { GraphViolation, RuleViolation } from '../types';
import { classifyNodes } from '../util/graph-helpers';
import { extractEntityInfo } from './schemas';

Expand All @@ -20,7 +19,7 @@ interface ValidationResult {
refErrors: Array<{ file: string; parent: string; error: string }>;
duplicateErrors: Array<{ title: string; files: string[] }>;
ruleViolations: RuleViolation[];
hierarchyViolations: HierarchyViolation[];
hierarchyViolations: GraphViolation[];
orphanCount: number;
skipped: string[];
nonSpace: string[];
Expand Down Expand Up @@ -187,12 +186,10 @@ export async function validate(path: string, options: { schema: string; template
}

// Validate all hierarchy constraints (field references and structure)
const linkTargetIndex = buildTargetIndex(nodes);
const hierarchyValidation = validateHierarchyWithFields(nodes, metadata);
const relValidation = validateRelationships(nodes, metadata, linkTargetIndex);
const hierarchyValidation = validateGraph(nodes, metadata);

result.refErrors.push(...hierarchyValidation.refErrors, ...relValidation.refErrors);
result.hierarchyViolations = [...hierarchyValidation.violations, ...relValidation.violations];
result.refErrors.push(...hierarchyValidation.refErrors);
result.hierarchyViolations = [...hierarchyValidation.violations];

// Calculate orphan count (informational, not a validation error)
if (metadata.hierarchy) {
Expand Down
2 changes: 1 addition & 1 deletion src/integrations/miro/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export function computeNodeHash(node: SpaceNode): string {
status: node.schemaData.status,
summary: node.schemaData.summary,
priority: node.schemaData.priority,
parents: node.resolvedParents,
parents: node.resolvedParents.filter((r) => r.source === 'hierarchy').map((r) => r.title),
};
return createHash('sha256').update(JSON.stringify(relevant)).digest('hex').slice(0, 16);
}
Expand Down
3 changes: 2 additions & 1 deletion src/integrations/miro/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,8 @@ export async function miroSync(spaceOrPath: string, options: SyncOptions): Promi
const desiredEdges = new Map<string, { parentTitle: string; childTitle: string }>();
for (const node of nodes) {
const childTitle = node.schemaData.title as string;
for (const parentTitle of node.resolvedParents) {
for (const { title: parentTitle, source } of node.resolvedParents) {
if (source !== 'hierarchy') continue;
// Both endpoints must have verified cards on the board
if (verifiedCardIds.has(parentTitle) && verifiedCardIds.has(childTitle)) {
const key = `${parentTitle}\u2192${childTitle}`;
Expand Down
Loading
Loading