dsh-xray 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,7 +27,7 @@
27
27
 
28
28
  **dsh-xray does.**
29
29
 
30
- > **Status: 0.4.x — static + runtime imaging with context-cost attribution.** Static commands work even when dsh cannot boot; `deps`/`health`/`cost`/`shadow` and the agent tool need the plugin mounted.
30
+ > **Status: 0.6.x — static + runtime imaging, context-cost attribution, and a web panel.** Static commands work even when dsh cannot boot; `deps`/`health`/`cost`/`shadow` and the agent tool need the plugin mounted.
31
31
 
32
32
  ---
33
33
 
@@ -79,6 +79,9 @@ Per-plugin fiber lifecycle state, startup failures, transition history.
79
79
  ### 🤖 Agent Self-Introspection
80
80
  The `xray_composition` tool lets agents inspect their own capability set.
81
81
 
82
+ ### 🖥️ Web Panel
83
+ Mounted in `dsh web`, the plugin serves a zero-dependency panel at **`/xray`** — summary, health, deps (with the disable-cascade table), cost, and shadow views, live from the running composition. JSON endpoints under `/xray/api/*` serve the same data.
84
+
82
85
  What every request actually carries — prompt sections observed at assembly, blended with tool schemas:
83
86
 
84
87
  ```console
package/README.zh.md CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  [English](./README.md)
12
12
 
13
- > **状态:0.4.x — 静态 + 运行时成像,含上下文成本归因。** 静态命令在 dsh 起不来时照样能用;`deps`/`health`/`cost`/`shadow` 和 agent 工具需要插件已挂载。
13
+ > **状态:0.6.x — 静态 + 运行时成像、上下文成本归因、Web 面板。** 静态命令在 dsh 起不来时照样能用;`deps`/`health`/`cost`/`shadow` 和 agent 工具需要插件已挂载。
14
14
 
15
15
  `dsh --dump-config` 只给你原始组合树,插件面板只给你平铺列表。它们都不回答:这个插件*为什么*在这、停用它会*连带瘫掉什么*、它在*悄悄消耗什么*。dsh-xray 回答这些。
16
16
 
@@ -91,6 +91,10 @@ orphan overrides (silently skipped) (1)
91
91
 
92
92
  挂载进树后,dsh-xray 注册 `xray_composition` 工具(`view: summary | deps | health | cost | shadow`),agent 可以自答"我有哪些能力 / 哪个插件提供 X / 为什么 Y 不可用"。
93
93
 
94
+ ## Web 面板
95
+
96
+ 在 `dsh web` 中挂载后,插件在 **`/xray`** 提供零依赖面板——summary、health、deps(含停用级联表)、cost、shadow 五个视图,数据实时来自运行中的组合树;`/xray/api/*` 提供同源 JSON。
97
+
94
98
  ## 安全立场
95
99
 
96
100
  dsh-xray 只读不执行。patch 文件里的 loader `!!js` 表达式解析为不透明标记、绝不求值;CLI 从不执行插件代码(`audit` 是对源码文本的模式扫描);挂载的插件只写 `$DSH_HOME/xray/` 目录。详见 [SECURITY.md](./SECURITY.md)。
package/bin/xray.js CHANGED
@@ -12,6 +12,7 @@ function parseArgs(argv) {
12
12
  for (let i = 0; i < argv.length; i++) {
13
13
  const a = argv[i];
14
14
  if (a === '--profile' || a === '-p') args.profile = argv[++i];
15
+ else if (a === '--against') args.against = argv[++i];
15
16
  else if (a === '--json') args.json = true;
16
17
  else args._.push(a);
17
18
  }
@@ -118,7 +119,44 @@ function cmdDiff(args) {
118
119
  function cmdSnapshot(args) {
119
120
  const data = collectStatic(args.profile);
120
121
  const { dump } = tryDump(args.profile);
121
- console.log(JSON.stringify(model.snapshot(data, dump), null, 2));
122
+ const current = model.snapshot(data, dump);
123
+ const againstFile = args.against;
124
+ if (!againstFile) return console.log(JSON.stringify(current, null, 2));
125
+
126
+ const fs = require('node:fs');
127
+ const { compareSnapshots } = require('../lib/compare.js');
128
+ const saved = JSON.parse(fs.readFileSync(againstFile, 'utf8'));
129
+ const result = compareSnapshots(saved, current);
130
+ if (args.json) return console.log(JSON.stringify(result, null, 2));
131
+
132
+ if (result.identical) {
133
+ return console.log(`composition identical to snapshot from ${result.savedAt}`);
134
+ }
135
+ console.log(`composition drifted from snapshot (${result.savedAt}):`);
136
+ for (const b of result.changes.bundles) {
137
+ if (b.change === 'added') console.log(` bundle + ${b.name}@${b.version}`);
138
+ else if (b.change === 'removed') console.log(` bundle - ${b.name}`);
139
+ else
140
+ console.log(
141
+ ` bundle ~ ${b.name}: ${b.change} ${b.from.version ?? b.from.patchHash} → ${b.to.version ?? b.to.patchHash}`,
142
+ );
143
+ }
144
+ for (const p of result.changes.patches) {
145
+ console.log(
146
+ ` patch ${p.change === 'added' ? '+' : p.change === 'removed' ? '-' : '~'} ${p.kind}${p.change === 'content' ? `: ${p.from} → ${p.to}` : ''}`,
147
+ );
148
+ }
149
+ for (const p of result.changes.packages) {
150
+ if (p.change === 'added') console.log(` package + ${p.name}@${p.version}`);
151
+ else if (p.change === 'removed') console.log(` package - ${p.name}`);
152
+ else console.log(` package ~ ${p.name}: ${p.from} → ${p.to}`);
153
+ }
154
+ if (result.changes.composed) {
155
+ console.log(
156
+ ` composed tree hash: ${result.changes.composed.from} → ${result.changes.composed.to}`,
157
+ );
158
+ }
159
+ process.exitCode = 1;
122
160
  }
123
161
 
124
162
  function readRuntimeSnapshot() {
@@ -266,7 +304,7 @@ Commands:
266
304
  attribute which layer introduced each row, and who patched it since
267
305
  conflicts rows whose fields have multiple writers, and who wins
268
306
  diff declared (static layers) vs actual (dump-config) tree
269
- snapshot content-addressed lockfile of the effective composition
307
+ snapshot content-addressed lockfile; --against <file> diffs a saved one
270
308
  deps service dependency graph from the live runtime snapshot
271
309
  health plugin lifecycle health from the live runtime snapshot
272
310
  cost estimated context-token cost of each model-facing tool schema
@@ -109,6 +109,41 @@ function collectStatic(profileName) {
109
109
  });
110
110
  }
111
111
 
112
+ // Repository plugins: the third-party plugin-console mechanism mounts
113
+ // `.dsh-plugin` directories under the harness home; each carries its own
114
+ // patch file. Not dsh core — absence is normal.
115
+ const repoRoot = path.join(home, '.dsh-plugin');
116
+ if (fs.existsSync(repoRoot)) {
117
+ let entries = [];
118
+ try {
119
+ entries = fs.readdirSync(repoRoot, { withFileTypes: true }).filter((e) => e.isDirectory());
120
+ } catch {
121
+ /* unreadable repository root: skip */
122
+ }
123
+ for (const e of entries) {
124
+ const dir = path.join(repoRoot, e.name);
125
+ const pkg = readJson(path.join(dir, 'package.json'));
126
+ const rel = pkg?.dsh?.bundle?.patch;
127
+ if (!rel) continue;
128
+ const file = path.join(dir, rel);
129
+ if (!fs.existsSync(file)) {
130
+ warnings.push(`repository plugin ${e.name}: patch missing (${rel})`);
131
+ continue;
132
+ }
133
+ const text = fs.readFileSync(file, 'utf8');
134
+ const { value, error } = parseYaml(text, file);
135
+ if (error) warnings.push(error);
136
+ layers.push({
137
+ kind: 'repository',
138
+ name: pkg.name ?? e.name,
139
+ version: pkg.version ?? null,
140
+ file,
141
+ entries: Array.isArray(value) ? value : [],
142
+ text,
143
+ });
144
+ }
145
+ }
146
+
112
147
  // Out-of-tree plugins: profile dependencies carrying a `dsh` field.
113
148
  const packages = [];
114
149
  for (const dep of Object.keys(manifest.dependencies ?? {})) {
package/lib/compare.js ADDED
@@ -0,0 +1,74 @@
1
+ // Snapshot comparison: current composition vs a saved lockfile.
2
+
3
+ function indexBy(list, key) {
4
+ const m = new Map();
5
+ for (const item of list ?? []) m.set(item[key], item);
6
+ return m;
7
+ }
8
+
9
+ /**
10
+ * F9b: compare a live snapshot against a saved one (`xray snapshot > lock.json`).
11
+ * Returns per-category changes; `identical` is true only when everything matches.
12
+ */
13
+ function compareSnapshots(saved, current) {
14
+ if (saved?.schema !== 'dsh-xray/snapshot@1') {
15
+ throw new Error(`not a dsh-xray snapshot: schema=${saved?.schema ?? 'missing'}`);
16
+ }
17
+ const changes = { bundles: [], patches: [], packages: [], composed: null };
18
+
19
+ const savedBundles = indexBy(saved.bundles, 'name');
20
+ const currentBundles = indexBy(current.bundles, 'name');
21
+ for (const [name, b] of currentBundles) {
22
+ const old = savedBundles.get(name);
23
+ if (!old) changes.bundles.push({ name, change: 'added', version: b.version });
24
+ else if (old.version !== b.version || old.patchHash !== b.patchHash) {
25
+ changes.bundles.push({
26
+ name,
27
+ change: old.version !== b.version ? 'version' : 'patch-content',
28
+ from: { version: old.version, patchHash: old.patchHash },
29
+ to: { version: b.version, patchHash: b.patchHash },
30
+ });
31
+ }
32
+ }
33
+ for (const name of savedBundles.keys()) {
34
+ if (!currentBundles.has(name)) changes.bundles.push({ name, change: 'removed' });
35
+ }
36
+
37
+ const savedPatches = indexBy(saved.patches, 'kind');
38
+ const currentPatches = indexBy(current.patches, 'kind');
39
+ for (const [kind, p] of currentPatches) {
40
+ const old = savedPatches.get(kind);
41
+ if (!old) changes.patches.push({ kind, change: 'added' });
42
+ else if (old.hash !== p.hash)
43
+ changes.patches.push({ kind, change: 'content', from: old.hash, to: p.hash });
44
+ }
45
+ for (const kind of savedPatches.keys()) {
46
+ if (!currentPatches.has(kind)) changes.patches.push({ kind, change: 'removed' });
47
+ }
48
+
49
+ const savedPkgs = indexBy(saved.packages, 'name');
50
+ const currentPkgs = indexBy(current.packages, 'name');
51
+ for (const [name, p] of currentPkgs) {
52
+ const old = savedPkgs.get(name);
53
+ if (!old) changes.packages.push({ name, change: 'added', version: p.version });
54
+ else if (old.version !== p.version) {
55
+ changes.packages.push({ name, change: 'version', from: old.version, to: p.version });
56
+ }
57
+ }
58
+ for (const name of savedPkgs.keys()) {
59
+ if (!currentPkgs.has(name)) changes.packages.push({ name, change: 'removed' });
60
+ }
61
+
62
+ if (saved.composedHash && current.composedHash && saved.composedHash !== current.composedHash) {
63
+ changes.composed = { from: saved.composedHash, to: current.composedHash };
64
+ }
65
+
66
+ const identical =
67
+ !changes.bundles.length &&
68
+ !changes.patches.length &&
69
+ !changes.packages.length &&
70
+ !changes.composed;
71
+ return { identical, savedAt: saved.createdAt, profile: saved.profile, changes };
72
+ }
73
+
74
+ module.exports = { compareSnapshots };
package/lib/index.js CHANGED
@@ -91,6 +91,43 @@ function apply(ctx) {
91
91
  ];
92
92
  }, 'xray-runtime-snapshot');
93
93
 
94
+ // Web panel: mounts when the profile composes a webServer (dsh web).
95
+ // Headless profiles simply never activate this subplugin.
96
+ ctx.plugin({
97
+ name: 'dsh-xray-panel',
98
+ inject: ['webServer'],
99
+ apply: (wctx) => {
100
+ const { mountPanel } = require('./panel.js');
101
+ const freshSnap = () => {
102
+ const snap = snapshotRegistry(ctx);
103
+ snap.transitions = Object.fromEntries(transitions);
104
+ snap.promptAssembly = lastAssembly;
105
+ return snap;
106
+ };
107
+ wctx.effect(
108
+ () =>
109
+ mountPanel(wctx.webServer, {
110
+ summary: () => {
111
+ const snap = freshSnap();
112
+ return {
113
+ plugins: snap.plugins.length,
114
+ unhealthy: health(snap).unhealthy.length,
115
+ services: Object.keys(serviceGraph(snap).services).length,
116
+ toolSchemaTokens: contextCost(snap).totalTokens,
117
+ capturedAt: snap.capturedAt,
118
+ };
119
+ },
120
+ deps: () => serviceGraph(freshSnap()),
121
+ health: () => health(freshSnap()),
122
+ cost: () => contextCost(freshSnap()),
123
+ shadow: () => shadowing(freshSnap()),
124
+ }),
125
+ 'xray-panel-routes',
126
+ );
127
+ logger.info('xray panel mounted at /xray');
128
+ },
129
+ });
130
+
94
131
  // Agent self-introspection tool: only when a tool registry exists
95
132
  // (headless/web both have one; keep it optional so xray mounts anywhere).
96
133
  ctx.plugin({
package/lib/panel.js ADDED
@@ -0,0 +1,213 @@
1
+ // Self-contained web panel: one HTML page + JSON endpoints, mounted on the
2
+ // host webServer. No client-module bundle, no React, no build step — the
3
+ // model layer already computes everything; this only renders it.
4
+
5
+ const PAGE = `<!doctype html>
6
+ <html lang="en">
7
+ <head>
8
+ <meta charset="utf-8">
9
+ <meta name="viewport" content="width=device-width, initial-scale=1">
10
+ <title>dsh-xray</title>
11
+ <style>
12
+ :root { color-scheme: dark; }
13
+ * { box-sizing: border-box; margin: 0; }
14
+ body { font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
15
+ background: #0d1117; color: #e6edf3; padding: 24px; }
16
+ h1 { font-size: 16px; margin-bottom: 4px; }
17
+ .sub { color: #8b949e; margin-bottom: 20px; }
18
+ nav { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
19
+ nav button { background: #21262d; color: #e6edf3; border: 1px solid #30363d;
20
+ border-radius: 6px; padding: 5px 12px; cursor: pointer; font: inherit; }
21
+ nav button.active { background: #1f6feb; border-color: #1f6feb; }
22
+ table { border-collapse: collapse; width: 100%; margin-top: 8px; }
23
+ th, td { text-align: left; padding: 4px 10px; border-bottom: 1px solid #21262d; }
24
+ th { color: #8b949e; font-weight: normal; }
25
+ .num { text-align: right; }
26
+ .bar { background: #1f6feb; height: 10px; border-radius: 2px; display: inline-block; }
27
+ .warn { color: #f85149; }
28
+ .ok { color: #7ee787; }
29
+ .muted { color: #8b949e; }
30
+ #status { margin: 12px 0; color: #8b949e; }
31
+ .tag { background: #21262d; border-radius: 4px; padding: 1px 6px; margin-left: 6px; font-size: 11px; }
32
+ </style>
33
+ </head>
34
+ <body>
35
+ <h1>dsh-xray</h1>
36
+ <div class="sub">composition X-ray — live from this harness</div>
37
+ <nav id="nav"></nav>
38
+ <div id="status"></div>
39
+ <div id="content"></div>
40
+ <script>
41
+ const views = ['summary', 'health', 'deps', 'cost', 'shadow'];
42
+ const esc = (s) => String(s ?? '').replace(/[&<>]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
43
+ const nav = document.getElementById('nav');
44
+ const content = document.getElementById('content');
45
+ const status = document.getElementById('status');
46
+ let active = 'summary';
47
+
48
+ for (const v of views) {
49
+ const b = document.createElement('button');
50
+ b.textContent = v;
51
+ b.onclick = () => { active = v; render(); };
52
+ b.id = 'nav-' + v;
53
+ nav.appendChild(b);
54
+ }
55
+
56
+ function table(headers, rows) {
57
+ return '<table><tr>' + headers.map((h) => '<th>' + esc(h) + '</th>').join('') + '</tr>'
58
+ + rows.join('') + '</table>';
59
+ }
60
+ function bar(share) {
61
+ return '<span class="bar" style="width:' + Math.max(2, share * 2) + 'px"></span>';
62
+ }
63
+
64
+ const renderers = {
65
+ summary(d) {
66
+ return table(['metric', 'value'], [
67
+ '<tr><td>plugins mounted</td><td class="num">' + d.plugins + '</td></tr>',
68
+ '<tr><td>unhealthy</td><td class="num ' + (d.unhealthy ? 'warn' : 'ok') + '">' + d.unhealthy + '</td></tr>',
69
+ '<tr><td>services</td><td class="num">' + d.services + '</td></tr>',
70
+ '<tr><td>context tokens (tools + sections)</td><td class="num">~' + d.toolSchemaTokens + '</td></tr>',
71
+ ]) + '<p class="muted" style="margin-top:12px">captured ' + esc(d.capturedAt) + '</p>';
72
+ },
73
+ health(d) {
74
+ let html = '<p><span class="ok">' + d.healthy.length + ' healthy</span>'
75
+ + (d.waiting.length ? ' · ' + d.waiting.length + ' waiting' : '')
76
+ + (d.unhealthy.length ? ' · <span class="warn">' + d.unhealthy.length + ' unhealthy</span>' : '') + '</p>';
77
+ if (d.unhealthy.length) {
78
+ html += table(['plugin', 'fiber', 'state', 'error'], d.unhealthy.flatMap((p) =>
79
+ p.fibers.map((f) => '<tr><td>' + esc(p.name) + '</td><td class="num">' + f.uid
80
+ + '</td><td class="warn">' + esc(f.state) + '</td><td>' + esc(f.error ?? '') + '</td></tr>')));
81
+ }
82
+ if (d.waiting.length) {
83
+ html += table(['waiting plugin', 'wants'], d.waiting.map((p) =>
84
+ '<tr><td>' + esc(p.name) + '</td><td>' + esc(p.inject.join(', ')) + '</td></tr>'));
85
+ }
86
+ return html;
87
+ },
88
+ deps(d) {
89
+ const services = Object.entries(d.services).map(([name, node]) =>
90
+ '<tr><td>' + esc(name) + '</td><td>' + esc(node.providers.join(', ') || '—')
91
+ + '</td><td>' + esc(node.consumers.join(', ') || '—') + '</td></tr>');
92
+ let html = table(['service', 'provided by', 'consumed by'], services);
93
+ const cascade = Object.entries(d.cascade);
94
+ if (cascade.length) {
95
+ html = '<h3 style="margin:8px 0">disable-cascade</h3>'
96
+ + table(['provider', 'affects'], cascade.map(([p, a]) =>
97
+ '<tr><td>' + esc(p) + '</td><td>' + esc(a.join(', ')) + '</td></tr>'))
98
+ + '<h3 style="margin:16px 0 8px">services</h3>' + html;
99
+ }
100
+ if (d.unsatisfied.length) {
101
+ html = '<p class="warn">' + d.unsatisfied.length + ' unsatisfied inject(s)</p>' + html;
102
+ }
103
+ return html;
104
+ },
105
+ cost(d) {
106
+ let html = '<p>~' + d.totalTokens + ' tokens: ' + d.toolCount + ' tool schema(s) ~' + d.toolTokens
107
+ + ' + ' + d.sectionCount + ' prompt section(s) ~' + d.sectionTokens + '</p>';
108
+ if (d.sections.length) {
109
+ html += '<h3 style="margin:12px 0 4px">prompt sections</h3>'
110
+ + table(['section', 'tokens', 'share', ''], d.sections.map((s) =>
111
+ '<tr><td>' + esc(s.name) + '</td><td class="num">~' + s.tokens + '</td><td class="num">'
112
+ + s.share + '%</td><td>' + bar(s.share) + '</td></tr>'));
113
+ } else {
114
+ html += '<p class="muted">no prompt assembly observed yet — send one agent message first</p>';
115
+ }
116
+ html += '<h3 style="margin:12px 0 4px">tool schemas</h3>'
117
+ + table(['tool', 'tokens', 'share', ''], d.tools.map((t) =>
118
+ '<tr><td>' + esc(t.name) + '</td><td class="num">~' + t.tokens + '</td><td class="num">'
119
+ + t.share + '%</td><td>' + bar(t.share) + '</td></tr>'));
120
+ return html;
121
+ },
122
+ shadow(d) {
123
+ let html = d.services.length
124
+ ? table(['service', 'providers'], d.services.map((s) =>
125
+ '<tr><td>' + esc(s.service) + '</td><td class="warn">' + esc(s.providers.join(' AND ')) + '</td></tr>'))
126
+ : '<p class="ok">no service is provided by more than one plugin</p>';
127
+ if (d.registrars.length) {
128
+ html += '<h3 style="margin:12px 0 4px">registrars</h3>'
129
+ + table(['plugin', 'registrations'], d.registrars.map((r) =>
130
+ '<tr><td>' + esc(r.plugin) + '</td><td class="num">' + r.registrations + '</td></tr>'));
131
+ }
132
+ return html;
133
+ },
134
+ };
135
+
136
+ async function render() {
137
+ for (const v of views) document.getElementById('nav-' + v).className = v === active ? 'active' : '';
138
+ status.textContent = 'loading ' + active + '…';
139
+ try {
140
+ const res = await fetch('/xray/api/' + active);
141
+ if (!res.ok) throw new Error(await res.text());
142
+ const data = await res.json();
143
+ content.innerHTML = renderers[active](data);
144
+ status.textContent = '';
145
+ } catch (err) {
146
+ status.innerHTML = '<span class="warn">' + esc(err.message) + '</span>';
147
+ content.innerHTML = '';
148
+ }
149
+ }
150
+ render();
151
+ setInterval(() => { if (active === 'health' || active === 'summary') render(); }, 5000);
152
+ </script>
153
+ </body>
154
+ </html>`;
155
+
156
+ function sendJson(response, code, value) {
157
+ const body = JSON.stringify(value);
158
+ response.writeHead(code, {
159
+ 'content-type': 'application/json; charset=utf-8',
160
+ 'cache-control': 'no-store',
161
+ });
162
+ response.end(body);
163
+ }
164
+
165
+ /**
166
+ * Mount the panel routes. `views` supplies fresh data per request:
167
+ * { summary, deps, health, cost, shadow } — each a () => object.
168
+ * Returns the disposers webServer.register produced.
169
+ */
170
+ function mountPanel(webServer, views) {
171
+ const disposers = [];
172
+ disposers.push(
173
+ webServer.register({
174
+ kind: 'exact',
175
+ path: '/xray',
176
+ handler: (request, response) => {
177
+ if (request.method !== 'GET') {
178
+ response.writeHead(405, { allow: 'GET' });
179
+ response.end();
180
+ return;
181
+ }
182
+ response.writeHead(200, {
183
+ 'content-type': 'text/html; charset=utf-8',
184
+ 'cache-control': 'no-store',
185
+ });
186
+ response.end(PAGE);
187
+ },
188
+ }),
189
+ );
190
+ for (const [name, compute] of Object.entries(views)) {
191
+ disposers.push(
192
+ webServer.register({
193
+ kind: 'exact',
194
+ path: `/xray/api/${name}`,
195
+ handler: (request, response) => {
196
+ if (request.method !== 'GET') {
197
+ response.writeHead(405, { allow: 'GET' });
198
+ response.end();
199
+ return;
200
+ }
201
+ try {
202
+ sendJson(response, 200, compute());
203
+ } catch (err) {
204
+ sendJson(response, 500, { error: err.message });
205
+ }
206
+ },
207
+ }),
208
+ );
209
+ }
210
+ return disposers;
211
+ }
212
+
213
+ module.exports = { mountPanel, PAGE };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-xray",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "X-ray for your DeepSeek Harness — see what's actually loaded, why, and what it costs you.",
5
5
  "repository": {
6
6
  "type": "git",