@platformos/platformos-mcp-supervisor 0.0.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +105 -0
  2. package/dist/adapter-input.d.ts +113 -0
  3. package/dist/adapter-input.js +210 -0
  4. package/dist/adapter-input.spec.d.ts +1 -0
  5. package/dist/adapter-input.spec.js +275 -0
  6. package/dist/bin/args.d.ts +15 -2
  7. package/dist/bin/args.js +31 -3
  8. package/dist/bin/args.spec.d.ts +1 -0
  9. package/dist/bin/args.spec.js +35 -0
  10. package/dist/bin/platformos-mcp-supervisor.js +6 -2
  11. package/dist/check-docs.d.ts +21 -0
  12. package/dist/check-docs.js +38 -0
  13. package/dist/check-docs.spec.d.ts +1 -0
  14. package/dist/check-docs.spec.js +87 -0
  15. package/dist/context.d.ts +34 -0
  16. package/dist/context.js +12 -0
  17. package/dist/cost-model.d.ts +188 -0
  18. package/dist/cost-model.js +197 -0
  19. package/dist/cost-model.spec.d.ts +1 -0
  20. package/dist/cost-model.spec.js +91 -0
  21. package/dist/deadline.d.ts +27 -0
  22. package/dist/deadline.js +40 -0
  23. package/dist/deadline.spec.d.ts +1 -0
  24. package/dist/deadline.spec.js +83 -0
  25. package/dist/enrich/enrich.d.ts +105 -0
  26. package/dist/enrich/enrich.js +136 -0
  27. package/dist/enrich/enrich.spec.d.ts +1 -0
  28. package/dist/enrich/enrich.spec.js +184 -0
  29. package/dist/enrich/symbol.d.ts +16 -0
  30. package/dist/enrich/symbol.js +58 -0
  31. package/dist/enrich/symbol.spec.d.ts +1 -0
  32. package/dist/enrich/symbol.spec.js +89 -0
  33. package/dist/impact/dependants.d.ts +75 -0
  34. package/dist/impact/dependants.js +104 -0
  35. package/dist/impact/diff.d.ts +27 -0
  36. package/dist/impact/diff.js +33 -0
  37. package/dist/impact/impact.d.ts +21 -0
  38. package/dist/impact/impact.js +160 -0
  39. package/dist/impact/impact.spec.d.ts +1 -0
  40. package/dist/impact/impact.spec.js +357 -0
  41. package/dist/impact/project-scan.d.ts +39 -0
  42. package/dist/impact/project-scan.js +62 -0
  43. package/dist/impact/project-scan.spec.d.ts +1 -0
  44. package/dist/impact/project-scan.spec.js +121 -0
  45. package/dist/index.d.ts +1 -1
  46. package/dist/lint/fixes.d.ts +28 -0
  47. package/dist/lint/fixes.js +30 -0
  48. package/dist/lint/lint-batch.d.ts +100 -0
  49. package/dist/lint/lint-batch.js +98 -0
  50. package/dist/lint/lint-batch.spec.d.ts +1 -0
  51. package/dist/lint/lint-batch.spec.js +503 -0
  52. package/dist/lint/lint.d.ts +11 -1
  53. package/dist/lint/lint.js +9 -3
  54. package/dist/result/assemble.d.ts +11 -9
  55. package/dist/result/assemble.js +63 -16
  56. package/dist/result/assemble.spec.d.ts +1 -0
  57. package/dist/result/assemble.spec.js +319 -0
  58. package/dist/result/blocking.d.ts +92 -0
  59. package/dist/result/blocking.js +116 -0
  60. package/dist/result/blocking.spec.d.ts +1 -0
  61. package/dist/result/blocking.spec.js +201 -0
  62. package/dist/result/impact-states.d.ts +19 -0
  63. package/dist/result/impact-states.js +10 -0
  64. package/dist/result/response-budget.d.ts +9 -0
  65. package/dist/result/response-budget.js +133 -0
  66. package/dist/result/response-budget.spec.d.ts +1 -0
  67. package/dist/result/response-budget.spec.js +222 -0
  68. package/dist/result/types.d.ts +212 -104
  69. package/dist/result/types.js +4 -13
  70. package/dist/transport/instructions-coverage.spec.d.ts +1 -0
  71. package/dist/transport/instructions-coverage.spec.js +176 -0
  72. package/dist/transport/instructions.d.ts +1 -0
  73. package/dist/transport/instructions.js +150 -0
  74. package/dist/transport/process-guards.d.ts +43 -0
  75. package/dist/transport/process-guards.js +63 -0
  76. package/dist/transport/process-guards.spec.d.ts +1 -0
  77. package/dist/transport/process-guards.spec.js +200 -0
  78. package/dist/transport/server.d.ts +6 -1
  79. package/dist/transport/server.js +21 -10
  80. package/dist/transport/validate-code.d.ts +50 -15
  81. package/dist/transport/validate-code.js +238 -33
  82. package/dist/transport/validate-code.spec.d.ts +1 -0
  83. package/dist/transport/validate-code.spec.js +2303 -0
  84. package/dist/validate/batch-bounds.d.ts +34 -0
  85. package/dist/validate/batch-bounds.js +65 -0
  86. package/dist/validate/batch-coherence.d.ts +21 -0
  87. package/dist/validate/batch-coherence.js +54 -0
  88. package/dist/validate/batch-coherence.spec.d.ts +1 -0
  89. package/dist/validate/batch-coherence.spec.js +111 -0
  90. package/dist/validate/validate-buffers.d.ts +27 -0
  91. package/dist/validate/validate-buffers.js +319 -0
  92. package/package.json +8 -7
@@ -1,49 +1,254 @@
1
1
  /**
2
- * Registration of the `validate_code` MCP tool.
2
+ * Registration of the `validate_code` MCP tool — the supervisor's entire surface.
3
3
  *
4
- * Current slice: the handler runs the real lint (`check()` via the check-node
5
- * seam) and returns the detection results mapped into `ValidateCodeResult`.
6
- * The ergonomic stages (enrich → advise → richer result assembly) are layered
7
- * in by later tasks; the handler shape stays the same.
4
+ * ONE TOOL, TWO INPUT SHAPES. Validating one file and validating a coordinated multi-file
5
+ * change are the same operation on a different number of buffers, so they are one tool:
6
+ *
7
+ * validate_code({ file_path, content }) -> a single result
8
+ * validate_code({ files: [...] }) -> one result per file + a batch gate
9
+ *
10
+ * There is no `many` flag — which form was sent is evident from the arguments.
11
+ *
12
+ * PASSING SEVERAL FILES IS MORE ACCURATE, not just faster: with every buffer overlaid at
13
+ * once a partial created alongside its caller resolves, where one at a time it is reported
14
+ * missing. The description says so, because that is why an agent should reach for it.
15
+ *
16
+ * This module owns only the MCP surface: schema, description, registration, and mapping
17
+ * input shape to output shape. All logic lives in `validate/`.
8
18
  */
9
19
  import { z } from 'zod';
10
- import { runLint } from '../lint/lint.js';
11
- import { assembleResult } from '../result/assemble.js';
20
+ import { getAppPaths, PlatformOSFileType } from '@platformos/platformos-common';
21
+ import { validateBuffers, } from '../validate/validate-buffers.js';
22
+ import { MAX_BATCH_BYTES, MAX_BATCH_FILES, batchTooLarge } from '../validate/batch-bounds.js';
23
+ import { collidingBufferPaths } from '../validate/batch-coherence.js';
24
+ import { assembleNotApplicableResult } from '../result/assemble.js';
25
+ /**
26
+ * A non-blank path. Blank is malformed input, refused at the protocol boundary.
27
+ *
28
+ * BOTH halves are load-bearing and neither is redundant. The refinement is what
29
+ * refuses a whitespace-only path; `.min(1)` is the half that SURVIVES conversion to
30
+ * JSON Schema, as `minLength`. A refinement converts to nothing at all, so with the
31
+ * refinement alone the rule exists only on this side of the wire — the model is
32
+ * never told, sends `""`, and learns the rule from a rejection. Drop either half and
33
+ * a test fails: the emitted schema loses `minLength`, or `' '` starts being accepted.
34
+ *
35
+ * A FUNCTION, not a shared constant, for the same wire-level reason: the SDK's
36
+ * converter dedupes by schema IDENTITY, so one instance reused across both input
37
+ * forms emits `{ $ref: '#/properties/file_path' }` for the second use. That drags the
38
+ * single-file description into the batch entries, where "Pair with `content`" is
39
+ * wrong, and puts a `$ref` in a tool inputSchema — which not every MCP client
40
+ * resolves. A fresh instance per use costs nothing and emits in full.
41
+ */
42
+ const filePath = () => z
43
+ .string()
44
+ .min(1)
45
+ .refine((value) => value.trim().length > 0, { message: 'file_path must not be empty' });
46
+ /** File contents. Empty is legal — an empty file is a file. Fresh per use, as above. */
47
+ const content = () => z.string();
48
+ /**
49
+ * An example path of each kind, for the agent-facing prose below.
50
+ *
51
+ * DERIVED from `platformos-common`'s tables, not spelled out: an agent imitates these
52
+ * strings when it invents a path, so an example naming a directory the platform stopped
53
+ * accepting teaches the mistake to every caller.
54
+ *
55
+ * The first entry of `getAppPaths` is the canonical directory for the type; the rest are
56
+ * legacy aliases, which are exactly what an example should not suggest.
57
+ */
58
+ const exampleOf = (type, name) => `${getAppPaths(type)[0]}/${name}.liquid`;
59
+ const EXAMPLE_PARTIAL = exampleOf(PlatformOSFileType.Partial, 'card');
60
+ const EXAMPLE_PAGE = exampleOf(PlatformOSFileType.Page, 'home');
61
+ const EXAMPLE_PROMO = exampleOf(PlatformOSFileType.Partial, 'promo');
12
62
  /**
13
63
  * zod raw shape for the tool input (validated by the MCP SDK before dispatch).
14
64
  *
15
- * Typed as `ZodRawShape` (not the inferred literal shape) so the SDK's
16
- * `registerTool` does not perform excessively deep type instantiation over the
17
- * schema; the handler casts the validated args to `ValidateCodeParams`.
65
+ * Both forms are optional at the schema level and the handler requires exactly one,
66
+ * because a zod union of two object shapes defeats the SDK's JSON-Schema conversion
67
+ * — it emits `anyOf`, which several MCP clients do not surface to the model at all.
68
+ * Two optional fields with a documented rule converts cleanly and reads clearly.
69
+ *
70
+ * WHAT THE MODEL SEES IS THE CONVERSION, NOT THIS. Everything an agent knows about
71
+ * the arguments arrives as the JSON Schema this shape converts to, so a constraint
72
+ * that does not convert may as well not be advertised at all. Prefer a zod builtin
73
+ * over a `.refine` wherever one expresses the rule; `transport/validate-code.spec.ts`
74
+ * pins the emitted document end to end.
75
+ *
76
+ * Typed as `ZodRawShape` (not the inferred literal shape) so `registerTool` does not
77
+ * instantiate excessively deeply over the schema (TS2589 under zod 3.25).
18
78
  */
19
79
  export const VALIDATE_CODE_INPUT = {
20
- file_path: z
21
- .string()
22
- .describe('Path of the file under edit (absolute, or relative to the project root).'),
23
- content: z.string().describe('The file contents to validate (the in-memory buffer).'),
24
- mode: z
25
- .enum(['full', 'quick'])
80
+ file_path: filePath()
81
+ .optional()
82
+ .describe('Path of the file to validate, absolute or relative to the project root ' +
83
+ `(e.g. "${EXAMPLE_PARTIAL}"). Pair with \`content\`.`),
84
+ content: content()
26
85
  .optional()
27
- .describe('Analysis depth. `full` (default) runs the heavier ergonomic stages; `quick` is lint + enrichment.'),
86
+ .describe('The exact file contents you are about to write. Pair with `file_path`.'),
87
+ files: z
88
+ .array(z.object({
89
+ file_path: filePath().describe('Path of THIS file, absolute or relative to the project root.'),
90
+ content: content().describe('The exact contents you are about to write to THIS file.'),
91
+ }))
92
+ .min(1)
93
+ .max(MAX_BATCH_FILES)
94
+ .optional()
95
+ .describe('Two or more files to validate together, each with its own `file_path` and ' +
96
+ '`content`. Files in one call can reference each other.'),
28
97
  };
29
- const DESCRIPTION = 'Validate a platformOS Liquid/GraphQL/YAML file before writing it. Returns structured errors, ' +
30
- 'warnings, infos, proposed fixes, and a must_fix_before_write gate.';
98
+ const DESCRIPTION = `Validate platformOS Liquid, GraphQL and YAML code BEFORE writing it to disk.
99
+
100
+ Pass EITHER one file OR several — never both.
101
+
102
+ One file:
103
+ { "file_path": "${EXAMPLE_PARTIAL}",
104
+ "content": "<div>{{ title }}</div>" }
105
+
106
+ Several files (use this whenever one change touches more than one file):
107
+ { "files": [
108
+ { "file_path": "${EXAMPLE_PAGE}",
109
+ "content": "{% render 'promo' %}" },
110
+ { "file_path": "${EXAMPLE_PROMO}",
111
+ "content": "<div>Promo</div>" }
112
+ ] }
113
+
114
+ Sending several together is faster AND more accurate: they can reference each
115
+ other, so the new partial above resolves. Validated one at a time, the page would
116
+ be reported as rendering a missing partial. List each file at most once — the same
117
+ file twice is refused, because only one of the two could be checked.
118
+
119
+ Returns, for one file:
120
+ { "status": "ok" | "warning" | "error" | "not_applicable",
121
+ "must_fix_before_write": boolean,
122
+ "errors": [...], "warnings": [...], "infos": [...],
123
+ "impact": { ... } }
124
+
125
+ For several files:
126
+ { "must_fix_before_write": boolean,
127
+ "files": [ { "file_path": "...", "result": { ...as above... } } ] }
128
+
129
+ must_fix_before_write true means the file will not work — do not write it. false
130
+ means nothing fatal was found, which is not a guarantee of correctness.
131
+ status "not_applicable" means the file was NOT checked (see not_applicable_reason);
132
+ that is neither approval nor refusal.
133
+ A "truncated" field appears only when a file produced too many findings to return
134
+ in full; it carries the true total per list. The lists keep the top of the file,
135
+ and the verdict above is always computed from ALL findings, not the shortened list.`;
136
+ /**
137
+ * The agent-facing prose, exported so tests can pin claims that must not rot. The
138
+ * description is the agent's whole understanding of this tool; a stale sentence
139
+ * here misleads silently.
140
+ */
141
+ export const TOOL_TEXT = { VALIDATE_CODE_DESCRIPTION: DESCRIPTION };
31
142
  export function registerValidateCode(server, ctx) {
32
- server.registerTool('validate_code', { description: DESCRIPTION, inputSchema: VALIDATE_CODE_INPUT }, async (args) => toToolResult(await runValidateCode(ctx, args)));
143
+ server.registerTool('validate_code', { description: DESCRIPTION, inputSchema: VALIDATE_CODE_INPUT }, async (args) => ({
144
+ content: [
145
+ {
146
+ type: 'text',
147
+ text: JSON.stringify(await runValidateCode(ctx, args)),
148
+ },
149
+ ],
150
+ }));
33
151
  }
34
- /** Lint the buffer via the check-node seam and assemble the result. */
35
- async function runValidateCode(ctx, params) {
36
- const mode = params.mode ?? 'full';
37
- ctx.log(`validate_code: ${params.file_path} (${mode})`);
38
- const diagnostics = await runLint({
39
- projectDir: ctx.projectDir,
40
- filePath: params.file_path,
41
- content: params.content,
42
- });
43
- return assembleResult(diagnostics, mode);
152
+ /**
153
+ * The tool handler: normalize the input shape, validate, and shape the response to
154
+ * match what was asked.
155
+ *
156
+ * Returns the flat single-file result for the single form and the per-file envelope
157
+ * for the batch form — the response mirrors the request, so a caller never parses a
158
+ * shape it did not ask for.
159
+ */
160
+ export async function runValidateCode(ctx, params, overrides = {}) {
161
+ const requested = requestedBuffers(params);
162
+ if ('error' in requested) {
163
+ ctx.log(`validate_code: rejected — ${requested.error.reason}`);
164
+ return assembleNotApplicableResult(requested.error);
165
+ }
166
+ const { buffers, isBatch } = requested;
167
+ ctx.log(isBatch ? `validate_code: ${buffers.length} file(s)` : `validate_code: ${buffers[0].filePath}`);
168
+ // Request-level refusals, decided before any work starts. First refusal wins; the cheaper
169
+ // check runs first.
170
+ //
171
+ // GATED ON MORE THAN ONE BUFFER, not on which form carried it. One file is one file, and
172
+ // the two forms must answer identically — the shape an agent happened to reach for cannot
173
+ // change a verdict. Both refusals ask a question a lone buffer cannot answer YES to:
174
+ // collision needs two entries naming one file, and the caps exist because a batch can hand
175
+ // the server work no single buffer could. Collision was already a no-op at one buffer; the
176
+ // total-byte cap was NOT, and it fired FIRST — so a 300 KiB file sent as a one-file batch
177
+ // was refused with "split it into smaller batches", advice its caller cannot follow, and
178
+ // was never told MAX_BUFFER_BYTES, the bound it actually had to get under. Whether ONE
179
+ // buffer is admissible is `bufferTooLarge`'s question, asked per buffer inside
180
+ // `validateBuffers` for both forms alike.
181
+ const refusal = buffers.length > 1
182
+ ? (batchTooLarge(buffers) ?? collidingBufferPaths(ctx.projectDir, buffers))
183
+ : undefined;
184
+ if (refusal) {
185
+ ctx.log(`validate_code: refused (${refusal.code}) — ${refusal.reason}`);
186
+ return {
187
+ must_fix_before_write: false,
188
+ files: buffers.map((buffer) => ({
189
+ file_path: buffer.filePath,
190
+ result: assembleNotApplicableResult(refusal),
191
+ })),
192
+ next_step: refusal.reason,
193
+ };
194
+ }
195
+ const byFile = await validateBuffers(ctx, buffers, overrides);
196
+ if (!isBatch) {
197
+ // `validateBuffers` seeds an entry per requested buffer, so this always hits.
198
+ return byFile.get(buffers[0].filePath);
199
+ }
200
+ const files = buffers.map((buffer) => ({
201
+ file_path: buffer.filePath,
202
+ result: byFile.get(buffer.filePath),
203
+ }));
204
+ return {
205
+ // A file that was merely NOT CHECKED must never block the changeset: this is the
206
+ // OR of the files' own gates, and a `not_applicable` file's gate is false.
207
+ must_fix_before_write: files.some((entry) => entry.result.must_fix_before_write),
208
+ files,
209
+ };
44
210
  }
45
- /** Wrap a result in the MCP text-content envelope (every result is one JSON text block). */
46
- function toToolResult(result) {
47
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
211
+ /**
212
+ * Read the two accepted input forms into the one internal shape.
213
+ *
214
+ * The SDK validates types; what it cannot express is "exactly one form". Both failures are
215
+ * the caller's malformed request rather than a property of any file, and are reported as
216
+ * such — silently preferring one form would validate something the caller did not ask about.
217
+ */
218
+ function requestedBuffers(params) {
219
+ const hasSingle = params.file_path !== undefined || params.content !== undefined;
220
+ const hasBatch = params.files !== undefined && params.files.length > 0;
221
+ if (hasSingle && hasBatch) {
222
+ return {
223
+ error: {
224
+ code: 'internal_error',
225
+ reason: 'Both `files` and `file_path`/`content` were provided. Send ONE file as ' +
226
+ '`file_path` + `content`, or several as `files` — not both. Nothing was checked.',
227
+ },
228
+ };
229
+ }
230
+ if (hasBatch) {
231
+ return {
232
+ buffers: params.files.map((file) => ({
233
+ filePath: file.file_path,
234
+ content: file.content,
235
+ })),
236
+ isBatch: true,
237
+ };
238
+ }
239
+ if (params.file_path === undefined || params.content === undefined) {
240
+ return {
241
+ error: {
242
+ code: 'internal_error',
243
+ reason: 'No file to validate. Send one file as `file_path` + `content`, or several as ' +
244
+ '`files`. Nothing was checked.',
245
+ },
246
+ };
247
+ }
248
+ return {
249
+ buffers: [{ filePath: params.file_path, content: params.content }],
250
+ isBatch: false,
251
+ };
48
252
  }
253
+ export { MAX_BATCH_BYTES, MAX_BATCH_FILES };
49
254
  //# sourceMappingURL=validate-code.js.map
@@ -0,0 +1 @@
1
+ export {};