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
4 changes: 4 additions & 0 deletions .agents/hooks/lint-and-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
# Exit code 0: all checks passed (allow)
# Exit code 2: checks failed (deny/block for both Claude Code and Gemini CLI)

if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0 # Let Claude stop
fi

STATUS=0
echo "🔍 Running pre-stop checks..." >&2

Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ ost-tools schemas use a Draft-07-based metaschema that adds a top-level `$metada
"levels": ["outcome", { "type": "opportunity", "selfRef": true }, "solution", "assumption_test"],
"allowSkipLevels": false
},
"relationships": [
{
"parent": "opportunity",
"type": "assumption",
"format": "table",
"matchers": ["Assumptions"]
}
],
"aliases": { "experiment": "assumption_test" },
"rules": [
{
Expand All @@ -83,6 +91,7 @@ Schema hierarchy levels support DAG (multi-parent) relationships via configurabl
{ "type": "opportunity", "selfRef": true }
{ "type": "solution", "field": "fulfills", "multiple": true }
{ "type": "requirement", "field": "generates", "fieldOn": "parent", "multiple": true }
{ "type": "solution", "field": "solutions", "fieldOn": "parent", "multiple": true, "selfRefField": "parent" }
```

| Property | Default | Description |
Expand All @@ -92,6 +101,24 @@ Schema hierarchy levels support DAG (multi-parent) relationships via configurabl
| `fieldOn` | `"child"` | Which side holds the field: `"child"` (child points up) or `"parent"` (parent points down) |
| `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 |
| `selfRefField` | _undefined_ | Optional field for same-type parent relationships (always on child-side and singular) |

The `selfRefField` property enables different fields for regular vs same-type relationships. For example, requirements can list solutions via `solutions` on the requirement node, while solutions can reference parent solutions via `parent` on the solution node.

**Adjacent Relationships** (`$metadata.relationships`) define connections between types outside the primary hierarchy — such as an `activity` having many `task` nodes. They drive embedded parsing (typed headings, lists, tables) and template generation.

| Property | Default | Description |
|---|---|---|
| `parent` | required | Parent canonical type |
| `type` | required | Child canonical type |
| `field` | `"parent"` | Frontmatter field holding the wikilink(s). Required when `fieldOn: "parent"`. |
| `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 |
| `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]]"]`).

Metadata is composable across `$ref` graphs:
- zero or one metadata provider may define `hierarchy`
Expand Down
49 changes: 44 additions & 5 deletions docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,20 @@ flowchart TD
subgraph dir [Space Directory]
direction TB
tp[Typed Page<br>type: goal in frontmatter]
en[Embedded Nodes<br>headings with type annotations<br>or anchor-implied types]
en[Embedded Nodes<br>headings, bullets, or table rows<br>explicitly typed, relationship-implied,<br>or anchor-implied]
other[Other files<br>no frontmatter → skipped<br>no type field → nonSpace]
tp -->|body parsed for| en
end

subgraph soap [Space on a Page]
direction TB
sf[Single .md file<br>type: space_on_a_page]
hn[Heading nodes<br>depth → type via hierarchy]
bn[Bullet nodes<br>explicit inline type annotation]
hn[Heading nodes<br>depth → type via hierarchy<br>or relationship-implied]
bn[Bullet nodes<br>explicitly typed or relationship-implied]
tn[Table rows<br>typed via relationship heading<br>or first-column name]
sf -->|headings| hn
sf -->|typed bullets| bn
sf -->|typed tables| tn
end

pe[parse-embedded<br>extractEmbeddedNodes]
Expand All @@ -44,8 +46,10 @@ A **space directory** is a directory of markdown files that backs a `space`. Eac
Each `.md` file with a `type` frontmatter field is a **typed page** — it represents one node. Its body is also scanned for **embedded nodes**:

- **Heading with `[type:: x]`** or **anchor-implied type** (e.g. `## My Goal ^goal1`) → becomes a child node.
- **Relationship headings** (e.g. `### Assumptions`) → when matched to a relationship definition in the schema, signals that following content (list items or table rows) should be typed as that relationship's child type, without requiring explicit inline annotations.
- **Untyped headings** → update the depth stack for parent resolution but do not become nodes.
- **Typed bullet items** (`- [type:: solution] Title`) → become child nodes at any nesting depth.
- **Table rows** under a relationship heading → become child nodes of the relationship type.
- **YAML blocks** and **unbracketed `key:: value` paragraph fields** → merged into the current node's `schemaData`.

Parsing behaviour for a space directory:
Expand Down Expand Up @@ -152,10 +156,32 @@ A **hierarchy edge** is a directional link connecting a child node to one or mor

| Option | Default | Meaning |
|---|---|---|
| `field` | `"parent"` | The frontmatter field that holds the wikilink(s) |
| `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 |
| `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) |

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

```
"levels": [
"Activities",
{
"type": "Capabilities",
"field": "capabilities",
"fieldOn": "parent",
"multiple": true,
"selfRefField": "parent"
}
]
```

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)

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.

Expand All @@ -174,6 +200,19 @@ Two forms are supported:
| 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).

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.

Relationships support two link directions via `field` and `fieldOn`:

- **`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.

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.

### Anchor

An **anchor** is a block anchor (e.g. `^goal1`) appended to a heading in a `typed page`, using Obsidian block anchor syntax. Anchors serve two purposes:
Expand Down
52 changes: 52 additions & 0 deletions docs/schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,61 @@ 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 |
| `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.

| Field | Type | Default | Description |
|---|---|---|---|
| `parent` | `string` | **Required** | The parent's canonical type name |
| `type` | `string` | **Required** | The child's canonical type name |
| `field` | `string` | `"parent"` | The frontmatter field that holds the wikilink(s) for this relationship |
| `fieldOn` | `string` | `"child"` | `"child"` (child holds a link to parent) or `"parent"` (parent holds an array of child links) |
| `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 |

**`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.

**`fieldOn: "parent"` — parent-side array** — the parent node has an array field (`field`) holding wikilinks to child nodes. When `field` is required, it must be specified. Embedded parsing appends `[[Child]]` entries to the parent node's field array; validation checks that each entry resolves to a node of the declared child type.

**Example — child-side (default):**

```json5
"relationships": [
{
"parent": "opportunity",
"type": "assumption",
"format": "table",
"matchers": ["Assumptions", "/assum.*/"],
"embeddedTemplateFields": ["assumption", "status", "confidence"]
}
]
```

**Example — parent-side array:**

```json5
"relationships": [
{
"parent": "activity",
"type": "task",
"field": "tasks",
"fieldOn": "parent",
"format": "list",
"matchers": ["Tasks"],
"multi": true
}
]
```

With this configuration, embedded task items under an Activity's "Tasks" heading populate `activity.tasks` as `[[Task Title]]` wikilinks, and validation confirms each entry resolves to a `task` node.

`HierarchyLevel` options:

| Option | Default | Meaning |
Expand Down
9 changes: 9 additions & 0 deletions schemas/_ost_tools_base.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@
}
},
"required": ["status"]
},
"parentNodeProps": {
"type": "object",
"properties": {
"parent": {
"$ref": "#/$defs/wikilink",
"description": "Parent node"
}
}
}
}
}
84 changes: 70 additions & 14 deletions schemas/general.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,24 @@
"outcome": "goal",
"assumption_test": "experiment",
"test": "experiment"
}
},
"relationships": [
{
"parent": "opportunity",
"type": "problem_statement",
"format": "heading",
"matchers": ["Problem statement", "What problem are we solving?"],
"multi": false
},
{
"parent": "opportunity",
"type": "assumption",
"format": "table",
"matchers": ["Assumptions", "^Assumptions?$"],
"embeddedTemplateFields": ["assumption", "status", "confidence"],
"multi": true
}
]
},
"oneOf": [
{
Expand Down Expand Up @@ -62,11 +79,11 @@
"type": "object",
"allOf": [
{ "$ref": "ost-tools://_ost_tools_base#/$defs/baseNodeProps" },
{ "$ref": "ost-tools://_ost_tools_base#/$defs/parentNodeProps" },
{ "$ref": "ost-tools://_ost_tools_base#/$defs/ostEntityProps" }
],
"properties": {
"type": { "const": "mission" },
"parent": { "$ref": "ost-tools://_ost_tools_base#/$defs/wikilink" }
"type": { "const": "mission" }
},
"required": ["type"],
"additionalProperties": true,
Expand All @@ -82,11 +99,11 @@
"type": "object",
"allOf": [
{ "$ref": "ost-tools://_ost_tools_base#/$defs/baseNodeProps" },
{ "$ref": "ost-tools://_ost_tools_base#/$defs/parentNodeProps" },
{ "$ref": "ost-tools://_ost_tools_base#/$defs/ostEntityProps" }
],
"properties": {
"type": { "enum": ["goal", "outcome"] },
"parent": { "$ref": "ost-tools://_ost_tools_base#/$defs/wikilink" },
"priority": { "$ref": "ost-tools://_ost_tools_base#/$defs/priority" }
},
"required": ["type"],
Expand All @@ -104,11 +121,11 @@
"type": "object",
"allOf": [
{ "$ref": "ost-tools://_ost_tools_base#/$defs/baseNodeProps" },
{ "$ref": "ost-tools://_ost_tools_base#/$defs/parentNodeProps" },
{ "$ref": "ost-tools://_ost_tools_base#/$defs/ostEntityProps" }
],
"properties": {
"type": { "const": "opportunity" },
"parent": { "$ref": "ost-tools://_ost_tools_base#/$defs/wikilink" },
"priority": { "$ref": "ost-tools://_ost_tools_base#/$defs/priority" },
"impact": { "$ref": "ost-tools://_ost_tools_base#/$defs/assessment" },
"feasibility": { "$ref": "ost-tools://_ost_tools_base#/$defs/assessment" },
Expand Down Expand Up @@ -137,14 +154,11 @@
"type": "object",
"allOf": [
{ "$ref": "ost-tools://_ost_tools_base#/$defs/baseNodeProps" },
{ "$ref": "ost-tools://_ost_tools_base#/$defs/parentNodeProps" },
{ "$ref": "ost-tools://_ost_tools_base#/$defs/ostEntityProps" }
],
"properties": {
"type": { "const": "solution" },
"parent": {
"$ref": "ost-tools://_ost_tools_base#/$defs/wikilink",
"description": "Parent opportunity or solution (wikilink)"
},
"priority": { "$ref": "ost-tools://_ost_tools_base#/$defs/priority" },
"impact": { "$ref": "ost-tools://_ost_tools_base#/$defs/assessment" },
"feasibility": { "$ref": "ost-tools://_ost_tools_base#/$defs/assessment" },
Expand Down Expand Up @@ -173,14 +187,11 @@
"type": "object",
"allOf": [
{ "$ref": "ost-tools://_ost_tools_base#/$defs/baseNodeProps" },
{ "$ref": "ost-tools://_ost_tools_base#/$defs/parentNodeProps" },
{ "$ref": "ost-tools://_ost_tools_base#/$defs/ostEntityProps" }
],
"properties": {
"type": { "enum": ["experiment", "assumption_test", "test"] },
"parent": {
"$ref": "ost-tools://_ost_tools_base#/$defs/wikilink",
"description": "Parent solution (wikilink)"
}
"type": { "enum": ["experiment", "assumption_test", "test"] }
},
"required": ["type"],
"additionalProperties": true,
Expand All @@ -196,6 +207,51 @@
"parent": "[[Mobile App Solution]]"
}
]
},
{
"type": "object",
"allOf": [
{ "$ref": "ost-tools://_ost_tools_base#/$defs/baseNodeProps" },
{ "$ref": "ost-tools://_ost_tools_base#/$defs/parentNodeProps" },
{ "$ref": "ost-tools://_ost_tools_base#/$defs/ostEntityProps" }
],
"properties": {
"type": { "const": "assumption" },
"confidence": {
"type": "string",
"enum": ["low", "medium", "high"],
"description": "Confidence level in this assumption"
}
},
"required": ["type"],
"additionalProperties": true,
"examples": [
{
"type": "assumption",
"status": "identified",
"parent": "[[Some Opportunity]]",
"confidence": "medium"
}
]
},
{
"type": "object",
"allOf": [
{ "$ref": "ost-tools://_ost_tools_base#/$defs/baseNodeProps" },
{ "$ref": "ost-tools://_ost_tools_base#/$defs/parentNodeProps" },
{ "$ref": "ost-tools://_ost_tools_base#/$defs/ostEntityProps" }
],
"properties": {
"type": { "const": "problem_statement" }
},
"required": ["type"],
"additionalProperties": true,
"examples": [
{
"type": "problem_statement",
"status": "identified"
}
]
}
]
}
Loading
Loading