@tacuchi/agent-workflow-cli 25.1.2 → 25.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 +2 -1
  2. package/dist/adapters/node-process.js +35 -0
  3. package/dist/adapters/node-process.js.map +1 -1
  4. package/dist/application/amend-service.js +393 -0
  5. package/dist/application/amend-service.js.map +1 -0
  6. package/dist/application/doctor/actions.js +158 -0
  7. package/dist/application/doctor/actions.js.map +1 -0
  8. package/dist/application/doctor/apply.js +399 -0
  9. package/dist/application/doctor/apply.js.map +1 -0
  10. package/dist/application/doctor/auth-dsn.js +143 -0
  11. package/dist/application/doctor/auth-dsn.js.map +1 -0
  12. package/dist/application/doctor/auth-flow.js +28 -0
  13. package/dist/application/doctor/auth-flow.js.map +1 -0
  14. package/dist/application/doctor/auth-registry.js +12 -0
  15. package/dist/application/doctor/auth-registry.js.map +1 -0
  16. package/dist/application/doctor/hosts.js +41 -0
  17. package/dist/application/doctor/hosts.js.map +1 -0
  18. package/dist/application/doctor/native-host-state.js +211 -0
  19. package/dist/application/doctor/native-host-state.js.map +1 -0
  20. package/dist/application/doctor/prepare.js +262 -0
  21. package/dist/application/doctor/prepare.js.map +1 -0
  22. package/dist/application/doctor/provider-installation.js +124 -0
  23. package/dist/application/doctor/provider-installation.js.map +1 -0
  24. package/dist/application/doctor/provider-mcps.js +650 -0
  25. package/dist/application/doctor/provider-mcps.js.map +1 -0
  26. package/dist/application/doctor/provider-plugins-hooks.js +112 -0
  27. package/dist/application/doctor/provider-plugins-hooks.js.map +1 -0
  28. package/dist/application/doctor/provider-skills.js +212 -0
  29. package/dist/application/doctor/provider-skills.js.map +1 -0
  30. package/dist/application/doctor/provider-tools-auth.js +201 -0
  31. package/dist/application/doctor/provider-tools-auth.js.map +1 -0
  32. package/dist/application/doctor/provider-visibility.js +149 -0
  33. package/dist/application/doctor/provider-visibility.js.map +1 -0
  34. package/dist/application/doctor/repair-runner.js +169 -0
  35. package/dist/application/doctor/repair-runner.js.map +1 -0
  36. package/dist/application/doctor/report.js +155 -0
  37. package/dist/application/doctor/report.js.map +1 -0
  38. package/dist/application/doctor/types.js +5 -0
  39. package/dist/application/doctor/types.js.map +1 -0
  40. package/dist/application/flow/advance.js +62 -9
  41. package/dist/application/flow/advance.js.map +1 -1
  42. package/dist/application/flow/flow-service.js +18 -7
  43. package/dist/application/flow/flow-service.js.map +1 -1
  44. package/dist/application/flow/run-projection.js +6 -1
  45. package/dist/application/flow/run-projection.js.map +1 -1
  46. package/dist/application/flow/submit.js +143 -3
  47. package/dist/application/flow/submit.js.map +1 -1
  48. package/dist/application/mcp-doctor-service.js +1 -1
  49. package/dist/application/mcp-doctor-service.js.map +1 -1
  50. package/dist/application/mcp-host-reader.js +134 -26
  51. package/dist/application/mcp-host-reader.js.map +1 -1
  52. package/dist/application/mcp-host-receipt-service.js +9 -7
  53. package/dist/application/mcp-host-receipt-service.js.map +1 -1
  54. package/dist/application/self/install-skill.js +10 -2
  55. package/dist/application/self/install-skill.js.map +1 -1
  56. package/dist/application/self/mcp-config.js +1 -1
  57. package/dist/application/self/mcp-config.js.map +1 -1
  58. package/dist/application/session-narrative.js +15 -0
  59. package/dist/application/session-narrative.js.map +1 -1
  60. package/dist/application/source-boundary-policy.js +87 -6
  61. package/dist/application/source-boundary-policy.js.map +1 -1
  62. package/dist/cli/commands/amend.js +122 -0
  63. package/dist/cli/commands/amend.js.map +1 -0
  64. package/dist/cli/commands/doctor.js +300 -0
  65. package/dist/cli/commands/doctor.js.map +1 -0
  66. package/dist/cli/commands/index.js +4 -0
  67. package/dist/cli/commands/index.js.map +1 -1
  68. package/dist/cli/dispatch-plan.js +47 -0
  69. package/dist/cli/dispatch-plan.js.map +1 -0
  70. package/dist/cli/help-groups.js +8 -0
  71. package/dist/cli/help-groups.js.map +1 -1
  72. package/dist/cli/main.js +54 -10
  73. package/dist/cli/main.js.map +1 -1
  74. package/dist/cli/parser.js +20 -0
  75. package/dist/cli/parser.js.map +1 -1
  76. package/dist/domain/doctor/auth.js +65 -0
  77. package/dist/domain/doctor/auth.js.map +1 -0
  78. package/dist/domain/doctor/model.js +82 -0
  79. package/dist/domain/doctor/model.js.map +1 -0
  80. package/dist/domain/doctor/operations.js +114 -0
  81. package/dist/domain/doctor/operations.js.map +1 -0
  82. package/dist/domain/flow/authority.js +8 -0
  83. package/dist/domain/flow/authority.js.map +1 -1
  84. package/dist/domain/flow/run-state.js +181 -25
  85. package/dist/domain/flow/run-state.js.map +1 -1
  86. package/dist/domain/redaction.js +40 -0
  87. package/dist/domain/redaction.js.map +1 -1
  88. package/package.json +1 -1
  89. package/skills/w/SKILL.md +2 -1
  90. package/skills/w/commands/README.md +2 -1
  91. package/skills/w/commands/doctor.md +31 -0
  92. package/skills/w/context/MANIFEST.json +4 -0
@@ -0,0 +1,650 @@
1
+ /**
2
+ * MCPs — the drift of our own connections, every entry each host holds, and
3
+ * what the host itself says about them.
4
+ *
5
+ * Three reads because there are three different questions, and answering only
6
+ * the first is what makes today's `mcp doctor` blind to the MCP that is broken
7
+ * on this machine without being Workline's. Ownership decides what may be
8
+ * ACTED on, never what may be reported: an entry nobody here wrote still costs
9
+ * the person a working host when it fails, so it is diagnosed and given
10
+ * written guidance, and never an action.
11
+ */
12
+ import { doctorFindingId, } from "../../domain/doctor/model.js";
13
+ import { mcpEntryNameFor } from "../../domain/mcp-entry.js";
14
+ import { WORKLINE_MCP_ENTRY_NAME, worklineMcpEntry } from "../../domain/workline-mcp-entry.js";
15
+ import { readMcpConnections } from "../mcp-connections-service.js";
16
+ import { hasEmbeddedCredential, runMcpDoctor } from "../mcp-doctor-service.js";
17
+ import { classifyMcpEntry } from "../mcp-entry-classification.js";
18
+ import { readMcpEntry, scanMcpEntries } from "../mcp-host-reader.js";
19
+ import { NATIVE_MCP_HOSTS, readNativeMcpState, } from "./native-host-state.js";
20
+ import { coverage } from "./types.js";
21
+ const CATEGORY = "mcps";
22
+ const SCOPES = ["workspace", "global"];
23
+ export function createMcpsProvider(deps = {}) {
24
+ return {
25
+ category: CATEGORY,
26
+ async run(input) {
27
+ // No early return for "no host takes MCP by file": with an empty list every
28
+ // loop below produces nothing and `coverageFor` already answers
29
+ // `not-applicable` per host, so a guard here would be a second spelling of
30
+ // the same answer — and the kind of branch no test can tell apart.
31
+ const hosts = input.hosts.filter((host) => host.mcp_host !== null);
32
+ const configured = await configuredEntries(input, hosts);
33
+ const findings = configured.findings;
34
+ const native = input.skipNative
35
+ ? { findings: [], failures: new Map() }
36
+ : nativeFindings(hosts, configured.owners, deps.native ?? {});
37
+ for (const finding of native.findings)
38
+ findings.set(finding.id, finding);
39
+ return {
40
+ coverage: input.hosts.map((host) => coverageFor(host, input.skipNative, native.failures, configured.unreadable.get(host.host) ?? [])),
41
+ findings: [...findings.values()],
42
+ };
43
+ },
44
+ };
45
+ }
46
+ /** Los dos productores que leen ARCHIVOS: el registro de conexiones y el barrido del host. */
47
+ async function configuredEntries(input, hosts) {
48
+ const findings = new Map();
49
+ const owners = new Map();
50
+ const unreadable = new Map();
51
+ const connections = readMcpConnections(input.ctx.paths, input.ctx.env);
52
+ const ourNames = new Set(connections.map((connection) => mcpEntryNameFor(connection.name)));
53
+ const mcpHosts = hosts.map((host) => host.mcp_host);
54
+ // Primero gana: los dos scopes emiten un hallazgo por el mismo nombre y
55
+ // `workspace` se recorre antes, que es el orden que el cruce tenía.
56
+ const remember = (finding, entryName) => {
57
+ findings.set(finding.id, finding);
58
+ const key = ownerKey(finding.host, entryName);
59
+ if (!owners.has(key))
60
+ owners.set(key, finding);
61
+ };
62
+ for (const scope of SCOPES) {
63
+ for (const report of driftReports(input, mcpHosts, connections, scope)) {
64
+ // `runMcpDoctor` reports by MCP host id (`claude`) and the report is
65
+ // keyed by catalog id (`claude-code`). Emitting the engine's id here
66
+ // split one host into two rows AND — worse — made `nativeFindings`
67
+ // never recognize its own connection as ours, so a Workline MCP the
68
+ // host cannot connect came back as somebody else's warning instead of
69
+ // the blocking finding the spec promises.
70
+ remember(ownConnectionFinding(report, catalogHostOf(hosts, report.host)), report.instance);
71
+ }
72
+ const swept = await fileEntryFindings(input, hosts, scope, ourNames);
73
+ for (const entry of swept.entries)
74
+ remember(entry.finding, entry.entryName);
75
+ for (const [host, targets] of swept.unreadable) {
76
+ unreadable.set(host, [...(unreadable.get(host) ?? []), ...targets]);
77
+ }
78
+ }
79
+ return { findings, owners, unreadable };
80
+ }
81
+ /** Host + nombre tal como el archivo lo escribió: la clave del cruce, nunca la etiqueta. */
82
+ function ownerKey(host, entryName) {
83
+ return `${host}\u0000${entryName}`;
84
+ }
85
+ /** The catalog id for an MCP host id, falling back to the engine's own id. */
86
+ function catalogHostOf(hosts, mcpHost) {
87
+ return hosts.find((candidate) => candidate.mcp_host === mcpHost)?.host ?? mcpHost;
88
+ }
89
+ // Sin conexiones registradas el motor devuelve cero reportes por su propia
90
+ // construcción (un doble bucle hosts × conexiones), así que una guarda acá sería
91
+ // la segunda forma de escribir la misma respuesta: la rama que ninguna prueba
92
+ // puede distinguir, igual que la que este archivo ya se niega a agregar arriba.
93
+ function driftReports(input, hosts, connections, scope) {
94
+ const doctor = runMcpDoctor(input.ctx.env, input.ctx.paths, {
95
+ scope,
96
+ workspace: input.workspaceDir,
97
+ hosts,
98
+ connections,
99
+ });
100
+ return doctor.reports;
101
+ }
102
+ /**
103
+ * One of our own connections, as `runMcpDoctor` already judged it.
104
+ *
105
+ * The status vocabulary is not re-derived here — that engine owns the eight
106
+ * drift states — only translated. `ok` emits its healthy finding rather than
107
+ * being dropped, because "the report showed nothing about qtc-cert" and "qtc-cert
108
+ * is fine" have to be different lines.
109
+ */
110
+ function ownConnectionFinding(report, host) {
111
+ const id = doctorFindingId(host, CATEGORY, `${report.scope}:${report.instance}`);
112
+ const base = {
113
+ id,
114
+ host,
115
+ category: CATEGORY,
116
+ resource: {
117
+ kind: "mcp-entry",
118
+ name: `${report.instance} (${report.scope})`,
119
+ locator: report.target,
120
+ },
121
+ evidence: driftEvidence(report),
122
+ };
123
+ if (report.status === "ok") {
124
+ return {
125
+ ...base,
126
+ state: "healthy",
127
+ summary: `la conexión ${report.instance} está registrada y coincide en ${host}`,
128
+ impact: "el host puede levantar este MCP con la configuración vigente",
129
+ ownership: "ours",
130
+ remediation: { kind: "none", action: null, guidance: [] },
131
+ };
132
+ }
133
+ if (report.entry_state === "foreign") {
134
+ return {
135
+ ...base,
136
+ state: "warning",
137
+ summary: `${report.instance} existe en ${host} con una forma que no es la de Workline`,
138
+ impact: "Workline no puede tocarla: reemplazarla borraría configuración de otra persona",
139
+ ownership: "foreign",
140
+ remediation: {
141
+ kind: "manual",
142
+ action: null,
143
+ guidance: [`revisá ${report.target} y decidí a mano si esa entrada debe quedar`],
144
+ },
145
+ };
146
+ }
147
+ const repair = driftRepair(report);
148
+ return {
149
+ ...base,
150
+ state: "warning",
151
+ summary: driftSummary(report, host),
152
+ impact: "el host no levantará este MCP como Workline lo dejó configurado",
153
+ ownership: "ours",
154
+ remediation: { kind: "manual", action: null, guidance: driftGuidance(report) },
155
+ ...(repair === null ? {} : { proposal: repair }),
156
+ };
157
+ }
158
+ /**
159
+ * Qué operación arregla cada clase de drift — y cuáles no tienen ninguna.
160
+ *
161
+ * Los dos estados de la variable DSN no aparecen acá a propósito: su remedio es
162
+ * que la persona exporte la variable, y el CLI no puede hacerlo sin custodiar el
163
+ * valor. El resto se mapea a la operación que ya escribe esa configuración.
164
+ */
165
+ function driftRepair(report) {
166
+ const args = { host: report.host, instance: report.instance, scope: report.scope };
167
+ switch (report.status) {
168
+ case "missing-mcp":
169
+ return { op: "mcp.setup", args };
170
+ case "legacy-entry":
171
+ // La entrada quedó con una forma anterior EN SU LUGAR: se reescribe donde
172
+ // está. Mover una entrada de la ubicación histórica es otra operación, y la
173
+ // decide el descriptor de abajo, que es el que sabe de qué archivo salió.
174
+ return { op: "mcp.setup", args };
175
+ case "extra-entry":
176
+ return { op: "mcp.remove", args };
177
+ default:
178
+ return null;
179
+ }
180
+ }
181
+ /**
182
+ * El nombre de una variable, escrito de la única forma que sobrevive al informe.
183
+ *
184
+ * Todo el informe pasa por `redactSensitiveValue`, que trata `…DSN` seguido de
185
+ * `=`, `:` o UN ESPACIO como una asignación y reemplaza la palabra siguiente por
186
+ * `***`. Sobre una negación eso no oscurece: MIENTE. «DB_X_DSN no está en …»
187
+ * sale como «DB_X_DSN *** está en …», que afirma exactamente lo contrario de lo
188
+ * observado; y una guía «export DB_X_DSN=***» copiada tal cual deja esa basura
189
+ * en el `.zshenv` de la persona y hace que el doctor declare sana para siempre
190
+ * una conexión que no puede autenticarse.
191
+ *
192
+ * El paréntesis de cierre no es un separador, así que `(DB_X_DSN)` atraviesa el
193
+ * redactor entero. Es la misma defensa que el proveedor de tools-auth documenta
194
+ * en su evidencia, y acá cubre las tres superficies: evidencia, resumen y guía.
195
+ */
196
+ function named(variable) {
197
+ return `(${variable})`;
198
+ }
199
+ /**
200
+ * La evidencia de un drift se COMPONE acá; el `detail` del motor sólo se refleja
201
+ * blindado.
202
+ *
203
+ * Ese texto lo escribe `runMcpDoctor`, que lo comparte con `aw mcp doctor` y no
204
+ * se puede tocar desde este informe: nombra la variable de DSN en medio de una
205
+ * frase («MCP 'x' registrado pero DB_X_DSN no está en …»), que es justo la forma
206
+ * que el redactor invierte. Se blinda cada mención antes de emitirla.
207
+ */
208
+ function driftEvidence(report) {
209
+ return [
210
+ `estado de la entrada: ${report.entry_state}`,
211
+ `drift: ${report.status}`,
212
+ ...(report.detail === undefined ? [] : [shieldVariables(report.detail, report.dsn.key)]),
213
+ ];
214
+ }
215
+ /**
216
+ * Toda mención de una variable de entorno, entre paréntesis.
217
+ *
218
+ * Dos pasadas porque el motor la nombra de dos maneras: entera
219
+ * (`DB_QTC_CERT_DSN`) y como la palabra suelta `DSN` («Ni DSN ni MCP
220
+ * registrados»). La segunda no toca lo ya blindado: dentro de `(DB_X_DSN)` la
221
+ * `DSN` va precedida de `_`, que es carácter de palabra, así que `\bDSN\b` no
222
+ * casa ahí.
223
+ */
224
+ function shieldVariables(text, key) {
225
+ return text
226
+ .split(key)
227
+ .join(named(key))
228
+ .replace(/\bDSN\b/g, named("DSN"));
229
+ }
230
+ // Prose names the host by its CATALOG id, so it reads the same as the `host`
231
+ // field beside it. Guidance keeps the ENGINE id, because `aw mcp setup --host`
232
+ // takes `claude`, not `claude-code`: the two ids are not interchangeable and the
233
+ // difference is exactly which one a person is about to type.
234
+ function driftSummary(report, host) {
235
+ switch (report.status) {
236
+ case "missing-mcp":
237
+ return `falta la entrada de ${report.instance} en ${host}`;
238
+ case "dsn-mismatch":
239
+ return `${report.instance} está en ${host} pero su variable ${named(report.dsn.key)} no coincide`;
240
+ case "missing-dsn":
241
+ return `${report.instance} no tiene visible su variable ${named(report.dsn.key)}`;
242
+ case "extra-entry":
243
+ return `${host} tiene una entrada de ${report.instance} que ya no está registrada`;
244
+ case "legacy-entry":
245
+ return `${report.instance} en ${host} quedó con la forma de una versión anterior`;
246
+ case "malformed-entry":
247
+ return `la entrada de ${report.instance} en ${host} no se puede decodificar`;
248
+ default:
249
+ return `${report.instance} en ${host}: ${report.status}`;
250
+ }
251
+ }
252
+ function driftGuidance(report) {
253
+ switch (report.status) {
254
+ case "missing-mcp":
255
+ case "legacy-entry":
256
+ return [`aw mcp setup --host ${report.host} --instance ${report.instance}`];
257
+ case "extra-entry":
258
+ return [`aw mcp remove --host ${report.host} --instance ${report.instance}`];
259
+ case "missing-dsn":
260
+ case "dsn-mismatch":
261
+ return [`exportá la variable ${named(report.dsn.key)} y volvé a registrar la conexión`];
262
+ default:
263
+ return [`revisá ${report.target}`];
264
+ }
265
+ }
266
+ async function fileEntryFindings(input, hosts, scope, ourNames) {
267
+ const scopeDir = scope === "global" ? input.ctx.env.homeDir() : input.workspaceDir;
268
+ const entries = [];
269
+ const unreadable = new Map();
270
+ for (const host of hosts) {
271
+ const mcpHost = host.mcp_host;
272
+ const scan = scanMcpEntries(mcpHost, scopeDir, scope);
273
+ // Un archivo que no decodifica no declara CERO entradas: no declara ninguna
274
+ // que se haya podido leer. La diferencia es la cobertura entera de este host.
275
+ if (scan.unreadable.length > 0)
276
+ unreadable.set(host.host, scan.unreadable);
277
+ for (const name of scan.names) {
278
+ if (ourNames.has(name))
279
+ continue;
280
+ const snapshot = readMcpEntry(mcpHost, scopeDir, name, scope);
281
+ // Workline's own elicitation descriptor is not a registered DB connection,
282
+ // so the connection registry does not know it — and calling it foreign
283
+ // would tell the person their own install belongs to somebody else.
284
+ if (name === WORKLINE_MCP_ENTRY_NAME) {
285
+ entries.push({
286
+ finding: worklineEntryFinding(host.host, mcpHost, scope, snapshot),
287
+ entryName: name,
288
+ });
289
+ continue;
290
+ }
291
+ const problems = await entryProblems(input, snapshot);
292
+ entries.push({
293
+ finding: foreignEntryFinding(host.host, scope, name, snapshot, problems),
294
+ entryName: name,
295
+ });
296
+ }
297
+ }
298
+ return { entries, unreadable };
299
+ }
300
+ /**
301
+ * What can be observed about an entry nobody registered here.
302
+ *
303
+ * Three questions and no verdict: whether it decodes, whether somebody pasted a
304
+ * credential into the file, and whether its binary resolves anywhere. Ownership
305
+ * is decided elsewhere and is never inferred from these.
306
+ */
307
+ async function entryProblems(input, snapshot) {
308
+ const problems = [];
309
+ // `malformed` lo decide el lector, y hasta este lote sólo se le preguntaba por
310
+ // las entradas stdio de Workline: una entrada REMOTA legítima —`{type:"http"|
311
+ // "sse", url}`, el caso normal y no el borde— no tiene `command` y salía
312
+ // reportada como «no tiene la forma de un servidor MCP decodificable», con un
313
+ // impacto («el host puede fallar al levantarla») que describe un problema que
314
+ // no existe. El lector ya distingue las dos formas; acá sólo se lo consulta.
315
+ if (snapshot.malformed === true || (snapshot.exists === false && snapshot.present === true)) {
316
+ problems.push("la entrada no tiene la forma de un servidor MCP decodificable");
317
+ }
318
+ if (hasEmbeddedCredential(snapshot)) {
319
+ problems.push("la entrada contiene algo con forma de credencial embebida");
320
+ }
321
+ const unresolved = await unresolvedBinary(input, snapshot.command);
322
+ if (unresolved !== null)
323
+ problems.push(unresolved);
324
+ return problems;
325
+ }
326
+ /**
327
+ * Techo de largo para el nombre de una entrada ajena en el informe.
328
+ *
329
+ * Generoso para identificar cualquier nombre real —el más largo de la captura no
330
+ * llega a treinta— y acotado para que una clave de mil caracteres no empuje el
331
+ * resto del renglón fuera de la pantalla.
332
+ */
333
+ const ENTRY_NAME_MAX = 80;
334
+ /**
335
+ * Techo para una ruta, un comando o la prosa del host.
336
+ *
337
+ * Más alto que el del nombre a propósito: una ruta absoluta real pasa los ochenta
338
+ * caracteres sin esfuerzo y recortarla convierte la evidencia en algo que la
339
+ * persona no puede ir a mirar. Sigue habiendo techo porque el dato es ajeno.
340
+ */
341
+ const FREE_TEXT_MAX = 200;
342
+ /**
343
+ * Un dato AJENO que va a parar a un renglón del informe, saneado para mostrarse.
344
+ *
345
+ * Una clave JSON —y un `command`, y el nombre que el propio host imprime—
346
+ * admiten saltos de línea, retornos y controles, y la proyección humana une el
347
+ * informe con `\n`: una entrada llamada
348
+ * `"inocente\n ✔ host/mcps/x — la conexion esta sana"` imprimía un renglón de
349
+ * hallazgo que ningún hallazgo produjo, tapando visualmente uno real o
350
+ * inventando un veredicto. Sanear sólo el nombre y dejar crudo el resto deja
351
+ * abierta la misma puerta con otra llave, así que pasa por acá todo texto que
352
+ * este proveedor no escribió.
353
+ *
354
+ * Saneado NO es anónimo: el archivo va siempre en `locator` y en la evidencia,
355
+ * así que el recurso sigue siendo ubicable incluso cuando el texto se recortó.
356
+ */
357
+ function displayText(raw, max = FREE_TEXT_MAX) {
358
+ const flat = raw
359
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: es exactamente lo que hay que sacar
360
+ .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ")
361
+ .replace(/\s+/g, " ")
362
+ .trim();
363
+ return flat.length > max ? `${flat.slice(0, max)}…` : flat;
364
+ }
365
+ /** El nombre de una entrada, saneado y con un texto de reemplazo si no quedó nada legible. */
366
+ function displayEntryName(name) {
367
+ const shown = displayText(name, ENTRY_NAME_MAX);
368
+ return shown.length === 0 ? "(entrada con un nombre ilegible)" : shown;
369
+ }
370
+ /**
371
+ * El id lleva el nombre CRUDO, y eso es deliberado.
372
+ *
373
+ * Sanearlo para el id parecía más prudente y era peor: los hallazgos viven en un
374
+ * `Map` indexado por id, y dos nombres distintos que se reducen al mismo token
375
+ * (`"a b"` y `"a/b"`, o dos nombres largos que comparten su prefijo) colapsan en
376
+ * UNA fila y la otra desaparece en silencio — un doctor que se come un hallazgo
377
+ * es peor que uno que lo muestra con un nombre raro. Dentro de un host y un
378
+ * scope los nombres son claves de un objeto JSON, así que son únicos por
379
+ * construcción y el id lo es también.
380
+ *
381
+ * Lo que el nombre ajeno podía romper era la PROYECCIÓN —una clave con saltos de
382
+ * línea forja renglones de hallazgo falsos—, y eso se ataja donde se imprime:
383
+ * `renderHuman` sanea cada línea que emite. Identidad y presentación son dos
384
+ * problemas, y mezclarlos costaba la identidad.
385
+ */
386
+ function foreignEntryFinding(host, scope, rawName, snapshot, problems) {
387
+ const healthy = problems.length === 0;
388
+ const name = displayEntryName(rawName);
389
+ return {
390
+ id: doctorFindingId(host, CATEGORY, `${scope}:${rawName}`),
391
+ host,
392
+ category: CATEGORY,
393
+ resource: { kind: "mcp-entry", name: `${name} (${scope})`, locator: snapshot.target },
394
+ state: healthy ? "healthy" : "warning",
395
+ summary: healthy
396
+ ? `${name} es una entrada ajena y está bien formada`
397
+ : `${name} es una entrada ajena con problemas: ${problems.join("; ")}`,
398
+ impact: healthy
399
+ ? "no es de Workline y no necesita nada"
400
+ : "el host puede fallar al levantarla, y Workline no puede corregirla porque no es suya",
401
+ evidence: [
402
+ `archivo: ${snapshot.target}`,
403
+ ...(name === rawName ? [] : ["el nombre se normalizó para el informe; el archivo manda"]),
404
+ ...(snapshot.remote === true
405
+ ? ["entrada remota: el host la alcanza por URL, sin binario que levantar"]
406
+ : []),
407
+ ...problems,
408
+ ],
409
+ ownership: "foreign",
410
+ remediation: healthy
411
+ ? { kind: "none", action: null, guidance: [] }
412
+ : {
413
+ kind: "manual",
414
+ action: null,
415
+ guidance: [`revisá ${snapshot.target} con quien haya configurado '${name}'`],
416
+ },
417
+ };
418
+ }
419
+ /**
420
+ * The elicitation descriptor Workline writes into each host, judged by the same
421
+ * predicate every other Workline descriptor is judged by.
422
+ *
423
+ * `classifyMcpEntry` compares the WHOLE generated shape, which is what stops a
424
+ * release upgrade from calling the previous generation's descriptor foreign.
425
+ */
426
+ function worklineEntryFinding(host, mcpHost, scope, snapshot) {
427
+ const expected = worklineMcpEntry(mcpHost);
428
+ const state = classifyMcpEntry(mcpHost, snapshot, expected, {
429
+ name: WORKLINE_MCP_ENTRY_NAME,
430
+ dsnVar: "",
431
+ }).state;
432
+ const ours = state === "current" || state === "known-legacy";
433
+ const base = {
434
+ id: doctorFindingId(host, CATEGORY, `${scope}:${WORKLINE_MCP_ENTRY_NAME}`),
435
+ host,
436
+ category: CATEGORY,
437
+ resource: {
438
+ kind: "mcp-entry",
439
+ name: `${WORKLINE_MCP_ENTRY_NAME} (${scope})`,
440
+ locator: snapshot.target,
441
+ },
442
+ evidence: [`archivo: ${snapshot.target}`, `estado de la entrada: ${state}`],
443
+ };
444
+ if (state === "current") {
445
+ return {
446
+ ...base,
447
+ state: "healthy",
448
+ summary: `el descriptor de Workline está vigente en ${host}`,
449
+ impact: "el host puede pedirle una elección estructurada a la persona por MCP",
450
+ ownership: "ours",
451
+ remediation: { kind: "none", action: null, guidance: [] },
452
+ };
453
+ }
454
+ if (ours) {
455
+ // Para ESTE descriptor, `known-legacy` sólo puede significar una cosa: está
456
+ // en una ubicación que el host todavía lee y hay que moverlo. La otra mitad
457
+ // de `known-legacy` —una GENERACIÓN anterior— exige un
458
+ // `--descriptor-generation` en los argumentos, que el descriptor de
459
+ // elicitación no tiene; esa mitad vive en las conexiones de base de datos, y
460
+ // su reparación es reescribir en el lugar (`mcp.setup`), no mover.
461
+ return {
462
+ ...base,
463
+ state: "warning",
464
+ summary: `el descriptor de Workline en ${host} quedó en una ubicación que ya no es la vigente`,
465
+ impact: "el host puede cargar el descriptor viejo junto al vigente",
466
+ ownership: "ours",
467
+ remediation: {
468
+ kind: "manual",
469
+ action: null,
470
+ guidance: [`aw mcp migrate --host ${mcpHost} --scope ${scope}`],
471
+ },
472
+ proposal: {
473
+ op: "mcp.migrate",
474
+ args: { host: mcpHost, instance: WORKLINE_MCP_ENTRY_NAME, scope },
475
+ },
476
+ };
477
+ }
478
+ return {
479
+ ...base,
480
+ state: "warning",
481
+ summary: `hay una entrada '${WORKLINE_MCP_ENTRY_NAME}' en ${host} que Workline no escribió`,
482
+ impact: "Workline la preserva: reemplazarla borraría configuración de otra persona",
483
+ ownership: "foreign",
484
+ remediation: {
485
+ kind: "manual",
486
+ action: null,
487
+ guidance: [`revisá ${snapshot.target} antes de tocar esa entrada`],
488
+ },
489
+ };
490
+ }
491
+ /**
492
+ * A stdio command that resolves nowhere. Absolute paths are checked as paths, not through PATH.
493
+ *
494
+ * Las dos ramas van por un puerto: `ctx.process.which` y `ctx.fs.exists`. La
495
+ * rama de ruta absoluta consultaba `existsSync` del disco real, y eso hacía que
496
+ * el mismo archivo de configuración diera `healthy` en la máquina de quien
497
+ * instaló el binario y `warning` en CI — el resultado del doctor dejaba de ser
498
+ * función de lo que el contexto le entrega.
499
+ */
500
+ async function unresolvedBinary(input, command) {
501
+ if (command === undefined || command.length === 0)
502
+ return null;
503
+ // El comando lo escribió otra persona igual que el nombre, así que se nombra
504
+ // saneado: se resuelve la ruta CRUDA y se muestra la versión de una sola línea.
505
+ const shown = displayText(command);
506
+ if (command.includes("/") || command.includes("\\")) {
507
+ return (await input.ctx.fs.exists(command))
508
+ ? null
509
+ : `el binario '${shown}' no existe en esa ruta`;
510
+ }
511
+ const resolved = await input.ctx.process.which(command);
512
+ return resolved === undefined ? `el binario '${shown}' no está en el PATH` : null;
513
+ }
514
+ /**
515
+ * The host's own verdict, merged onto the entries already found.
516
+ *
517
+ * The asymmetry is deliberate and it is the spec's: OUR MCP that the host cannot
518
+ * connect is `blocking`, because Workline promised that capability and it is not
519
+ * there. Somebody else's failing MCP is a `warning` with written guidance and no
520
+ * action, because the failure is real but the resource is not ours to repair.
521
+ */
522
+ function nativeFindings(hosts, owners, deps) {
523
+ const findings = [];
524
+ const failures = new Map();
525
+ for (const host of hosts) {
526
+ // The native binary is named after the host's MCP id, not its catalog id:
527
+ // Claude Code is `claude-code` in the catalog and `claude` on the PATH, and
528
+ // comparing the wrong one silently skips the host it was meant to inspect.
529
+ const binary = host.mcp_host;
530
+ if (binary === null || !isNativeHost(binary))
531
+ continue;
532
+ const read = readNativeMcpState(binary, deps);
533
+ if (!read.ok) {
534
+ failures.set(host.host, { failure: read.failure, reason: read.reason });
535
+ continue;
536
+ }
537
+ for (const server of read.servers) {
538
+ findings.push(nativeFinding(host.host, server, owners.get(ownerKey(host.host, server.name))));
539
+ }
540
+ }
541
+ return { findings, failures };
542
+ }
543
+ function nativeFinding(host, server, owned) {
544
+ const ours = owned?.ownership === "ours";
545
+ // Todo lo que sigue lo imprimió el host, no este proveedor: el nombre de un
546
+ // servidor del JSON de codex admite saltos de línea igual que una clave de
547
+ // configuración, y el detalle es texto libre del host. Se muestra saneado; el
548
+ // cruce con el dueño ya se hizo contra el nombre CRUDO, que es el que el
549
+ // archivo escribió.
550
+ const name = displayEntryName(server.name);
551
+ const evidence = [
552
+ `${server.host} reporta '${name}' como ${server.health}`,
553
+ ...(server.detail === null ? [] : [displayText(server.detail)]),
554
+ ...(server.auth_status === null ? [] : [`auth_status: ${displayText(server.auth_status)}`]),
555
+ ...(server.transport === null ? [] : [`transporte: ${displayText(server.transport)}`]),
556
+ ];
557
+ const base = {
558
+ id: doctorFindingId(host, CATEGORY, `native:${server.name}`),
559
+ host,
560
+ category: CATEGORY,
561
+ resource: { kind: "mcp-server", name, locator: null },
562
+ evidence,
563
+ ownership: (ours ? "ours" : "foreign"),
564
+ };
565
+ if (server.health === "connected") {
566
+ return {
567
+ ...base,
568
+ state: "healthy",
569
+ summary: `${host} conecta '${name}'`,
570
+ impact: "el servidor está disponible para el host",
571
+ remediation: { kind: "none", action: null, guidance: [] },
572
+ };
573
+ }
574
+ if (server.health === "disabled") {
575
+ return {
576
+ ...base,
577
+ state: "healthy",
578
+ summary: `'${name}' está deshabilitado a propósito en ${host}`,
579
+ impact: "no está disponible, y eso es lo que alguien decidió",
580
+ remediation: { kind: "none", action: null, guidance: [] },
581
+ };
582
+ }
583
+ if (server.health === "unverified") {
584
+ return {
585
+ ...base,
586
+ state: "unverified",
587
+ summary: `${host} reportó '${name}' en un formato que este lector no reconoce`,
588
+ impact: "no se puede afirmar que el servidor esté sano ni que esté roto",
589
+ remediation: {
590
+ kind: "manual",
591
+ action: null,
592
+ guidance: [`corré '${host} mcp list' y leé el estado de '${name}' a mano`],
593
+ },
594
+ };
595
+ }
596
+ const needsAuth = server.health === "needs-auth";
597
+ return {
598
+ ...base,
599
+ state: ours ? "blocking" : "warning",
600
+ summary: needsAuth
601
+ ? `'${name}' necesita autenticación en ${host}`
602
+ : `${host} no logra conectar '${name}'`,
603
+ impact: ours
604
+ ? "una capacidad que Workline configuró no está disponible en este host"
605
+ : "el host arrastra un servidor que no levanta; Workline no lo tocó y no lo va a tocar",
606
+ remediation: {
607
+ kind: "manual",
608
+ action: null,
609
+ guidance: ours
610
+ ? [`revisá la conexión con 'aw mcp doctor' y volvé a registrarla si hace falta`]
611
+ : [`autenticá o corregí '${name}' con la herramienta que lo instaló`],
612
+ },
613
+ };
614
+ }
615
+ function coverageFor(host, skipNative, failures, unreadable) {
616
+ if (host.mcp_host === null) {
617
+ return coverage(CATEGORY, host.host, "not-applicable", "el host no toma MCP por archivo");
618
+ }
619
+ // Fail-closed, y antes que cualquier otra cosa: si un archivo que el host
620
+ // carga no se pudo decodificar, no se miró lo que declara. Decir «comprobada»
621
+ // sobre bytes que nadie leyó es exactamente la cobertura que este modelo
622
+ // existe para prohibir — y el veredicto salía 0 sin ninguna evidencia mientras
623
+ // el host, con ese archivo roto, no levanta NINGÚN MCP.
624
+ if (unreadable.length > 0) {
625
+ return coverage(CATEGORY, host.host, "unavailable", `no se pudo decodificar ${unreadable.join(", ")}: no se miró ninguna entrada de ese archivo`);
626
+ }
627
+ if (!isNativeHost(host.mcp_host)) {
628
+ return coverage(CATEGORY, host.host, "checked");
629
+ }
630
+ if (skipNative) {
631
+ return coverage(CATEGORY, host.host, "skipped", "--skip-native: no se consultó el estado nativo de los MCP del host");
632
+ }
633
+ const failed = failures.get(host.host);
634
+ if (failed === undefined)
635
+ return coverage(CATEGORY, host.host, "checked");
636
+ // Un host `residual-config` es, por definición, un directorio de configuración
637
+ // que quedó sin runtime: que su binario no esté es su estado NORMAL, no un
638
+ // proveedor caído. Contabilizarlo como `unavailable` hacía que un directorio
639
+ // huérfano de un host desinstalado meses atrás volviera roja —exit 1, y con
640
+ // ella el build de CI— una máquina impecable. Se distingue «no pude mirar
641
+ // porque el host no está» de «no pude mirar y eso es un problema».
642
+ if (failed.failure === "absent" && host.status === "residual-config") {
643
+ return coverage(CATEGORY, host.host, "skipped", `${failed.reason}: el host quedó sin runtime, así que no hay estado nativo que consultar`);
644
+ }
645
+ return coverage(CATEGORY, host.host, "unavailable", failed.reason);
646
+ }
647
+ function isNativeHost(host) {
648
+ return NATIVE_MCP_HOSTS.includes(host);
649
+ }
650
+ //# sourceMappingURL=provider-mcps.js.map