structured-context 0.13.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +2 -2
  2. package/dist/commands/dump.js +1 -1
  3. package/dist/commands/validate.js +8 -8
  4. package/dist/filter/augment-nodes.d.ts +5 -4
  5. package/dist/filter/augment-nodes.js +20 -19
  6. package/dist/filter/expand-include.d.ts +2 -2
  7. package/dist/filter/expand-include.js +15 -17
  8. package/dist/filter/filter-nodes.js +2 -2
  9. package/dist/judge/context.js +12 -12
  10. package/dist/judge/judge-node.d.ts +3 -3
  11. package/dist/judge/judge-node.js +7 -7
  12. package/dist/judge/prompt.js +3 -3
  13. package/dist/judge/provider/fake.d.ts +1 -1
  14. package/dist/judge/provider/fake.js +3 -6
  15. package/dist/judge/provider/jev.js +1 -1
  16. package/dist/judge/provider/openai-decisions.js +1 -1
  17. package/dist/judge/types.d.ts +4 -1
  18. package/dist/plugins/markdown/index.d.ts +6 -0
  19. package/dist/plugins/markdown/index.js +1 -0
  20. package/dist/plugins/markdown/parse-embedded.d.ts +7 -2
  21. package/dist/plugins/markdown/parse-embedded.js +23 -21
  22. package/dist/plugins/markdown/read-space.js +9 -8
  23. package/dist/plugins/markdown/render-bullets.js +3 -3
  24. package/dist/plugins/markdown/util.d.ts +11 -0
  25. package/dist/plugins/markdown/util.js +21 -0
  26. package/dist/plugins/mermaid/render-mermaid.js +5 -6
  27. package/dist/plugins/miro/cache.js +1 -1
  28. package/dist/plugins/miro/sync.js +3 -2
  29. package/dist/read/resolve-graph-edges.js +12 -18
  30. package/dist/schema/evaluate-rule.d.ts +4 -4
  31. package/dist/schema/evaluate-rule.js +14 -10
  32. package/dist/schema/gating.d.ts +10 -10
  33. package/dist/schema/gating.js +45 -44
  34. package/dist/schema/validate-graph.js +8 -11
  35. package/dist/schema/validate-rules.d.ts +4 -4
  36. package/dist/schema/validate-rules.js +15 -17
  37. package/dist/schemas/_ost_strict.json +1 -1
  38. package/dist/space-graph.d.ts +9 -9
  39. package/dist/space-graph.js +16 -16
  40. package/dist/testing.d.ts +1 -1
  41. package/dist/testing.js +3 -3
  42. package/dist/types.d.ts +14 -10
  43. package/dist/validate.d.ts +2 -2
  44. package/dist/validate.js +18 -19
  45. package/docs/concepts.md +25 -7
  46. package/docs/config.md +8 -0
  47. package/docs/rules.md +17 -13
  48. package/package.json +1 -1
  49. package/schemas/_ost_strict.json +1 -1
package/dist/validate.js CHANGED
@@ -182,17 +182,17 @@ async function validateNodes(context, nodes, unresolvedRefs, options = {}) {
182
182
  result.nodeFailures.push({ node, errors: schemaValidator.errors || [] });
183
183
  }
184
184
  }
185
- // Detect duplicate node keys (titles)
186
- const titleToFiles = new Map();
185
+ // Detect duplicate node ids. Display titles may repeat.
186
+ const idToFiles = new Map();
187
187
  for (const node of nodes) {
188
- if (!titleToFiles.has(node.title)) {
189
- titleToFiles.set(node.title, []);
188
+ if (!idToFiles.has(node.id)) {
189
+ idToFiles.set(node.id, []);
190
190
  }
191
- titleToFiles.get(node.title).push(node.label);
191
+ idToFiles.get(node.id).push(node.id);
192
192
  }
193
- for (const [title, files] of titleToFiles) {
193
+ for (const [id, files] of idToFiles) {
194
194
  if (files.length > 1) {
195
- result.duplicateErrors.push({ title, files });
195
+ result.duplicateErrors.push({ id, files });
196
196
  }
197
197
  }
198
198
  // Validate all hierarchy constraints (field references and structure)
@@ -207,10 +207,9 @@ async function validateNodes(context, nodes, unresolvedRefs, options = {}) {
207
207
  if (metadata.rules) {
208
208
  const seeds = [
209
209
  ...result.nodeFailures.map(({ node }) => {
210
- const file = node.source?.file ?? node.label;
211
- return { file, title: String(node.schemaData.title ?? file), reason: 'schema' };
210
+ return { file: node.source?.file ?? node.id, id: node.id, reason: 'schema' };
212
211
  }),
213
- ...result.hierarchyViolations.map((v) => ({ file: v.file, title: v.nodeTitle, reason: 'hierarchy' })),
212
+ ...result.hierarchyViolations.map((v) => ({ file: v.file, id: v.file, reason: 'hierarchy' })),
214
213
  ];
215
214
  const trimReport = { nodes: new Set(), ruleIds: new Set() };
216
215
  const { violations, gates } = await evaluateSpaceRules(nodes, metadata, seeds, { ...options, trimReport });
@@ -223,10 +222,10 @@ async function validateNodes(context, nodes, unresolvedRefs, options = {}) {
223
222
  for (const node of nodes) {
224
223
  for (const link of node.resolvedLinks) {
225
224
  if (link.location === 'internal') {
226
- const key = `${node.label}::${link.target}`;
225
+ const key = `${node.id}::${link.target}`;
227
226
  if (!seen.has(key)) {
228
227
  seen.add(key);
229
- result.unresolvedContentLinks.push({ file: node.label, target: link.target });
228
+ result.unresolvedContentLinks.push({ file: node.id, target: link.target });
230
229
  }
231
230
  }
232
231
  }
@@ -249,9 +248,9 @@ export async function validateSpace(context, options = {}) {
249
248
  const depths = computeHierarchyDepths(fullGraph);
250
249
  const included = [];
251
250
  for (const node of nodes) {
252
- const nodeDepth = depths.get(node.title);
251
+ const nodeDepth = depths.get(node.id);
253
252
  if (nodeDepth !== undefined && nodeDepth > options.depth) {
254
- skippedByDepth.push({ file: node.label, title: node.title });
253
+ skippedByDepth.push({ file: node.id, title: node.title });
255
254
  }
256
255
  else {
257
256
  included.push(node);
@@ -268,7 +267,7 @@ export async function validateSpace(context, options = {}) {
268
267
  validCount: result.validCount,
269
268
  nodeErrorCount: result.nodeFailures.length,
270
269
  nodeErrors: result.nodeFailures.map(({ node, errors }) => ({
271
- file: node.source?.file ?? node.label,
270
+ file: node.source?.file ?? node.id,
272
271
  ...(node.source?.line !== undefined ? { line: node.source.line } : {}),
273
272
  errors,
274
273
  nodeData: node.schemaData,
@@ -308,7 +307,7 @@ function issuesForFile(result, label, context, includeGated) {
308
307
  const errors = {};
309
308
  const warnings = {};
310
309
  for (const { node, errors: ajvErrors, invalidType } of result.nodeFailures) {
311
- if (node.label !== label)
310
+ if (node.id !== label)
312
311
  continue;
313
312
  const position = {
314
313
  ...(node.source?.line !== undefined ? { line: node.source.line } : {}),
@@ -328,13 +327,13 @@ function issuesForFile(result, label, context, includeGated) {
328
327
  }
329
328
  }
330
329
  // Duplicate key errors — include if this file is one of the duplicates
331
- for (const { title, files } of result.duplicateErrors) {
330
+ for (const { id, files } of result.duplicateErrors) {
332
331
  if (!files.includes(label))
333
332
  continue;
334
333
  const others = files.filter((f) => f !== label);
335
- errors[`duplicate:${title}`] = {
334
+ errors[`duplicate:${id}`] = {
336
335
  kind: 'duplicate',
337
- message: `Duplicate title "${title}" also exists in: ${others.join(', ')}`,
336
+ message: `Duplicate id "${id}" also exists in: ${others.join(', ')}`,
338
337
  };
339
338
  }
340
339
  // Broken links and hierarchy violations from this file
package/docs/concepts.md CHANGED
@@ -97,12 +97,30 @@ A **space node** (or **node** for short) is a single entity in a `space` — a n
97
97
 
98
98
  Node types are defined by the schema in use and may vary across schemas. Examples from the default schema: `vision`, `mission`, `goal`, `opportunity`, `solution`. The tooling is not prescriptive about which types exist — schemas are designed to be extended and replaced.
99
99
 
100
+ Each node has an `id` (its unique key), a `title` (its display name) and `linkTargets` (the names wikilinks may use to reach it). See [Node key](#node-key).
101
+
100
102
  Each node retains its physical `source` location (`file`, and `line` / `column` where available) so that validation diagnostics, edge errors, and downstream tooling pinpoint the exact location in the vault.
101
103
 
102
104
  A node's `content` is the markdown source of its body; see [Node content](#node-content).
103
105
 
104
106
  > `space_on_a_page` and `dashboard` are not `space node` types — they are `tooling types`.
105
107
 
108
+ ### Node key
109
+
110
+ Three values on every node do separate jobs:
111
+
112
+ | Value | Job | Unique? | Markdown source |
113
+ |---|---|---|---|
114
+ | `id` | Identifies the node: graph key, parent lookup, gating, duplicate detection, and the file shown in messages | Yes. Two nodes with the same `id` are a duplicate error | Path relative to the space root, with extension (`projects/alpha/Overview.md`). Embedded nodes use `<page id>#<heading>` |
115
+ | `title` | Display name in `show`, rendered output and judge prompts | No | `title:` frontmatter, else the containing folder's name for a [folder index](#folder-index), else the file's basename |
116
+ | `linkTargets` | The names a wikilink may use to reach the node | Per name. A name shared by several nodes is reported as ambiguous where a link uses it | The explicit `title:`, the basename, and every path suffix without `.md` |
117
+
118
+ A wikilink is looked up in `linkTargets`, and the result is stored as the target node's `id`. Other values hold the rest of the picture: `source` is where the node is defined, `resolvedParents` lists parents by `id`, and `resolvedLinks` classifies content links. A parent field resolves exactly as a link does.
119
+
120
+ ### Folder index
121
+
122
+ A **folder index** is a page that stands for its folder: `Overview.md` or `index.md` inside `projects/alpha/`. Obsidian has no container pages, so a node with child pages is a folder plus an index page. Listing the index basenames in the markdown plugin's [`folderIndex`](config.md#folderindex) config gives each folder index the folder's name as its `title`. The derived title is for display only and never a link target.
123
+
106
124
  ### Embedded node
107
125
 
108
126
  An **embedded node** is a `space node` defined *within* a containing document rather than as its own file. Embedded nodes are declared using markdown heading syntax with inline field annotations (e.g. `[type:: goal]`) or `anchor-implied types`, and are extracted at parse time.
@@ -187,9 +205,9 @@ The **per-node input cap** limits the input for one evaluated node and its relat
187
205
 
188
206
  ### Cascade gating
189
207
 
190
- **Cascade gating** is the mechanism by which a schema failure, hierarchy structure violation, or blocking rule violation on a node suppresses rule evaluation for that node's `hierarchy` subtree. It exists so downstream checks — especially costly semantic (model-judged) rules — aren't run against inputs already known to be invalid.
208
+ **Cascade gating** is the mechanism by which a schema validation error, hierarchy structure violation, or gating rule violation on a node suppresses rule evaluation for that node's `hierarchy` subtree. It exists so downstream checks — especially costly semantic (model-judged) rules — aren't run against inputs already known to be invalid.
191
209
 
192
- A **coverage gate** records one instance of this: the gating node, what kind of failure triggered it (`schema`, `hierarchy`, or a specific rule `id`), and the set of nodes whose rules were suppressed as a result. `sctx validate` reports coverage gates so both humans and agents can see the validation "edge" — where evaluation stopped and why. See [docs/rules.md](rules.md#cascade-gating) for the full model, including the per-rule `gatesDescendants` field and the `--include-gated` / `--depth` CLI options.
210
+ A **coverage gate** records one instance of this: the gating node, what kind of violation triggered it (`schema`, `hierarchy`, or a specific rule `id`), and the set of nodes whose rules were suppressed as a result. `sctx validate` reports coverage gates so both humans and agents can see the validation "edge" — where evaluation stopped and why. See [docs/rules.md](rules.md#cascade-gating) for the full model, including the per-rule `gatesDescendants` field and the `--include-gated` / `--depth` CLI options.
193
211
 
194
212
  ---
195
213
 
@@ -288,7 +306,7 @@ A **wikilink** is the `[[Title]]` linking syntax (compatible with Obsidian) used
288
306
 
289
307
  | Form | Example | Resolves to |
290
308
  |---|---|---|
291
- | Plain title | `[[My Goal]]` | The `space node` whose title equals `My Goal` |
309
+ | Name or path | `[[My Goal]]`, `[[alpha/Overview]]` | The `space node` with that name in its `linkTargets`. A bare name shared by several nodes is ambiguous; add enough of the path to make it unique, as Obsidian does |
292
310
  | Anchor ref | `[[vision_page#^goal1]]` | The `embedded node` with `anchor` `goal1` inside `vision_page.md` |
293
311
 
294
312
  ### Resolved parents
@@ -299,7 +317,7 @@ Each entry is a `ResolvedParentRef` object:
299
317
 
300
318
  | Field | Type | Description |
301
319
  |---|---|---|
302
- | `title` | `string` | The parent node's title |
320
+ | `id` | `NodeId` | The parent node's `id` |
303
321
  | `field` | `string` | The frontmatter field that held the wikilink |
304
322
  | `source` | `'hierarchy' \| 'relationship'` | Whether the edge came from a hierarchy level or a relationship |
305
323
  | `hierarchy` | `string` | Name of the hierarchy the edge belongs to; present only when `source` is `'hierarchy'` |
@@ -356,12 +374,12 @@ Keywords (`WHERE`, `SELECT`) are case-insensitive.
356
374
 
357
375
  ### Predicate context
358
376
 
359
- The WHERE predicate is a [JSONata](https://docs.jsonata.org/overview) expression evaluated per node. Node fields (from `schemaData`) are accessible directly — e.g. `resolvedType`, `status`. `label` (relative file path, e.g. `"solutions/My Solution.md"`) and `title` are always available regardless of schema. Additionally, two pre-computed traversal arrays are available:
377
+ The WHERE predicate is a [JSONata](https://docs.jsonata.org/overview) expression evaluated per node. Node fields (from `schemaData`) are accessible directly — e.g. `resolvedType`, `status`. `id` (e.g. `"solutions/My Solution.md"`) and `title` are always available regardless of schema. `resolvedParentIds` lists the `id` of each parent and `resolvedParentTitles` their titles. Additionally, two pre-computed traversal arrays are available:
360
378
 
361
379
  | Field | Description |
362
380
  |-------|-------------|
363
- | `ancestors[]` | Flat array of ancestor nodes, nearest first, deduplicated by title |
364
- | `descendants[]` | Flat array of descendant nodes, nearest first, deduplicated by title |
381
+ | `ancestors[]` | Flat array of ancestor nodes, nearest first, deduplicated by `id` |
382
+ | `descendants[]` | Flat array of descendant nodes, nearest first, deduplicated by `id` |
365
383
 
366
384
  Each entry in `ancestors[]` or `descendants[]` includes all schema fields of the target node, plus edge metadata:
367
385
 
package/docs/config.md CHANGED
@@ -147,6 +147,14 @@ Maps file/frontmatter field names to canonical schema field names:
147
147
  - `templateDir` — directory containing template files (used by `template-sync` and excluded when parsing)
148
148
  - `templatePrefix` — filename prefix for templates (default blank)
149
149
 
150
+ ### `folderIndex`
151
+
152
+ Names the pages that stand for their folder (see [Folder index](concepts.md#folder-index)). A page whose basename matches, case-insensitively, takes the containing folder's name as its `title` (eg `Project A/index.md` is parsed to `Project A`). An explicit `title:` still wins. A match at the space root keeps its basename. When empty or absent, titles come from the basename.
153
+
154
+ ```json
155
+ { "plugins": { "markdown": { "folderIndex": ["Overview", "index"] } } }
156
+ ```
157
+
150
158
  ### `typeInference`
151
159
 
152
160
  Automatically assigns a node type based on folder structure when no `type` field is present in frontmatter. Explicit `type:` always takes precedence.
package/docs/rules.md CHANGED
@@ -84,7 +84,9 @@ Prefer choosing the right category and relying on its default. Set `severity` on
84
84
 
85
85
  ### Order within a rule
86
86
 
87
- A rule runs its parts in this order: `when`, then `expression`, then the `semantic` part. If the `expression` fails, the `semantic` part does not run on that node.
87
+ A rule runs its parts in this order: `when`, then `expression`, then the `semantic` part. If the `expression` does not return `true`, the `semantic` part does not run on that node.
88
+
89
+ A node with a [gating](#cascade-gating) violation from an `expression` has none of its `semantic` parts evaluated. A non-gating violation, such as `info`, does not stop them.
88
90
 
89
91
  An error in a `when` or `expression` is reported as a non-gating `error` violation.
90
92
 
@@ -105,8 +107,9 @@ Each evaluation receives:
105
107
 
106
108
  Useful resolved fields on nodes:
107
109
  - `resolvedType`
108
- - `resolvedParentTitle`
109
- - `resolvedParentTitles`
110
+ - `id`: the node's unique key. Compare nodes by `id`, since titles may repeat.
111
+ - `resolvedParentId`, `resolvedParentIds`: the `id` of the first parent, and of all parents
112
+ - `resolvedParentTitle`, `resolvedParentTitles`: the display titles of the same parents
110
113
 
111
114
  Prefer `resolvedType` over raw `type` so aliases are handled correctly.
112
115
 
@@ -115,7 +118,7 @@ Prefer `resolvedType` over raw `type` so aliases are handled correctly.
115
118
  Inside `nodes[...]`, bare names refer to each candidate node. Use `$$` to reference outer variables:
116
119
 
117
120
  ```jsonata
118
- $count(nodes[resolvedParentTitle=$$.current.title and resolvedType='solution'])
121
+ $count(nodes[resolvedParentId=$$.current.id and resolvedType='solution'])
119
122
  ```
120
123
 
121
124
  ### Common patterns
@@ -136,7 +139,7 @@ $not($exists(current.due)) or $toMillis(current.due) > $millis()
136
139
  $toMillis(current.stale_after) > $toMillis(current.generated.at)
137
140
  ```
138
141
 
139
- A datetime without an offset (`2026-07-01T00:00:00`) is read in the machine's local time zone, so require an offset with `format: "date-time"` when results must not depend on where validation runs. `$toMillis()` also needs seconds: it cannot parse a minute-precision datetime such as `2026-07-01T09:15`, which `format: "date-or-date-time"` accepts. A value `$toMillis()` cannot parse makes the rule fail.
142
+ A datetime without an offset (`2026-07-01T00:00:00`) is read in the machine's local time zone, so require an offset with `format: "date-time"` when results must not depend on where validation runs. `$toMillis()` also needs seconds: it cannot parse a minute-precision datetime such as `2026-07-01T09:15`, which `format: "date-or-date-time"` accepts. A value `$toMillis()` cannot parse makes the expression fail.
140
143
 
141
144
  The same applies to [filter expressions](concepts.md#filter-expressions): compare dates with `$toMillis()`, not as strings.
142
145
 
@@ -168,27 +171,28 @@ Example override:
168
171
  ## Cascade gating
169
172
 
170
173
  When a schema uses `$metadata.hierarchy`, rule evaluation cascades top-down through the
171
- hierarchy tree (breadth-first from roots and orphans). A **gate** occurs when a node fails
172
- in a way that makes its descendants' rules untrustworthy or wasteful to run:
174
+ hierarchy tree (breadth-first from roots and orphans). A **gate** occurs when a node has
175
+ a violation that makes its descendants' rules untrustworthy or wasteful to run:
173
176
 
174
- - **Schema validation failure** on a node — always gates. The node's own rules are
177
+ - **Schema validation error** on a node — always gates. The node's own rules are
175
178
  suppressed too, since its data is structurally invalid.
176
179
  - **Hierarchy structure violation** on a node (e.g. an invalid parent) — always gates the
177
180
  node's subtree, though the node's own rules still run.
178
- - **A rule failure with `gatesDescendants: true`** — gates the node's subtree, though the
179
- failing node's own other rules still run.
181
+ - **A gating rule violation** — a violation with `error` severity, or any violation of a rule with
182
+ `gatesDescendants: true`. It gates the node's subtree and skips the node's own semantic rules; its other
183
+ expressions still run.
180
184
 
181
185
  Gating exists so that expensive or semantic downstream rules (see [Semantic rules](semantic-rules.md)) aren't wasted
182
186
  evaluating nodes whose ancestors are already known-bad, and so noise doesn't drown out the
183
- actionable failure. It is per-subtree: once a node is gated, its descendants inherit the same
187
+ actionable violation. It is per-subtree: once a node is gated, its descendants inherit the same
184
188
  gate (the original blocker), rather than each generating a new one. Cascade gating only applies to nodes within the
185
189
  hierarchy DAG.
186
190
 
187
- A rule has one `gatesDescendants` setting. It applies to whichever part failed, the `expression` or the `semantic` part. Its default comes from the severity of that failure: `true` for `error`, and `false` for `warning` and `info`. Set it explicitly when that default doesn't fit.
191
+ A rule has one `gatesDescendants` setting. It applies to whichever part reported the violation, the `expression` or the `semantic` part. Its default comes from the severity of that violation: `true` for `error`, and `false` for `warning` and `info`. Set it explicitly when that default doesn't fit.
188
192
 
189
193
  ### Coverage output
190
194
 
191
- `sctx validate` reports gating as **coverage gates**: which node's failure (schema,
195
+ `sctx validate` reports gating as **coverage gates**: which node's violation (schema,
192
196
  hierarchy, or a specific `ruleId`) suppressed rule evaluation, and the list of files gated
193
197
  as a result. This makes the validation "edge" visible — e.g. "this blocked goal means all
194
198
  opportunities beneath it were not evaluated" — for both human reading and agent consumption
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "structured-context",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "description": "Keep your content clean and your thinking clear with assistive tools for structured knowledge",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
@@ -34,7 +34,7 @@
34
34
  "category": "best-practice",
35
35
  "description": "Explore multiple candidate solutions (aim for at least three) for the target opportunity",
36
36
  "type": "opportunity",
37
- "expression": "(current.status != 'exploring' and current.status != 'active') or $count(nodes[resolvedParentTitle=$$.current.title and resolvedType='solution']) >= 3"
37
+ "expression": "(current.status != 'exploring' and current.status != 'active') or $count(nodes[resolvedParentId=$$.current.id and resolvedType='solution']) >= 3"
38
38
  },
39
39
  {
40
40
  "id": "opportunity-customer-voice",