sysprom 1.13.1 → 1.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.
package/README.md CHANGED
@@ -17,44 +17,87 @@ npm install -g github:ExaDev/SysProM
17
17
  npx sysprom --help
18
18
  ```
19
19
 
20
- Both `sysprom` and `spm` are available as commands.
20
+ Both `sysprom` and `spm` are available as commands — use `sysprom` for new projects.
21
21
 
22
22
  ## CLI
23
23
 
24
24
  ```sh
25
25
  # Convert between formats
26
- spm json2md .spm.json ./.spm
27
- spm md2json ./.spm output.spm.json
26
+ sysprom json2md --input .SysProM.json --output ./.SysProM
27
+ sysprom md2json --input ./.SysProM --output output.SysProM.json
28
28
 
29
- # Validate and summarise (auto-detects .spm.json in current directory)
30
- spm validate
31
- spm stats
29
+ # Validate and summarise (auto-detects .SysProM.json in current directory)
30
+ sysprom validate
31
+ sysprom stats
32
32
 
33
33
  # Query nodes and relationships
34
- spm query nodes --type decision
35
- spm query node D1
36
- spm query rels --from D1
37
- spm query trace I1
38
- spm query timeline
39
- spm query state-at 2026-03-22
34
+ sysprom query nodes --type decision
35
+ sysprom query node D1
36
+ sysprom query rels --from D1
37
+ sysprom query trace I1
38
+ sysprom query timeline
39
+ sysprom query state-at 2026-03-22
40
40
 
41
41
  # Add nodes (ID auto-generated from type prefix if --id omitted)
42
- spm add invariant --name "New Rule" --description "Must hold"
43
- spm add decision --name "Choose X" \
42
+ sysprom add invariant --name "New Rule" --description "Must hold"
43
+ sysprom add decision --name "Choose X" \
44
44
  --option "OPT-A:Use framework X" --option "OPT-B:Use framework Y" \
45
45
  --selected OPT-A --rationale "Lower migration effort"
46
46
 
47
47
  # Remove nodes
48
- spm remove INV23
48
+ sysprom remove INV23
49
49
 
50
50
  # Update nodes, relationships, and metadata
51
- spm update node D1 --status deprecated
52
- spm update add-rel D1 affects EL5
53
- spm update remove-rel D1 affects EL5
54
- spm update meta --fields version=2
51
+ sysprom update node D1 --status deprecated
52
+ sysprom update add-rel D1 affects EL5
53
+ sysprom update remove-rel D1 affects EL5
54
+ sysprom update meta --fields version=2
55
55
  ```
56
56
 
57
- All commands auto-detect the document — they search the current directory for `.spm.json`, `.spm.md`, or `.spm/` (in that priority order), then fall back to `*.spm.json`, `*.spm.md`, or `*.spm/`. Use `--path` to specify an explicit path.
57
+ All commands auto-detect the document — they search the current directory for `.SysProM.json`, `.SysProM.md`, or `.SysProM/` (in that priority order), then fall back to `.spm.json`, `.spm.md`, or `.spm/`. Use `--path` to specify an explicit path. Note: `spm` is an alias for `sysprom` for backwards compatibility.
58
+
59
+ ## MCP Server
60
+
61
+ SysProM includes an MCP (Model Context Protocol) server exposing 11 tools over stdio transport. Any MCP-compatible agent — Cursor, Windsurf, VS Code Copilot, Cline, or custom clients — can use it.
62
+
63
+ ### Configuration
64
+
65
+ Add the following to your MCP client's configuration (e.g. `.cursor/mcp.json`, `.vscode/mcp.json`, `cline_mcp_settings.json`, or equivalent):
66
+
67
+ ```json
68
+ {
69
+ "mcpServers": {
70
+ "sysprom": {
71
+ "command": "npx",
72
+ "args": ["-y", "sysprom", "mcp"]
73
+ }
74
+ }
75
+ }
76
+ ```
77
+
78
+ Or via the CLI subcommand (equivalent):
79
+
80
+ ```sh
81
+ sysprom mcp # starts the MCP server on stdio
82
+ ```
83
+
84
+ ### Available Tools
85
+
86
+ | Tool | Description |
87
+ |------|-------------|
88
+ | `validate` | Validate a SysProM document and return issues |
89
+ | `stats` | Return document statistics |
90
+ | `query-nodes` | Query nodes by type, status, or text |
91
+ | `query-node` | Retrieve a single node by ID |
92
+ | `query-relationships` | Query relationships by source, target, or type |
93
+ | `trace` | Trace refinement chains from a node |
94
+ | `add-node` | Add a new node to the document |
95
+ | `remove-node` | Remove a node by ID |
96
+ | `update-node` | Update fields on an existing node |
97
+ | `add-relationship` | Add a relationship between nodes |
98
+ | `remove-relationship` | Remove a relationship |
99
+
100
+ All tools accept a `path` parameter to specify the SysProM document location.
58
101
 
59
102
  ## Programmatic API
60
103
 
@@ -101,7 +144,7 @@ import {
101
144
  } from "sysprom";
102
145
 
103
146
  // Validate
104
- const doc = JSON.parse(fs.readFileSync(".spm.json", "utf8"));
147
+ const doc = JSON.parse(fs.readFileSync(".SysProM.json", "utf8"));
105
148
  const result = validate(doc);
106
149
  console.log(result.valid, result.issues);
107
150
 
@@ -194,7 +237,7 @@ SysProM models systems as directed graphs across abstraction layers — intent,
194
237
  SysProM is format-agnostic. This repository includes:
195
238
 
196
239
  - **JSON** — validated against `schema.json`, supports recursive subsystems
197
- - **Markdown** — single file (`.spm.md`), multi-document folder, or recursive nested folders with automatic grouping by type
240
+ - **Markdown** — single file (`.SysProM.md`), multi-document folder, or recursive nested folders with automatic grouping by type
198
241
 
199
242
  Round-trip conversion between JSON and Markdown is supported with zero information loss.
200
243
 
@@ -214,29 +257,29 @@ pnpm spm <command> # Run the CLI from source (e.g. pnpm spm validate ...)
214
257
 
215
258
  ## Self-Description
216
259
 
217
- `.spm.json` is SysProM describing itself — the specification, its decisions, invariants, changes, and worked examples are all encoded as a SysProM document. The `./.spm/` folder contains the same content as human-readable Markdown.
260
+ `.SysProM.json` is SysProM describing itself — the specification, its decisions, invariants, changes, and worked examples are all encoded as a SysProM document. The `./.SysProM/` folder contains the same content as human-readable Markdown.
218
261
 
219
- All significant activity — decisions, changes, new capabilities, and invariants — should be recorded in the self-describing document. Updates can be made either by editing the Markdown files in `./.spm/` directly or by using the CLI:
262
+ All significant activity — decisions, changes, new capabilities, and invariants — should be recorded in the self-describing document. Updates can be made either by editing the Markdown files in `./.SysProM/` directly or by using the CLI:
220
263
 
221
264
  ```sh
222
265
  # Add a decision via the CLI
223
- spm add decision --id D23 --name "My Decision" --context "Why this was needed"
266
+ sysprom add decision --id D23 --name "My Decision" --context "Why this was needed"
224
267
 
225
- # Or edit ./.spm/DECISIONS.md directly, then sync
226
- spm md2json ./.spm .spm.json
268
+ # Or edit ./.SysProM/DECISIONS.md directly, then sync
269
+ sysprom md2json --input ./.SysProM --output .SysProM.json
227
270
  ```
228
271
 
229
272
  Keep both representations in sync after any change:
230
273
 
231
274
  ```sh
232
275
  # JSON → Markdown
233
- spm json2md .spm.json ./.spm
276
+ sysprom json2md --input .SysProM.json --output ./.SysProM
234
277
 
235
278
  # Markdown → JSON
236
- spm md2json ./.spm .spm.json
279
+ sysprom md2json --input ./.SysProM --output .SysProM.json
237
280
  ```
238
281
 
239
- > **Important:** Always keep `.spm.json` and `./.spm/` up to date with current activity and in sync with each other. Record all decisions, changes, and new capabilities as they happen. After any change to either representation, run the appropriate conversion command above. Validate with `spm validate` before committing.
282
+ > **Important:** Always keep `.SysProM.json` and `./.SysProM/` up to date with current activity and in sync with each other. Record all decisions, changes, and new capabilities as they happen. After any change to either representation, run the appropriate conversion command above. Validate with `sysprom validate` before committing.
240
283
 
241
284
  ## Claude Code Plugin
242
285
 
@@ -1,9 +1,7 @@
1
1
  import * as z from "zod";
2
2
  import type { CommandDef } from "../define-command.js";
3
- declare const argsSchema: z.ZodObject<{
4
- path: z.ZodDefault<z.ZodOptional<z.ZodString>>;
5
- }, z.core.$strip>;
6
3
  declare const optsSchema: z.ZodObject<{
4
+ path: z.ZodDefault<z.ZodOptional<z.ZodString>>;
7
5
  title: z.ZodOptional<z.ZodString>;
8
6
  scope: z.ZodOptional<z.ZodString>;
9
7
  format: z.ZodOptional<z.ZodEnum<{
@@ -12,5 +10,5 @@ declare const optsSchema: z.ZodObject<{
12
10
  dir: "dir";
13
11
  }>>;
14
12
  }, z.core.$strict>;
15
- export declare const initCommand: CommandDef<typeof argsSchema, typeof optsSchema>;
13
+ export declare const initCommand: CommandDef<z.ZodObject<z.ZodRawShape>, typeof optsSchema>;
16
14
  export {};
@@ -15,9 +15,9 @@ function formatToIoFormat(fmt) {
15
15
  }
16
16
  }
17
17
  const suffixMap = {
18
- json: ".spm.json",
19
- md: ".spm.md",
20
- dir: ".spm",
18
+ json: ".SysProM.json",
19
+ md: ".SysProM.md",
20
+ dir: ".SysProM",
21
21
  };
22
22
  function resolveInitTarget(pathArg, format) {
23
23
  const resolved = resolve(pathArg);
@@ -30,20 +30,26 @@ function resolveInitTarget(pathArg, format) {
30
30
  };
31
31
  }
32
32
  const fmt = format ?? "dir";
33
+ const suffix = suffixMap[fmt];
34
+ // If path already ends with the correct suffix, use it as-is
35
+ if (resolved.endsWith(suffix)) {
36
+ return {
37
+ outputPath: resolved,
38
+ ioFormat: formatToIoFormat(fmt),
39
+ };
40
+ }
33
41
  return {
34
- outputPath: `${resolved}${suffixMap[fmt]}`,
42
+ outputPath: `${resolved}${suffix}`,
35
43
  ioFormat: formatToIoFormat(fmt),
36
44
  };
37
45
  }
38
- const argsSchema = z.object({
46
+ const optsSchema = z
47
+ .object({
39
48
  path: z
40
49
  .string()
41
50
  .optional()
42
51
  .default(".")
43
52
  .describe("Target path (default: current directory)"),
44
- });
45
- const optsSchema = z
46
- .object({
47
53
  title: z.string().optional().describe("Document title"),
48
54
  scope: z.string().optional().describe("Document scope"),
49
55
  format: z
@@ -56,10 +62,9 @@ export const initCommand = {
56
62
  name: "init",
57
63
  description: initDocumentOp.def.description,
58
64
  apiLink: initDocumentOp.def.name,
59
- args: argsSchema,
60
65
  opts: optsSchema,
61
- action(args, opts) {
62
- const { outputPath, ioFormat } = resolveInitTarget(args.path, opts.format);
66
+ action(_args, opts) {
67
+ const { outputPath, ioFormat } = resolveInitTarget(opts.path, opts.format);
63
68
  if (existsSync(outputPath)) {
64
69
  console.error(`Already exists: ${outputPath}`);
65
70
  process.exit(1);
@@ -1,2 +1,9 @@
1
+ import * as z from "zod";
1
2
  import type { CommandDef } from "../define-command.js";
2
- export declare const json2mdCommand: CommandDef;
3
+ declare const optsSchema: z.ZodObject<{
4
+ input: z.ZodString;
5
+ output: z.ZodString;
6
+ singleFile: z.ZodOptional<z.ZodBoolean>;
7
+ }, z.core.$strict>;
8
+ export declare const json2mdCommand: CommandDef<z.ZodObject<z.ZodRawShape>, typeof optsSchema>;
9
+ export {};
@@ -4,35 +4,24 @@ import { resolve } from "node:path";
4
4
  import { SysProMDocument } from "../../schema.js";
5
5
  import { jsonToMarkdown } from "../../json-to-md.js";
6
6
  import { jsonToMarkdownOp } from "../../operations/index.js";
7
- function isArgs(arg) {
8
- return (typeof arg === "object" && arg !== null && "input" in arg && "output" in arg);
9
- }
10
- function isOpts(opt) {
11
- return typeof opt === "object" && opt !== null;
12
- }
7
+ const optsSchema = z
8
+ .object({
9
+ input: z.string().describe("Path to SysProM JSON file"),
10
+ output: z.string().describe("Output path (file or directory)"),
11
+ singleFile: z
12
+ .boolean()
13
+ .optional()
14
+ .describe("Force single-file output format"),
15
+ })
16
+ .strict();
13
17
  export const json2mdCommand = {
14
18
  name: "json2md",
15
19
  description: jsonToMarkdownOp.def.description,
16
20
  apiLink: jsonToMarkdownOp.def.name,
17
- args: z.object({
18
- input: z.string().describe("Path to SysProM JSON file"),
19
- output: z.string().describe("Output path (file or directory)"),
20
- }),
21
- opts: z
22
- .object({
23
- singleFile: z
24
- .boolean()
25
- .optional()
26
- .describe("Force single-file output format"),
27
- })
28
- .strict(),
29
- action(args, opts) {
30
- if (!isArgs(args))
31
- throw new Error("Invalid args");
32
- if (!isOpts(opts))
33
- throw new Error("Invalid opts");
34
- const inputPath = resolve(args.input);
35
- const outputPath = resolve(args.output);
21
+ opts: optsSchema,
22
+ action(_args, opts) {
23
+ const inputPath = resolve(opts.input);
24
+ const outputPath = resolve(opts.output);
36
25
  const raw = JSON.parse(readFileSync(inputPath, "utf8"));
37
26
  if (!SysProMDocument.is(raw)) {
38
27
  const result = SysProMDocument.safeParse(raw);
@@ -1,2 +1,8 @@
1
+ import * as z from "zod";
1
2
  import type { CommandDef } from "../define-command.js";
2
- export declare const md2jsonCommand: CommandDef;
3
+ declare const optsSchema: z.ZodObject<{
4
+ input: z.ZodString;
5
+ output: z.ZodString;
6
+ }, z.core.$strict>;
7
+ export declare const md2jsonCommand: CommandDef<z.ZodObject<z.ZodRawShape>, typeof optsSchema>;
8
+ export {};
@@ -4,23 +4,20 @@ import { resolve } from "node:path";
4
4
  import { markdownToJson } from "../../md-to-json.js";
5
5
  import { canonicalise } from "../../canonical-json.js";
6
6
  import { markdownToJsonOp } from "../../operations/index.js";
7
- function isArgs(arg) {
8
- return (typeof arg === "object" && arg !== null && "input" in arg && "output" in arg);
9
- }
7
+ const optsSchema = z
8
+ .object({
9
+ input: z.string().describe("Path to SysProM Markdown (file or directory)"),
10
+ output: z.string().describe("Output JSON file path"),
11
+ })
12
+ .strict();
10
13
  export const md2jsonCommand = {
11
14
  name: "md2json",
12
15
  description: markdownToJsonOp.def.description,
13
16
  apiLink: markdownToJsonOp.def.name,
14
- args: z.object({
15
- input: z.string().describe("Path to SysProM Markdown (file or directory)"),
16
- output: z.string().describe("Output JSON file path"),
17
- }),
18
- opts: z.object({}).strict(),
19
- action(args) {
20
- if (!isArgs(args))
21
- throw new Error("Invalid args");
22
- const inputPath = resolve(args.input);
23
- const outputPath = resolve(args.output);
17
+ opts: optsSchema,
18
+ action(_args, opts) {
19
+ const inputPath = resolve(opts.input);
20
+ const outputPath = resolve(opts.output);
24
21
  const doc = markdownToJson(inputPath);
25
22
  writeFileSync(outputPath, canonicalise(doc, { indent: "\t" }) + "\n");
26
23
  console.log(`Written to ${outputPath}`);
@@ -6,10 +6,8 @@ import { planInitOp, planAddTaskOp, planStatusOp, planProgressOp, planGateOp, }
6
6
  // ============================================================================
7
7
  // Subcommands
8
8
  // ============================================================================
9
- const initArgs = z.object({
10
- output: z.string().describe("Path to output SysProM file"),
11
- });
12
9
  const initOpts = z.object({
10
+ output: z.string().describe("Path to output SysProM file"),
13
11
  prefix: z.string().describe("Plan prefix (e.g. PLAN)"),
14
12
  name: z.string().optional().describe("Plan name (defaults to prefix)"),
15
13
  });
@@ -17,10 +15,9 @@ const initSubcommand = {
17
15
  name: "init",
18
16
  description: planInitOp.def.description,
19
17
  apiLink: planInitOp.def.name,
20
- args: initArgs,
21
18
  opts: initOpts,
22
- action(args, opts) {
23
- const outputPath = args.output;
19
+ action(_args, opts) {
20
+ const outputPath = opts.output;
24
21
  const prefix = opts.prefix;
25
22
  const name = opts.name ?? prefix;
26
23
  if (existsSync(outputPath)) {
@@ -45,11 +45,9 @@ function compareDocuments(oldDoc, newDoc) {
45
45
  // ============================================================================
46
46
  // Subcommands
47
47
  // ============================================================================
48
- const importArgs = z.object({
48
+ const importOpts = z.object({
49
49
  speckitDir: z.string().describe("Path to Spec-Kit feature directory"),
50
50
  output: z.string().describe("Path to output SysProM file"),
51
- });
52
- const importOpts = z.object({
53
51
  prefix: z
54
52
  .string()
55
53
  .optional()
@@ -58,11 +56,10 @@ const importOpts = z.object({
58
56
  const importSubcommand = {
59
57
  name: "import",
60
58
  description: speckitImportOp.def.description,
61
- args: importArgs,
62
59
  opts: importOpts,
63
- action(args, opts) {
64
- const specKitDir = resolve(args.speckitDir);
65
- const outputPath = resolve(args.output);
60
+ action(_args, opts) {
61
+ const specKitDir = resolve(opts.speckitDir);
62
+ const outputPath = resolve(opts.output);
66
63
  if (!existsSync(specKitDir)) {
67
64
  console.error(`Error: Spec-Kit directory does not exist: ${specKitDir}`);
68
65
  process.exit(1);
@@ -97,21 +94,18 @@ const importSubcommand = {
97
94
  console.log(` ${String(nodeCount)} nodes, ${String(relationshipCount)} relationships`);
98
95
  },
99
96
  };
100
- const exportArgs = z.object({
97
+ const exportOpts = z.object({
101
98
  input: z.string().describe("Path to SysProM document"),
102
99
  speckitDir: z.string().describe("Path to Spec-Kit output directory"),
103
- });
104
- const exportOpts = z.object({
105
100
  prefix: z.string().describe("ID prefix identifying nodes to export"),
106
101
  });
107
102
  const exportSubcommand = {
108
103
  name: "export",
109
104
  description: speckitExportOp.def.description,
110
- args: exportArgs,
111
105
  opts: exportOpts,
112
- action(args, opts) {
113
- const inputPath = resolve(args.input);
114
- const specKitDir = resolve(args.speckitDir);
106
+ action(_args, opts) {
107
+ const inputPath = resolve(opts.input);
108
+ const specKitDir = resolve(opts.speckitDir);
115
109
  if (!opts.prefix) {
116
110
  console.error("Error: --prefix flag is required for export (identifies which nodes to export)");
117
111
  process.exit(1);
@@ -125,11 +119,9 @@ const exportSubcommand = {
125
119
  console.log(` Generated Spec-Kit files with prefix: ${opts.prefix}`);
126
120
  },
127
121
  };
128
- const syncArgs = z.object({
122
+ const syncSubOpts = z.object({
129
123
  input: z.string().describe("Path to SysProM document"),
130
124
  speckitDir: z.string().describe("Path to Spec-Kit directory"),
131
- });
132
- const syncOpts = z.object({
133
125
  prefix: z
134
126
  .string()
135
127
  .optional()
@@ -138,11 +130,10 @@ const syncOpts = z.object({
138
130
  const syncSubcommand = {
139
131
  name: "sync",
140
132
  description: speckitSyncOp.def.description,
141
- args: syncArgs,
142
- opts: syncOpts,
143
- action(args, opts) {
144
- const inputPath = resolve(args.input);
145
- const specKitDir = resolve(args.speckitDir);
133
+ opts: syncSubOpts,
134
+ action(_args, opts) {
135
+ const inputPath = resolve(opts.input);
136
+ const specKitDir = resolve(opts.speckitDir);
146
137
  if (!existsSync(inputPath)) {
147
138
  console.error(`Error: Input file does not exist: ${inputPath}`);
148
139
  process.exit(1);
@@ -226,11 +217,9 @@ const syncSubcommand = {
226
217
  }
227
218
  },
228
219
  };
229
- const diffArgs = z.object({
220
+ const diffSubOpts = z.object({
230
221
  input: z.string().describe("Path to SysProM document"),
231
222
  speckitDir: z.string().describe("Path to Spec-Kit directory"),
232
- });
233
- const diffOpts = z.object({
234
223
  prefix: z
235
224
  .string()
236
225
  .optional()
@@ -239,11 +228,10 @@ const diffOpts = z.object({
239
228
  const diffSubcommand = {
240
229
  name: "diff",
241
230
  description: speckitDiffOp.def.description,
242
- args: diffArgs,
243
- opts: diffOpts,
244
- action(args, opts) {
245
- const inputPath = resolve(args.input);
246
- const specKitDir = resolve(args.speckitDir);
231
+ opts: diffSubOpts,
232
+ action(_args, opts) {
233
+ const inputPath = resolve(opts.input);
234
+ const specKitDir = resolve(opts.speckitDir);
247
235
  if (!existsSync(inputPath)) {
248
236
  console.error(`Error: Input file does not exist: ${inputPath}`);
249
237
  process.exit(1);
@@ -1,3 +1,4 @@
1
+ import * as z from "zod";
1
2
  import type { CommandDef } from "../define-command.js";
2
3
  import { type BidirectionalSyncResult, type ConflictStrategy } from "../../operations/index.js";
3
4
  interface SyncCommandInput {
@@ -14,5 +15,13 @@ interface SyncCommandInput {
14
15
  * const result = syncCommand({ jsonPath: "doc.spm.json", mdPath: "doc.spm.md" });
15
16
  */
16
17
  export declare function syncCommand(input: SyncCommandInput): BidirectionalSyncResult;
17
- export declare const syncCommandDef: CommandDef;
18
+ declare const syncOpts: z.ZodObject<{
19
+ input: z.ZodString;
20
+ output: z.ZodString;
21
+ preferJson: z.ZodOptional<z.ZodBoolean>;
22
+ preferMd: z.ZodOptional<z.ZodBoolean>;
23
+ dryRun: z.ZodOptional<z.ZodBoolean>;
24
+ report: z.ZodOptional<z.ZodBoolean>;
25
+ }, z.core.$strict>;
26
+ export declare const syncCommandDef: CommandDef<z.ZodObject<z.ZodRawShape>, typeof syncOpts>;
18
27
  export {};
@@ -47,47 +47,36 @@ export function syncCommand(input) {
47
47
  }
48
48
  return result;
49
49
  }
50
- function isArgs(arg) {
51
- return (typeof arg === "object" && arg !== null && "input" in arg && "output" in arg);
52
- }
53
- function isOpts(opt) {
54
- return typeof opt === "object" && opt !== null;
55
- }
50
+ const syncOpts = z
51
+ .object({
52
+ input: z.string().describe("Path to JSON file"),
53
+ output: z.string().describe("Path to Markdown file"),
54
+ preferJson: z
55
+ .boolean()
56
+ .optional()
57
+ .describe("Prefer JSON as source of truth in conflicts"),
58
+ preferMd: z
59
+ .boolean()
60
+ .optional()
61
+ .describe("Prefer Markdown as source of truth in conflicts"),
62
+ dryRun: z
63
+ .boolean()
64
+ .optional()
65
+ .describe("Preview changes without writing files"),
66
+ report: z
67
+ .boolean()
68
+ .optional()
69
+ .describe("Report conflicts without resolving"),
70
+ })
71
+ .strict();
56
72
  export const syncCommandDef = {
57
73
  name: "sync",
58
74
  description: "Synchronise JSON and Markdown representations with conflict resolution",
59
75
  apiLink: "syncDocuments",
60
- args: z.object({
61
- input: z.string().describe("Path to JSON file"),
62
- output: z.string().describe("Path to Markdown file"),
63
- }),
64
- opts: z
65
- .object({
66
- preferJson: z
67
- .boolean()
68
- .optional()
69
- .describe("Prefer JSON as source of truth in conflicts"),
70
- preferMd: z
71
- .boolean()
72
- .optional()
73
- .describe("Prefer Markdown as source of truth in conflicts"),
74
- dryRun: z
75
- .boolean()
76
- .optional()
77
- .describe("Preview changes without writing files"),
78
- report: z
79
- .boolean()
80
- .optional()
81
- .describe("Report conflicts without resolving"),
82
- })
83
- .strict(),
84
- action(args, opts) {
85
- if (!isArgs(args))
86
- throw new Error("Invalid args");
87
- if (!isOpts(opts))
88
- throw new Error("Invalid opts");
89
- const jsonPath = resolve(args.input);
90
- const mdPath = resolve(args.output);
76
+ opts: syncOpts,
77
+ action(_args, opts) {
78
+ const jsonPath = resolve(opts.input);
79
+ const mdPath = resolve(opts.output);
91
80
  // Determine conflict strategy
92
81
  let strategy = "json";
93
82
  if (opts.preferMd)
@@ -1,26 +1,29 @@
1
1
  import * as z from "zod";
2
2
  import { type Format } from "../io.js";
3
3
  import type { SysProMDocument } from "../schema.js";
4
- /** @deprecated Use --path option in readOpts/mutationOpts instead. */
5
- export declare const inputArg: z.ZodString;
6
4
  /** Empty args schema for commands that take no positional arguments. */
7
5
  export declare const noArgs: z.ZodObject<{}, z.core.$strict>;
8
6
  /**
9
7
  * Resolve a SysProM document path. If no explicit path is given, search the
10
8
  * working directory by priority:
11
- * 1. .spm.json 2. .spm.md 3. .spm/
12
- * 4. .sysprom.json 5. .sysprom.md 6. .sysprom/
13
- * 7. *.spm.json 8. *.spm.md 9. *.spm/
14
- * 10. *.sysprom.json 11. *.sysprom.md 12. *.sysprom/
9
+ * 1. .SysProM.json 2. .SysProM.md 3. .SysProM/
10
+ * 4. .spm.json 5. .spm.md 6. .spm/
11
+ * 7. .sysprom.json 8. .sysprom.md 9. .sysprom/
12
+ * 10. *.SysProM.json 11. *.SysProM.md 12. *.SysProM/
13
+ * 13. *.spm.json 14. *.spm.md 15. *.spm/
14
+ * 16. *.sysprom.json 17. *.sysprom.md 18. *.sysprom/
15
15
  *
16
- * All matching is case-insensitive. Glob tiers must have exactly one match.
16
+ * Matching is case-insensitive for base names and directory matches.
17
+ * Files like .SysProM.json, .sysprom.json, and .SYSPROM.JSON are treated
18
+ * as equivalent and all match the .SysProM.json priority tier.
19
+ * Glob tiers must have exactly one match.
17
20
  * @param input - Explicit document path, or undefined for auto-detection.
18
21
  * @param cwd - Working directory to search from (defaults to `.`).
19
22
  * @returns The resolved document path.
20
23
  * @example
21
24
  * ```ts
22
- * resolveInput() // => auto-detects ".spm.json" in cwd
23
- * resolveInput("my-doc.spm.json") // => "my-doc.spm.json"
25
+ * resolveInput() // => auto-detects ".SysProM.json" in cwd
26
+ * resolveInput("my-doc.SysProM.json") // => "my-doc.SysProM.json"
24
27
  * ```
25
28
  */
26
29
  export declare function resolveInput(input?: string, cwd?: string): string;
@@ -6,10 +6,6 @@ import { jsonToMarkdownMultiDoc } from "../json-to-md.js";
6
6
  // ---------------------------------------------------------------------------
7
7
  // Reusable CLI schemas — shared across all commands
8
8
  // ---------------------------------------------------------------------------
9
- /** @deprecated Use --path option in readOpts/mutationOpts instead. */
10
- export const inputArg = z
11
- .string()
12
- .describe("SysProM document path (JSON, .md, or directory)");
13
9
  /** Empty args schema for commands that take no positional arguments. */
14
10
  export const noArgs = z.object({}).strict();
15
11
  /** Shared --path option for specifying the SysProM document location. */
@@ -23,27 +19,34 @@ const pathOpt = z
23
19
  /**
24
20
  * Resolve a SysProM document path. If no explicit path is given, search the
25
21
  * working directory by priority:
26
- * 1. .spm.json 2. .spm.md 3. .spm/
27
- * 4. .sysprom.json 5. .sysprom.md 6. .sysprom/
28
- * 7. *.spm.json 8. *.spm.md 9. *.spm/
29
- * 10. *.sysprom.json 11. *.sysprom.md 12. *.sysprom/
22
+ * 1. .SysProM.json 2. .SysProM.md 3. .SysProM/
23
+ * 4. .spm.json 5. .spm.md 6. .spm/
24
+ * 7. .sysprom.json 8. .sysprom.md 9. .sysprom/
25
+ * 10. *.SysProM.json 11. *.SysProM.md 12. *.SysProM/
26
+ * 13. *.spm.json 14. *.spm.md 15. *.spm/
27
+ * 16. *.sysprom.json 17. *.sysprom.md 18. *.sysprom/
30
28
  *
31
- * All matching is case-insensitive. Glob tiers must have exactly one match.
29
+ * Matching is case-insensitive for base names and directory matches.
30
+ * Files like .SysProM.json, .sysprom.json, and .SYSPROM.JSON are treated
31
+ * as equivalent and all match the .SysProM.json priority tier.
32
+ * Glob tiers must have exactly one match.
32
33
  * @param input - Explicit document path, or undefined for auto-detection.
33
34
  * @param cwd - Working directory to search from (defaults to `.`).
34
35
  * @returns The resolved document path.
35
36
  * @example
36
37
  * ```ts
37
- * resolveInput() // => auto-detects ".spm.json" in cwd
38
- * resolveInput("my-doc.spm.json") // => "my-doc.spm.json"
38
+ * resolveInput() // => auto-detects ".SysProM.json" in cwd
39
+ * resolveInput("my-doc.SysProM.json") // => "my-doc.SysProM.json"
39
40
  * ```
40
41
  */
41
42
  export function resolveInput(input, cwd) {
42
43
  if (input)
43
44
  return input;
44
45
  const dir = resolve(cwd ?? ".");
45
- // Exact names to check, in priority order (case-insensitive)
46
46
  const exactNames = [
47
+ ".SysProM.json",
48
+ ".SysProM.md",
49
+ ".SysProM",
47
50
  ".spm.json",
48
51
  ".spm.md",
49
52
  ".spm",
@@ -52,9 +55,42 @@ export function resolveInput(input, cwd) {
52
55
  ".sysprom",
53
56
  ];
54
57
  const entries = readdirSync(dir);
58
+ // Phase 1: Try exact case-sensitive matches first
59
+ for (const name of exactNames) {
60
+ const isDirSuffix = name.endsWith(".SysProM") ||
61
+ name.endsWith(".spm") ||
62
+ name.endsWith(".sysprom");
63
+ const found = entries.filter((e) => e === name);
64
+ if (found.length === 1) {
65
+ // Before returning, check for case-variant collisions on case-sensitive
66
+ // filesystems (e.g. both .spm.json and .SPM.json exist).
67
+ const nameLower = name.toLowerCase();
68
+ const caseVariants = entries.filter((e) => e !== name && e.toLowerCase() === nameLower);
69
+ if (caseVariants.length > 0) {
70
+ throw new Error(`Multiple SysProM documents found: ${[name, ...caseVariants].join(", ")}. Specify one explicitly.`);
71
+ }
72
+ const candidate = join(dir, found[0]);
73
+ if (isDirSuffix) {
74
+ try {
75
+ if (statSync(candidate).isDirectory())
76
+ return candidate;
77
+ }
78
+ catch {
79
+ /* skip */
80
+ }
81
+ }
82
+ else {
83
+ return candidate;
84
+ }
85
+ }
86
+ }
87
+ // Phase 2: Try case-insensitive matches (supports case variations like .SPM.JSON)
55
88
  for (const name of exactNames) {
56
- const isDirSuffix = name.endsWith(".spm") || name.endsWith(".sysprom");
57
- const found = entries.filter((e) => e.toLowerCase() === name);
89
+ const isDirSuffix = name.endsWith(".SysProM") ||
90
+ name.endsWith(".spm") ||
91
+ name.endsWith(".sysprom");
92
+ const nameLower = name.toLowerCase();
93
+ const found = entries.filter((e) => e.toLowerCase() === nameLower);
58
94
  if (found.length > 1) {
59
95
  throw new Error(`Multiple SysProM documents found: ${found.join(", ")}. Specify one explicitly.`);
60
96
  }
@@ -76,6 +112,9 @@ export function resolveInput(input, cwd) {
76
112
  }
77
113
  // Glob suffixes in priority order (case-insensitive)
78
114
  const globSuffixes = [
115
+ ".SysProM.json",
116
+ ".SysProM.md",
117
+ ".SysProM",
79
118
  ".spm.json",
80
119
  ".spm.md",
81
120
  ".spm",
@@ -84,11 +123,16 @@ export function resolveInput(input, cwd) {
84
123
  ".sysprom",
85
124
  ];
86
125
  for (const suffix of globSuffixes) {
87
- const isDirSuffix = suffix === ".spm" || suffix === ".sysprom";
126
+ const isDirSuffix = suffix === ".SysProM" || suffix === ".spm" || suffix === ".sysprom";
127
+ const suffixLower = suffix.toLowerCase();
88
128
  const matches = entries
89
129
  .filter((e) => {
90
130
  const lower = e.toLowerCase();
91
- return lower.endsWith(suffix) && lower !== suffix;
131
+ // Match files ending with this suffix (case-insensitive) that don't start with "."
132
+ // and are not exact matches (those were already checked)
133
+ return (lower.endsWith(suffixLower) &&
134
+ lower !== suffixLower &&
135
+ !e.startsWith("."));
92
136
  })
93
137
  .map((e) => join(dir, e))
94
138
  .filter((p) => {
@@ -22,6 +22,58 @@ function renderFrontMatter(fields) {
22
22
  lines.push("---");
23
23
  return lines.join("\n");
24
24
  }
25
+ // ---------------------------------------------------------------------------
26
+ // Node location map (for hyperlinking)
27
+ // ---------------------------------------------------------------------------
28
+ /** GitHub-compatible heading anchor slug. */
29
+ function slugify(text) {
30
+ return text
31
+ .toLowerCase()
32
+ .replace(/[^\w\s-]/g, "")
33
+ .replace(/\s/g, "-");
34
+ }
35
+ /** Heading anchor for a node: `id--name` slugified from `### ID — Name`. */
36
+ function nodeAnchor(n) {
37
+ return slugify(`${n.id} — ${n.name}`);
38
+ }
39
+ /** Build a map from node ID to its markdown file and heading anchor. */
40
+ function buildNodeLocationMap(nodes, mode) {
41
+ const map = new Map();
42
+ for (const n of nodes) {
43
+ const anchor = nodeAnchor(n);
44
+ if (mode === "single-file") {
45
+ map.set(n.id, { file: "", anchor });
46
+ }
47
+ else {
48
+ const file = fileForNodeType(n.type);
49
+ map.set(n.id, { file, anchor });
50
+ }
51
+ }
52
+ return map;
53
+ }
54
+ /** Determine the markdown file a node type belongs to in multi-doc mode. */
55
+ function fileForNodeType(type) {
56
+ for (const [fileName, types] of Object.entries(NODE_FILE_MAP)) {
57
+ if (types.includes(type))
58
+ return `${fileName}.md`;
59
+ }
60
+ return "README.md";
61
+ }
62
+ /**
63
+ * Format a node ID as a markdown hyperlink.
64
+ * In single-file mode: `[ID](#anchor)`
65
+ * In multi-doc mode: `[ID](./FILE.md#anchor)`
66
+ * Falls back to plain ID if the node isn't in the map.
67
+ */
68
+ function linkNodeId(id, nodeMap, currentFile) {
69
+ const loc = nodeMap.get(id);
70
+ if (!loc)
71
+ return id;
72
+ if (loc.file === "" || loc.file === currentFile) {
73
+ return `[${id}](#${loc.anchor})`;
74
+ }
75
+ return `[${id}](./${loc.file}#${loc.anchor})`;
76
+ }
25
77
  function indexRelationshipsFrom(rels) {
26
78
  const idx = new Map();
27
79
  for (const r of rels) {
@@ -79,7 +131,7 @@ function renderLifecycle(lifecycle) {
79
131
  return `- [${checkbox}] ${label}`;
80
132
  });
81
133
  }
82
- function renderNodeRelationships(nodeId, fromIdx) {
134
+ function renderNodeRelationships(nodeId, fromIdx, nodeMap, currentFile) {
83
135
  const rels = fromIdx.get(nodeId);
84
136
  if (!rels || rels.length === 0)
85
137
  return [];
@@ -97,12 +149,12 @@ function renderNodeRelationships(nodeId, fromIdx) {
97
149
  ? RELATIONSHIP_TYPE_LABELS[type]
98
150
  : type;
99
151
  if (targets.length === 1) {
100
- lines.push(`- ${label}: ${targets[0]}`);
152
+ lines.push(`- ${label}: ${linkNodeId(targets[0], nodeMap, currentFile)}`);
101
153
  }
102
154
  else {
103
155
  lines.push(`- ${label}:`);
104
156
  for (const t of targets) {
105
- lines.push(` - ${t}`);
157
+ lines.push(` - ${linkNodeId(t, nodeMap, currentFile)}`);
106
158
  }
107
159
  }
108
160
  }
@@ -123,7 +175,7 @@ function renderExternalReferences(refs) {
123
175
  }
124
176
  return lines;
125
177
  }
126
- function renderNode(n, headingLevel, fromIdx) {
178
+ function renderNode(n, headingLevel, fromIdx, nodeMap, currentFile) {
127
179
  const prefix = "#".repeat(headingLevel);
128
180
  const lines = [];
129
181
  lines.push(`${prefix} ${n.id} — ${n.name}`);
@@ -132,7 +184,7 @@ function renderNode(n, headingLevel, fromIdx) {
132
184
  lines.push(renderText(n.description));
133
185
  lines.push("");
134
186
  }
135
- const rels = renderNodeRelationships(n.id, fromIdx);
187
+ const rels = renderNodeRelationships(n.id, fromIdx, nodeMap, currentFile);
136
188
  if (rels.length > 0) {
137
189
  lines.push(...rels);
138
190
  lines.push("");
@@ -207,7 +259,7 @@ function renderNode(n, headingLevel, fromIdx) {
207
259
  if (n.includes && n.includes.length > 0) {
208
260
  lines.push("Includes:");
209
261
  for (const inc of n.includes) {
210
- lines.push(`- ${inc}`);
262
+ lines.push(`- ${linkNodeId(inc, nodeMap, currentFile)}`);
211
263
  }
212
264
  lines.push("");
213
265
  }
@@ -233,8 +285,9 @@ function renderNode(n, headingLevel, fromIdx) {
233
285
  const subNodes = n.subsystem.nodes;
234
286
  const subRels = n.subsystem.relationships ?? [];
235
287
  const subIdx = indexRelationshipsFrom(subRels);
288
+ const subMap = buildNodeLocationMap(subNodes, "single-file");
236
289
  for (const sub of subNodes) {
237
- lines.push(...renderNode(sub, headingLevel + 2, subIdx));
290
+ lines.push(...renderNode(sub, headingLevel + 2, subIdx, subMap));
238
291
  }
239
292
  }
240
293
  return lines;
@@ -242,7 +295,7 @@ function renderNode(n, headingLevel, fromIdx) {
242
295
  // ---------------------------------------------------------------------------
243
296
  // File generators
244
297
  // ---------------------------------------------------------------------------
245
- function renderNodesGrouped(nodes, types, fromIdx, headingLevel) {
298
+ function renderNodesGrouped(nodes, types, fromIdx, headingLevel, nodeMap, currentFile) {
246
299
  const lines = [];
247
300
  for (const type of types) {
248
301
  const matching = nodes.filter((n) => n.type === type);
@@ -252,12 +305,12 @@ function renderNodesGrouped(nodes, types, fromIdx, headingLevel) {
252
305
  lines.push(`${"#".repeat(headingLevel)} ${label}`);
253
306
  lines.push("");
254
307
  for (const n of matching) {
255
- lines.push(...renderNode(n, headingLevel + 1, fromIdx));
308
+ lines.push(...renderNode(n, headingLevel + 1, fromIdx, nodeMap, currentFile));
256
309
  }
257
310
  }
258
311
  return lines;
259
312
  }
260
- function generateReadme(doc, fromIdx) {
313
+ function generateReadme(doc, fromIdx, nodeMap) {
261
314
  const lines = [];
262
315
  const title = doc.metadata?.title ?? "SysProM";
263
316
  lines.push(renderFrontMatter({
@@ -329,7 +382,7 @@ function generateReadme(doc, fromIdx) {
329
382
  // Views
330
383
  const views = doc.nodes.filter((n) => n.type === "view");
331
384
  if (views.length > 0) {
332
- lines.push(...renderNodesGrouped(doc.nodes, ["view"], fromIdx, 2));
385
+ lines.push(...renderNodesGrouped(doc.nodes, ["view"], fromIdx, 2, nodeMap, "README.md"));
333
386
  }
334
387
  // Graph-level external references
335
388
  if (doc.external_references && doc.external_references.length > 0) {
@@ -347,7 +400,7 @@ function generateReadme(doc, fromIdx) {
347
400
  }
348
401
  return lines.join("\n") + "\n";
349
402
  }
350
- function generateDocFile(doc, fileName, types, fromIdx) {
403
+ function generateDocFile(doc, fileName, types, fromIdx, nodeMap) {
351
404
  const lines = [];
352
405
  lines.push(renderFrontMatter({
353
406
  title: fileName.replace(".md", ""),
@@ -356,7 +409,7 @@ function generateDocFile(doc, fileName, types, fromIdx) {
356
409
  lines.push("");
357
410
  lines.push(`# ${fileName.replace(".md", "")}`);
358
411
  lines.push("");
359
- lines.push(...renderNodesGrouped(doc.nodes, types, fromIdx, 2));
412
+ lines.push(...renderNodesGrouped(doc.nodes, types, fromIdx, 2, nodeMap, `${fileName}.md`));
360
413
  return lines.join("\n") + "\n";
361
414
  }
362
415
  /**
@@ -371,6 +424,7 @@ function generateDocFile(doc, fileName, types, fromIdx) {
371
424
  */
372
425
  export function jsonToMarkdownSingle(doc) {
373
426
  const fromIdx = indexRelationshipsFrom(doc.relationships ?? []);
427
+ const nodeMap = buildNodeLocationMap(doc.nodes, "single-file");
374
428
  const lines = [];
375
429
  const title = doc.metadata?.title ?? "SysProM";
376
430
  lines.push(renderFrontMatter({
@@ -394,7 +448,7 @@ export function jsonToMarkdownSingle(doc) {
394
448
  "milestone",
395
449
  "version",
396
450
  ];
397
- lines.push(...renderNodesGrouped(doc.nodes, allTypes, fromIdx, 2));
451
+ lines.push(...renderNodesGrouped(doc.nodes, allTypes, fromIdx, 2, nodeMap));
398
452
  // Relationships summary
399
453
  if (doc.relationships && doc.relationships.length > 0) {
400
454
  lines.push("## Relationships");
@@ -433,12 +487,13 @@ export function jsonToMarkdownSingle(doc) {
433
487
  export function jsonToMarkdownMultiDoc(doc, outDir) {
434
488
  mkdirSync(outDir, { recursive: true });
435
489
  const fromIdx = indexRelationshipsFrom(doc.relationships ?? []);
436
- writeFileSync(join(outDir, "README.md"), generateReadme(doc, fromIdx));
490
+ const nodeMap = buildNodeLocationMap(doc.nodes, "multi-doc");
491
+ writeFileSync(join(outDir, "README.md"), generateReadme(doc, fromIdx, nodeMap));
437
492
  for (const [fileName, types] of Object.entries(NODE_FILE_MAP)) {
438
493
  const hasNodes = doc.nodes.some((n) => types.includes(n.type));
439
494
  if (!hasNodes)
440
495
  continue;
441
- writeFileSync(join(outDir, `${fileName}.md`), generateDocFile(doc, fileName, types, fromIdx));
496
+ writeFileSync(join(outDir, `${fileName}.md`), generateDocFile(doc, fileName, types, fromIdx, nodeMap));
442
497
  }
443
498
  // Subsystem folders or single files
444
499
  const subsystemNodes = doc.nodes.filter((n) => n.subsystem);
@@ -2,7 +2,7 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import * as z from "zod";
5
- import { loadDocument } from "../io.js";
5
+ import { loadDocument, saveDocument } from "../io.js";
6
6
  import { NodeType, RelationshipType } from "../schema.js";
7
7
  import { validateOp, statsOp, queryNodesOp, queryNodeOp, queryRelationshipsOp, traceFromNodeOp, addNodeOp, removeNodeOp, updateNodeOp, addRelationshipOp, removeRelationshipOp, nextIdOp, } from "../operations/index.js";
8
8
  // Create MCP server instance
@@ -145,14 +145,14 @@ server.registerTool("add-node", {
145
145
  description: z.string().optional().describe("Node description"),
146
146
  }),
147
147
  }, ({ path, type, id, name, description }) => {
148
- const { doc } = loadDocument(path);
148
+ const loaded = loadDocument(path);
149
149
  const nodeType = NodeType.safeParse(type);
150
150
  if (!nodeType.success) {
151
151
  throw new Error(`Invalid node type: "${type}". Valid types: ${NodeType.options.join(", ")}`);
152
152
  }
153
- const nodeId = id ?? nextIdOp({ doc, type: nodeType.data });
153
+ const nodeId = id ?? nextIdOp({ doc: loaded.doc, type: nodeType.data });
154
154
  const updated = addNodeOp({
155
- doc,
155
+ doc: loaded.doc,
156
156
  node: {
157
157
  id: nodeId,
158
158
  type: nodeType.data,
@@ -160,6 +160,7 @@ server.registerTool("add-node", {
160
160
  ...(description && { description }),
161
161
  },
162
162
  });
163
+ saveDocument(updated, loaded.format, loaded.path);
163
164
  return {
164
165
  content: [
165
166
  {
@@ -181,8 +182,9 @@ server.registerTool("remove-node", {
181
182
  id: z.string().describe("Node ID"),
182
183
  }),
183
184
  }, ({ path, id }) => {
184
- const { doc } = loadDocument(path);
185
- const result = removeNodeOp({ doc, id });
185
+ const loaded = loadDocument(path);
186
+ const result = removeNodeOp({ doc: loaded.doc, id });
187
+ saveDocument(result.doc, loaded.format, loaded.path);
186
188
  return {
187
189
  content: [
188
190
  {
@@ -205,7 +207,7 @@ server.registerTool("update-node", {
205
207
  fields: z.record(z.string(), z.unknown()).describe("Fields to update"),
206
208
  }),
207
209
  }, ({ path, id, fields }) => {
208
- const { doc } = loadDocument(path);
210
+ const loaded = loadDocument(path);
209
211
  // Validate fields are valid node property updates
210
212
  const validFields = Object.entries(fields).reduce((acc, [key, value]) => {
211
213
  // Allow common node fields; unknown fields are silently ignored
@@ -231,10 +233,11 @@ server.registerTool("update-node", {
231
233
  return acc;
232
234
  }, {});
233
235
  const updated = updateNodeOp({
234
- doc,
236
+ doc: loaded.doc,
235
237
  id,
236
238
  fields: validFields,
237
239
  });
240
+ saveDocument(updated, loaded.format, loaded.path);
238
241
  const node = updated.nodes.find((n) => n.id === id);
239
242
  return {
240
243
  content: [
@@ -255,19 +258,20 @@ server.registerTool("add-relationship", {
255
258
  type: z.string().describe("Relationship type"),
256
259
  }),
257
260
  }, ({ path, from, to, type }) => {
258
- const { doc } = loadDocument(path);
261
+ const loaded = loadDocument(path);
259
262
  const relType = RelationshipType.safeParse(type);
260
263
  if (!relType.success) {
261
264
  throw new Error(`Invalid relationship type: "${type}". Valid types: ${RelationshipType.options.join(", ")}`);
262
265
  }
263
266
  const updated = addRelationshipOp({
264
- doc,
267
+ doc: loaded.doc,
265
268
  rel: {
266
269
  from,
267
270
  to,
268
271
  type: relType.data,
269
272
  },
270
273
  });
274
+ saveDocument(updated, loaded.format, loaded.path);
271
275
  return {
272
276
  content: [
273
277
  {
@@ -290,17 +294,18 @@ server.registerTool("remove-relationship", {
290
294
  type: z.string().describe("Relationship type"),
291
295
  }),
292
296
  }, ({ path, from, to, type }) => {
293
- const { doc } = loadDocument(path);
297
+ const loaded = loadDocument(path);
294
298
  const relType = RelationshipType.safeParse(type);
295
299
  if (!relType.success) {
296
300
  throw new Error(`Invalid relationship type: "${type}". Valid types: ${RelationshipType.options.join(", ")}`);
297
301
  }
298
302
  const result = removeRelationshipOp({
299
- doc,
303
+ doc: loaded.doc,
300
304
  from,
301
305
  to,
302
306
  type: relType.data,
303
307
  });
308
+ saveDocument(result.doc, loaded.format, loaded.path);
304
309
  return {
305
310
  content: [
306
311
  {
@@ -2,6 +2,10 @@ import * as z from "zod";
2
2
  import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
3
3
  import { join, basename } from "node:path";
4
4
  import { NODE_FILE_MAP, NODE_LABEL_TO_TYPE, RELATIONSHIP_TYPE_LABELS, RELATIONSHIP_LABEL_TO_TYPE, NodeType, RelationshipType, NodeStatus, ExternalReferenceRole, } from "./schema.js";
5
+ /** Strip markdown link syntax `[text](url)` → `text`. */
6
+ function stripMarkdownLink(s) {
7
+ return s.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
8
+ }
5
9
  const LABEL_TO_TYPE = Object.fromEntries(Object.entries(NODE_LABEL_TO_TYPE).map(([k, v]) => [k.toLowerCase(), v]));
6
10
  const operationType = z.enum(["add", "update", "remove", "link"]);
7
11
  function parseNodeType(s) {
@@ -162,18 +166,18 @@ function parseListItems(body, prefix) {
162
166
  collecting = true;
163
167
  const inline = line.slice(prefix.length + 1).trim();
164
168
  if (inline) {
165
- items.push(inline);
169
+ items.push(stripMarkdownLink(inline));
166
170
  collecting = false;
167
171
  }
168
172
  continue;
169
173
  }
170
174
  if (collecting && line.startsWith(" - ")) {
171
- items.push(line.slice(4));
175
+ items.push(stripMarkdownLink(line.slice(4)));
172
176
  }
173
177
  else if (collecting &&
174
178
  line.startsWith("- ") &&
175
179
  !isRelationshipLabel(line)) {
176
- items.push(line.slice(2));
180
+ items.push(stripMarkdownLink(line.slice(2)));
177
181
  }
178
182
  else if (collecting) {
179
183
  collecting = false;
@@ -210,7 +214,7 @@ function parseRelationshipsFromBody(body, nodeId) {
210
214
  if (items.length === 0) {
211
215
  const val = parseSingleValue(body, `- ${label}`);
212
216
  if (val) {
213
- rels.push({ from: nodeId, to: val, type: relType });
217
+ rels.push({ from: nodeId, to: stripMarkdownLink(val), type: relType });
214
218
  }
215
219
  }
216
220
  else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sysprom",
3
- "version": "1.13.1",
3
+ "version": "1.14.0",
4
4
  "description": "SysProM — System Provenance Model CLI and library",
5
5
  "author": "ExaDev",
6
6
  "homepage": "https://exadev.github.io/SysProM",