dsh-knj-workflow 2026.8.25 → 2026.8.280

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,13 +42,11 @@
42
42
  ## 安装
43
43
 
44
44
  ```sh
45
- # 在插件目录打包
46
- pnpm pack # 生成 dsh-knj-workflow-0.1.0.tgz
47
-
48
- # 安装到 dsh web profile(重启 dsh web 生效)
49
- dsh plugin --profile web add ./dsh-knj-workflow-0.1.0.tgz
45
+ dsh plugin --profile web add dsh-knj-workflow
50
46
  ```
51
47
 
48
+ > npm 包 [`dsh-knj-workflow`](https://www.npmjs.com/package/dsh-knj-workflow);安装后重启 dsh web 生效。
49
+
52
50
  配置(可选,在 profile 的 `cordis.patch.yml` 中按 id `knj-workflow` 覆盖):
53
51
 
54
52
  | 键 | 默认 | 说明 |
package/lib/client.js CHANGED
@@ -157,8 +157,8 @@ window.__ModuleLoader__.load({
157
157
  .knj-item-foot{display:flex;align-items:center;gap:10px;margin-top:10px}
158
158
  .knj-item-foot .knj-progress{flex:1;width:auto}
159
159
  .knj-item-pct{font-size:12px;color:var(--dsw-alias-label-secondary);min-width:36px;text-align:right;font-variant-numeric:tabular-nums}
160
- /* 分页器 */
161
- .knj-pager{display:flex;align-items:center;justify-content:center;gap:14px;margin-top:14px}
160
+ /* 分页器(sticky 钉在看板底部可见区,列表内部滚动时无需滚到底即可翻页) */
161
+ .knj-pager{display:flex;align-items:center;justify-content:center;gap:14px;margin-top:10px;position:sticky;bottom:0;background:var(--dsw-specific-menu);padding:10px 0 6px;border-top:1px solid var(--dsw-alias-border-l2);z-index:2}
162
162
  .knj-pager button{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;width:32px;height:32px;cursor:pointer;color:var(--dsw-alias-label-primary);font-size:16px}
163
163
  .knj-pager button:hover{background:var(--dsw-alias-interactive-bg-hover)}
164
164
  .knj-pager button:disabled{opacity:.4;cursor:not-allowed}
@@ -1592,6 +1592,14 @@ window.__ModuleLoader__.load({
1592
1592
  const title = view.title || view.name || (path ? path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() : '');
1593
1593
  if (path) cwdOptions.push({ path, title: title || path });
1594
1594
  }
1595
+ // 没有活跃会话时,「默认(当前工作区)」没有可继承的 cwd(服务端会直接报
1596
+ // 「无法确定任务工作目录」):预选第一个工作区,让无会话直建任务也能一次成功。
1597
+ // 探测失败则保持默认,由服务端校验兜底。
1598
+ try {
1599
+ const ag = _ctx?.get?.('agents');
1600
+ const live = ag?.currentInitiator?.() ?? ag?.roots?.()?.[0];
1601
+ if (!live && cwdOptions.length > 0 && !this.state.cwd) this.setState({ cwd: cwdOptions[0].path });
1602
+ } catch { /* 探测失败保持默认 */ }
1595
1603
  this.setState({ cwdOptions });
1596
1604
  } catch { /* 加载失败保持空 */ }
1597
1605
  }
package/lib/index.js CHANGED
@@ -317,7 +317,7 @@ function defaultWorkflow() {
317
317
  // ---------------------------------------------------------------------------
318
318
  // workflow 桥接:启动、事件监听
319
319
  // ---------------------------------------------------------------------------
320
- class WorkflowBridge {
320
+ export class WorkflowBridge {
321
321
  constructor(ctx, store, orchestratorScript) {
322
322
  this.ctx = ctx;
323
323
  this.store = store;
@@ -335,37 +335,70 @@ class WorkflowBridge {
335
335
 
336
336
  // 为每个任务动态创建一个专用顶层会话作为 parent(隔离用户现有会话,
337
337
  // 不让 workflow 的 subagent 挂在「随机」的当前会话下);失败则回退现有会话。
338
+ // seed(可继承配置的现有会话)不是必需的:任务自带 cwd 时,即使宿主里没有
339
+ // 任何活跃会话(刚启动 dsh web 直接在插件面板建任务:HTTP 链路无 initiator、
340
+ // roots 为空)也能自建专用 parent;无 seed 时 preset 用默认、模型用任务配置
341
+ // 或宿主默认。
338
342
  const agents = this.ctx.get('agents');
339
343
  let parent = null;
340
344
  let parentHandle = null;
341
- if (typeof agents?.create === 'function') {
342
- const seed = agents.currentInitiator?.() ?? agents.roots?.()?.[0];
343
- if (seed) {
344
- try {
345
- // 关键:专用 parent 必须带 cwd(工作目录),否则它的 subagent 继承不到 cwd,
346
- // 会以「no working directory for the child」启动即失败(task 被误标 success)。
347
- // 优先用任务 cwd,其次继承 seed 会话的 cwd。
348
- const seedCwd = seed.session?.header?.cwd;
349
- const parentCwd = task.cwd || seedCwd;
350
- parentHandle = await agents.create({
351
- sessionId: `knj-task-${task.id}-${randomUUID().slice(0, 8)}`,
352
- agentOptions: {
353
- // 任务级模型配置优先(新建任务表单指定,如当前会话模型额度不足换其他模型);
354
- // 未配置则继承发起会话(seed)的模型。
355
- ...((task.model || seed.options?.model) != null ? { model: task.model || seed.options?.model } : {}),
356
- ...((task.provider || seed.options?.provider) != null ? { provider: task.provider || seed.options?.provider } : {}),
357
- ...(seed.options?.maxTokens != null ? { maxTokens: seed.options.maxTokens } : {}),
345
+ const seed = agents?.currentInitiator?.() ?? agents?.roots?.()?.[0] ?? null;
346
+ // 关键:专用 parent 必须带 cwd(工作目录),否则它的 subagent 继承不到 cwd,
347
+ // 会以「no working directory for the child」启动即失败(task 被误标 success)。
348
+ // 优先用任务 cwd,其次继承 seed 会话的 cwd;两者皆无且无 seed 时不创建,
349
+ // 走下面的可操作报错(选个工作目录 / 先打开一个会话)。
350
+ const parentCwd = task.cwd || seed?.session?.header?.cwd || null;
351
+ if (typeof agents?.create === 'function' && (seed || parentCwd)) {
352
+ try {
353
+ // 关键:专用 parent 必须挂 agent preset(与宿主 apiproxy 的 composeAgent 同构)。
354
+ // agents.create 出来的新 agent scope 没有 parent:不挂 preset 时它——以及经
355
+ // composeFrom 继承其组合的全部 workflow subagent——只能看到宿主全局层,
356
+ // skill 工具与 catalog(含 ~/.dsh/skills 用户级 skills)、write/bash/pwsh 等
357
+ // preset 层工具全部缺失(subagent 只能瞎猜 write_file/shell 之类幻觉工具名)。
358
+ // preset id 必须在创建会话之前 resolve:header 在 session 边界快照 meta 时
359
+ // 定型,setup 里才发现的 id 永远进不了 header。挂载本身放在 setup 里,
360
+ // 失败会回滚整个创建(外层 catch 回退到现有会话,其 preset 天然可用)。
361
+ // 无 seed 时 resolve(undefined) 回落到默认 preset,与 apiproxy 行为一致。
362
+ const presets = this.ctx.get('agentPresets');
363
+ const presetId = presets
364
+ ? (await presets.resolve(seed?.session?.header?.agentPreset))?.id
365
+ : undefined;
366
+ parentHandle = await agents.create({
367
+ sessionId: `knj-task-${task.id}-${randomUUID().slice(0, 8)}`,
368
+ agentOptions: {
369
+ // 任务级模型配置优先(新建任务表单指定,如当前会话模型额度不足换其他模型);
370
+ // 未配置则继承发起会话(seed)的模型;无 seed 时省略 → 宿主默认模型。
371
+ ...((task.model || seed?.options?.model) != null ? { model: task.model || seed?.options?.model } : {}),
372
+ ...((task.provider || seed?.options?.provider) != null ? { provider: task.provider || seed?.options?.provider } : {}),
373
+ ...(seed?.options?.maxTokens != null ? { maxTokens: seed.options.maxTokens } : {}),
374
+ },
375
+ meta: {
376
+ ...(parentCwd ? { cwd: parentCwd } : {}),
377
+ ...(presetId ? { agentPreset: presetId } : {}),
378
+ },
379
+ ...(presets ? {
380
+ setup: async (agentCtx) => {
381
+ await presets.mount(agentCtx, presetId);
358
382
  },
359
- ...(parentCwd ? { meta: { cwd: parentCwd } } : {}),
360
- });
361
- parent = parentHandle?.agent;
362
- } catch (error) {
363
- this.ctx.logger?.warn?.(`dsh-knj-workflow: 创建专用 parent 会话失败,回退现有会话: ${error?.message || error}`);
364
- }
383
+ } : {}),
384
+ });
385
+ parent = parentHandle?.agent;
386
+ } catch (error) {
387
+ this.ctx.logger?.warn?.(`dsh-knj-workflow: 创建专用 parent 会话失败,回退现有会话: ${error?.message || error}`);
365
388
  }
366
389
  }
367
390
  if (!parent) parent = agents?.currentInitiator?.() ?? agents?.roots?.()?.[0];
368
- if (!parent) throw new Error('no active agent to own the workflow run');
391
+ if (!parent) {
392
+ // 到这里说明:宿主没有任何活跃会话(currentInitiator/roots 皆空),且上面没能
393
+ // 自建专用 parent。给出能直接照做的提示,而不是让人摸不着头脑的内部错误。
394
+ if (typeof agents?.create !== 'function') {
395
+ throw new Error('no active agent to own the workflow run(agents.create 不可用:需加载 agent-loop 插件)');
396
+ }
397
+ if (!parentCwd) {
398
+ throw new Error('无法确定任务工作目录:当前没有活跃会话可继承。请在「新建任务」表单中选择工作目录,或先打开任意一个会话再启动任务');
399
+ }
400
+ throw new Error('no active agent to own the workflow run(专用 parent 会话创建失败,详见宿主日志;也可先打开一个会话再试)');
401
+ }
369
402
 
370
403
  // 记录 parent 会话 id(agent.id === session.id,见 agent-loop enter 校验),
371
404
  // 前端「执行记录」用它 refreshSubagents 并把每个节点 subagent 跳转回原生会话。
@@ -497,6 +530,17 @@ class WorkflowBridge {
497
530
  this.listeners.delete(task.id);
498
531
  this.runs.delete(task.id);
499
532
  }
533
+ // 关键:run 是 holder-owned,契约要求持有者在 result 落定后调用 run.dispose()
534
+ // 释放引擎侧资源(等待子清理收敛)。不调用的话引擎 run 持续占用 parent 的
535
+ // machine,下面的 parentHandle.dispose() 会挂在 machine.whenIdle() 上永不返回,
536
+ // detachAgent(注册表注销)永远执行不到——实测复现:每个成功任务都遗留一个
537
+ // 常驻 knj-task-* root agent。与 dsh-tool-workflow 的规范持有方式
538
+ // (finally { await run.dispose() })对齐。dispose 失败不阻塞终态,记 warn 可观测。
539
+ try {
540
+ await run.dispose?.();
541
+ } catch (error) {
542
+ this.ctx.logger?.warn?.(`dsh-knj-workflow: run dispose 失败(parent 可能泄漏): ${error?.message || error}`);
543
+ }
500
544
  // 终态后释放专用 parent 会话(按 runId,避免误删新 run 的 parent)
501
545
  const handle = this.parentHandles.get(run.id);
502
546
  if (handle) {
@@ -697,7 +741,19 @@ function registerRoutes(ctx, store, bridge, prefix) {
697
741
  };
698
742
  await store.saveTask(task);
699
743
  if (body.autoStart !== false) {
700
- await bridge.startTask(task);
744
+ try {
745
+ await bridge.startTask(task);
746
+ } catch (e) {
747
+ // 启动失败必须落库为 failed(与 /tasks/:id/start 一致),否则任务永远停在
748
+ // pending,界面只剩一个瞬时 toast,用户不知道可以在修复后点「启动」重试。
749
+ await store.mutateTask(task.id, (tt) => {
750
+ tt.status = 'failed';
751
+ tt.error = `启动失败:${e instanceof Error ? e.message : String(e)}`;
752
+ tt.finishedAt = nowIso();
753
+ }).catch(() => {});
754
+ sendJson(c.res, 500, { error: e instanceof Error ? e.message : String(e) });
755
+ return;
756
+ }
701
757
  task.status = 'running';
702
758
  task.startedAt = nowIso();
703
759
  await store.saveTask(task);
@@ -1019,7 +1075,17 @@ function registerCommands(ctx, store, bridge) {
1019
1075
  createdAt: nowIso(),
1020
1076
  };
1021
1077
  await store.saveTask(task);
1022
- await bridge.startTask(task);
1078
+ try {
1079
+ await bridge.startTask(task);
1080
+ } catch (e) {
1081
+ // 与 POST /tasks 一致:启动失败落库为 failed,避免任务永久停在 pending
1082
+ await store.mutateTask(task.id, (tt) => {
1083
+ tt.status = 'failed';
1084
+ tt.error = `启动失败:${e instanceof Error ? e.message : String(e)}`;
1085
+ tt.finishedAt = nowIso();
1086
+ }).catch(() => {});
1087
+ return { success: false, text: `任务 ${task.id} 已创建但启动失败:${e instanceof Error ? e.message : String(e)}。可在「开发任务」面板修复后重新启动。` };
1088
+ }
1023
1089
  task.status = 'running';
1024
1090
  task.startedAt = nowIso();
1025
1091
  await store.saveTask(task);
@@ -0,0 +1,176 @@
1
+ /**
2
+ * dsh-knj-workflow WorkflowBridge.startTask 测试(node:test)
3
+ *
4
+ * 背景缺陷:专用 parent 会话经 agents.create() 创建时没挂 agent preset,
5
+ * 导致 workflow subagent(composeFrom 继承 parent 的组合)看不到 preset 层的
6
+ * skill 工具与 catalog(~/.dsh/skills 等用户级/项目级 skills 全部缺失),
7
+ * 连 write/bash 等 preset 层工具都没有。
8
+ *
9
+ * 运行:node --test lib/index.test.js(需能 resolve @deepseek-ai/schemastery,
10
+ * 建议在已安装副本目录 ~/.dsh/profiles/web/node_modules/dsh-knj-workflow 下跑)
11
+ */
12
+ import { test } from 'node:test';
13
+ import assert from 'node:assert/strict';
14
+ import { WorkflowBridge } from './index.js';
15
+
16
+ /** 最小 workflow 快照:start → task → end。 */
17
+ const WF = {
18
+ id: 'wf1', name: 'WF',
19
+ nodes: [
20
+ { id: 'start', type: 'start' },
21
+ { id: 'n1', type: 'task', title: '做一件事', body: { prompt: '干活' } },
22
+ { id: 'end', type: 'end' },
23
+ ],
24
+ edges: [
25
+ { from: 'start', to: 'n1' },
26
+ { from: 'n1', to: 'end' },
27
+ ],
28
+ };
29
+
30
+ function makeTask() {
31
+ return { id: 'task-x', title: 'T', workflowId: 'wf1', cwd: 'D:/w', workflowSnapshot: WF };
32
+ }
33
+
34
+ /** 默认的 agentPresets mock:resolve(undefined) 回落 'standard',mount 记录调用。 */
35
+ function defaultPresetService(recorded) {
36
+ return {
37
+ async resolve(id) { return { id: id ?? 'standard' }; },
38
+ async mount(agentCtx, id) { recorded.mountCalls.push([agentCtx?.tag, id]); },
39
+ };
40
+ }
41
+
42
+ /** 构造 mock 环境:agents 服务(create/currentInitiator)、agentPresets 服务、workflowEngine。
43
+ * live='seed'(默认):存在可继承的 seed 会话;live='none':宿主无任何活跃会话
44
+ * (刚启动 dsh web 直接在插件面板建任务的场景:HTTP 链路无 initiator、roots 为空)。 */
45
+ function makeEnv({ seedPreset = 'standard', presetService = 'default', live = 'seed' } = {}) {
46
+ const recorded = { createOpts: null, mountCalls: [], engineStartOpts: null, runDisposeCalls: 0 };
47
+ const presetSvc = presetService === 'default'
48
+ ? defaultPresetService(recorded)
49
+ : presetService === 'none' ? undefined : presetService;
50
+ const engine = {
51
+ start(opts) {
52
+ recorded.engineStartOpts = opts;
53
+ return {
54
+ id: 'run-1',
55
+ cancel() {},
56
+ dispose() { recorded.runDisposeCalls += 1; },
57
+ result: Promise.resolve({ stopReason: 'completed', value: { results: {} } }),
58
+ };
59
+ },
60
+ };
61
+ const parentAgent = {
62
+ id: 'parent-1',
63
+ scope: { ctx: { get: (n) => (n === 'workflowEngine' ? engine : undefined) } },
64
+ };
65
+ const seed = {
66
+ session: { header: { cwd: 'D:/w', ...(seedPreset ? { agentPreset: seedPreset } : {}) } },
67
+ options: {},
68
+ };
69
+ const agents = {
70
+ currentInitiator: () => (live === 'none' ? undefined : seed),
71
+ roots: () => (live === 'none' ? [] : [seed]),
72
+ async create(opts) {
73
+ recorded.createOpts = opts;
74
+ return { agent: parentAgent, dispose: async () => {} };
75
+ },
76
+ };
77
+ const ctx = {
78
+ on() {}, off() {},
79
+ logger: { info() {}, warn() {} },
80
+ get(name) {
81
+ if (name === 'agents') return agents;
82
+ if (name === 'agentPresets') return presetSvc;
83
+ return undefined;
84
+ },
85
+ };
86
+ const store = {
87
+ tasksDir: '.tasks',
88
+ getWorkflow: async () => WF,
89
+ saveTask: async () => {},
90
+ mutateTask: async () => {},
91
+ saveResults: async () => {},
92
+ writeStage: async () => {},
93
+ };
94
+ return { ctx, store, recorded, agents };
95
+ }
96
+
97
+ test('startTask: 专用 parent 必须挂 agent preset(setup 调 agentPresets.mount)', async () => {
98
+ const { ctx, store, recorded } = makeEnv();
99
+ const bridge = new WorkflowBridge(ctx, store, 'script');
100
+ await bridge.startTask(makeTask(), {});
101
+
102
+ assert.ok(recorded.createOpts, 'agents.create 应被调用');
103
+ assert.equal(typeof recorded.createOpts.setup, 'function',
104
+ 'agents.create 必须传 setup:否则 parent(及其全部 workflow subagent)不挂 preset,' +
105
+ 'skill 工具/catalog 与 write/bash 等 preset 层工具全部缺失');
106
+ const agentCtx = { tag: 'parent-scope' };
107
+ await recorded.createOpts.setup(agentCtx);
108
+ assert.equal(recorded.mountCalls.length, 1, 'setup 应调用 agentPresets.mount 一次');
109
+ assert.equal(recorded.mountCalls[0][0], 'parent-scope', 'mount 收到的是 agent scope ctx');
110
+ });
111
+
112
+ test('startTask: meta.agentPreset 需写入会话头(继承 seed 的 preset)', async () => {
113
+ const { ctx, store, recorded } = makeEnv({ seedPreset: 'standard' });
114
+ const bridge = new WorkflowBridge(ctx, store, 'script');
115
+ await bridge.startTask(makeTask(), {});
116
+ assert.equal(recorded.createOpts?.meta?.agentPreset, 'standard',
117
+ 'header 在 session 边界快照 meta 时定型,preset id 必须随 meta 传入(供持久化/前端展示/subagent 继承语义)');
118
+ });
119
+
120
+ test('startTask: seed 无 preset 时用默认 preset(resolve(undefined) → defaultId)', async () => {
121
+ const { ctx, store, recorded } = makeEnv({ seedPreset: undefined });
122
+ const bridge = new WorkflowBridge(ctx, store, 'script');
123
+ await bridge.startTask(makeTask(), {});
124
+ assert.equal(typeof recorded.createOpts?.setup, 'function');
125
+ await recorded.createOpts.setup({ tag: 'x' });
126
+ assert.equal(recorded.mountCalls[0]?.[1], 'standard', 'resolve(undefined) 应回落到默认 preset id');
127
+ assert.equal(recorded.createOpts.meta?.agentPreset, 'standard');
128
+ });
129
+
130
+ test('startTask: 无 agentPresets 服务时优雅降级(不传 setup 也不炸)', async () => {
131
+ const { ctx, store, recorded } = makeEnv({ presetService: 'none' });
132
+ const bridge = new WorkflowBridge(ctx, store, 'script');
133
+ await bridge.startTask(makeTask(), {});
134
+ assert.ok(recorded.createOpts, '仍应创建专用 parent');
135
+ assert.equal(recorded.createOpts.setup, undefined,
136
+ '无 roster 部署没有 preset 可挂,行为应与 apiproxy composeAgent 一致:什么都不挂');
137
+ assert.equal(recorded.engineStartOpts?.parent?.id, 'parent-1', 'workflow 引擎仍以专用 parent 启动');
138
+ });
139
+
140
+ test('startTask: 无活跃会话但任务带 cwd → 仍自建专用 parent(默认 preset)', async () => {
141
+ const { ctx, store, recorded } = makeEnv({ live: 'none' });
142
+ const bridge = new WorkflowBridge(ctx, store, 'script');
143
+ const task = makeTask(); // cwd: 'D:/w'
144
+ await bridge.startTask(task, {});
145
+
146
+ assert.ok(recorded.createOpts, '无 seed 也应自建专用 parent(此前直接抛 no active agent)');
147
+ assert.equal(recorded.createOpts.meta?.cwd, 'D:/w', 'cwd 来自任务配置');
148
+ assert.equal(recorded.createOpts.meta?.agentPreset, 'standard', '无 seed 时 resolve(undefined) 回落默认 preset');
149
+ assert.equal(recorded.createOpts.agentOptions?.model, undefined, '无 seed 无任务模型时省略 model → 宿主默认');
150
+ assert.equal(recorded.engineStartOpts?.parent?.id, 'parent-1', 'workflow 引擎以自建 parent 启动');
151
+ });
152
+
153
+ test('startTask: 无活跃会话且无 cwd → 抛可操作错误(指引选工作目录/先开会话)', async () => {
154
+ const { ctx, store, recorded } = makeEnv({ live: 'none' });
155
+ const bridge = new WorkflowBridge(ctx, store, 'script');
156
+ const task = makeTask();
157
+ delete task.cwd;
158
+
159
+ await assert.rejects(
160
+ () => bridge.startTask(task, {}),
161
+ (err) => /工作目录/.test(err.message) && /no active agent|会话/.test(err.message),
162
+ '错误信息必须说清「无法确定工作目录」并给出可照做的下一步,而不是裸的内部错误');
163
+ assert.equal(recorded.createOpts, null, '无 cwd 无 seed 时不应调用 agents.create(建出来 subagent 也会因缺 cwd 失败)');
164
+ });
165
+
166
+ test('startTask: run 终态后必须调用 run.dispose(否则引擎占用 parent machine,parent 永不注销)', async () => {
167
+ const { ctx, store, recorded } = makeEnv();
168
+ const bridge = new WorkflowBridge(ctx, store, 'script');
169
+ await bridge.startTask(makeTask(), {});
170
+ // run.result 立即 resolve,.then 回调(finalizeTask → 清理 → dispose)在微任务链执行:
171
+ // setImmediate 排空后再断言,保证回调已跑完。
172
+ await new Promise((resolve) => setImmediate(resolve));
173
+ assert.equal(recorded.runDisposeCalls, 1,
174
+ 'result 落定后应恰好调用一次 run.dispose()(holder 契约,与 dsh-tool-workflow 的 finally { await run.dispose() } 对齐);' +
175
+ '否则引擎 run 不释放,parentHandle.dispose 挂在 machine.whenIdle() 上,knj-task-* parent 永久泄漏');
176
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
- {
1
+ {
2
2
  "name": "dsh-knj-workflow",
3
- "version": "2026.8.25",
3
+ "version": "2026.8.280",
4
4
  "description": "KNJ 开发任务编排插件:配置驱动的工作流 + 开发任务管理 + 可视化阶段进度(DeepSeek Harness)",
5
5
  "author": "knj",
6
6
  "license": "MIT",