structured-context 0.11.0 → 0.13.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 (138) hide show
  1. package/README.md +52 -198
  2. package/dist/api.d.ts +17 -15
  3. package/dist/api.js +12 -9
  4. package/dist/commands/diagram.d.ts +1 -0
  5. package/dist/commands/diagram.js +7 -1
  6. package/dist/commands/docs.js +1 -0
  7. package/dist/commands/dump.d.ts +15 -2
  8. package/dist/commands/dump.js +18 -4
  9. package/dist/commands/providers.d.ts +4 -0
  10. package/dist/commands/providers.js +52 -0
  11. package/dist/commands/render.d.ts +4 -0
  12. package/dist/commands/render.js +64 -9
  13. package/dist/commands/schemas.d.ts +2 -2
  14. package/dist/commands/schemas.js +133 -22
  15. package/dist/commands/show.d.ts +1 -0
  16. package/dist/commands/show.js +7 -1
  17. package/dist/commands/spaces.d.ts +3 -1
  18. package/dist/commands/spaces.js +11 -2
  19. package/dist/commands/validate-file.d.ts +3 -0
  20. package/dist/commands/validate-file.js +24 -6
  21. package/dist/commands/validate.d.ts +4 -0
  22. package/dist/commands/validate.js +79 -23
  23. package/dist/config.d.ts +25 -1
  24. package/dist/config.js +106 -12
  25. package/dist/filter/augment-nodes.d.ts +3 -1
  26. package/dist/filter/augment-nodes.js +12 -7
  27. package/dist/filter/filter-nodes.js +1 -1
  28. package/dist/index.d.ts +1 -1
  29. package/dist/index.js +96 -31
  30. package/dist/judge/context.d.ts +20 -0
  31. package/dist/judge/context.js +105 -0
  32. package/dist/judge/factory.d.ts +44 -0
  33. package/dist/judge/factory.js +148 -0
  34. package/dist/judge/index.d.ts +13 -0
  35. package/dist/judge/index.js +8 -0
  36. package/dist/judge/judge-node.d.ts +27 -0
  37. package/dist/judge/judge-node.js +122 -0
  38. package/dist/judge/prompt.d.ts +10 -0
  39. package/dist/judge/prompt.js +92 -0
  40. package/dist/judge/provider/anthropic.d.ts +17 -0
  41. package/dist/judge/provider/anthropic.js +43 -0
  42. package/dist/judge/provider/fake.d.ts +26 -0
  43. package/dist/judge/provider/fake.js +56 -0
  44. package/dist/judge/provider/jev.d.ts +37 -0
  45. package/dist/judge/provider/jev.js +169 -0
  46. package/dist/judge/provider/openai-decisions.d.ts +21 -0
  47. package/dist/judge/provider/openai-decisions.js +84 -0
  48. package/dist/judge/provider/openai.d.ts +20 -0
  49. package/dist/judge/provider/openai.js +53 -0
  50. package/dist/judge/provider/per-rule.d.ts +10 -0
  51. package/dist/judge/provider/per-rule.js +18 -0
  52. package/dist/judge/trim.d.ts +27 -0
  53. package/dist/judge/trim.js +62 -0
  54. package/dist/judge/types.d.ts +92 -0
  55. package/dist/judge/types.js +9 -0
  56. package/dist/judge/verdicts.d.ts +24 -0
  57. package/dist/judge/verdicts.js +82 -0
  58. package/dist/load-space-graph.d.ts +7 -5
  59. package/dist/load-space-graph.js +19 -8
  60. package/dist/plugins/loader.js +3 -3
  61. package/dist/plugins/markdown/extract-content-links.js +8 -29
  62. package/dist/plugins/markdown/index.js +8 -2
  63. package/dist/plugins/markdown/parse-embedded.d.ts +8 -0
  64. package/dist/plugins/markdown/parse-embedded.js +195 -24
  65. package/dist/plugins/markdown/read-space.js +42 -14
  66. package/dist/plugins/markdown/render-directory.d.ts +11 -0
  67. package/dist/plugins/markdown/render-directory.js +130 -0
  68. package/dist/plugins/markdown/template-sync.js +3 -4
  69. package/dist/plugins/markdown/util.d.ts +26 -5
  70. package/dist/plugins/markdown/util.js +56 -15
  71. package/dist/plugins/mermaid/render-mermaid.js +2 -1
  72. package/dist/plugins/miro/sync.js +7 -6
  73. package/dist/plugins/util.d.ts +39 -1
  74. package/dist/plugins/util.js +139 -0
  75. package/dist/read/resolve-graph-edges.d.ts +1 -1
  76. package/dist/read/resolve-graph-edges.js +46 -27
  77. package/dist/read/wikilink-utils.d.ts +17 -0
  78. package/dist/read/wikilink-utils.js +40 -0
  79. package/dist/render/render.d.ts +3 -1
  80. package/dist/render/render.js +2 -2
  81. package/dist/schema/evaluate-rule.d.ts +33 -2
  82. package/dist/schema/evaluate-rule.js +58 -4
  83. package/dist/schema/gating.d.ts +69 -0
  84. package/dist/schema/gating.js +253 -0
  85. package/dist/schema/meta-schemas.d.ts +13 -0
  86. package/dist/schema/meta-schemas.js +50 -0
  87. package/dist/schema/metadata-contract.d.ts +952 -183
  88. package/dist/schema/metadata-contract.js +177 -33
  89. package/dist/schema/rule-filter.d.ts +12 -0
  90. package/dist/schema/rule-filter.js +46 -0
  91. package/dist/schema/rule-severity.d.ts +13 -0
  92. package/dist/schema/rule-severity.js +33 -0
  93. package/dist/schema/schema.d.ts +14 -8
  94. package/dist/schema/schema.js +308 -199
  95. package/dist/schema/validate-graph.d.ts +2 -1
  96. package/dist/schema/validate-graph.js +40 -23
  97. package/dist/schema/validate-rules.d.ts +16 -3
  98. package/dist/schema/validate-rules.js +91 -29
  99. package/dist/schemas/_knowledge_wiki.json +24 -24
  100. package/dist/schemas/_okf.json +172 -0
  101. package/dist/schemas/_ost_strict.json +17 -8
  102. package/dist/schemas/_sctx_base.json +3 -3
  103. package/dist/schemas/_strategy_general.json +38 -38
  104. package/dist/schemas/generated/0.12.0/_structured_context_schema_meta.json +229 -0
  105. package/dist/schemas/generated/0.13.0/_structured_context_schema_meta.json +400 -0
  106. package/dist/schemas/generated/latest/_structured_context_schema_meta.json +400 -0
  107. package/dist/schemas/knowledge_wiki.json +10 -9
  108. package/dist/schemas/okf.json +8 -0
  109. package/dist/schemas/strategy_general.json +15 -14
  110. package/dist/schemas/strict_ost.json +17 -16
  111. package/dist/space-context.d.ts +5 -2
  112. package/dist/space-context.js +9 -4
  113. package/dist/space-graph.d.ts +9 -7
  114. package/dist/space-graph.js +7 -5
  115. package/dist/testing.d.ts +33 -0
  116. package/dist/testing.js +72 -0
  117. package/dist/types.d.ts +64 -16
  118. package/dist/validate.d.ts +62 -9
  119. package/dist/validate.js +229 -114
  120. package/docs/concepts.md +65 -9
  121. package/docs/config.md +78 -1
  122. package/docs/rules.md +168 -50
  123. package/docs/schemas.md +152 -52
  124. package/package.json +17 -11
  125. package/schemas/_knowledge_wiki.json +24 -24
  126. package/schemas/_okf.json +172 -0
  127. package/schemas/_ost_strict.json +17 -8
  128. package/schemas/_sctx_base.json +3 -3
  129. package/schemas/_strategy_general.json +38 -38
  130. package/schemas/generated/0.12.0/_structured_context_schema_meta.json +229 -0
  131. package/schemas/generated/0.13.0/_structured_context_schema_meta.json +400 -0
  132. package/schemas/generated/latest/_structured_context_schema_meta.json +400 -0
  133. package/schemas/knowledge_wiki.json +10 -9
  134. package/schemas/okf.json +8 -0
  135. package/schemas/strategy_general.json +15 -14
  136. package/schemas/strict_ost.json +17 -16
  137. package/dist/schemas/generated/_structured_context_schema_meta.json +0 -191
  138. package/schemas/generated/_structured_context_schema_meta.json +0 -191
@@ -0,0 +1,130 @@
1
+ import { posix } from 'node:path';
2
+ import matter from 'gray-matter';
3
+ import { getMarkdownConfig } from '../../index.js';
4
+ import { invertFieldMap, MATTER_OPTIONS } from './util';
5
+ /**
6
+ * Sanitize a node title into a valid filesystem filename.
7
+ * Strips Obsidian/OS forbidden characters: \ / : * ? " < > | # ^ [ ]
8
+ */
9
+ export function sanitizeFilename(title) {
10
+ const sanitized = title
11
+ .replace(/[\\/:*?"<>|#^[\]]/g, '-')
12
+ .replace(/\s+/g, ' ')
13
+ .trim()
14
+ .replace(/^[-.]+/, '')
15
+ .replace(/[-.]+$/, '');
16
+ return sanitized || 'untitled';
17
+ }
18
+ const PREFERRED_FIELD_ORDER = ['type', 'title', 'status', 'summary', 'status_tweet'];
19
+ const EDGE_FIELDS = new Set(['parent', 'dependsOn', 'flowsTo', 'anchors', 'pipelineOf']);
20
+ /**
21
+ * Format frontmatter object with preferred key ordering for Obsidian notes.
22
+ */
23
+ function sortFrontmatterKeys(data) {
24
+ const keys = Object.keys(data);
25
+ const preferred = [];
26
+ const edge = [];
27
+ const middle = [];
28
+ for (const k of PREFERRED_FIELD_ORDER) {
29
+ if (keys.includes(k))
30
+ preferred.push(k);
31
+ }
32
+ for (const k of keys) {
33
+ if (EDGE_FIELDS.has(k)) {
34
+ edge.push(k);
35
+ }
36
+ else if (!PREFERRED_FIELD_ORDER.includes(k)) {
37
+ middle.push(k);
38
+ }
39
+ }
40
+ const sorted = {};
41
+ for (const k of [...preferred, ...middle, ...edge]) {
42
+ sorted[k] = data[k];
43
+ }
44
+ return sorted;
45
+ }
46
+ /**
47
+ * Render a space graph as a markdown directory with one file per node.
48
+ */
49
+ export function renderMarkdownDirectory(context, graph, options) {
50
+ const markdownConfig = getMarkdownConfig(context.space?.plugins);
51
+ const reverseFieldMap = markdownConfig.fieldMap ? invertFieldMap(markdownConfig.fieldMap) : {};
52
+ const foldersOption = options.data?.folders;
53
+ let useFolders = false;
54
+ if (foldersOption === 'type') {
55
+ useFolders = true;
56
+ }
57
+ else if (foldersOption === 'flat') {
58
+ useFolders = false;
59
+ }
60
+ else if (markdownConfig.typeInference?.folderMap) {
61
+ useFolders = true;
62
+ }
63
+ // Build type-to-folder mapping from typeInference.folderMap if available
64
+ const folderMap = markdownConfig.typeInference?.folderMap;
65
+ const typeToFolder = new Map();
66
+ if (folderMap) {
67
+ for (const [folderPath, mappedType] of Object.entries(folderMap)) {
68
+ const cleanFolder = folderPath.replace(/\\/g, '/').replace(/\/+$/, '');
69
+ typeToFolder.set(mappedType.toLowerCase(), cleanFolder);
70
+ }
71
+ }
72
+ const files = [];
73
+ const usedPaths = new Set();
74
+ for (const node of graph.nodes.values()) {
75
+ const baseName = sanitizeFilename(node.title);
76
+ let folder = '';
77
+ if (useFolders) {
78
+ const typeKey = (node.resolvedType || node.type || '').toLowerCase();
79
+ folder = typeToFolder.get(typeKey) ?? (node.resolvedType || node.type || 'other');
80
+ }
81
+ let candidateName = `${baseName}.md`;
82
+ let counter = 2;
83
+ let relPath = folder ? posix.join(folder, candidateName) : candidateName;
84
+ while (usedPaths.has(relPath)) {
85
+ candidateName = `${baseName}-${counter}.md`;
86
+ relPath = folder ? posix.join(folder, candidateName) : candidateName;
87
+ counter++;
88
+ }
89
+ usedPaths.add(relPath);
90
+ const finalBase = candidateName.slice(0, -3);
91
+ // Prepare frontmatter data from schemaData with reverse fieldMap
92
+ const rawData = node.schemaData ?? {};
93
+ const frontmatterData = {};
94
+ for (const [key, value] of Object.entries(rawData)) {
95
+ // Body content is not written to frontmatter
96
+ if (key === 'content')
97
+ continue;
98
+ // Omit title only if it matches the final filename base
99
+ if (key === 'title' && finalBase === node.title)
100
+ continue;
101
+ const fileKey = reverseFieldMap[key] ?? key;
102
+ frontmatterData[fileKey] = value;
103
+ }
104
+ // Preserve exact title if final filename differed or disambiguation occurred
105
+ if (finalBase !== node.title && !frontmatterData.title) {
106
+ const titleKey = reverseFieldMap.title ?? 'title';
107
+ frontmatterData[titleKey] = node.title;
108
+ }
109
+ // Ensure type is in frontmatter
110
+ const nodeType = node.type || node.resolvedType;
111
+ if (nodeType && !frontmatterData.type) {
112
+ const typeKey = reverseFieldMap.type ?? 'type';
113
+ frontmatterData[typeKey] = nodeType;
114
+ }
115
+ const sortedData = sortFrontmatterKeys(frontmatterData);
116
+ const body = (node.content ?? '').trim();
117
+ let fileContent;
118
+ if (Object.keys(sortedData).length > 0) {
119
+ fileContent = matter.stringify(body ? `\n${body}\n` : '', sortedData, MATTER_OPTIONS);
120
+ }
121
+ else {
122
+ fileContent = body ? `${body}\n` : '';
123
+ }
124
+ files.push({
125
+ path: relPath,
126
+ content: fileContent,
127
+ });
128
+ }
129
+ return { files };
130
+ }
@@ -2,9 +2,8 @@ import { readFileSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import fg from 'fast-glob';
4
4
  import matter from 'gray-matter';
5
- import yaml from 'js-yaml';
6
5
  import { mergeVariantProperties, resolveRef } from '../../schema/schema-refs';
7
- import { invertFieldMap } from './util';
6
+ import { dumpYaml, invertFieldMap, MATTER_OPTIONS } from './util';
8
7
  // Fields derived from the filesystem — present at validation time but not written to frontmatter
9
8
  const DERIVED_FIELDS = new Set(['title', 'content']);
10
9
  function enumPlaceholder(def) {
@@ -76,7 +75,7 @@ export function generateNewContent(nodeType, variant, schema, schemaRefRegistry,
76
75
  const toCanonicalKey = (k) => fieldMap[k] ?? k;
77
76
  const exampleWithPlaceholders = withEnumPlaceholders(example, properties, schema, schemaRefRegistry);
78
77
  const remappedExample = Object.fromEntries(Object.entries(exampleWithPlaceholders).map(([k, v]) => [toFileKey(k), v]));
79
- let frontmatterYaml = yaml.dump(remappedExample, { lineWidth: -1 }).trim();
78
+ let frontmatterYaml = dumpYaml(remappedExample, { lineWidth: -1 }).trim();
80
79
  // Append property descriptions as comments
81
80
  // YAML lines use file field names; look up properties using canonical key.
82
81
  const lines = frontmatterYaml.split('\n');
@@ -206,7 +205,7 @@ export async function templateSync(context, options) {
206
205
  continue;
207
206
  }
208
207
  const body = content.slice(fmMatch[0].length);
209
- const parsed = matter(content);
208
+ const parsed = matter(content, MATTER_OPTIONS);
210
209
  const nodeType = parsed.data.type;
211
210
  if (!nodeType) {
212
211
  continue;
@@ -1,11 +1,32 @@
1
- import type { TypeInferenceConfig } from '.';
1
+ import { type DumpOptions, type Schema } from 'js-yaml';
2
+ import type { TypeInferenceConfig } from '../../index.js';
2
3
  export declare function inferTypeFromPath(filePath: string, config: TypeInferenceConfig, knownTypes: Set<string>, typeAliases: Record<string, string> | undefined): string | undefined;
3
4
  /**
4
- * Coerce Date objects in frontmatter/YAML data to ISO date strings (YYYY-MM-DD).
5
- * gray-matter and js-yaml parse unquoted ISO dates (e.g. `date: 2026-03-31`) as
6
- * JavaScript Date objects, which are not valid JSON and fail string type validation.
5
+ * YAML schema for frontmatter and embedded YAML blocks: YAML 1.2 Core schema extended with
6
+ * YAML 1.1 merge keys (`<<`), collections (binary, omap, pairs, set), and custom Date scalar
7
+ * handling that avoids js-yaml's timestamp type. Dates and datetimes stay as the strings
8
+ * written (e.g. `2026-03-31`, `2026-06-20T22:53:05+10:00`) rather than becoming Date objects,
9
+ * which lose precision and offset and are not valid JSON.
7
10
  */
8
- export declare function coerceDates(data: Record<string, unknown>): Record<string, unknown>;
11
+ export declare const YAML_SCHEMA: Schema;
12
+ export declare function parseYaml(text: string): unknown;
13
+ export type DumpYamlOptions = DumpOptions & {
14
+ replacer?: (key: string, value: unknown) => unknown;
15
+ };
16
+ /** Dumps with YAML_SCHEMA. Date values (e.g. from non-markdown plugins) are written as ISO 8601 strings. */
17
+ export declare function dumpYaml(data: unknown, options?: DumpYamlOptions): string;
18
+ /**
19
+ * gray-matter options using YAML_SCHEMA. Passing options also bypasses gray-matter's parse cache,
20
+ * which is corrupted by a caught parse error (https://github.com/jonschlinkert/gray-matter/issues/166).
21
+ */
22
+ export declare const MATTER_OPTIONS: {
23
+ engines: {
24
+ yaml: {
25
+ parse: (text: string) => object;
26
+ stringify: (data: object) => string;
27
+ };
28
+ };
29
+ };
9
30
  /**
10
31
  * Apply field remapping to a data object.
11
32
  * Renames keys according to fieldMap (file field name → canonical field name).
@@ -1,4 +1,5 @@
1
1
  import { posix } from 'node:path';
2
+ import { binaryTag, CORE_SCHEMA, defineScalarTag, dump, load, mergeTag, omapTag, pairsTag, setTag, visit, } from 'js-yaml';
2
3
  export function inferTypeFromPath(filePath, config, knownTypes, typeAliases) {
3
4
  if (config.mode === 'off')
4
5
  return undefined;
@@ -37,26 +38,66 @@ export function inferTypeFromPath(filePath, config, knownTypes, typeAliases) {
37
38
  }
38
39
  return undefined;
39
40
  }
41
+ const dateTag = defineScalarTag('tag:yaml.org,2002:timestamp', {
42
+ identify: (data) => data instanceof Date,
43
+ represent: (data) => data.toISOString(),
44
+ resolve: (source) => source,
45
+ });
40
46
  /**
41
- * Coerce Date objects in frontmatter/YAML data to ISO date strings (YYYY-MM-DD).
42
- * gray-matter and js-yaml parse unquoted ISO dates (e.g. `date: 2026-03-31`) as
43
- * JavaScript Date objects, which are not valid JSON and fail string type validation.
47
+ * YAML schema for frontmatter and embedded YAML blocks: YAML 1.2 Core schema extended with
48
+ * YAML 1.1 merge keys (`<<`), collections (binary, omap, pairs, set), and custom Date scalar
49
+ * handling that avoids js-yaml's timestamp type. Dates and datetimes stay as the strings
50
+ * written (e.g. `2026-03-31`, `2026-06-20T22:53:05+10:00`) rather than becoming Date objects,
51
+ * which lose precision and offset and are not valid JSON.
44
52
  */
45
- export function coerceDates(data) {
46
- const result = {};
47
- for (const [key, value] of Object.entries(data)) {
48
- if (value instanceof Date) {
49
- result[key] = value.toISOString().slice(0, 10);
50
- }
51
- else if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
52
- result[key] = coerceDates(value);
53
- }
54
- else {
55
- result[key] = value;
53
+ export const YAML_SCHEMA = CORE_SCHEMA.withTags(mergeTag, binaryTag, omapTag, pairsTag, setTag, dateTag);
54
+ export function parseYaml(text) {
55
+ return load(text, { schema: YAML_SCHEMA });
56
+ }
57
+ function applyReplacer(key, value, replacer) {
58
+ const replaced = replacer(key, value);
59
+ if (Array.isArray(replaced)) {
60
+ return replaced.map((item, index) => applyReplacer(String(index), item, replacer));
61
+ }
62
+ if (replaced !== null && typeof replaced === 'object' && Object.getPrototypeOf(replaced) === Object.prototype) {
63
+ const res = {};
64
+ for (const [k, v] of Object.entries(replaced)) {
65
+ const vReplaced = applyReplacer(k, v, replacer);
66
+ if (vReplaced !== undefined) {
67
+ res[k] = vReplaced;
68
+ }
56
69
  }
70
+ return res;
57
71
  }
58
- return result;
72
+ return replaced;
73
+ }
74
+ /** Dumps with YAML_SCHEMA. Date values (e.g. from non-markdown plugins) are written as ISO 8601 strings. */
75
+ export function dumpYaml(data, options) {
76
+ const input = options?.replacer ? applyReplacer('', data, options.replacer) : data;
77
+ const { replacer: _ignored, transform, ...dumpOpts } = options ?? {};
78
+ return dump(input, {
79
+ ...dumpOpts,
80
+ schema: YAML_SCHEMA,
81
+ transform: (docs) => {
82
+ visit(docs, (node) => {
83
+ if (node.kind === 'scalar' && (node.tag === '!!timestamp' || node.tag === 'tag:yaml.org,2002:timestamp')) {
84
+ node.tagged = false;
85
+ node.tag = 'tag:yaml.org,2002:str';
86
+ }
87
+ });
88
+ transform?.(docs);
89
+ },
90
+ });
59
91
  }
92
+ /**
93
+ * gray-matter options using YAML_SCHEMA. Passing options also bypasses gray-matter's parse cache,
94
+ * which is corrupted by a caught parse error (https://github.com/jonschlinkert/gray-matter/issues/166).
95
+ */
96
+ export const MATTER_OPTIONS = {
97
+ engines: {
98
+ yaml: { parse: (text) => parseYaml(text), stringify: (data) => dumpYaml(data) },
99
+ },
100
+ };
60
101
  /**
61
102
  * Apply field remapping to a data object.
62
103
  * Renames keys according to fieldMap (file field name → canonical field name).
@@ -15,7 +15,8 @@ function safeNodeId(id) {
15
15
  return id.replace(/[^a-zA-Z0-9_-]/g, '_');
16
16
  }
17
17
  export function renderMermaid(graph) {
18
- const { hierarchyRoots, orphans, hierarchyChildren: children, hierarchyTitles: hierarchyNodeSet, levels } = graph;
18
+ const { hierarchyRoots, orphans, hierarchyChildren: children, hierarchyTitles: hierarchyNodeSet } = graph;
19
+ const levels = graph.hierarchy?.levels ?? [];
19
20
  let mmd = 'graph TD\n';
20
21
  for (let i = 0; i < levels.length; i++) {
21
22
  const level = levels[i];
@@ -16,6 +16,7 @@ export async function miroSync(context, graph, options) {
16
16
  if (!configuredFrameId && !options.newFrame)
17
17
  throw new Error('No frameId in miro plugin config. Pass --new-frame "Title" to create one.');
18
18
  // 3. Filter to hierarchy nodes only (graph already built by caller)
19
+ const levels = graph.hierarchy?.levels ?? [];
19
20
  const nodes = [...graph.nodes.values()].filter((n) => graph.hierarchyTitles.has(n.title));
20
21
  if (nodes.length === 0) {
21
22
  return 'No space nodes found.';
@@ -28,7 +29,7 @@ export async function miroSync(context, graph, options) {
28
29
  let layoutOffset = null;
29
30
  if (options.newFrame) {
30
31
  // Calculate layout bounds to size the frame appropriately
31
- const { bounds } = layoutNewCards(nodes, new Map(), graph.levels);
32
+ const { bounds } = layoutNewCards(nodes, new Map(), levels);
32
33
  const frameWidth = Math.max(1600, bounds.maxX - bounds.minX);
33
34
  const frameHeight = Math.max(1200, bounds.maxY - bounds.minY);
34
35
  // Miro positions child items relative to the parent frame's top-left corner.
@@ -155,7 +156,7 @@ export async function miroSync(context, graph, options) {
155
156
  }
156
157
  }
157
158
  // Compute positions for new cards
158
- const { positions: newPositions } = layoutNewCards(newNodes, existingPositions, graph.levels);
159
+ const { positions: newPositions } = layoutNewCards(newNodes, existingPositions, levels);
159
160
  // 7. Create new cards
160
161
  let createdCount = 0;
161
162
  for (const node of newNodes) {
@@ -185,7 +186,7 @@ export async function miroSync(context, graph, options) {
185
186
  title: buildCardTitle(node),
186
187
  description: buildCardDescription(node),
187
188
  },
188
- style: { cardTheme: getCardColor(type, graph.levels) },
189
+ style: { cardTheme: getCardColor(type, levels) },
189
190
  position: { x: pos.x, y: pos.y, origin: 'center' },
190
191
  parent: { id: frameId },
191
192
  geometry: { width: CARD_WIDTH },
@@ -231,7 +232,7 @@ export async function miroSync(context, graph, options) {
231
232
  title: buildCardTitle(node),
232
233
  description: buildCardDescription(node),
233
234
  },
234
- style: { cardTheme: getCardColor(type, graph.levels) },
235
+ style: { cardTheme: getCardColor(type, levels) },
235
236
  position: { x: 0, y: 0, origin: 'center' },
236
237
  parent: { id: frameId },
237
238
  geometry: { width: CARD_WIDTH },
@@ -256,8 +257,8 @@ export async function miroSync(context, graph, options) {
256
257
  const desiredEdges = new Map();
257
258
  for (const node of nodes) {
258
259
  const childTitle = node.title;
259
- for (const { title: parentTitle, source } of node.resolvedParents) {
260
- if (source !== 'hierarchy')
260
+ for (const { title: parentTitle, source, hierarchy } of node.resolvedParents) {
261
+ if (source !== 'hierarchy' || hierarchy !== graph.hierarchy?.name)
261
262
  continue;
262
263
  // Both endpoints must have verified cards on the board
263
264
  if (verifiedCardIds.has(parentTitle) && verifiedCardIds.has(childTitle)) {
@@ -1,4 +1,6 @@
1
1
  import type { AnySchemaObject } from 'ajv';
2
+ import type { Config, SpaceConfig } from '../config';
3
+ import type { PluginSchemaSource } from '../schema/schema';
2
4
  import type { SpaceGraph } from '../space-graph';
3
5
  import type { BaseNode, ParseIssue, SpaceContext } from '../types';
4
6
  export declare const PLUGIN_PREFIX = "sctx-";
@@ -43,6 +45,21 @@ export type RenderOptions = {
43
45
  /** Plugin-specific extra data (e.g. CLI flags forwarded by a convenience command). */
44
46
  data?: Record<string, unknown>;
45
47
  };
48
+ /** A single file generated as part of a multi-file render output. */
49
+ export type RenderFileOutput = {
50
+ /** Relative path of the file within the destination directory (e.g. 'goals/My Goal.md'). */
51
+ path: string;
52
+ /** File content to write. */
53
+ content: string;
54
+ };
55
+ /** Multi-file render output generated by a render hook. */
56
+ export type RenderMultiFileOutput = {
57
+ files: RenderFileOutput[];
58
+ };
59
+ /** Render hook output: either a single string (stdout/single file) or multiple files. */
60
+ export type RenderResult = string | RenderMultiFileOutput;
61
+ /** Type guard for multi-file render result. */
62
+ export declare function isRenderMultiFileOutput(result: RenderResult): result is RenderMultiFileOutput;
46
63
  /**
47
64
  * The render hook on a plugin: declares available formats and handles rendering.
48
65
  * Unlike parse/templateSync (first-responder), render hooks are additive —
@@ -50,7 +67,7 @@ export type RenderOptions = {
50
67
  */
51
68
  export type RenderHook = {
52
69
  formats: RenderFormat[];
53
- render: (context: PluginContext, graph: SpaceGraph, options: RenderOptions) => Promise<string> | string;
70
+ render: (context: PluginContext, graph: SpaceGraph, options: RenderOptions) => Promise<RenderResult> | RenderResult;
54
71
  };
55
72
  /**
56
73
  * Plugin contract:
@@ -63,7 +80,28 @@ export type StructuredContextPlugin = {
63
80
  /** JSON Schema used to validate the plugin's config block. Fields with `format: 'path'`
64
81
  * are resolved relative to the config directory by `resolveConfigPaths` in the loader. */
65
82
  configSchema: AnySchemaObject;
83
+ /** Optional directory containing JSON schemas contributed by this plugin. */
84
+ schemasDir?: string;
66
85
  parse?: ParseHook;
67
86
  templateSync?: TemplateSyncHook;
68
87
  render?: RenderHook;
69
88
  };
89
+ /**
90
+ * Synchronously locate the root directory of an external plugin.
91
+ * Resolution order:
92
+ * 1. Config-adjacent: {configDir}/plugins/{name} or {configDir}/plugins/sctx-{name}
93
+ * 2. Node module: resolve from configDir using standard module resolution
94
+ */
95
+ export declare function resolvePluginDir(pluginName: string, configDir: string): string | undefined;
96
+ /**
97
+ * Synchronously locate the schemas directory for a plugin.
98
+ * Convention:
99
+ * 1. package.json "sctx": { "schemas": "..." }
100
+ * 2. Convention: <pluginDir>/schemas
101
+ * 3. Fallback for test fixtures: {configDir}/plugins/schemas
102
+ */
103
+ export declare function resolvePluginSchemasDir(pluginName: string, configDir: string): string | undefined;
104
+ /**
105
+ * Synchronously collect all plugin schema sources available to a space.
106
+ */
107
+ export declare function resolveSpacePluginSchemas(space: SpaceConfig | undefined, config: Config, configDir: string): PluginSchemaSource[];
@@ -1,3 +1,6 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
1
4
  import { PLUGIN_PREFIX as _PLUGIN_PREFIX } from '../constants';
2
5
  export const PLUGIN_PREFIX = _PLUGIN_PREFIX;
3
6
  export const CONFIG_PLUGINS_DIR = 'plugins';
@@ -9,3 +12,139 @@ export function normalizePluginName(name) {
9
12
  export function shortenPluginName(name) {
10
13
  return name.startsWith(PLUGIN_PREFIX) ? name.slice(PLUGIN_PREFIX.length) : name;
11
14
  }
15
+ /** Type guard for multi-file render result. */
16
+ export function isRenderMultiFileOutput(result) {
17
+ return typeof result === 'object' && result !== null && Array.isArray(result.files);
18
+ }
19
+ /**
20
+ * Synchronously locate the root directory of an external plugin.
21
+ * Resolution order:
22
+ * 1. Config-adjacent: {configDir}/plugins/{name} or {configDir}/plugins/sctx-{name}
23
+ * 2. Node module: resolve from configDir using standard module resolution
24
+ */
25
+ export function resolvePluginDir(pluginName, configDir) {
26
+ const normalized = normalizePluginName(pluginName);
27
+ const short = shortenPluginName(pluginName);
28
+ for (const candidate of [normalized, short]) {
29
+ const localDir = resolve(configDir, CONFIG_PLUGINS_DIR, candidate);
30
+ if (existsSync(localDir)) {
31
+ try {
32
+ if (statSync(localDir).isDirectory()) {
33
+ return localDir;
34
+ }
35
+ }
36
+ catch { }
37
+ }
38
+ }
39
+ // Node module resolution from configDir
40
+ for (const candidate of [normalized, short]) {
41
+ try {
42
+ const req = createRequire(join(configDir, 'package.json'));
43
+ const pkgPath = req.resolve(`${candidate}/package.json`);
44
+ return dirname(pkgPath);
45
+ }
46
+ catch {
47
+ const nmCandidate = resolve(configDir, 'node_modules', candidate);
48
+ if (existsSync(nmCandidate)) {
49
+ try {
50
+ if (statSync(nmCandidate).isDirectory()) {
51
+ return nmCandidate;
52
+ }
53
+ }
54
+ catch { }
55
+ }
56
+ }
57
+ }
58
+ return undefined;
59
+ }
60
+ /**
61
+ * Synchronously locate the schemas directory for a plugin.
62
+ * Convention:
63
+ * 1. package.json "sctx": { "schemas": "..." }
64
+ * 2. Convention: <pluginDir>/schemas
65
+ * 3. Fallback for test fixtures: {configDir}/plugins/schemas
66
+ */
67
+ export function resolvePluginSchemasDir(pluginName, configDir) {
68
+ const dir = resolvePluginDir(pluginName, configDir);
69
+ if (dir) {
70
+ // 1. package.json sctx.schemas
71
+ const pkgJsonPath = join(dir, 'package.json');
72
+ if (existsSync(pkgJsonPath)) {
73
+ try {
74
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
75
+ if (pkg.sctx?.schemas && typeof pkg.sctx.schemas === 'string') {
76
+ const customSchemas = resolve(dir, pkg.sctx.schemas);
77
+ if (existsSync(customSchemas) && statSync(customSchemas).isDirectory()) {
78
+ return customSchemas;
79
+ }
80
+ }
81
+ }
82
+ catch { }
83
+ }
84
+ // 2. Convention: <pluginDir>/schemas
85
+ const conventionDir = join(dir, 'schemas');
86
+ if (existsSync(conventionDir)) {
87
+ try {
88
+ if (statSync(conventionDir).isDirectory()) {
89
+ return conventionDir;
90
+ }
91
+ }
92
+ catch { }
93
+ }
94
+ }
95
+ // 3. Check config-adjacent plugins/schemas as fallback (e.g. test fixtures)
96
+ const fallbackSchemas = resolve(configDir, CONFIG_PLUGINS_DIR, 'schemas');
97
+ if (existsSync(fallbackSchemas)) {
98
+ try {
99
+ if (statSync(fallbackSchemas).isDirectory()) {
100
+ return fallbackSchemas;
101
+ }
102
+ }
103
+ catch { }
104
+ }
105
+ return undefined;
106
+ }
107
+ /**
108
+ * Synchronously collect all plugin schema sources available to a space.
109
+ */
110
+ export function resolveSpacePluginSchemas(space, config, configDir) {
111
+ const pluginSources = [];
112
+ const seenPlugins = new Set();
113
+ const maybeAddPlugin = (name) => {
114
+ const shortName = shortenPluginName(name);
115
+ if (seenPlugins.has(shortName))
116
+ return;
117
+ seenPlugins.add(shortName);
118
+ const schemasDir = resolvePluginSchemasDir(name, configDir);
119
+ if (schemasDir) {
120
+ pluginSources.push({
121
+ pluginName: shortName,
122
+ schemasDir,
123
+ });
124
+ }
125
+ };
126
+ // 1. Plugin referenced by space.schema prefix if any (e.g. sctx-wardley-mapping/wardley_map.json)
127
+ const rawSchema = space?.schema ?? config.schema;
128
+ if (rawSchema && !isAbsolute(rawSchema) && !rawSchema.startsWith('.')) {
129
+ const normalized = rawSchema.replace(/\\/g, '/');
130
+ const slashIdx = normalized.indexOf('/');
131
+ if (slashIdx !== -1) {
132
+ const prefix = normalized.slice(0, slashIdx);
133
+ maybeAddPlugin(prefix);
134
+ }
135
+ }
136
+ // 2. Plugins configured on space
137
+ if (space?.plugins) {
138
+ for (const name of Object.keys(space.plugins)) {
139
+ maybeAddPlugin(name);
140
+ }
141
+ }
142
+ // 3. Plugins configured globally
143
+ const globalPlugins = config.plugins;
144
+ if (globalPlugins) {
145
+ for (const name of Object.keys(globalPlugins)) {
146
+ maybeAddPlugin(name);
147
+ }
148
+ }
149
+ return pluginSources;
150
+ }
@@ -1,7 +1,7 @@
1
1
  import type { BaseNode, SchemaMetadata, SpaceNode, UnresolvedRef } from '../types';
2
2
  /**
3
3
  * Enrich parsed nodes into SpaceNodes by applying type alias resolution and resolving
4
- * parent links using the hierarchy levels and relationships from schema metadata.
4
+ * parent links using the levels of every hierarchy and the relationships from schema metadata.
5
5
  *
6
6
  * Returns the enriched nodes and any unresolved refs (broken/invalid wikilinks).
7
7
  */