structured-context 0.16.0 → 0.17.1

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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +15 -15
  3. package/dist/api.d.ts +4 -2
  4. package/dist/api.js +2 -0
  5. package/dist/browser.d.ts +14 -0
  6. package/dist/browser.js +13 -0
  7. package/dist/commands/dump.d.ts +2 -0
  8. package/dist/commands/dump.js +3 -1
  9. package/dist/commands/schemas.js +3 -2
  10. package/dist/commands/spaces.js +4 -6
  11. package/dist/commands/validate.js +20 -19
  12. package/dist/config/health.d.ts +10 -6
  13. package/dist/config/health.js +11 -44
  14. package/dist/config/index.d.ts +4 -0
  15. package/dist/config/index.js +15 -6
  16. package/dist/filter/augment-nodes.d.ts +2 -2
  17. package/dist/filter/augment-nodes.js +2 -2
  18. package/dist/filter/expand-include.js +2 -2
  19. package/dist/index.js +9 -1
  20. package/dist/judge/context.js +1 -1
  21. package/dist/load-space-graph.js +1 -13
  22. package/dist/plugins/markdown/parse-embedded.js +4 -4
  23. package/dist/plugins/miro/cache.js +1 -1
  24. package/dist/plugins/miro/sync.js +2 -2
  25. package/dist/read/resolve-graph-edges.js +3 -3
  26. package/dist/schema/list-schemas.js +2 -2
  27. package/dist/schema/metadata-contract.d.ts +34 -0
  28. package/dist/schema/metadata-contract.js +2 -2
  29. package/dist/schema/validate-graph.d.ts +2 -2
  30. package/dist/schema/validate-graph.js +4 -4
  31. package/dist/schemas/generated/space-graph/1.json +216 -0
  32. package/dist/schemas/generated/space-graph/latest.json +216 -0
  33. package/dist/space-context.js +11 -1
  34. package/dist/space-graph-data.d.ts +237 -0
  35. package/dist/space-graph-data.js +129 -0
  36. package/dist/space-graph.d.ts +12 -1
  37. package/dist/space-graph.js +25 -1
  38. package/dist/testing.js +3 -3
  39. package/dist/types.d.ts +2 -2
  40. package/docs/concepts.md +8 -4
  41. package/package.json +6 -2
  42. package/schemas/generated/space-graph/1.json +216 -0
  43. package/schemas/generated/space-graph/latest.json +216 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Roger Barnes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -21,6 +21,18 @@ Command line tools:
21
21
  ```
22
22
  > sctx validate strategy
23
23
 
24
+ Orphans (hierarchy nodes - no parent):
25
+ Solution looking for a problem.md
26
+
27
+ Broken links:
28
+ Steal underpants.md: [[Gnome goals]] → Link target "Gnome goals" in field "related" not found
29
+
30
+ Rule violations:
31
+ WARNING (1):
32
+ Build Death Star.md: Opportunities should contribute to at least one Outcome [workflow: opportunity-has-outcome]
33
+ INFO (1):
34
+ High tech juice press.md: Explore multiple candidate solutions (aim for at least three) for the target opportunity [best-practice: solution-quantity]
35
+
24
36
  Space Validation Results
25
37
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
26
38
  Content and structure
@@ -35,18 +47,6 @@ Content and structure
35
47
  Orphans (hierarchy nodes - no parent) 1
36
48
  Unresolved content links 0
37
49
  Excluded during parsing 0
38
-
39
- Orphans (hierarchy nodes - no parent):
40
- Solution looking for a problem.md
41
-
42
- Broken links:
43
- Steal underpants.md: [[Gnome goals]] → Link target "Gnome goals" in field "related" not found
44
-
45
- Rule violations:
46
- WARNING (1):
47
- Build Death Star.md: Opportunities should contribute to at least one Outcome [workflow: opportunity-has-outcome]
48
- INFO (1):
49
- High tech juice press.md: Explore multiple candidate solutions (aim for at least three) for the target opportunity [best-practice: solution-quantity]
50
50
  ```
51
51
 
52
52
  Rule violations carry a severity (`error`, `warning` or `info`) that defaults from the rule's category. Only errors fail validation — see [docs/rules.md](docs/rules.md#rule-shape).
@@ -79,10 +79,10 @@ claude plugin install structured-context
79
79
 
80
80
  Ask Claude to set up a space (or run `/setup-space`) in the folder that holds your notes. The plugin's hooks need Bun.
81
81
 
82
- Skills can also be installed standalone without the plugin:
82
+ Skills can also be installed standalone without the plugin, and kept current with `npx skills update`:
83
83
 
84
84
  ```
85
- npx skills add https://github.com/mindsocket/structured-context/tree/main/plugin/skills/structured-context
85
+ npx skills add https://github.com/mindsocket/structured-context/tree/main/plugin/skills
86
86
  ```
87
87
 
88
88
  ## Concepts
@@ -237,7 +237,7 @@ Keeps Obsidian template files in sync with schema examples:
237
237
  ## Programmatic API
238
238
 
239
239
  The package exposes a library entry point at `structured-context/api` for embedding the tooling
240
- in other applications. See
240
+ in other applications, and `structured-context/browser` for loading an exported space graph in a browser. See
241
241
  [docs/api.md](docs/api.md) for the full reference and usage examples.
242
242
 
243
243
  ## Development
package/dist/api.d.ts CHANGED
@@ -18,6 +18,7 @@
18
18
  * const validation = await validateSpace(ctx, { readResult });
19
19
  */
20
20
  export type { AnySchemaObject, SchemaObject, ValidateFunction } from 'ajv';
21
+ export { type SpaceGraph, type SpaceNode, spaceGraphFrom } from './browser';
21
22
  export type { Config, SpaceConfig } from './config';
22
23
  export { loadConfig, resolveConfigPath, resolveSchema, resolveSchemaPath, setConfigPath } from './config';
23
24
  export { addSpace } from './config/write';
@@ -31,7 +32,8 @@ export { listSchemas } from './schema/list-schemas';
31
32
  export type { SharedEmbeddingFields } from './schema/metadata-contract';
32
33
  export { bundledSchemasDir, loadSchema, type PluginSchemaSource, setBundledSchemasDir } from './schema/schema';
33
34
  export { createSpaceContext, SpaceNotFoundError } from './space-context';
34
- export { buildSpaceGraph, type SpaceGraph } from './space-graph';
35
- export type { BaseNode, ContentLink, EdgeDefinition, FileNotInSpaceResult, FileValidationIssue, FileValidationResult, Hierarchy, HierarchyLevel, ParseIssue, ReadSpaceResult, Relationship, ResolvedContentLink, RuleCategory, RuleSeverity, RuleViolation, SchemaMetadata, SchemaWithMetadata, SourceLocation, SpaceContext, SpaceNode, UnresolvedRef, ValidateFileOutput, } from './types';
35
+ export { buildSpaceGraph } from './space-graph';
36
+ export { exportSpaceGraph } from './space-graph-data';
37
+ export type { BaseNode, ContentLink, EdgeDefinition, FileNotInSpaceResult, FileValidationIssue, FileValidationResult, Hierarchy, HierarchyLevel, ParseIssue, ReadSpaceResult, Relationship, ResolvedContentLink, RuleCategory, RuleSeverity, RuleViolation, SchemaMetadata, SchemaWithMetadata, SourceLocation, SpaceContext, UnresolvedRef, ValidateFileOutput, } from './types';
36
38
  export type { ValidationResult } from './validate';
37
39
  export { validateFile, validateSpace } from './validate';
package/dist/api.js CHANGED
@@ -18,6 +18,7 @@
18
18
  * const graph = await assembleSpaceGraph(ctx, { readResult });
19
19
  * const validation = await validateSpace(ctx, { readResult });
20
20
  */
21
+ export { spaceGraphFrom } from './browser';
21
22
  export { loadConfig, resolveConfigPath, resolveSchema, resolveSchemaPath, setConfigPath } from './config';
22
23
  export { addSpace } from './config/write';
23
24
  export { LOCAL_CONFIG_FILE, PLUGIN_PREFIX } from './constants';
@@ -29,4 +30,5 @@ export { listSchemas } from './schema/list-schemas';
29
30
  export { bundledSchemasDir, loadSchema, setBundledSchemasDir } from './schema/schema';
30
31
  export { createSpaceContext, SpaceNotFoundError } from './space-context';
31
32
  export { buildSpaceGraph } from './space-graph';
33
+ export { exportSpaceGraph } from './space-graph-data';
32
34
  export { validateFile, validateSpace } from './validate';
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Browser-safe API for structured-context: nothing here imports Node built-ins.
3
+ *
4
+ * Rebuild a graph that a server exported with exportSpaceGraph:
5
+ *
6
+ * import { spaceGraphFrom } from 'structured-context/browser';
7
+ * const graph = spaceGraphFrom(await (await fetch('/graph.json')).json());
8
+ *
9
+ * Everything here is also exported from `structured-context/api`. Derive other types from these, e.g.
10
+ * `Parameters<typeof spaceGraphFrom>[0]` for the exported data or `SpaceNode['id']` for a node id.
11
+ */
12
+ export type { SpaceGraph } from './space-graph';
13
+ export { spaceGraphFrom } from './space-graph-data';
14
+ export type { SpaceNode } from './types';
@@ -0,0 +1,13 @@
1
+ // Public entry point: do not add exports without the maintainer's agreement (AGENTS.md, "Public API surface").
2
+ /**
3
+ * Browser-safe API for structured-context: nothing here imports Node built-ins.
4
+ *
5
+ * Rebuild a graph that a server exported with exportSpaceGraph:
6
+ *
7
+ * import { spaceGraphFrom } from 'structured-context/browser';
8
+ * const graph = spaceGraphFrom(await (await fetch('/graph.json')).json());
9
+ *
10
+ * Everything here is also exported from `structured-context/api`. Derive other types from these, e.g.
11
+ * `Parameters<typeof spaceGraphFrom>[0]` for the exported data or `SpaceNode['id']` for a node id.
12
+ */
13
+ export { spaceGraphFrom } from './space-graph-data';
@@ -4,6 +4,8 @@ export interface DumpOptions {
4
4
  filter?: string;
5
5
  /** Hierarchy used when evaluating the filter (default: the main hierarchy). */
6
6
  hierarchy?: string;
7
+ /** Emit SpaceGraphData (nodes plus every hierarchy) instead of nodes, source and diagnostics. */
8
+ graph?: boolean;
7
9
  }
8
10
  /**
9
11
  * Build the dump payload: every parsed node, or only nodes matched by a filter.
@@ -1,5 +1,6 @@
1
1
  import { assembleSpaceGraph } from '../load-space-graph';
2
2
  import { readSpace } from '../read/read-space';
3
+ import { exportSpaceGraph } from '../space-graph-data';
3
4
  /**
4
5
  * Build the dump payload: every parsed node, or only nodes matched by a filter.
5
6
  *
@@ -16,5 +17,6 @@ export async function buildDump(context, options = {}) {
16
17
  }
17
18
  export async function dump(context, options = {}) {
18
19
  const { nodes, source, diagnostics } = await buildDump(context, options);
19
- console.log(JSON.stringify({ nodes, source, diagnostics }, null, 2));
20
+ const output = options.graph ? exportSpaceGraph(nodes, context.schema.metadata) : { nodes, source, diagnostics };
21
+ console.log(JSON.stringify(output, null, 2));
20
22
  }
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
2
  import { join, resolve } from 'node:path';
3
- import { loadConfig } from '../config';
3
+ import { loadConfig, loadConfigOrEmpty } from '../config';
4
4
  import { discoverPlugins } from '../plugins/loader';
5
5
  import { normalizePluginName, resolvePluginSchemasDir, resolveSpacePluginSchemas, shortenPluginName, } from '../plugins/util';
6
6
  import { listSchemas } from '../schema/list-schemas';
@@ -231,7 +231,8 @@ export async function printSchemas(options = {}) {
231
231
  }
232
232
  }
233
233
  export async function showSchema(file, options) {
234
- const config = loadConfig();
234
+ // A schema named by file needs no config; a space does, so a missing one is worth a warning.
235
+ const config = options.space ? loadConfig() : loadConfigOrEmpty();
235
236
  let schemaPath;
236
237
  let schema;
237
238
  let schemaRefRegistry;
@@ -1,6 +1,5 @@
1
1
  import { basename, resolve } from 'node:path';
2
- import { loadConfig } from '../config';
3
- import { checkConfig, hasBlockingProblem } from '../config/health';
2
+ import { hasBlockingProblem, inspectConfig } from '../config/health';
4
3
  import { addSpace } from '../config/write';
5
4
  import { SctxError } from '../errors';
6
5
  function renderConfigValue(v) {
@@ -9,7 +8,7 @@ function renderConfigValue(v) {
9
8
  return String(v);
10
9
  }
11
10
  export function listSpaces(options = {}) {
12
- const health = checkConfig();
11
+ const { health, config } = inspectConfig();
13
12
  if (options.json) {
14
13
  console.log(JSON.stringify(health));
15
14
  if (hasBlockingProblem(health))
@@ -27,10 +26,9 @@ export function listSpaces(options = {}) {
27
26
  console.log('');
28
27
  if (hasBlockingProblem(health))
29
28
  process.exitCode = 1;
30
- if (!health.config.exists)
31
- return;
32
29
  }
33
- const config = loadConfig();
30
+ if (!config)
31
+ return;
34
32
  if (health.problems.length === 0)
35
33
  console.log(`Config: ${path} (${health.config.source})\n`);
36
34
  for (const space of config.spaces) {
@@ -91,26 +91,7 @@ export async function validate(context, options = {}) {
91
91
  const countStr = String(count).padStart(3);
92
92
  return `${label.padEnd(40)} ${color}${countStr}${reset}`;
93
93
  };
94
- console.log(`\nSpace Validation Results`);
95
- console.log(`━`.repeat(45));
96
- console.log('Content and structure');
97
- console.log(fmt(' Valid', result.validCount));
98
- console.log(fmt(' Schema validation errors', result.nodeErrorCount, true));
99
- console.log(fmt(' Broken links', result.refErrors.length, true));
100
- console.log(fmt(' Duplicate ids', result.duplicateErrors.length, true));
101
- console.log(fmt(' Rule violations (error)', ruleCounts.error, true));
102
- console.log(fmt(' Rule violations (warning)', ruleCounts.warning, true, true));
103
- console.log(fmt(' Rule violations (info)', ruleCounts.info, true, true));
104
- console.log(fmt(' Hierarchy violations', result.hierarchyViolations.length, true));
105
- console.log(fmt(' Orphans (hierarchy nodes - no parent)', result.orphans.length, true, true));
106
- console.log(fmt(' Unresolved content links', result.unresolvedContentLinks.length, true, true));
107
94
  const parseIssueErrorCount = result.parseIssues.filter((i) => i.severity === 'error').length;
108
- console.log(fmt(' Excluded during parsing', result.parseIssues.length, true, parseIssueErrorCount === 0));
109
- if (result.depthLimit !== undefined) {
110
- console.log(fmt(' Skipped by depth limit', result.skippedByDepth.length, true, true));
111
- }
112
- const gatedFileCount = result.coverageGates.reduce((sum, g) => sum + g.gatedFiles.length, 0);
113
- console.log(fmt(' Rule checks gated (cascade)', gatedFileCount, true, true));
114
95
  if (result.orphans.length > 0) {
115
96
  console.log(`\nOrphans (hierarchy nodes - no parent):`);
116
97
  for (const node of result.orphans)
@@ -207,6 +188,26 @@ export async function validate(context, options = {}) {
207
188
  if (result.contextTrimmed.nodeCount > 0) {
208
189
  console.log(`\nContext trimmed for ${result.contextTrimmed.nodeCount} node(s) across rules ${result.contextTrimmed.ruleIds.join(', ')}.`);
209
190
  }
191
+ // Last, so a truncated tail (`| tail`) still shows the counts.
192
+ console.log(`\nSpace Validation Results`);
193
+ console.log(`━`.repeat(45));
194
+ console.log('Content and structure');
195
+ console.log(fmt(' Valid', result.validCount));
196
+ console.log(fmt(' Schema validation errors', result.nodeErrorCount, true));
197
+ console.log(fmt(' Broken links', result.refErrors.length, true));
198
+ console.log(fmt(' Duplicate ids', result.duplicateErrors.length, true));
199
+ console.log(fmt(' Rule violations (error)', ruleCounts.error, true));
200
+ console.log(fmt(' Rule violations (warning)', ruleCounts.warning, true, true));
201
+ console.log(fmt(' Rule violations (info)', ruleCounts.info, true, true));
202
+ console.log(fmt(' Hierarchy violations', result.hierarchyViolations.length, true));
203
+ console.log(fmt(' Orphans (hierarchy nodes - no parent)', result.orphans.length, true, true));
204
+ console.log(fmt(' Unresolved content links', result.unresolvedContentLinks.length, true, true));
205
+ console.log(fmt(' Excluded during parsing', result.parseIssues.length, true, parseIssueErrorCount === 0));
206
+ if (result.depthLimit !== undefined) {
207
+ console.log(fmt(' Skipped by depth limit', result.skippedByDepth.length, true, true));
208
+ }
209
+ const gatedFileCount = result.coverageGates.reduce((sum, g) => sum + g.gatedFiles.length, 0);
210
+ console.log(fmt(' Rule checks gated (cascade)', gatedFileCount, true, true));
210
211
  console.log(`\n`);
211
212
  if (result.nodeErrorCount > 0 ||
212
213
  result.refErrors.length > 0 ||
@@ -1,5 +1,5 @@
1
1
  import { type SctxErrorCode } from '../errors';
2
- import { type ResolvedConfigPath } from '.';
2
+ import { type Config, type ResolvedConfigPath } from '.';
3
3
  export type ConfigProblem = {
4
4
  code: SctxErrorCode;
5
5
  severity: 'error' | 'warning' | 'info';
@@ -21,11 +21,15 @@ export type ConfigHealth = {
21
21
  };
22
22
  export declare function fixFor(code: SctxErrorCode): string;
23
23
  /**
24
- * Check that the config for the working directory can be found and loaded, and that each space's
25
- * path and schema exist. Never throws: every failure becomes a problem with a stable code.
24
+ * Find and load the config for the working directory, reporting failures as problems with stable
25
+ * codes instead of throwing. It reports on the config only: what a space's `path` means belongs to
26
+ * the plugin that reads it, and a schema is checked when it is loaded.
26
27
  */
27
- export declare function checkConfig(options?: {
28
+ export declare function inspectConfig(options?: {
28
29
  startDir?: string;
29
- }): ConfigHealth;
30
- /** True when the health check found something that stops validation from running. */
30
+ }): {
31
+ health: ConfigHealth;
32
+ config?: Config;
33
+ };
34
+ /** True when the config problems stop validation from running. */
31
35
  export declare function hasBlockingProblem(health: ConfigHealth): boolean;
@@ -1,8 +1,7 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
- import JSON5 from 'json5';
1
+ import { existsSync } from 'node:fs';
3
2
  import { LOCAL_CONFIG_FILE } from '../constants';
4
3
  import { SctxError } from '../errors';
5
- import { getConfigSourceFiles, loadConfig, resolveConfigPath, resolveSchema } from '.';
4
+ import { getConfigSourceFiles, loadConfig, resolveConfigPath } from '.';
6
5
  const FIXES = {
7
6
  E_CONFIG_NOT_FOUND: `Create a config with \`sctx spaces add <name> <path> --schema <schema> --config <folder>/${LOCAL_CONFIG_FILE}\`, or use the setup-space skill.`,
8
7
  E_CONFIG_INVALID: 'Fix the JSON in the config file named in the message; `sctx docs config` describes the shape.',
@@ -11,7 +10,7 @@ const FIXES = {
11
10
  E_SPACE_PATH_MISSING: 'The folder or file was moved or deleted. Correct `path` in the config, or remove the space.',
12
11
  E_SCHEMA_MISSING: 'Set `schema` on the space or at the top level of the config file.',
13
12
  E_SCHEMA_NOT_FOUND: 'Check the schema name or path. `sctx schemas list` shows the bundled schemas.',
14
- E_SCHEMA_INVALID: 'The schema file is not valid JSON5. Open it and fix the syntax.',
13
+ E_SCHEMA_INVALID: 'The schema could not be loaded. The message says what to fix; `sctx docs schema` describes the format.',
15
14
  };
16
15
  export function fixFor(code) {
17
16
  return FIXES[code] ?? '';
@@ -20,10 +19,11 @@ function problem(code, severity, message, space) {
20
19
  return { code, severity, message, fix: fixFor(code), ...(space ? { space } : {}) };
21
20
  }
22
21
  /**
23
- * Check that the config for the working directory can be found and loaded, and that each space's
24
- * path and schema exist. Never throws: every failure becomes a problem with a stable code.
22
+ * Find and load the config for the working directory, reporting failures as problems with stable
23
+ * codes instead of throwing. It reports on the config only: what a space's `path` means belongs to
24
+ * the plugin that reads it, and a schema is checked when it is loaded.
25
25
  */
26
- export function checkConfig(options = {}) {
26
+ export function inspectConfig(options = {}) {
27
27
  const resolved = resolveConfigPath(options.startDir);
28
28
  const exists = existsSync(resolved.path);
29
29
  const health = { config: { ...resolved, exists }, configFiles: [], spaces: [], problems: [] };
@@ -32,7 +32,7 @@ export function checkConfig(options = {}) {
32
32
  ? `No ${LOCAL_CONFIG_FILE} found from the working directory upward, and no global config at ${resolved.path}`
33
33
  : `Config file not found: ${resolved.path}`;
34
34
  health.problems.push(problem('E_CONFIG_NOT_FOUND', resolved.source === 'none' ? 'info' : 'error', detail));
35
- return health;
35
+ return { health };
36
36
  }
37
37
  let config;
38
38
  try {
@@ -41,46 +41,13 @@ export function checkConfig(options = {}) {
41
41
  catch (err) {
42
42
  const code = err instanceof SctxError ? err.code : 'E_CONFIG_INVALID';
43
43
  health.problems.push(problem(code, 'error', err.message));
44
- return health;
44
+ return { health };
45
45
  }
46
46
  health.configFiles = [...getConfigSourceFiles()];
47
47
  health.spaces = config.spaces.map((s) => ({ name: s.name, path: s.path, schema: s.schema ?? config.schema }));
48
- if (config.spaces.length === 0) {
49
- health.problems.push(problem('E_NO_SPACES', 'warning', `Config ${resolved.path} defines no spaces`));
50
- }
51
- for (const space of config.spaces) {
52
- if (!existsSync(space.path)) {
53
- health.problems.push(problem('E_SPACE_PATH_MISSING', 'error', `Space "${space.name}" path does not exist: ${space.path}`, space.name));
54
- }
55
- const schemaRef = space.schema ?? config.schema;
56
- if (!schemaRef) {
57
- health.problems.push(problem('E_SCHEMA_MISSING', 'error', `Space "${space.name}" has no schema`, space.name));
58
- continue;
59
- }
60
- let schemaPath;
61
- try {
62
- schemaPath = resolveSchema(config, space);
63
- }
64
- catch (err) {
65
- health.problems.push(problem('E_SCHEMA_NOT_FOUND', 'error', err.message, space.name));
66
- continue;
67
- }
68
- if (/^https?:\/\//i.test(schemaPath))
69
- continue;
70
- if (!existsSync(schemaPath)) {
71
- health.problems.push(problem('E_SCHEMA_NOT_FOUND', 'error', `Space "${space.name}" schema not found: ${schemaPath}`, space.name));
72
- continue;
73
- }
74
- try {
75
- JSON5.parse(readFileSync(schemaPath, 'utf-8'));
76
- }
77
- catch (err) {
78
- health.problems.push(problem('E_SCHEMA_INVALID', 'error', `Space "${space.name}" schema ${schemaPath} is not valid: ${err.message}`, space.name));
79
- }
80
- }
81
- return health;
48
+ return { health, config };
82
49
  }
83
- /** True when the health check found something that stops validation from running. */
50
+ /** True when the config problems stop validation from running. */
84
51
  export function hasBlockingProblem(health) {
85
52
  return health.problems.some((p) => p.severity === 'error');
86
53
  }
@@ -48,6 +48,10 @@ export declare function configPath(startDir?: string): string;
48
48
  export declare function loadConfig(options?: {
49
49
  startDir?: string;
50
50
  }): Config;
51
+ /** Like `loadConfig`, for commands that work without a config: a missing config is empty, with no warning. */
52
+ export declare function loadConfigOrEmpty(options?: {
53
+ startDir?: string;
54
+ }): Config;
51
55
  /** Get the full space config entry by name. Throws if not found. */
52
56
  export declare function getSpaceConfig(name: string, config: Config): SpaceConfig;
53
57
  /**
@@ -72,7 +72,7 @@ export function setConfigPath(path) {
72
72
  /** Get all config file paths that were loaded (main config + included configs). */
73
73
  export function getConfigSourceFiles() {
74
74
  if (_spaceSourceFiles.size === 0) {
75
- loadConfig();
75
+ loadConfigOrEmpty();
76
76
  }
77
77
  return new Set(_spaceSourceFiles.values());
78
78
  }
@@ -169,12 +169,21 @@ function expandHome(p) {
169
169
  return p === '~' || p.startsWith('~/') ? join(process.env.HOME ?? homedir(), p.slice(1)) : p;
170
170
  }
171
171
  export function loadConfig(options = {}) {
172
- const { path, source } = resolveConfigPath(options.startDir);
172
+ return loadConfigFile(options.startDir, true);
173
+ }
174
+ /** Like `loadConfig`, for commands that work without a config: a missing config is empty, with no warning. */
175
+ export function loadConfigOrEmpty(options = {}) {
176
+ return loadConfigFile(options.startDir, false);
177
+ }
178
+ function loadConfigFile(startDir, warnIfMissing) {
179
+ const { path, source } = resolveConfigPath(startDir);
173
180
  if (!existsSync(path)) {
174
- const where = source === 'none'
175
- ? `no ${LOCAL_CONFIG_FILE} found from ${resolve(options.startDir ?? process.cwd())} upward, and none at ${path}`
176
- : path;
177
- console.warn(`E_CONFIG_NOT_FOUND: Config file not found (${where})`);
181
+ if (warnIfMissing) {
182
+ const where = source === 'none'
183
+ ? `no ${LOCAL_CONFIG_FILE} found from ${resolve(startDir ?? process.cwd())} upward, and none at ${path}`
184
+ : path;
185
+ console.warn(`E_CONFIG_NOT_FOUND: Config file not found (${where})`);
186
+ }
178
187
  return { spaces: [] };
179
188
  }
180
189
  const config = _loadConfig(path);
@@ -2,7 +2,7 @@ import type { NodeId, SpaceNode } from '../types';
2
2
  /** Edge metadata merged into each ancestor/descendant entry. */
3
3
  export type EdgeMetadata = {
4
4
  _field: string;
5
- _source: 'hierarchy' | 'relationship';
5
+ _kind: 'hierarchy' | 'relationship';
6
6
  /** Hierarchy name, present only for hierarchy edges. */
7
7
  _hierarchy?: string;
8
8
  _selfRef: boolean;
@@ -21,6 +21,6 @@ export type AugmentedFlatNode = Record<string, unknown> & {
21
21
  *
22
22
  * - ancestors: BFS from node via resolvedParents, nearest first, deduplicated by id.
23
23
  * - descendants: BFS via childrenIndex, nearest first, deduplicated by id.
24
- * - Each entry merges the parent/child node's fields with edge metadata (_field, _source, _hierarchy, _selfRef).
24
+ * - Each entry merges the parent/child node's fields with edge metadata (_field, _kind, _hierarchy, _selfRef).
25
25
  */
26
26
  export declare function augmentNode(node: SpaceNode, nodeIndex: ReadonlyMap<NodeId, SpaceNode>, childrenIndex: ReadonlyMap<NodeId, readonly SpaceNode[]>): AugmentedFlatNode;
@@ -2,7 +2,7 @@
2
2
  function edgeMetadata(ref) {
3
3
  return {
4
4
  _field: ref.field,
5
- _source: ref.source,
5
+ _kind: ref.kind,
6
6
  ...(ref.hierarchy !== undefined ? { _hierarchy: ref.hierarchy } : {}),
7
7
  _selfRef: ref.selfRef,
8
8
  };
@@ -22,7 +22,7 @@ function flattenData(node) {
22
22
  *
23
23
  * - ancestors: BFS from node via resolvedParents, nearest first, deduplicated by id.
24
24
  * - descendants: BFS via childrenIndex, nearest first, deduplicated by id.
25
- * - Each entry merges the parent/child node's fields with edge metadata (_field, _source, _hierarchy, _selfRef).
25
+ * - Each entry merges the parent/child node's fields with edge metadata (_field, _kind, _hierarchy, _selfRef).
26
26
  */
27
27
  export function augmentNode(node, nodeIndex, childrenIndex) {
28
28
  const ancestors = buildAncestors(node, nodeIndex);
@@ -139,13 +139,13 @@ function applyDirective(node, aug, directive, childrenIndex, addById) {
139
139
  case 'relationships': {
140
140
  // Relationship-sourced ancestors (matched node is the child side)
141
141
  for (const a of aug.ancestors) {
142
- if (a._source === 'relationship' && matchesRelSpec(node, a, directive, true)) {
142
+ if (a._kind === 'relationship' && matchesRelSpec(node, a, directive, true)) {
143
143
  addById(a.id);
144
144
  }
145
145
  }
146
146
  // Relationship-sourced descendants (matched node is the parent side)
147
147
  for (const d of aug.descendants) {
148
- if (d._source === 'relationship' && matchesRelSpec(node, d, directive, false)) {
148
+ if (d._kind === 'relationship' && matchesRelSpec(node, d, directive, false)) {
149
149
  addById(d.id);
150
150
  }
151
151
  }
package/dist/index.js CHANGED
@@ -16,6 +16,7 @@ import { validate, watchValidate } from './commands/validate';
16
16
  import { validateFileCommand } from './commands/validate-file';
17
17
  import { loadConfig, setConfigPath } from './config';
18
18
  import { CLI_NAME } from './constants';
19
+ import { SctxError } from './errors';
19
20
  import { JudgeConfigError } from './judge/factory';
20
21
  import { createSpaceContext, SpaceNotFoundError } from './space-context';
21
22
  // Once node:process is loaded (vfile, via unified, loads it), Bun's console.log drops piped output
@@ -121,6 +122,7 @@ program
121
122
  .argument('<space-name>', 'Space name')
122
123
  .option('--filter <filter>', 'Filter view name (from config) or inline filter expression')
123
124
  .option('--hierarchy <name>', 'Hierarchy used to evaluate --filter (default: the main hierarchy)')
125
+ .option('--graph', 'Output the space graph format: nodes plus every hierarchy')
124
126
  .action(async (spaceName, options) => dump(buildSpaceContext(spaceName), options));
125
127
  program
126
128
  .command('miro-sync')
@@ -227,4 +229,10 @@ renderCmd
227
229
  await render(buildSpaceContext(spaceName), format, options);
228
230
  });
229
231
  program.addCommand(renderCmd);
230
- program.parse();
232
+ // Expected errors print as one line; anything else keeps its stack trace
233
+ program.parseAsync().catch((err) => {
234
+ if (!(err instanceof SctxError))
235
+ throw err;
236
+ console.error(`${err.code}: ${err.message}`);
237
+ process.exitCode = 1;
238
+ });
@@ -49,7 +49,7 @@ export function relatedNodes(graph, node, context) {
49
49
  const parentsOf = (n) => graph.hierarchy === undefined
50
50
  ? []
51
51
  : n.resolvedParents
52
- .filter((p) => p.source === 'hierarchy' && p.hierarchy === hierarchyName)
52
+ .filter((p) => p.kind === 'hierarchy' && p.hierarchy === hierarchyName)
53
53
  .map((p) => graph.nodes.get(p.id))
54
54
  .filter((p) => p !== undefined);
55
55
  const childrenOf = (n) => graph.hierarchyChildren.get(n.id) ?? [];
@@ -1,7 +1,7 @@
1
1
  import { filterNodes } from './filter/filter-nodes';
2
2
  import { readSpace } from './read/read-space';
3
3
  import { createSpaceContext } from './space-context';
4
- import { buildSpaceGraph } from './space-graph';
4
+ import { buildSpaceGraph, selectHierarchy } from './space-graph';
5
5
  /**
6
6
  * Assemble a SpaceGraph from a space context: read the space, drop nodes that
7
7
  * fail schema validation, build the graph for the selected hierarchy (the main
@@ -26,18 +26,6 @@ export async function assembleSpaceGraph(context, options = {}) {
26
26
  }
27
27
  return graph;
28
28
  }
29
- /** Resolve a hierarchy by name, or the main hierarchy when no name is given. */
30
- function selectHierarchy(metadata, name) {
31
- if (name === undefined)
32
- return metadata.hierarchy;
33
- const hierarchies = metadata.hierarchies ?? [];
34
- const hierarchy = hierarchies.find((h) => h.name === name);
35
- if (!hierarchy) {
36
- const available = hierarchies.map((h) => h.name).join(', ');
37
- throw new Error(`Unknown hierarchy: "${name}".${available ? ` Available: ${available}` : ' The schema defines no hierarchies.'}`);
38
- }
39
- return hierarchy;
40
- }
41
29
  /**
42
30
  * Load a named space and return its assembled, filtered SpaceGraph.
43
31
  *
@@ -364,7 +364,7 @@ export function extractEmbeddedNodes(body, options) {
364
364
  fieldOn: rel.fieldOn,
365
365
  multiple: rel.multiple,
366
366
  templateFormat: rel.templateFormat ?? 'heading',
367
- source: 'relationship',
367
+ kind: 'relationship',
368
368
  matchers: rel.matchers,
369
369
  embeddedTemplateFields: rel.embeddedTemplateFields,
370
370
  };
@@ -378,7 +378,7 @@ export function extractEmbeddedNodes(body, options) {
378
378
  fieldOn: level.fieldOn,
379
379
  multiple: level.multiple,
380
380
  templateFormat: level.templateFormat ?? 'heading',
381
- source: 'hierarchy',
381
+ kind: 'hierarchy',
382
382
  matchers: level.matchers,
383
383
  embeddedTemplateFields: level.embeddedTemplateFields,
384
384
  };
@@ -435,7 +435,7 @@ export function extractEmbeddedNodes(body, options) {
435
435
  fieldOn: currentLevel.fieldOn,
436
436
  multiple: currentLevel.multiple,
437
437
  templateFormat: currentLevel.templateFormat ?? 'list',
438
- source: 'hierarchy',
438
+ kind: 'hierarchy',
439
439
  matchers: immediateParentLevel.matchers,
440
440
  };
441
441
  }
@@ -651,7 +651,7 @@ export function extractEmbeddedNodes(body, options) {
651
651
  // e.g. definition.type='capabilities', definition.parent='application' → capabilities is above application.
652
652
  const typeIdx = hierarchy.indexOf(definition.type);
653
653
  const parentIdx = hierarchy.indexOf(definition.parent);
654
- const isParentLevelMatch = definition.source === 'hierarchy' && typeIdx !== -1 && parentIdx !== -1 && typeIdx < parentIdx;
654
+ const isParentLevelMatch = definition.kind === 'hierarchy' && typeIdx !== -1 && parentIdx !== -1 && typeIdx < parentIdx;
655
655
  const activeNodeFieldAppendArg = isParentLevelMatch && semanticParent.node
656
656
  ? { node: semanticParent.node, field: definition.field }
657
657
  : undefined;
@@ -30,7 +30,7 @@ export function computeNodeHash(node) {
30
30
  status: node.schemaData.status,
31
31
  summary: node.schemaData.summary,
32
32
  priority: node.schemaData.priority,
33
- parents: node.resolvedParents.filter((r) => r.source === 'hierarchy').map((r) => r.id),
33
+ parents: node.resolvedParents.filter((r) => r.kind === 'hierarchy').map((r) => r.id),
34
34
  };
35
35
  return createHash('sha256').update(JSON.stringify(relevant)).digest('hex').slice(0, 16);
36
36
  }
@@ -257,8 +257,8 @@ export async function miroSync(context, graph, options) {
257
257
  const desiredEdges = new Map();
258
258
  for (const node of nodes) {
259
259
  const childTitle = node.title;
260
- for (const { id: parentId, source, hierarchy } of node.resolvedParents) {
261
- if (source !== 'hierarchy' || hierarchy !== graph.hierarchy?.name)
260
+ for (const { id: parentId, kind, hierarchy } of node.resolvedParents) {
261
+ if (kind !== 'hierarchy' || hierarchy !== graph.hierarchy?.name)
262
262
  continue;
263
263
  const parentTitle = graph.nodes.get(parentId)?.title ?? parentId;
264
264
  // Both endpoints must have verified cards on the board