@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
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Server-level instructions, returned to the client in the `initialize` response
3
+ * and surfaced to the model alongside the tool list.
4
+ *
5
+ * SEPARATE FROM THE TOOL DESCRIPTION, which answers "what does this tool do and how do I
6
+ * call it". These answer "how do I USE this server correctly" — when to reach for it, and
7
+ * how to read an answer. The costly mistakes an agent makes here are interpretation
8
+ * mistakes, not calling mistakes.
9
+ *
10
+ * WHAT MAY BE WRITTEN HERE is a narrow rule (ARCHITECTURE.md §Invariants #6): this server
11
+ * describes ITSELF and never the platform, so a claim belongs here only if NO DIAGNOSTIC
12
+ * COULD CARRY IT —
13
+ *
14
+ * - how to read the answer, since no finding explains the envelope;
15
+ * - a SILENCE, which by definition no diagnostic can convey, and mistaking silence for
16
+ * approval is the failure this server exists to prevent.
17
+ *
18
+ * Platform semantics do not belong here: each is measured to fire a real diagnostic
19
+ * (`transport/instructions-coverage.spec.ts` holds the fixtures) and each diagnostic
20
+ * carries its check's documentation URL, so prose here would be a staler second copy.
21
+ *
22
+ * WRITING RULES: state what to DO, not how the server is built; every claim must be true
23
+ * of the current build, since overstating coverage converts "I do not know" into false
24
+ * confidence; keep it short enough to be read in full, because this is spent context on
25
+ * every session.
26
+ */
27
+ import { allChecks } from '@platformos/platformos-check-common';
28
+ /**
29
+ * The coverage line, DERIVED so it cannot describe a build that no longer exists.
30
+ *
31
+ * A COUNT, not a roll of check names: an agent does not choose checks, and each finding
32
+ * already names its own. What the count conveys is scale, which is a decision input before
33
+ * the first call.
34
+ */
35
+ function coverage() {
36
+ return `${allChecks.length} checks`;
37
+ }
38
+ export const SERVER_INSTRUCTIONS = `platformOS code validator.
39
+
40
+ WHEN TO USE
41
+ Call validate_code BEFORE writing or editing any platformOS Liquid, GraphQL or
42
+ YAML file — it validates an in-memory buffer, so call it with the content you are
43
+ about to write, not after writing. This is the primary quality gate. If you are changing several files as one coherent change,
44
+ send them together in a single call (see the tool's \`files\` parameter): files in
45
+ one call can reference each other, so a partial you are creating alongside its
46
+ caller resolves correctly. Sent one at a time, that same edit is reported broken.
47
+ List each file at most once — a changeset cannot hold two versions of one file, so a
48
+ request that names the same file twice is refused rather than guessed at.
49
+ Skipping this tool is the #1 cause of broken platformOS code.
50
+
51
+ HOW TO READ THE RESULT
52
+
53
+ must_fix_before_write
54
+ true -> Do NOT write the file. It will not parse, it will raise at runtime, or
55
+ the deploy converter will reject it — and a converter rejection fails the
56
+ WHOLE changeset, not just this file.
57
+ false -> Nothing BLOCKING was found. This is not a statement that the code is
58
+ correct, only that no known-fatal problem was detected. Keep your own
59
+ judgement.
60
+
61
+ status
62
+ ok | warning | error -> the file WAS checked; these describe what was found.
63
+ not_applicable -> the file was NOT checked at all. This is neither
64
+ approval nor refusal — it carries no opinion about the
65
+ file. Read not_applicable_reason before deciding:
66
+ outside_project - not inside the project this server serves
67
+ unsupported_type - not a file type platformOS lints
68
+ misplaced_source - a platformOS source outside every deployed subtree; nothing
69
+ checked it and nothing will load it either
70
+ ignored - excluded by the project's .platformos-check.yml
71
+ too_large - one buffer, or the request as a whole, is above its size
72
+ limit. Split the file, or send fewer files per call — the
73
+ reason text says which bound was hit
74
+ timed_out - validation was abandoned; retrying may work
75
+ internal_error - your REQUEST was malformed, or the validator hit a bug; the
76
+ reason text says which. A malformed request — both input
77
+ forms at once, neither, or one file listed twice — is yours
78
+ to fix and worth retrying once fixed.
79
+
80
+ errors / warnings / infos
81
+ Each list is ordered by line then column WITHIN ITSELF; the three are not one
82
+ ordered sequence, so concatenating them does not walk the file in order. Columns
83
+ count UTF-16 code units, so an emoji advances the column by 2.
84
+ Note that errors[] can be non-empty while must_fix_before_write is false: some
85
+ errors are real problems that do not stop the file working (an argument a partial
86
+ ignores, a missing asset, a missing image dimension). Fix them when you can; they
87
+ do not block the write.
88
+ Each finding names the check that produced it, and where see_also is present it links
89
+ that check's documentation — read it there rather than guessing at the rule.
90
+ A finding may carry fix (edits that are safe to apply as given) or suggestions
91
+ (alternatives to choose between). Their start_index / end_index are 0-based offsets
92
+ into the buffer you sent — NOT the 1-based line/column on the same finding. Apply
93
+ several edits from the end backwards, or account for the drift.
94
+
95
+ truncated
96
+ Present ONLY when a file had so many findings that the lists were shortened to
97
+ keep the answer a reasonable size; absent means the lists are complete. It gives
98
+ the true total per affected list, so "returned: 40, total: 900" means 860 more
99
+ exist. The lists keep the TOP of the file, where a cascade's root cause usually
100
+ is. status and must_fix_before_write are always computed from ALL findings, never
101
+ from the shortened list, so a truncated answer is never a softer verdict — fix
102
+ what is listed and validate again to see the rest.
103
+
104
+ impact
105
+ What this change BREAKS in files you are NOT editing — the one thing a per-file lint cannot
106
+ see, since it only ever looks at the buffers you sent. Each entry names the file and
107
+ carries the check's own findings: same message, same severity, same documentation link, and
108
+ the same fixes, exactly as if you had validated that file yourself.
109
+ It reports only what your change INTRODUCED. A problem that file already had is never
110
+ listed, however severe — it is not this edit's doing, and it would bury the one that is.
111
+ A break in someone else's file does NOT set must_fix_before_write: your buffer may be
112
+ perfectly correct. Fix the callers, or decide not to; that is yours to weigh.
113
+ status computed the dependants were linted with your change and without it
114
+ not_applicable nothing can reference this file by name
115
+ unavailable could not be run: a failure, a deadline, or a file referenced from
116
+ more text than can be examined in time
117
+ disabled this server runs with --no-impact, so nothing was attempted
118
+ unchecked_dependants appears when a file has more dependants than one request will lint.
119
+ Those were never checked, so an otherwise-clean answer carrying it is a PARTIAL one.
120
+ NOTHING HERE IS A CLEARANCE. An empty impact means no break was found among the dependants
121
+ that are VISIBLE, and a dependant can be invisible: {% render partial_name %} picks its
122
+ target at runtime, so no analysis can resolve it.
123
+ This server never tells you nothing depends on a file — if you are about to delete or
124
+ rename one, search the project yourself.
125
+
126
+ WHAT IS NOT CHECKED
127
+ ${coverage()} run against your buffer. Where a finding exists it explains itself and links
128
+ its documentation, so what follows is only the SILENCES — the places where finding nothing is not the same
129
+ as finding it correct, and where no diagnostic can tell you so.
130
+
131
+ Argument types are checked only where the platformOS documentation publishes a type.
132
+ An argument the documentation leaves untyped accepts more than one type and is never
133
+ reported — which is every argument of every core Liquid filter, because those coerce
134
+ rather than refuse. {{ 5 | upcase }} renders and is not reported. That silence is the
135
+ documentation's answer, not a guarantee the value is right.
136
+
137
+ A model schema's property TYPE is checked and an unknown one is reported. The rest of a
138
+ schema's shape is not: an unrecognised top-level key is rejected on deploy and nothing
139
+ reports it, so silence there is not a claim the key is valid. A duplicated property name
140
+ IS accepted by the platform.
141
+
142
+ Duplicate YAML keys are compared the way the platform's own parser resolves them, so
143
+ yes: and true: in one mapping are reported as the one key they become. That comparison
144
+ is NOT exhaustive — a few spellings collide on the platform and are not reported — so
145
+ silence there does not prove two keys are distinct.
146
+
147
+ Coverage is per project: checks can be enabled, disabled or ignored in the project's
148
+ .platformos-check.yml, so a clean result reflects that project's configuration rather
149
+ than a fixed universal standard.`;
150
+ //# sourceMappingURL=instructions.js.map
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Process lifetime guards: signals and last-resort error handlers.
3
+ *
4
+ * The supervisor is a long-lived stdio process an agent holds open for a whole session, and
5
+ * under Node's default an unhandled rejection is FATAL — from the agent's side the tool
6
+ * simply stops existing mid-session, with nothing in the JSON-RPC stream to explain it.
7
+ *
8
+ * THE SPLIT is deliberately not symmetric:
9
+ *
10
+ * - `unhandledRejection` — LOG AND KEEP SERVING. A rejected background promise does not
11
+ * imply the server's state is corrupt, and staying up beats vanishing.
12
+ * - `uncaughtException` — LOG, SHUT DOWN, EXIT NON-ZERO. The process state may genuinely
13
+ * be corrupt, so continuing to answer would risk returning wrong diagnostics.
14
+ *
15
+ * HARD CONSTRAINT: stdout belongs to the MCP JSON-RPC stream. Everything here logs through
16
+ * the injected {@link Logger}, which writes to stderr; a stray stdout write would corrupt
17
+ * the protocol.
18
+ *
19
+ * `exit` and `emitter` are injected so the handlers can be driven in a test without
20
+ * terminating the runner. The returned uninstall function is wired into `shutdown` so a
21
+ * start/stop/start cycle does not accumulate listeners.
22
+ */
23
+ import type { Logger } from '../logger.js';
24
+ /** The subset of `process` these guards touch. Narrow, so a test can pass a stub. */
25
+ export interface GuardEmitter {
26
+ on(event: string, listener: (...args: never[]) => void): unknown;
27
+ once(event: string, listener: (...args: never[]) => void): unknown;
28
+ removeListener(event: string, listener: (...args: never[]) => void): unknown;
29
+ }
30
+ export interface ProcessGuardOptions {
31
+ log: Logger;
32
+ /** The server's idempotent teardown. Awaited before a fatal exit. */
33
+ shutdown: (reason?: string) => Promise<void>;
34
+ /** Defaults to `process.exit`. Injected so tests never terminate the runner. */
35
+ exit?: (code: number) => void;
36
+ /** Defaults to `process`. Injected so tests can observe registration. */
37
+ emitter?: GuardEmitter;
38
+ }
39
+ /**
40
+ * Install signal handlers and last-resort error guards. Returns an uninstall
41
+ * function; calling it twice is harmless.
42
+ */
43
+ export declare function installProcessGuards(options: ProcessGuardOptions): () => void;
@@ -0,0 +1,63 @@
1
+ /** Signals that mean "stop cleanly"; both exit 0. */
2
+ const TERMINATION_SIGNALS = ['SIGINT', 'SIGTERM'];
3
+ /**
4
+ * Render a thrown/rejected value for the log.
5
+ *
6
+ * A rejection reason is NOT necessarily an Error — `Promise.reject('nope')` and
7
+ * `Promise.reject()` are both legal — so this never assumes `.stack` exists.
8
+ */
9
+ function describe(value) {
10
+ if (value instanceof Error)
11
+ return value.stack ?? `${value.name}: ${value.message}`;
12
+ // `String(Symbol())` throws; `String(undefined)` does not. Be total.
13
+ try {
14
+ return String(value);
15
+ }
16
+ catch {
17
+ return Object.prototype.toString.call(value);
18
+ }
19
+ }
20
+ /**
21
+ * Install signal handlers and last-resort error guards. Returns an uninstall
22
+ * function; calling it twice is harmless.
23
+ */
24
+ export function installProcessGuards(options) {
25
+ const { log, shutdown } = options;
26
+ const exit = options.exit ?? ((code) => process.exit(code));
27
+ const emitter = options.emitter ?? process;
28
+ const registered = [];
29
+ const onSignal = (signal) => () => {
30
+ void shutdown(signal).finally(() => exit(0));
31
+ };
32
+ // `once`: a second SIGINT while shutting down should not re-enter teardown.
33
+ for (const signal of TERMINATION_SIGNALS) {
34
+ const listener = onSignal(signal);
35
+ emitter.once(signal, listener);
36
+ registered.push({ event: signal, listener });
37
+ }
38
+ // `on`, not `once`: unhandled rejections are survivable and may recur, and each one must
39
+ // be reported.
40
+ const onUnhandledRejection = ((reason) => {
41
+ log(`unhandled promise rejection (server continues): ${describe(reason)}. ` +
42
+ `This is a bug — a background task rejected with nobody awaiting it.`);
43
+ });
44
+ emitter.on('unhandledRejection', onUnhandledRejection);
45
+ registered.push({ event: 'unhandledRejection', listener: onUnhandledRejection });
46
+ const onUncaughtException = ((error) => {
47
+ log(`uncaught exception, shutting down: ${describe(error)}`);
48
+ // Non-zero: a supervisor that died must not look like a clean exit to whatever
49
+ // supervises IT. `finally` so a failing shutdown still exits.
50
+ void shutdown('uncaught exception').finally(() => exit(1));
51
+ });
52
+ emitter.on('uncaughtException', onUncaughtException);
53
+ registered.push({ event: 'uncaughtException', listener: onUncaughtException });
54
+ let uninstalled = false;
55
+ return () => {
56
+ if (uninstalled)
57
+ return;
58
+ uninstalled = true;
59
+ for (const { event, listener } of registered)
60
+ emitter.removeListener(event, listener);
61
+ };
62
+ }
63
+ //# sourceMappingURL=process-guards.js.map
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,200 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { installProcessGuards } from './process-guards.js';
4
+ /**
5
+ * TASK-16. Under Node's default an unhandled rejection is FATAL, so one rejected
6
+ * promise on a background path used to take the whole stdio server down — the
7
+ * agent losing the tool mid-session with nothing in the JSON-RPC stream to explain
8
+ * it. These pin the asymmetry that makes the guards correct:
9
+ *
10
+ * - a rejection is survivable -> log, KEEP SERVING
11
+ * - an uncaught exception is not -> log, shut down, exit NON-ZERO
12
+ *
13
+ * `exit` and the emitter are injected throughout, so nothing here can terminate
14
+ * the test runner.
15
+ */
16
+ const harness = () => {
17
+ const logs = [];
18
+ const exits = [];
19
+ const shutdowns = [];
20
+ const emitter = new EventEmitter();
21
+ const uninstall = installProcessGuards({
22
+ log: (message) => logs.push(message),
23
+ shutdown: async (reason) => {
24
+ shutdowns.push(reason);
25
+ },
26
+ exit: (code) => exits.push(code),
27
+ emitter: emitter,
28
+ });
29
+ return { logs, exits, shutdowns, emitter, uninstall };
30
+ };
31
+ /** The handlers finish their work in a `.finally`, so yield the microtask queue. */
32
+ const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
33
+ describe('Unit: installProcessGuards — unhandled rejections are survivable', () => {
34
+ it('logs an Error rejection WITH its stack and does not exit', async () => {
35
+ const { logs, exits, shutdowns, emitter } = harness();
36
+ const error = new Error('background task failed');
37
+ emitter.emit('unhandledRejection', error);
38
+ await settle();
39
+ expect(logs).toHaveLength(1);
40
+ // The stack is what makes this diagnosable at all — the whole point of not
41
+ // just dying silently.
42
+ expect(logs[0]).toContain(error.stack);
43
+ expect(logs[0]).toContain('server continues');
44
+ // Survivable: no teardown, no exit.
45
+ expect(exits).toEqual([]);
46
+ expect(shutdowns).toEqual([]);
47
+ });
48
+ it('reports EVERY rejection, not just the first', async () => {
49
+ // Registered with `on`, not `once`: a `once` here would silence every
50
+ // rejection after the first, which is the opposite of the intent.
51
+ const { logs, emitter } = harness();
52
+ emitter.emit('unhandledRejection', new Error('first'));
53
+ emitter.emit('unhandledRejection', new Error('second'));
54
+ emitter.emit('unhandledRejection', new Error('third'));
55
+ await settle();
56
+ expect(logs).toHaveLength(3);
57
+ });
58
+ it.each([
59
+ ['a string', 'plain string reason', 'plain string reason'],
60
+ ['undefined', undefined, 'undefined'],
61
+ ['null', null, 'null'],
62
+ ['a number', 42, '42'],
63
+ ['a plain object', { code: 'E' }, '[object Object]'],
64
+ ])('survives a non-Error rejection value: %s', async (_label, reason, rendered) => {
65
+ // `Promise.reject('nope')` and `Promise.reject()` are both legal, so the guard
66
+ // must never assume `.stack`. A guard that throws while reporting a failure is
67
+ // worse than no guard.
68
+ const { logs, exits, emitter } = harness();
69
+ expect(() => emitter.emit('unhandledRejection', reason)).not.toThrow();
70
+ await settle();
71
+ expect(logs).toHaveLength(1);
72
+ expect(logs[0]).toContain(rendered);
73
+ expect(exits).toEqual([]);
74
+ });
75
+ it('survives a Symbol reason, which `String()` refuses to render', async () => {
76
+ // `String(Symbol())` THROWS. The renderer falls back rather than letting the
77
+ // guard become the crash.
78
+ const { logs, exits, emitter } = harness();
79
+ expect(() => emitter.emit('unhandledRejection', Symbol('nope'))).not.toThrow();
80
+ await settle();
81
+ expect(logs).toHaveLength(1);
82
+ expect(exits).toEqual([]);
83
+ });
84
+ });
85
+ describe('Unit: installProcessGuards — an uncaught exception is fatal', () => {
86
+ it('logs, shuts down gracefully, and exits NON-ZERO', async () => {
87
+ const { logs, exits, shutdowns, emitter } = harness();
88
+ const error = new Error('invariant broken');
89
+ emitter.emit('uncaughtException', error);
90
+ await settle();
91
+ expect(logs).toEqual([`uncaught exception, shutting down: ${error.stack}`]);
92
+ // Through the ordinary teardown, so a graph build in flight is still reaped.
93
+ expect(shutdowns).toEqual(['uncaught exception']);
94
+ // Non-zero: a supervisor that died must not look like a clean exit to whatever
95
+ // supervises IT.
96
+ expect(exits).toEqual([1]);
97
+ });
98
+ it('still exits when shutdown itself fails', async () => {
99
+ // The process state has already been declared unsound; a failing teardown must
100
+ // not wedge it open.
101
+ const logs = [];
102
+ const exits = [];
103
+ const emitter = new EventEmitter();
104
+ installProcessGuards({
105
+ log: (m) => logs.push(m),
106
+ shutdown: async () => {
107
+ throw new Error('teardown exploded');
108
+ },
109
+ exit: (code) => exits.push(code),
110
+ emitter: emitter,
111
+ });
112
+ emitter.emit('uncaughtException', new Error('boom'));
113
+ await settle();
114
+ expect(exits).toEqual([1]);
115
+ });
116
+ });
117
+ describe('Unit: installProcessGuards — termination signals', () => {
118
+ it.each(['SIGINT', 'SIGTERM'])('%s shuts down and exits ZERO', async (signal) => {
119
+ const { exits, shutdowns, emitter } = harness();
120
+ emitter.emit(signal);
121
+ await settle();
122
+ expect(shutdowns).toEqual([signal]);
123
+ // Zero: an operator asking us to stop is not a failure.
124
+ expect(exits).toEqual([0]);
125
+ });
126
+ it('handles a signal only once, so a second SIGINT does not re-enter teardown', async () => {
127
+ const { shutdowns, emitter } = harness();
128
+ emitter.emit('SIGINT');
129
+ emitter.emit('SIGINT');
130
+ await settle();
131
+ expect(shutdowns).toEqual(['SIGINT']);
132
+ });
133
+ });
134
+ describe('Unit: installProcessGuards — registration hygiene', () => {
135
+ it('registers exactly one listener per event', () => {
136
+ const emitter = new EventEmitter();
137
+ installProcessGuards({
138
+ log: () => { },
139
+ shutdown: async () => { },
140
+ exit: () => { },
141
+ emitter: emitter,
142
+ });
143
+ expect(['SIGINT', 'SIGTERM', 'unhandledRejection', 'uncaughtException'].map((event) => emitter.listenerCount(event))).toEqual([1, 1, 1, 1]);
144
+ });
145
+ it('uninstall removes every listener, so start/stop/start does not accumulate', () => {
146
+ const emitter = new EventEmitter();
147
+ const install = () => installProcessGuards({
148
+ log: () => { },
149
+ shutdown: async () => { },
150
+ exit: () => { },
151
+ emitter: emitter,
152
+ });
153
+ const first = install();
154
+ first();
155
+ // Second lifecycle: counts must be back to one each, not two.
156
+ const second = install();
157
+ expect(['SIGINT', 'SIGTERM', 'unhandledRejection', 'uncaughtException'].map((event) => emitter.listenerCount(event))).toEqual([1, 1, 1, 1]);
158
+ second();
159
+ expect(['SIGINT', 'SIGTERM', 'unhandledRejection', 'uncaughtException'].map((event) => emitter.listenerCount(event))).toEqual([0, 0, 0, 0]);
160
+ });
161
+ it('uninstall is idempotent', () => {
162
+ const emitter = new EventEmitter();
163
+ const uninstall = installProcessGuards({
164
+ log: () => { },
165
+ shutdown: async () => { },
166
+ exit: () => { },
167
+ emitter: emitter,
168
+ });
169
+ uninstall();
170
+ expect(() => uninstall()).not.toThrow();
171
+ expect(emitter.listenerCount('unhandledRejection')).toEqual(0);
172
+ });
173
+ it('an uninstalled guard no longer reacts', async () => {
174
+ const { logs, exits, emitter, uninstall } = harness();
175
+ uninstall();
176
+ emitter.emit('unhandledRejection', new Error('ignored'));
177
+ emitter.emit('uncaughtException', new Error('ignored'));
178
+ await settle();
179
+ expect([logs, exits]).toEqual([[], []]);
180
+ });
181
+ });
182
+ describe('Unit: installProcessGuards — stdout is never touched', () => {
183
+ it('logs only through the injected logger, never to the JSON-RPC stream', async () => {
184
+ // stdout belongs to MCP JSON-RPC. A stray write there corrupts the protocol,
185
+ // which is a WORSE failure than the crash being handled.
186
+ const stdoutWrite = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
187
+ try {
188
+ const { logs, emitter } = harness();
189
+ emitter.emit('unhandledRejection', new Error('noisy'));
190
+ emitter.emit('uncaughtException', new Error('fatal'));
191
+ await settle();
192
+ expect(stdoutWrite).not.toHaveBeenCalled();
193
+ expect(logs).toHaveLength(2);
194
+ }
195
+ finally {
196
+ stdoutWrite.mockRestore();
197
+ }
198
+ });
199
+ });
200
+ //# sourceMappingURL=process-guards.spec.js.map
@@ -7,8 +7,8 @@
7
7
  * user-facing surface.
8
8
  */
9
9
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
+ import { type SupervisorContext } from '../context.js';
10
11
  import { type Logger } from '../logger.js';
11
- import { type SupervisorContext } from './validate-code.js';
12
12
  export interface ServerOptions {
13
13
  /** Absolute project root that buffers are validated against. */
14
14
  projectDir: string;
@@ -16,6 +16,11 @@ export interface ServerOptions {
16
16
  log?: Logger;
17
17
  /** Advertised server version. */
18
18
  version?: string;
19
+ /**
20
+ * Whether cross-file impact runs. Defaults to ON — only an explicit `false`, from
21
+ * `--no-impact` or `POS_SUPERVISOR_NO_IMPACT`, turns it off.
22
+ */
23
+ impactEnabled?: boolean;
19
24
  }
20
25
  export interface ServerHandle {
21
26
  server: McpServer;
@@ -9,13 +9,25 @@
9
9
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
10
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
11
11
  import { createLogger } from '../logger.js';
12
+ import { installProcessGuards } from './process-guards.js';
13
+ import { SERVER_INSTRUCTIONS } from './instructions.js';
12
14
  import { registerValidateCode } from './validate-code.js';
13
15
  const SERVER_NAME = 'platformos-mcp-supervisor';
14
16
  const DEFAULT_VERSION = '0.0.1';
15
17
  export async function startServer(opts) {
16
18
  const log = opts.log ?? createLogger(SERVER_NAME);
17
- const context = { projectDir: opts.projectDir, log };
18
- const server = new McpServer({ name: SERVER_NAME, version: opts.version ?? DEFAULT_VERSION });
19
+ // NOTHING IS BUILT OR WARMED HERE, by design. check-node owns one lazy `App` per project
20
+ // at process level and reconciles it per call, and impact is derived per request from
21
+ // the project's text (`impact/project-scan.ts`) — so there is no graph to build at boot,
22
+ // nothing to keep fresh, and no "still computing" answer.
23
+ const context = {
24
+ projectDir: opts.projectDir,
25
+ log,
26
+ impactEnabled: opts.impactEnabled,
27
+ };
28
+ // `instructions` reaches the model with the tool list; without them an agent has only the
29
+ // tool description to go on and invents a reading of the result (see instructions.ts).
30
+ const server = new McpServer({ name: SERVER_NAME, version: opts.version ?? DEFAULT_VERSION }, { instructions: SERVER_INSTRUCTIONS });
19
31
  registerValidateCode(server, context);
20
32
  const transport = new StdioServerTransport();
21
33
  await server.connect(transport);
@@ -27,16 +39,15 @@ export async function startServer(opts) {
27
39
  closed = true;
28
40
  if (reason)
29
41
  log(`shutting down (${reason})`);
42
+ // Drop the process listeners as part of teardown, so a start/stop/start cycle
43
+ // in one process does not accumulate them.
44
+ uninstallGuards();
30
45
  await server.close();
31
46
  };
32
- installSignalHandlers(shutdown);
47
+ // Signals plus the last-resort error guards: without them a single unhandled
48
+ // rejection on a background path is fatal under Node's default, and the agent
49
+ // loses the tool mid-session with nothing in the JSON-RPC stream to explain it.
50
+ const uninstallGuards = installProcessGuards({ log, shutdown });
33
51
  return { server, context, shutdown };
34
52
  }
35
- function installSignalHandlers(shutdown) {
36
- for (const signal of ['SIGINT', 'SIGTERM']) {
37
- process.once(signal, () => {
38
- void shutdown(signal).finally(() => process.exit(0));
39
- });
40
- }
41
- }
42
53
  //# sourceMappingURL=server.js.map
@@ -1,26 +1,61 @@
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 { type ZodRawShape } from 'zod';
10
20
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
11
- import type { Logger } from '../logger.js';
12
- /** Per-server context threaded into the handler. */
13
- export interface SupervisorContext {
14
- /** Absolute project root the buffer is validated against. */
15
- projectDir: string;
16
- log: Logger;
17
- }
21
+ import type { SupervisorContext } from '../context.js';
22
+ import type { ValidateCodeParams, ValidateCodeResult, ValidateFilesResult } from '../result/types.js';
23
+ import { type ValidateAdapters } from '../validate/validate-buffers.js';
24
+ import { MAX_BATCH_BYTES, MAX_BATCH_FILES } from '../validate/batch-bounds.js';
18
25
  /**
19
26
  * zod raw shape for the tool input (validated by the MCP SDK before dispatch).
20
27
  *
21
- * Typed as `ZodRawShape` (not the inferred literal shape) so the SDK's
22
- * `registerTool` does not perform excessively deep type instantiation over the
23
- * schema; the handler casts the validated args to `ValidateCodeParams`.
28
+ * Both forms are optional at the schema level and the handler requires exactly one,
29
+ * because a zod union of two object shapes defeats the SDK's JSON-Schema conversion
30
+ * — it emits `anyOf`, which several MCP clients do not surface to the model at all.
31
+ * Two optional fields with a documented rule converts cleanly and reads clearly.
32
+ *
33
+ * WHAT THE MODEL SEES IS THE CONVERSION, NOT THIS. Everything an agent knows about
34
+ * the arguments arrives as the JSON Schema this shape converts to, so a constraint
35
+ * that does not convert may as well not be advertised at all. Prefer a zod builtin
36
+ * over a `.refine` wherever one expresses the rule; `transport/validate-code.spec.ts`
37
+ * pins the emitted document end to end.
38
+ *
39
+ * Typed as `ZodRawShape` (not the inferred literal shape) so `registerTool` does not
40
+ * instantiate excessively deeply over the schema (TS2589 under zod 3.25).
24
41
  */
25
42
  export declare const VALIDATE_CODE_INPUT: ZodRawShape;
43
+ /**
44
+ * The agent-facing prose, exported so tests can pin claims that must not rot. The
45
+ * description is the agent's whole understanding of this tool; a stale sentence
46
+ * here misleads silently.
47
+ */
48
+ export declare const TOOL_TEXT: {
49
+ readonly VALIDATE_CODE_DESCRIPTION: string;
50
+ };
26
51
  export declare function registerValidateCode(server: McpServer, ctx: SupervisorContext): void;
52
+ /**
53
+ * The tool handler: normalize the input shape, validate, and shape the response to
54
+ * match what was asked.
55
+ *
56
+ * Returns the flat single-file result for the single form and the per-file envelope
57
+ * for the batch form — the response mirrors the request, so a caller never parses a
58
+ * shape it did not ask for.
59
+ */
60
+ export declare function runValidateCode(ctx: SupervisorContext, params: ValidateCodeParams, overrides?: Partial<ValidateAdapters>): Promise<ValidateCodeResult | ValidateFilesResult>;
61
+ export { MAX_BATCH_BYTES, MAX_BATCH_FILES };