sysprom 1.16.0 → 1.17.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 +2 -0
- package/dist/src/cli/commands/add.js +52 -36
- package/dist/src/cli/commands/infer.js +44 -25
- package/dist/src/cli/commands/query.js +35 -37
- package/dist/src/cli/commands/speckit.js +81 -77
- package/dist/src/cli/commands/stats.js +4 -4
- package/dist/src/cli/commands/update.js +33 -20
- package/dist/src/io.js +59 -8
- package/dist/src/mcp/server.js +269 -112
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
/sɪs.prɒm/
|
|
4
4
|
|
|
5
|
+
[](https://www.npmjs.com/package/sysprom)[](https://github.com/ExaDev/SysProM)
|
|
6
|
+
|
|
5
7
|
A recursive, decision-driven model for recording where every part of a system came from, what decisions shaped it, and how it reached its current form.
|
|
6
8
|
|
|
7
9
|
## Install
|
|
@@ -31,6 +31,49 @@ const optsSchema = mutationOpts.extend({
|
|
|
31
31
|
.optional()
|
|
32
32
|
.describe("Option in format 'ID:description' or just 'description' (repeatable)"),
|
|
33
33
|
});
|
|
34
|
+
/**
|
|
35
|
+
* Populate optional node fields from CLI options.
|
|
36
|
+
* @param node - The node to populate
|
|
37
|
+
* @param description - Optional description
|
|
38
|
+
* @param status - Optional (pre-validated) status
|
|
39
|
+
* @param context - Optional decision context
|
|
40
|
+
* @param rationale - Optional decision rationale
|
|
41
|
+
* @param scope - Optional change scope
|
|
42
|
+
* @param selected - Optional selected option ID
|
|
43
|
+
* @param options - Optional array of option descriptions
|
|
44
|
+
* @example
|
|
45
|
+
* ```ts
|
|
46
|
+
* const node: Node = { id: "D1", type: "decision", name: "My Decision" };
|
|
47
|
+
* populateNodeFromOpts(node, desc, status, ctx, rat, scope, sel, opts);
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
function populateNodeFromOpts(node, description, status, context, rationale, scope, selected, options) {
|
|
51
|
+
if (description)
|
|
52
|
+
node.description = description;
|
|
53
|
+
if (status)
|
|
54
|
+
node.status = status;
|
|
55
|
+
if (context)
|
|
56
|
+
node.context = context;
|
|
57
|
+
if (rationale)
|
|
58
|
+
node.rationale = rationale;
|
|
59
|
+
if (scope?.length)
|
|
60
|
+
node.scope = scope;
|
|
61
|
+
if (selected)
|
|
62
|
+
node.selected = selected;
|
|
63
|
+
if (options?.length) {
|
|
64
|
+
node.options = options.map((arg, i) => {
|
|
65
|
+
const colonIdx = arg.indexOf(":");
|
|
66
|
+
if (colonIdx >= 0) {
|
|
67
|
+
return {
|
|
68
|
+
id: arg.slice(0, colonIdx),
|
|
69
|
+
description: arg.slice(colonIdx + 1),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const letter = String.fromCharCode(65 + i);
|
|
73
|
+
return { id: `${node.id}-OPT-${letter}`, description: arg };
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
34
77
|
export const addCommand = {
|
|
35
78
|
name: "add",
|
|
36
79
|
description: addNodeOp.def.description,
|
|
@@ -49,45 +92,18 @@ export const addCommand = {
|
|
|
49
92
|
console.error(`Unknown node type: "${type}". Valid types: ${NodeType.options.join(", ")}`);
|
|
50
93
|
process.exit(1);
|
|
51
94
|
}
|
|
95
|
+
if (opts.status && !NodeStatus.is(opts.status)) {
|
|
96
|
+
console.error(`Unknown status: "${opts.status}". Valid statuses: ${NodeStatus.options.join(", ")}`);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
52
99
|
const id = opts.id ?? nextIdOp({ doc, type });
|
|
53
|
-
// Build the node from CLI options
|
|
54
100
|
const node = { id, type, name: opts.name };
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (!NodeStatus.is(opts.status)) {
|
|
60
|
-
console.error(`Unknown status: "${opts.status}". Valid statuses: ${NodeStatus.options.join(", ")}`);
|
|
61
|
-
process.exit(1);
|
|
62
|
-
}
|
|
63
|
-
node.status = opts.status;
|
|
64
|
-
}
|
|
65
|
-
if (opts.context) {
|
|
66
|
-
node.context = opts.context;
|
|
67
|
-
}
|
|
68
|
-
if (opts.rationale) {
|
|
69
|
-
node.rationale = opts.rationale;
|
|
70
|
-
}
|
|
71
|
-
if (opts.scope && opts.scope.length > 0) {
|
|
72
|
-
node.scope = opts.scope;
|
|
73
|
-
}
|
|
74
|
-
if (opts.selected) {
|
|
75
|
-
node.selected = opts.selected;
|
|
76
|
-
}
|
|
77
|
-
if (opts.option && opts.option.length > 0) {
|
|
78
|
-
node.options = opts.option.map((arg, i) => {
|
|
79
|
-
const colonIdx = arg.indexOf(":");
|
|
80
|
-
if (colonIdx >= 0) {
|
|
81
|
-
return {
|
|
82
|
-
id: arg.slice(0, colonIdx),
|
|
83
|
-
description: arg.slice(colonIdx + 1),
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
// Auto-generate ID: D26-OPT-A, D26-OPT-B, etc.
|
|
87
|
-
const letter = String.fromCharCode(65 + i); // A, B, C, ...
|
|
88
|
-
return { id: `${id}-OPT-${letter}`, description: arg };
|
|
89
|
-
});
|
|
101
|
+
// Use type guard to narrow status type after validation
|
|
102
|
+
let status;
|
|
103
|
+
if (opts.status && NodeStatus.is(opts.status)) {
|
|
104
|
+
status = opts.status;
|
|
90
105
|
}
|
|
106
|
+
populateNodeFromOpts(node, opts.description, status, opts.context, opts.rationale, opts.scope, opts.selected, opts.option);
|
|
91
107
|
try {
|
|
92
108
|
const newDoc = addNodeOp({
|
|
93
109
|
doc,
|
|
@@ -5,9 +5,17 @@ import { inferCompletenessOp, inferLifecycleOp, inferImpactOp, inferDerivedOp, }
|
|
|
5
5
|
// ---------------------------------------------------------------------------
|
|
6
6
|
// Presentation helpers
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
8
|
+
function getScoreColour(score) {
|
|
9
|
+
if (score === 1)
|
|
10
|
+
return pc.green;
|
|
11
|
+
if (score >= 0.5)
|
|
12
|
+
return pc.yellow;
|
|
13
|
+
return pc.red;
|
|
14
|
+
}
|
|
8
15
|
function printCompletenessNode(r) {
|
|
9
|
-
const scoreColour = r.score
|
|
10
|
-
|
|
16
|
+
const scoreColour = getScoreColour(r.score);
|
|
17
|
+
const scoreText = `[${(r.score * 100).toFixed(0)}%]`;
|
|
18
|
+
console.log(`${pc.cyan(r.id.padEnd(12))} ${pc.dim(r.type.padEnd(16))} ${pc.bold(r.name)} ${scoreColour(scoreText)}`);
|
|
11
19
|
for (const issue of r.issues) {
|
|
12
20
|
console.log(` ${pc.dim("•")} ${pc.red(issue)}`);
|
|
13
21
|
}
|
|
@@ -21,7 +29,8 @@ function printLifecycleNode(r) {
|
|
|
21
29
|
unknown: pc.dim,
|
|
22
30
|
};
|
|
23
31
|
const colour = phaseColours[r.inferredPhase] ?? pc.dim;
|
|
24
|
-
|
|
32
|
+
const phaseLabel = colour(`[${r.inferredPhase}]`);
|
|
33
|
+
console.log(`${pc.cyan(r.id.padEnd(12))} ${pc.dim(r.type.padEnd(16))} ${pc.bold(r.name)} ${phaseLabel} ${pc.dim(r.inferredState)}`);
|
|
25
34
|
}
|
|
26
35
|
function printImpactNode(r) {
|
|
27
36
|
const typeColours = {
|
|
@@ -32,7 +41,9 @@ function printImpactNode(r) {
|
|
|
32
41
|
const colour = typeColours[r.impactType] ?? pc.dim;
|
|
33
42
|
const nodeName = r.node ? r.node.name : "(unknown)";
|
|
34
43
|
const indent = " ".repeat(r.distance);
|
|
35
|
-
|
|
44
|
+
const distanceLabel = pc.dim(`(${String(r.distance)})`);
|
|
45
|
+
const typeLabel = colour(`[${r.impactType}]`);
|
|
46
|
+
console.log(`${indent}${pc.cyan(r.id)} ${distanceLabel} ${typeLabel} ${pc.bold(nodeName)}`);
|
|
36
47
|
}
|
|
37
48
|
function printDerivedRelationship(r) {
|
|
38
49
|
const typeColours = {
|
|
@@ -41,7 +52,8 @@ function printDerivedRelationship(r) {
|
|
|
41
52
|
inverse: pc.green,
|
|
42
53
|
};
|
|
43
54
|
const colour = typeColours[r.derivationType] ?? pc.dim;
|
|
44
|
-
|
|
55
|
+
const derivationLabel = pc.dim(`[${r.derivationType}]`);
|
|
56
|
+
console.log(`${pc.cyan(r.from.padEnd(12))} ${colour(r.type.padEnd(20))} ${pc.cyan(r.to)} ${derivationLabel}`);
|
|
45
57
|
}
|
|
46
58
|
// ---------------------------------------------------------------------------
|
|
47
59
|
// Arg/opt schemas
|
|
@@ -79,12 +91,13 @@ const completenessSubcommand = {
|
|
|
79
91
|
console.log(JSON.stringify(result, null, 2));
|
|
80
92
|
}
|
|
81
93
|
else {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
94
|
+
const avgScore = (result.averageScore * 100).toFixed(1);
|
|
95
|
+
const completeText = pc.green(`${String(result.completeNodes)} complete`);
|
|
96
|
+
const incompleteText = pc.red(`${String(result.incompleteNodes)} incomplete`);
|
|
97
|
+
const scorePrefix = pc.dim(`Average score: ${avgScore}% | `);
|
|
98
|
+
const separator = pc.dim(" | ");
|
|
99
|
+
const summary = scorePrefix + completeText + separator + incompleteText;
|
|
100
|
+
console.log(pc.bold("\nCompleteness Analysis\n") + summary + "\n");
|
|
88
101
|
// Show incomplete nodes first
|
|
89
102
|
const incomplete = result.nodes.filter((n) => n.score < 1);
|
|
90
103
|
const complete = result.nodes.filter((n) => n.score === 1);
|
|
@@ -111,9 +124,9 @@ const lifecycleSubcommand = {
|
|
|
111
124
|
console.log(JSON.stringify(result, null, 2));
|
|
112
125
|
}
|
|
113
126
|
else {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
127
|
+
const { early, middle, late, terminal, unknown } = result.summary;
|
|
128
|
+
const summary = pc.dim(`Early: ${String(early)} | Middle: ${String(middle)} | Late: ${String(late)} | Terminal: ${String(terminal)} | Unknown: ${String(unknown)}`);
|
|
129
|
+
console.log(pc.bold("\nLifecycle Analysis\n") + summary + "\n");
|
|
117
130
|
for (const n of result.nodes)
|
|
118
131
|
printLifecycleNode(n);
|
|
119
132
|
}
|
|
@@ -149,9 +162,10 @@ const impactSubcommand = {
|
|
|
149
162
|
? ` [depth: ${String(args.maxDepth)}]`
|
|
150
163
|
: "";
|
|
151
164
|
const filterLabel = args.filter ? ` [filter: ${args.filter}]` : "";
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
165
|
+
const title = `\nImpact Analysis from ${args.id}${directionLabel}${depthLabel}${filterLabel}\n`;
|
|
166
|
+
const { direct, transitive, potential, total } = result.summary;
|
|
167
|
+
const summary = pc.dim(`Direct: ${String(direct)} | Transitive: ${String(transitive)} | Potential: ${String(potential)} | Total: ${String(total)}`);
|
|
168
|
+
console.log(pc.bold(title) + summary + "\n");
|
|
155
169
|
if (result.impactedNodes.length === 0) {
|
|
156
170
|
console.log(pc.dim("No impacted nodes found"));
|
|
157
171
|
}
|
|
@@ -175,9 +189,9 @@ const derivedSubcommand = {
|
|
|
175
189
|
console.log(JSON.stringify(result, null, 2));
|
|
176
190
|
}
|
|
177
191
|
else {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
192
|
+
const { transitive, composite, inverse, total } = result.summary;
|
|
193
|
+
const summary = pc.dim(`Transitive: ${String(transitive)} | Composite: ${String(composite)} | Inverse: ${String(inverse)} | Total: ${String(total)}`);
|
|
194
|
+
console.log(pc.bold("\nDerived Relationships\n") + summary + "\n");
|
|
181
195
|
if (result.derivedRelationships.length === 0) {
|
|
182
196
|
console.log(pc.dim("No derived relationships found"));
|
|
183
197
|
}
|
|
@@ -198,18 +212,23 @@ const allSubcommand = {
|
|
|
198
212
|
// Completeness
|
|
199
213
|
console.log(pc.bold("\n=== Completeness ===\n"));
|
|
200
214
|
const completeness = inferCompletenessOp({ doc });
|
|
201
|
-
|
|
202
|
-
|
|
215
|
+
const completeScore = (completeness.averageScore * 100).toFixed(1);
|
|
216
|
+
const completeText = pc.green(`${String(completeness.completeNodes)} complete`);
|
|
217
|
+
const incompleteText = pc.red(`${String(completeness.incompleteNodes)} incomplete`);
|
|
218
|
+
console.log(pc.dim(`Average score: ${completeScore}% | `) +
|
|
219
|
+
completeText +
|
|
203
220
|
pc.dim(" | ") +
|
|
204
|
-
|
|
221
|
+
incompleteText);
|
|
205
222
|
// Lifecycle
|
|
206
223
|
console.log(pc.bold("\n=== Lifecycle ===\n"));
|
|
207
224
|
const lifecycle = inferLifecycleOp({ doc });
|
|
208
|
-
|
|
225
|
+
const { early, middle, late, terminal, unknown } = lifecycle.summary;
|
|
226
|
+
console.log(pc.dim(`Early: ${String(early)} | Middle: ${String(middle)} | Late: ${String(late)} | Terminal: ${String(terminal)} | Unknown: ${String(unknown)}`));
|
|
209
227
|
// Derived
|
|
210
228
|
console.log(pc.bold("\n=== Derived Relationships ===\n"));
|
|
211
229
|
const derived = inferDerivedOp({ doc });
|
|
212
|
-
|
|
230
|
+
const { transitive, composite, inverse, total } = derived.summary;
|
|
231
|
+
console.log(pc.dim(`Transitive: ${String(transitive)} | Composite: ${String(composite)} | Inverse: ${String(inverse)} | Total: ${String(total)}`));
|
|
213
232
|
if (opts.json) {
|
|
214
233
|
console.log(JSON.stringify({
|
|
215
234
|
completeness,
|
|
@@ -7,43 +7,41 @@ import { NodeType, NodeStatus } from "../../schema.js";
|
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
8
8
|
// Presentation helpers
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
|
-
function
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
.map(([k, v]) => `${k}=${String(v)}`)
|
|
34
|
-
.join(", ");
|
|
35
|
-
console.log(` ${pc.dim("Lifecycle")}: ${states}`);
|
|
36
|
-
}
|
|
37
|
-
if (r.includes)
|
|
38
|
-
console.log(` ${pc.dim("Includes")}: ${r.includes.join(", ")}`);
|
|
39
|
-
console.log("");
|
|
10
|
+
function printNodeCompact(r) {
|
|
11
|
+
const desc = r.description
|
|
12
|
+
? " — " + textToString(r.description).slice(0, 60)
|
|
13
|
+
: "";
|
|
14
|
+
console.log(`${pc.cyan(r.id.padEnd(12))} ${pc.dim(r.type.padEnd(16))} ${pc.bold(r.name)}${desc}`);
|
|
15
|
+
}
|
|
16
|
+
function printNodeVerbose(r) {
|
|
17
|
+
console.log(`${pc.cyan(r.id)} — ${pc.bold(r.name)}`);
|
|
18
|
+
console.log(` ${pc.dim("Type")}: ${r.type}`);
|
|
19
|
+
if (r.status)
|
|
20
|
+
console.log(` ${pc.dim("Status")}: ${pc.yellow(r.status)}`);
|
|
21
|
+
if (r.description)
|
|
22
|
+
console.log(` ${pc.dim("Description")}: ${textToString(r.description)}`);
|
|
23
|
+
if (r.context)
|
|
24
|
+
console.log(` ${pc.dim("Context")}: ${textToString(r.context)}`);
|
|
25
|
+
if (r.rationale)
|
|
26
|
+
console.log(` ${pc.dim("Rationale")}: ${textToString(r.rationale)}`);
|
|
27
|
+
if (r.selected)
|
|
28
|
+
console.log(` ${pc.dim("Selected")}: ${r.selected}`);
|
|
29
|
+
if (r.options) {
|
|
30
|
+
console.log(` ${pc.dim("Options")}:`);
|
|
31
|
+
for (const o of r.options)
|
|
32
|
+
console.log(` ${o.id}: ${textToString(o.description)}`);
|
|
40
33
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
34
|
+
if (r.scope)
|
|
35
|
+
console.log(` ${pc.dim("Scope")}: ${r.scope.join(", ")}`);
|
|
36
|
+
if (r.lifecycle) {
|
|
37
|
+
const states = Object.entries(r.lifecycle)
|
|
38
|
+
.map(([k, v]) => `${k}=${String(v)}`)
|
|
39
|
+
.join(", ");
|
|
40
|
+
console.log(` ${pc.dim("Lifecycle")}: ${states}`);
|
|
46
41
|
}
|
|
42
|
+
if (r.includes)
|
|
43
|
+
console.log(` ${pc.dim("Includes")}: ${r.includes.join(", ")}`);
|
|
44
|
+
console.log("");
|
|
47
45
|
}
|
|
48
46
|
function isTraceTreeNode(value) {
|
|
49
47
|
if (typeof value !== "object" || value === null)
|
|
@@ -103,7 +101,7 @@ const nodesSubcommand = {
|
|
|
103
101
|
}
|
|
104
102
|
else {
|
|
105
103
|
for (const n of nodes)
|
|
106
|
-
|
|
104
|
+
printNodeCompact(n);
|
|
107
105
|
console.log(`\n${String(nodes.length)} node(s)`);
|
|
108
106
|
}
|
|
109
107
|
},
|
|
@@ -127,7 +125,7 @@ const nodeSubcommand = {
|
|
|
127
125
|
console.log(JSON.stringify(result.node, null, 2));
|
|
128
126
|
}
|
|
129
127
|
else {
|
|
130
|
-
|
|
128
|
+
printNodeVerbose(result.node);
|
|
131
129
|
if (result.outgoing.length > 0) {
|
|
132
130
|
console.log(`${pc.dim("Outgoing relationships")}:`);
|
|
133
131
|
for (const r of result.outgoing)
|
|
@@ -18,6 +18,82 @@ function detectFormat(outputPath) {
|
|
|
18
18
|
}
|
|
19
19
|
return "json"; // default
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Validate that both paths exist, exits with error if not.
|
|
23
|
+
* @param inputPath - Path to SysProM document
|
|
24
|
+
* @param specKitDir - Path to Spec-Kit directory
|
|
25
|
+
* @example
|
|
26
|
+
* ```ts
|
|
27
|
+
* validatePaths(inputPath, specKitDir);
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
function validatePaths(inputPath, specKitDir) {
|
|
31
|
+
if (!existsSync(inputPath)) {
|
|
32
|
+
console.error(`Error: Input file does not exist: ${inputPath}`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
if (!existsSync(specKitDir)) {
|
|
36
|
+
console.error(`Error: Spec-Kit directory does not exist: ${specKitDir}`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Search up to 5 parent directories for Spec-Kit constitution file.
|
|
42
|
+
* @param specKitDir - Path to Spec-Kit directory to start search from
|
|
43
|
+
* @returns Path to constitution file if found, undefined otherwise
|
|
44
|
+
* @example
|
|
45
|
+
* ```ts
|
|
46
|
+
* const path = findConstitutionPath(specKitDir);
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
function findConstitutionPath(specKitDir) {
|
|
50
|
+
let searchDir = dirname(specKitDir);
|
|
51
|
+
for (let i = 0; i < 5; i++) {
|
|
52
|
+
const project = detectSpecKitProject(searchDir);
|
|
53
|
+
if (project.constitutionPath)
|
|
54
|
+
return project.constitutionPath;
|
|
55
|
+
const parent = dirname(searchDir);
|
|
56
|
+
if (parent === searchDir)
|
|
57
|
+
break;
|
|
58
|
+
searchDir = parent;
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Print diff results in human-readable format.
|
|
64
|
+
* @param diff - The diff structure containing added, modified, and removed nodes
|
|
65
|
+
* @example
|
|
66
|
+
* ```ts
|
|
67
|
+
* printDiffResults(diff);
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
function printDiffResults(diff) {
|
|
71
|
+
console.log(`Diff between SysProM document and Spec-Kit directory:`);
|
|
72
|
+
if (diff.added.length === 0 &&
|
|
73
|
+
diff.modified.length === 0 &&
|
|
74
|
+
diff.removed.length === 0) {
|
|
75
|
+
console.log(` (no changes)`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (diff.added.length > 0) {
|
|
79
|
+
console.log(` Added: ${String(diff.added.length)} node(s)`);
|
|
80
|
+
for (const node of diff.added) {
|
|
81
|
+
console.log(` - ${node.id}: ${node.name}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (diff.modified.length > 0) {
|
|
85
|
+
console.log(` Modified: ${String(diff.modified.length)} node(s)`);
|
|
86
|
+
for (const { old } of diff.modified) {
|
|
87
|
+
console.log(` - ${old.id}: ${old.name}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (diff.removed.length > 0) {
|
|
91
|
+
console.log(` Removed: ${String(diff.removed.length)} node(s)`);
|
|
92
|
+
for (const node of diff.removed) {
|
|
93
|
+
console.log(` - ${node.id}: ${node.name}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
21
97
|
function compareDocuments(oldDoc, newDoc) {
|
|
22
98
|
const oldNodes = new Map(oldDoc.nodes.map((n) => [n.id, n]));
|
|
23
99
|
const newNodes = new Map(newDoc.nodes.map((n) => [n.id, n]));
|
|
@@ -134,32 +210,10 @@ const syncSubcommand = {
|
|
|
134
210
|
action(_args, opts) {
|
|
135
211
|
const inputPath = resolve(opts.input);
|
|
136
212
|
const specKitDir = resolve(opts.speckitDir);
|
|
137
|
-
|
|
138
|
-
console.error(`Error: Input file does not exist: ${inputPath}`);
|
|
139
|
-
process.exit(1);
|
|
140
|
-
}
|
|
141
|
-
if (!existsSync(specKitDir)) {
|
|
142
|
-
console.error(`Error: Spec-Kit directory does not exist: ${specKitDir}`);
|
|
143
|
-
process.exit(1);
|
|
144
|
-
}
|
|
145
|
-
// Determine the prefix: use flag if provided, otherwise use directory name
|
|
213
|
+
validatePaths(inputPath, specKitDir);
|
|
146
214
|
const idPrefix = opts.prefix ?? specKitDir.split("/").pop() ?? "FEAT";
|
|
147
|
-
// Load SysProM document
|
|
148
215
|
const { doc: syspromDoc, format } = loadDocument(inputPath);
|
|
149
|
-
|
|
150
|
-
let constitutionPath;
|
|
151
|
-
let searchDir = dirname(specKitDir);
|
|
152
|
-
for (let i = 0; i < 5; i++) {
|
|
153
|
-
const project = detectSpecKitProject(searchDir);
|
|
154
|
-
if (project.constitutionPath) {
|
|
155
|
-
constitutionPath = project.constitutionPath;
|
|
156
|
-
break;
|
|
157
|
-
}
|
|
158
|
-
const parent = dirname(searchDir);
|
|
159
|
-
if (parent === searchDir)
|
|
160
|
-
break;
|
|
161
|
-
searchDir = parent;
|
|
162
|
-
}
|
|
216
|
+
const constitutionPath = findConstitutionPath(specKitDir);
|
|
163
217
|
// Parse Spec-Kit feature
|
|
164
218
|
const specKitDoc = parseSpecKitFeature(specKitDir, idPrefix, constitutionPath);
|
|
165
219
|
// Compare documents
|
|
@@ -232,63 +286,13 @@ const diffSubcommand = {
|
|
|
232
286
|
action(_args, opts) {
|
|
233
287
|
const inputPath = resolve(opts.input);
|
|
234
288
|
const specKitDir = resolve(opts.speckitDir);
|
|
235
|
-
|
|
236
|
-
console.error(`Error: Input file does not exist: ${inputPath}`);
|
|
237
|
-
process.exit(1);
|
|
238
|
-
}
|
|
239
|
-
if (!existsSync(specKitDir)) {
|
|
240
|
-
console.error(`Error: Spec-Kit directory does not exist: ${specKitDir}`);
|
|
241
|
-
process.exit(1);
|
|
242
|
-
}
|
|
243
|
-
// Determine the prefix: use flag if provided, otherwise use directory name
|
|
289
|
+
validatePaths(inputPath, specKitDir);
|
|
244
290
|
const idPrefix = opts.prefix ?? specKitDir.split("/").pop() ?? "FEAT";
|
|
245
|
-
// Load SysProM document
|
|
246
291
|
const { doc: syspromDoc } = loadDocument(inputPath);
|
|
247
|
-
|
|
248
|
-
let constitutionPath;
|
|
249
|
-
let searchDir = dirname(specKitDir);
|
|
250
|
-
for (let i = 0; i < 5; i++) {
|
|
251
|
-
const project = detectSpecKitProject(searchDir);
|
|
252
|
-
if (project.constitutionPath) {
|
|
253
|
-
constitutionPath = project.constitutionPath;
|
|
254
|
-
break;
|
|
255
|
-
}
|
|
256
|
-
const parent = dirname(searchDir);
|
|
257
|
-
if (parent === searchDir)
|
|
258
|
-
break;
|
|
259
|
-
searchDir = parent;
|
|
260
|
-
}
|
|
261
|
-
// Parse Spec-Kit feature
|
|
292
|
+
const constitutionPath = findConstitutionPath(specKitDir);
|
|
262
293
|
const specKitDoc = parseSpecKitFeature(specKitDir, idPrefix, constitutionPath);
|
|
263
|
-
// Compare documents
|
|
264
294
|
const diff = compareDocuments(syspromDoc, specKitDoc);
|
|
265
|
-
|
|
266
|
-
console.log(`Diff between SysProM document and Spec-Kit directory:`);
|
|
267
|
-
if (diff.added.length === 0 &&
|
|
268
|
-
diff.modified.length === 0 &&
|
|
269
|
-
diff.removed.length === 0) {
|
|
270
|
-
console.log(` (no changes)`);
|
|
271
|
-
}
|
|
272
|
-
else {
|
|
273
|
-
if (diff.added.length > 0) {
|
|
274
|
-
console.log(` Added: ${String(diff.added.length)} node(s)`);
|
|
275
|
-
for (const node of diff.added) {
|
|
276
|
-
console.log(` - ${node.id}: ${node.name}`);
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
if (diff.modified.length > 0) {
|
|
280
|
-
console.log(` Modified: ${String(diff.modified.length)} node(s)`);
|
|
281
|
-
for (const { old } of diff.modified) {
|
|
282
|
-
console.log(` - ${old.id}: ${old.name}`);
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
if (diff.removed.length > 0) {
|
|
286
|
-
console.log(` Removed: ${String(diff.removed.length)} node(s)`);
|
|
287
|
-
for (const node of diff.removed) {
|
|
288
|
-
console.log(` - ${node.id}: ${node.name}`);
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
}
|
|
295
|
+
printDiffResults(diff);
|
|
292
296
|
},
|
|
293
297
|
};
|
|
294
298
|
// ============================================================================
|
|
@@ -12,13 +12,13 @@ export const statsCommand = {
|
|
|
12
12
|
console.log(`${pc.bold("SysProM Document")}: ${s.title}`);
|
|
13
13
|
console.log("");
|
|
14
14
|
console.log(pc.bold("Nodes by type:"));
|
|
15
|
-
for (const [type, count] of Object.entries(s.nodesByType).sort()) {
|
|
15
|
+
for (const [type, count] of Object.entries(s.nodesByType).sort(([a], [b]) => a.localeCompare(b))) {
|
|
16
16
|
console.log(` ${type.padEnd(20)} ${pc.cyan(String(count))}`);
|
|
17
17
|
}
|
|
18
18
|
console.log(` ${"TOTAL".padEnd(20)} ${pc.cyan(String(s.totalNodes))}`);
|
|
19
19
|
console.log("");
|
|
20
20
|
console.log(pc.bold("Relationships by type:"));
|
|
21
|
-
for (const [type, count] of Object.entries(s.relationshipsByType).sort()) {
|
|
21
|
+
for (const [type, count] of Object.entries(s.relationshipsByType).sort(([a], [b]) => a.localeCompare(b))) {
|
|
22
22
|
console.log(` ${type.padEnd(20)} ${pc.cyan(String(count))}`);
|
|
23
23
|
}
|
|
24
24
|
console.log(` ${"TOTAL".padEnd(20)} ${pc.cyan(String(s.totalRelationships))}`);
|
|
@@ -30,14 +30,14 @@ export const statsCommand = {
|
|
|
30
30
|
if (Object.keys(s.decisionLifecycle).length > 0) {
|
|
31
31
|
console.log("");
|
|
32
32
|
console.log(pc.bold("Decision lifecycle:"));
|
|
33
|
-
for (const [state, count] of Object.entries(s.decisionLifecycle).sort()) {
|
|
33
|
+
for (const [state, count] of Object.entries(s.decisionLifecycle).sort(([a], [b]) => a.localeCompare(b))) {
|
|
34
34
|
console.log(` ${state.padEnd(20)} ${pc.cyan(String(count))}`);
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
if (Object.keys(s.changeLifecycle).length > 0) {
|
|
38
38
|
console.log("");
|
|
39
39
|
console.log(pc.bold("Change lifecycle:"));
|
|
40
|
-
for (const [state, count] of Object.entries(s.changeLifecycle).sort()) {
|
|
40
|
+
for (const [state, count] of Object.entries(s.changeLifecycle).sort(([a], [b]) => a.localeCompare(b))) {
|
|
41
41
|
console.log(` ${state.padEnd(20)} ${pc.cyan(String(count))}`);
|
|
42
42
|
}
|
|
43
43
|
}
|
|
@@ -6,21 +6,44 @@ import { mutationOpts, loadDoc, persistDoc } from "../shared.js";
|
|
|
6
6
|
// CLI helper functions
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
8
8
|
function parseLifecycleValue(rawVal) {
|
|
9
|
-
if (rawVal === "true")
|
|
9
|
+
if (rawVal === "true")
|
|
10
10
|
return true;
|
|
11
|
-
|
|
12
|
-
if (rawVal === "false") {
|
|
11
|
+
if (rawVal === "false")
|
|
13
12
|
return false;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
return rawVal; // ISO date string
|
|
17
|
-
}
|
|
13
|
+
if (/^\d{4}-\d{2}-\d{2}/.test(rawVal))
|
|
14
|
+
return rawVal;
|
|
18
15
|
return rawVal;
|
|
19
16
|
}
|
|
20
17
|
function parseMetaValue(val) {
|
|
21
18
|
const numVal = Number(val);
|
|
22
19
|
return Number.isFinite(numVal) && val === String(numVal) ? numVal : val;
|
|
23
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Parse lifecycle field updates from key=value format.
|
|
23
|
+
* @param nodeLifecycle - Optional existing lifecycle object from the node
|
|
24
|
+
* @param lifecycleArgs - Array of key=value strings to parse
|
|
25
|
+
* @returns Lifecycle object with parsed values, or null if no args
|
|
26
|
+
* @example
|
|
27
|
+
* ```ts
|
|
28
|
+
* const lifecycle = parseLifecycleFields(node.lifecycle, ["created=2026-01-01"]);
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
function parseLifecycleFields(nodeLifecycle, lifecycleArgs) {
|
|
32
|
+
if (!lifecycleArgs.length)
|
|
33
|
+
return null;
|
|
34
|
+
const lifecycle = { ...nodeLifecycle };
|
|
35
|
+
for (const kv of lifecycleArgs) {
|
|
36
|
+
const eqIdx = kv.indexOf("=");
|
|
37
|
+
if (eqIdx < 0) {
|
|
38
|
+
console.error(`Invalid --lifecycle format: ${kv} (expected key=value)`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
const key = kv.slice(0, eqIdx);
|
|
42
|
+
const rawVal = kv.slice(eqIdx + 1);
|
|
43
|
+
lifecycle[key] = parseLifecycleValue(rawVal);
|
|
44
|
+
}
|
|
45
|
+
return lifecycle;
|
|
46
|
+
}
|
|
24
47
|
// ---------------------------------------------------------------------------
|
|
25
48
|
// Arg/opt schemas
|
|
26
49
|
// ---------------------------------------------------------------------------
|
|
@@ -80,19 +103,9 @@ const nodeSubcommand = {
|
|
|
80
103
|
fields.context = opts.context;
|
|
81
104
|
if (opts.rationale !== undefined)
|
|
82
105
|
fields.rationale = opts.rationale;
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const eqIdx = kv.indexOf("=");
|
|
87
|
-
if (eqIdx < 0) {
|
|
88
|
-
console.error(`Invalid --lifecycle format: ${kv} (expected key=value)`);
|
|
89
|
-
process.exit(1);
|
|
90
|
-
}
|
|
91
|
-
const key = kv.slice(0, eqIdx);
|
|
92
|
-
const rawVal = kv.slice(eqIdx + 1);
|
|
93
|
-
lifecycle[key] = parseLifecycleValue(rawVal);
|
|
94
|
-
}
|
|
95
|
-
fields.lifecycle = lifecycle;
|
|
106
|
+
const lifecycleFields = parseLifecycleFields(node.lifecycle, opts.lifecycle ?? []);
|
|
107
|
+
if (lifecycleFields) {
|
|
108
|
+
fields.lifecycle = lifecycleFields;
|
|
96
109
|
}
|
|
97
110
|
if (Object.keys(fields).length === 0) {
|
|
98
111
|
console.error("No fields specified to update.");
|
package/dist/src/io.js
CHANGED
|
@@ -12,6 +12,22 @@ function detectFormat(input) {
|
|
|
12
12
|
return "json";
|
|
13
13
|
return "single-md";
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Wrap an error with a descriptive prefix and attach the original as cause.
|
|
17
|
+
* @param prefix - The error prefix (e.g., "Failed to parse JSON")
|
|
18
|
+
* @param error - The caught error
|
|
19
|
+
* @example
|
|
20
|
+
* ```ts
|
|
21
|
+
* try {
|
|
22
|
+
* const data = JSON.parse(content);
|
|
23
|
+
* } catch (error) {
|
|
24
|
+
* wrapError("Failed to parse JSON", error);
|
|
25
|
+
* }
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
function wrapError(prefix, error) {
|
|
29
|
+
throw new Error(`${prefix}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
30
|
+
}
|
|
15
31
|
/**
|
|
16
32
|
* Load a SysProM document from a file (JSON or Markdown).
|
|
17
33
|
* @param input - File path or directory to load from.
|
|
@@ -23,25 +39,60 @@ function detectFormat(input) {
|
|
|
23
39
|
*/
|
|
24
40
|
export function loadDocument(input) {
|
|
25
41
|
const path = resolve(input);
|
|
42
|
+
// Validate file/directory exists
|
|
43
|
+
try {
|
|
44
|
+
statSync(path);
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
48
|
+
wrapError(`Document not found at ${path}. Create one first with init-document`, error);
|
|
49
|
+
}
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
26
52
|
const format = detectFormat(path);
|
|
27
53
|
let doc;
|
|
28
54
|
switch (format) {
|
|
29
55
|
case "json": {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
56
|
+
try {
|
|
57
|
+
const content = readFileSync(path, "utf8");
|
|
58
|
+
let raw;
|
|
59
|
+
try {
|
|
60
|
+
raw = JSON.parse(content);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
wrapError("Failed to parse JSON", error);
|
|
64
|
+
}
|
|
65
|
+
const result = SysProMDocument.safeParse(raw);
|
|
66
|
+
if (!result.success) {
|
|
67
|
+
const issues = result.error.issues
|
|
68
|
+
.map((i) => ` ${i.path.join(".")}: ${i.message}`)
|
|
69
|
+
.join("\n");
|
|
70
|
+
throw new Error(`Invalid SysProM document:\n${issues}`);
|
|
71
|
+
}
|
|
72
|
+
doc = result.data;
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
wrapError("Failed to load JSON document", error);
|
|
34
76
|
}
|
|
35
|
-
doc = result.data;
|
|
36
77
|
break;
|
|
37
78
|
}
|
|
38
79
|
case "single-md": {
|
|
39
|
-
|
|
40
|
-
|
|
80
|
+
try {
|
|
81
|
+
const content = readFileSync(path, "utf8");
|
|
82
|
+
doc = markdownSingleToJson(content);
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
wrapError("Failed to load Markdown document", error);
|
|
86
|
+
}
|
|
41
87
|
break;
|
|
42
88
|
}
|
|
43
89
|
case "multi-md": {
|
|
44
|
-
|
|
90
|
+
try {
|
|
91
|
+
doc = markdownMultiDocToJson(path);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
wrapError("Failed to load Markdown documents", error);
|
|
95
|
+
}
|
|
45
96
|
break;
|
|
46
97
|
}
|
|
47
98
|
}
|
package/dist/src/mcp/server.js
CHANGED
|
@@ -5,11 +5,76 @@ import * as z from "zod";
|
|
|
5
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, inferCompletenessOp, inferLifecycleOp, inferImpactOp, impactSummaryOp, inferDerivedOp, } from "../operations/index.js";
|
|
8
|
+
/**
|
|
9
|
+
* Wrap an error with a descriptive prefix and attach the original as cause.
|
|
10
|
+
* @param prefix - The error prefix (e.g., "Failed to add node")
|
|
11
|
+
* @param error - The caught error
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* try {
|
|
15
|
+
* someOperation();
|
|
16
|
+
* } catch (error) {
|
|
17
|
+
* wrapError("Failed to do X", error);
|
|
18
|
+
* }
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
function wrapError(prefix, error) {
|
|
22
|
+
throw new Error(`${prefix}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
23
|
+
}
|
|
8
24
|
// Create MCP server instance
|
|
9
25
|
const server = new McpServer({
|
|
10
26
|
name: "sysprom-mcp",
|
|
11
27
|
version: "1.0.0",
|
|
12
28
|
});
|
|
29
|
+
// Register init-document tool
|
|
30
|
+
server.registerTool("init-document", {
|
|
31
|
+
description: "Initialise a new SysProM document with metadata and empty structure",
|
|
32
|
+
inputSchema: z.object({
|
|
33
|
+
path: z
|
|
34
|
+
.string()
|
|
35
|
+
.describe("Output path for the document (must end in .json)"),
|
|
36
|
+
title: z.string().describe("Document title"),
|
|
37
|
+
description: z.string().optional().describe("Document description"),
|
|
38
|
+
scope: z.string().optional().describe("Document scope"),
|
|
39
|
+
}),
|
|
40
|
+
}, ({ path, title, description, scope }) => {
|
|
41
|
+
try {
|
|
42
|
+
const now = new Date().toISOString().split("T")[0];
|
|
43
|
+
const doc = {
|
|
44
|
+
$schema: "https://sysprom.org/schema.json",
|
|
45
|
+
metadata: {
|
|
46
|
+
title,
|
|
47
|
+
...(description && { description }),
|
|
48
|
+
...(scope && { scope }),
|
|
49
|
+
version: "1.0.0",
|
|
50
|
+
created: now,
|
|
51
|
+
},
|
|
52
|
+
nodes: [],
|
|
53
|
+
relationships: [],
|
|
54
|
+
};
|
|
55
|
+
try {
|
|
56
|
+
saveDocument(doc, "json", path);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
wrapError("Failed to save document", error);
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
content: [
|
|
63
|
+
{
|
|
64
|
+
type: "text",
|
|
65
|
+
text: JSON.stringify({
|
|
66
|
+
message: "Document initialised",
|
|
67
|
+
path,
|
|
68
|
+
title,
|
|
69
|
+
}, null, 2),
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
wrapError("init-document", error);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
13
78
|
// Register validate tool
|
|
14
79
|
server.registerTool("validate", {
|
|
15
80
|
description: "Validate a SysProM document and return any validation issues",
|
|
@@ -145,34 +210,50 @@ server.registerTool("add-node", {
|
|
|
145
210
|
description: z.string().optional().describe("Node description"),
|
|
146
211
|
}),
|
|
147
212
|
}, ({ path, type, id, name, description }) => {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
...(description && { description }),
|
|
161
|
-
},
|
|
162
|
-
});
|
|
163
|
-
saveDocument(updated, loaded.format, loaded.path);
|
|
164
|
-
return {
|
|
165
|
-
content: [
|
|
166
|
-
{
|
|
167
|
-
type: "text",
|
|
168
|
-
text: JSON.stringify({
|
|
169
|
-
message: "Node added",
|
|
213
|
+
try {
|
|
214
|
+
const loaded = loadDocument(path);
|
|
215
|
+
const nodeType = NodeType.safeParse(type);
|
|
216
|
+
if (!nodeType.success) {
|
|
217
|
+
throw new Error(`Invalid node type: "${type}". Valid types: ${NodeType.options.join(", ")}`);
|
|
218
|
+
}
|
|
219
|
+
const nodeId = id ?? nextIdOp({ doc: loaded.doc, type: nodeType.data });
|
|
220
|
+
let updated;
|
|
221
|
+
try {
|
|
222
|
+
updated = addNodeOp({
|
|
223
|
+
doc: loaded.doc,
|
|
224
|
+
node: {
|
|
170
225
|
id: nodeId,
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
226
|
+
type: nodeType.data,
|
|
227
|
+
name,
|
|
228
|
+
...(description && { description }),
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
wrapError("Failed to add node", error);
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
saveDocument(updated, loaded.format, loaded.path);
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
throw new Error(`Failed to save document: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
content: [
|
|
243
|
+
{
|
|
244
|
+
type: "text",
|
|
245
|
+
text: JSON.stringify({
|
|
246
|
+
message: "Node added",
|
|
247
|
+
id: nodeId,
|
|
248
|
+
nodeCount: updated.nodes.length,
|
|
249
|
+
}, null, 2),
|
|
250
|
+
},
|
|
251
|
+
],
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
wrapError("add-node", error);
|
|
256
|
+
}
|
|
176
257
|
});
|
|
177
258
|
// Register remove-node tool
|
|
178
259
|
server.registerTool("remove-node", {
|
|
@@ -182,21 +263,37 @@ server.registerTool("remove-node", {
|
|
|
182
263
|
id: z.string().describe("Node ID"),
|
|
183
264
|
}),
|
|
184
265
|
}, ({ path, id }) => {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
266
|
+
try {
|
|
267
|
+
const loaded = loadDocument(path);
|
|
268
|
+
let result;
|
|
269
|
+
try {
|
|
270
|
+
result = removeNodeOp({ doc: loaded.doc, id });
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
wrapError("Failed to remove node", error);
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
saveDocument(result.doc, loaded.format, loaded.path);
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
throw new Error(`Failed to save document: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
content: [
|
|
283
|
+
{
|
|
284
|
+
type: "text",
|
|
285
|
+
text: JSON.stringify({
|
|
286
|
+
message: `Node ${id} removed`,
|
|
287
|
+
nodeCount: result.doc.nodes.length,
|
|
288
|
+
warnings: result.warnings,
|
|
289
|
+
}, null, 2),
|
|
290
|
+
},
|
|
291
|
+
],
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
wrapError("remove-node", error);
|
|
296
|
+
}
|
|
200
297
|
});
|
|
201
298
|
// Register update-node tool
|
|
202
299
|
server.registerTool("update-node", {
|
|
@@ -207,11 +304,10 @@ server.registerTool("update-node", {
|
|
|
207
304
|
fields: z.record(z.string(), z.unknown()).describe("Fields to update"),
|
|
208
305
|
}),
|
|
209
306
|
}, ({ path, id, fields }) => {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
if ([
|
|
307
|
+
try {
|
|
308
|
+
const loaded = loadDocument(path);
|
|
309
|
+
// Track which fields are valid
|
|
310
|
+
const allowedFields = [
|
|
215
311
|
"name",
|
|
216
312
|
"description",
|
|
217
313
|
"status",
|
|
@@ -227,26 +323,55 @@ server.registerTool("update-node", {
|
|
|
227
323
|
"input",
|
|
228
324
|
"output",
|
|
229
325
|
"external_references",
|
|
230
|
-
]
|
|
231
|
-
|
|
326
|
+
];
|
|
327
|
+
const droppedFields = [];
|
|
328
|
+
const validFields = Object.entries(fields).reduce((acc, [key, value]) => {
|
|
329
|
+
if (allowedFields.includes(key)) {
|
|
330
|
+
acc[key] = value;
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
droppedFields.push(key);
|
|
334
|
+
}
|
|
335
|
+
return acc;
|
|
336
|
+
}, {});
|
|
337
|
+
let updated;
|
|
338
|
+
try {
|
|
339
|
+
updated = updateNodeOp({
|
|
340
|
+
doc: loaded.doc,
|
|
341
|
+
id,
|
|
342
|
+
fields: validFields,
|
|
343
|
+
});
|
|
232
344
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
345
|
+
catch (error) {
|
|
346
|
+
wrapError("Failed to update node", error);
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
saveDocument(updated, loaded.format, loaded.path);
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
throw new Error(`Failed to save document: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
353
|
+
}
|
|
354
|
+
const node = updated.nodes.find((n) => n.id === id);
|
|
355
|
+
return {
|
|
356
|
+
content: [
|
|
357
|
+
{
|
|
358
|
+
type: "text",
|
|
359
|
+
text: JSON.stringify({
|
|
360
|
+
message: "Node updated",
|
|
361
|
+
node,
|
|
362
|
+
...(droppedFields.length > 0 && {
|
|
363
|
+
warnings: [
|
|
364
|
+
`These fields are not updateable and were ignored: ${droppedFields.join(", ")}`,
|
|
365
|
+
],
|
|
366
|
+
}),
|
|
367
|
+
}, null, 2),
|
|
368
|
+
},
|
|
369
|
+
],
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
wrapError("update-node", error);
|
|
374
|
+
}
|
|
250
375
|
});
|
|
251
376
|
// Register add-relationship tool
|
|
252
377
|
server.registerTool("add-relationship", {
|
|
@@ -258,31 +383,47 @@ server.registerTool("add-relationship", {
|
|
|
258
383
|
type: z.string().describe("Relationship type"),
|
|
259
384
|
}),
|
|
260
385
|
}, ({ path, from, to, type }) => {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
386
|
+
try {
|
|
387
|
+
const loaded = loadDocument(path);
|
|
388
|
+
const relType = RelationshipType.safeParse(type);
|
|
389
|
+
if (!relType.success) {
|
|
390
|
+
throw new Error(`Invalid relationship type: "${type}". Valid types: ${RelationshipType.options.join(", ")}`);
|
|
391
|
+
}
|
|
392
|
+
let updated;
|
|
393
|
+
try {
|
|
394
|
+
updated = addRelationshipOp({
|
|
395
|
+
doc: loaded.doc,
|
|
396
|
+
rel: {
|
|
397
|
+
from,
|
|
398
|
+
to,
|
|
399
|
+
type: relType.data,
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
wrapError("Failed to add relationship", error);
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
saveDocument(updated, loaded.format, loaded.path);
|
|
408
|
+
}
|
|
409
|
+
catch (error) {
|
|
410
|
+
throw new Error(`Failed to save document: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
411
|
+
}
|
|
412
|
+
return {
|
|
413
|
+
content: [
|
|
414
|
+
{
|
|
415
|
+
type: "text",
|
|
416
|
+
text: JSON.stringify({
|
|
417
|
+
message: "Relationship added",
|
|
418
|
+
relationshipCount: (updated.relationships ?? []).length,
|
|
419
|
+
}, null, 2),
|
|
420
|
+
},
|
|
421
|
+
],
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
catch (error) {
|
|
425
|
+
wrapError("add-relationship", error);
|
|
265
426
|
}
|
|
266
|
-
const updated = addRelationshipOp({
|
|
267
|
-
doc: loaded.doc,
|
|
268
|
-
rel: {
|
|
269
|
-
from,
|
|
270
|
-
to,
|
|
271
|
-
type: relType.data,
|
|
272
|
-
},
|
|
273
|
-
});
|
|
274
|
-
saveDocument(updated, loaded.format, loaded.path);
|
|
275
|
-
return {
|
|
276
|
-
content: [
|
|
277
|
-
{
|
|
278
|
-
type: "text",
|
|
279
|
-
text: JSON.stringify({
|
|
280
|
-
message: "Relationship added",
|
|
281
|
-
relationshipCount: (updated.relationships ?? []).length,
|
|
282
|
-
}, null, 2),
|
|
283
|
-
},
|
|
284
|
-
],
|
|
285
|
-
};
|
|
286
427
|
});
|
|
287
428
|
// Register remove-relationship tool
|
|
288
429
|
server.registerTool("remove-relationship", {
|
|
@@ -294,29 +435,45 @@ server.registerTool("remove-relationship", {
|
|
|
294
435
|
type: z.string().describe("Relationship type"),
|
|
295
436
|
}),
|
|
296
437
|
}, ({ path, from, to, type }) => {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
438
|
+
try {
|
|
439
|
+
const loaded = loadDocument(path);
|
|
440
|
+
const relType = RelationshipType.safeParse(type);
|
|
441
|
+
if (!relType.success) {
|
|
442
|
+
throw new Error(`Invalid relationship type: "${type}". Valid types: ${RelationshipType.options.join(", ")}`);
|
|
443
|
+
}
|
|
444
|
+
let result;
|
|
445
|
+
try {
|
|
446
|
+
result = removeRelationshipOp({
|
|
447
|
+
doc: loaded.doc,
|
|
448
|
+
from,
|
|
449
|
+
to,
|
|
450
|
+
type: relType.data,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
wrapError("Failed to remove relationship", error);
|
|
455
|
+
}
|
|
456
|
+
try {
|
|
457
|
+
saveDocument(result.doc, loaded.format, loaded.path);
|
|
458
|
+
}
|
|
459
|
+
catch (error) {
|
|
460
|
+
throw new Error(`Failed to save document: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
461
|
+
}
|
|
462
|
+
return {
|
|
463
|
+
content: [
|
|
464
|
+
{
|
|
465
|
+
type: "text",
|
|
466
|
+
text: JSON.stringify({
|
|
467
|
+
message: "Relationship removed",
|
|
468
|
+
relationshipCount: (result.doc.relationships ?? []).length,
|
|
469
|
+
}, null, 2),
|
|
470
|
+
},
|
|
471
|
+
],
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
catch (error) {
|
|
475
|
+
wrapError("remove-relationship", error);
|
|
301
476
|
}
|
|
302
|
-
const result = removeRelationshipOp({
|
|
303
|
-
doc: loaded.doc,
|
|
304
|
-
from,
|
|
305
|
-
to,
|
|
306
|
-
type: relType.data,
|
|
307
|
-
});
|
|
308
|
-
saveDocument(result.doc, loaded.format, loaded.path);
|
|
309
|
-
return {
|
|
310
|
-
content: [
|
|
311
|
-
{
|
|
312
|
-
type: "text",
|
|
313
|
-
text: JSON.stringify({
|
|
314
|
-
message: "Relationship removed",
|
|
315
|
-
relationshipCount: (result.doc.relationships ?? []).length,
|
|
316
|
-
}, null, 2),
|
|
317
|
-
},
|
|
318
|
-
],
|
|
319
|
-
};
|
|
320
477
|
});
|
|
321
478
|
// Register infer-completeness tool
|
|
322
479
|
server.registerTool("infer-completeness", {
|