@deepseek-insight/dsi 0.6.9 → 0.6.11

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 (46) hide show
  1. package/.agents/skills/dsi-ov-setup/SKILL.md +134 -0
  2. package/.agents/skills/dsi-plugin-rack/rack.d.mts +35 -0
  3. package/.agents/skills/dsi-plugin-rack/rack.mjs +168 -26
  4. package/build/client/_app/immutable/assets/{2.BGqV97Jd.css → 2.BuaCdYrs.css} +1 -1
  5. package/build/client/_app/immutable/chunks/{UkmEG9Ws.js → BNTNz9oR.js} +1 -1
  6. package/build/client/_app/immutable/chunks/CiFavIif.js +1 -0
  7. package/build/client/_app/immutable/chunks/DN37js12.js +1 -0
  8. package/build/client/_app/immutable/chunks/KLsbJ_6V.js +2 -0
  9. package/build/client/_app/immutable/entry/{app.e7xoYywe.js → app.BNlAGT8a.js} +2 -2
  10. package/build/client/_app/immutable/entry/start.BQEoVg3q.js +1 -0
  11. package/build/client/_app/immutable/nodes/{1.E4wiL8sP.js → 1.CeFdv8vr.js} +1 -1
  12. package/build/client/_app/immutable/nodes/{2.BBV-AGuB.js → 2.Boib-Q5k.js} +195 -195
  13. package/build/client/_app/version.json +1 -1
  14. package/build/handler.js +4 -4
  15. package/build/index.js +4 -4
  16. package/build/server/chunks/chunks/{engine.js-CrdWRSwf.js → engine.js-1gYqtdBP.js} +17 -2
  17. package/build/server/chunks/chunks/{internal.js-BnJXHc1S.js → internal.js-XBbRSE8G.js} +2 -2
  18. package/build/server/chunks/chunks/{state.js-CAjHzrRZ.js → state.js-DgcRLBLq.js} +2 -2
  19. package/build/server/chunks/entries/endpoints/api/plugins/apply/{_server.ts.js-CpO--Jwf.js → _server.ts.js-BNEUWbaf.js} +2 -2
  20. package/build/server/chunks/entries/endpoints/api/plugins/reload/{_server.ts.js-BACFXWMj.js → _server.ts.js-Ct28d7_t.js} +2 -2
  21. package/build/server/chunks/entries/endpoints/api/plugins/snapshot/{_server.ts.js-BJg0hLlC.js → _server.ts.js-DswGIO4S.js} +2 -2
  22. package/build/server/chunks/entries/pages/{_error.svelte.js-BXeIuPj-.js → _error.svelte.js-CRYTX4NJ.js} +3 -3
  23. package/build/server/chunks/entries/pages/{_page.svelte.js-CW-EdJ00.js → _page.svelte.js-jCShpNgz.js} +164 -14
  24. package/build/server/chunks/{handler-DimUSmTW.js → handler-CDSfkiHz.js} +3 -3
  25. package/build/server/chunks/{index.js-BQUp4Xgn.js → index.js-BOeQQ7jF.js} +2 -2
  26. package/build/server/chunks/{manifest.js-BWy6zgHL.js → manifest.js-CvDWy0oM.js} +7 -7
  27. package/build/server/chunks/nodes/1.js-1rT6o2jA.js +9 -0
  28. package/build/server/chunks/nodes/{2.js-CzgP_zp4.js → 2.js-CGXIkDa5.js} +4 -4
  29. package/package.json +1 -1
  30. package/skills/dsi-add-plugin/SKILL.md +53 -0
  31. package/skills/dsi-add-plugin/references/allowbuilds-template.md +55 -0
  32. package/skills/dsi-add-plugin/references/checklist.md +98 -0
  33. package/skills/dsi-add-plugin/references/verification.md +67 -0
  34. package/template/.dsi/prompts.sqlite +0 -0
  35. package/template/.dsi/resources/plugins-reff.md +4 -2
  36. package/build/client/_app/immutable/chunks/C1F3aCiz.js +0 -2
  37. package/build/client/_app/immutable/chunks/CmnxnK6v.js +0 -1
  38. package/build/client/_app/immutable/chunks/jugX_o5s.js +0 -1
  39. package/build/client/_app/immutable/entry/start.CnkkMjJa.js +0 -1
  40. package/build/server/chunks/nodes/1.js-DJ3dsgdq.js +0 -9
  41. package/template/dsh-app-dev-preset/README.md +0 -69
  42. package/template/dsh-app-dev-preset/cordis.patch.yml +0 -219
  43. package/template/dsh-app-dev-preset/package.json +0 -7
  44. package/template/dsh-research-preset/README.md +0 -57
  45. package/template/dsh-research-preset/cordis.patch.yml +0 -118
  46. package/template/dsh-research-preset/package.json +0 -7
@@ -131,6 +131,140 @@ This is the only install path that reconciles `dsh.profile.bundles` and survives
131
131
  - Two config files, one directory (§1) — check the right one for the symptom.
132
132
  - `auth_mode: "dev"` = no auth, localhost only. Never bind `0.0.0.0` without switching to API-key auth (docs: Authentication guide).
133
133
  - Piped `init` answers are prompt-order-fragile (Step 3) — count prompts, don't trust defaults.
134
+ ---
135
+ name: dsi-ov-setup
136
+ description: Use when OpenViking memory is broken or missing in DeepSeek Harness — server disconnected, server not installed, ov.conf missing, plugin not loaded, or a fresh machine needs the whole OpenViking + DSH memory integration set up (plugin, skill, server). Covers diagnosis, install, config, start, and verification.
137
+ ---
138
+
139
+ # OpenViking + DSH Setup and Recovery
140
+
141
+ Companion KB: `dev/kb/setup/2026-09-16 - OpenViking Server + DSH Memory Plugin — From Cold Start to Recalled Memory.md` — read it for the why behind every step. This skill is the do-path.
142
+
143
+ ## 0. Diagnose first — which failure is this?
144
+
145
+ Run in order; stop at the first failing gate:
146
+
147
+ | Gate | Command | Healthy looks like |
148
+ |---|---|---|
149
+ | Server process | `curl -s --max-time 3 http://127.0.0.1:1933/health` | `{"status":"ok","healthy":true,...}` |
150
+ | Server config | `ls ~/.openviking/ov.conf` | exists |
151
+ | Client config | `cat ~/.openviking/ovcli.conf` | `url` = `http://127.0.0.1:1933` |
152
+ | Package | `openviking-server --version` | prints a version |
153
+ | Plugin installed | `grep @openviking ~/.dsh/profiles/web/package.json` + `ls ~/.dsh/profiles/web/node_modules/@openviking/dsh-memory-plugin` | both the bundle entry and the package exist |
154
+ | MCP connection | harness tool list shows `mcp__openviking__*` | tools present |
155
+
156
+ - Health fails + `ov.conf` missing → **Recovery path (§2)**, the common case.
157
+ - Health fails + `ov.conf` exists → run `openviking-server doctor`, fix what it names, go to §2 step 4.
158
+ - Plugin missing from manifest or `node_modules` → reinstall via the sanctioned path (§2 step 6): from the running host's checkout, `pnpm dsh plugin --profile web add @openviking/dsh-memory-plugin`. This persists both the dependency and the `dsh.profile.bundles` entry, so profile rebuilds keep it. A bare `pnpm install` in the profile dir is a dead end — bundles are not `dependencies` and pnpm reports "Already up to date" while the plugin stays missing (RCA 2026-09-18 — The Vanished Plugin).
159
+ - Health ok but MCP disconnected → the plugin reconnects on its own within a turn or two; if not after a full turn, restart the harness session. Do not reinstall anything.
160
+ - Package missing → fresh install: do §2 steps 1–6 from scratch.
161
+
162
+ ## 1. Facts to keep straight (most-confused pair)
163
+
164
+ - `~/.openviking/ovcli.conf` — **client** pointer: `{"url": "http://127.0.0.1:1933", "api_key": ""}`. Missing it breaks CLI commands only.
165
+ - `~/.openviking/ov.conf` — **server** model config (embedding + VLM). Missing it means the server process cannot start at all. This is the usual blocker.
166
+ - The DSH plugin (`@openviking/dsh-memory-plugin`, in the profile bundles at `~/.dsh/profiles/web/package.json`) is a **client only** — it never starts the server, and it reconnects automatically once the server is healthy. Never "fix" a disconnected MCP by touching the plugin.
167
+ - `openviking`/`ov` = CLI/SDK; `openviking-server` = the daemon. Same pip package.
168
+
169
+ ## 2. Recovery / install path
170
+
171
+ ### Step 1 — Install the package (skip if `openviking-server --version` works)
172
+
173
+ ```sh
174
+ pip install openviking --upgrade --force-reinstall
175
+ ```
176
+
177
+ Entry points land in `~/.local/bin`: `ov`, `openviking`, `openviking-server`, `vikingbot`.
178
+
179
+ ### Step 2 — CLI housekeeping
180
+
181
+ ```sh
182
+ ov language en # v0.3.23+ refuses most commands without this; writes ovcli.settings.conf
183
+ ov config add custom --name local --url http://127.0.0.1:1933 --activate -o json # only if ovcli.conf missing
184
+ ```
185
+
186
+ Note: both write under `~/.openviking/` — they fail under a workspace-only file sandbox; run with full file access.
187
+
188
+ ### Step 3 — Create the server config: `openviking-server init`
189
+
190
+ Preferred: run it interactively and let the human answer. If driving it with piped answers, the wizard's prompt order is exact and hidden prompts exist — the tested sequence for the recommended local setup:
191
+
192
+ ```
193
+ 1 # mode: step-by-step
194
+ 3 # embedding: lightweight CPU (llama.cpp, bge-small-zh-v1.5-f16, no key)
195
+ 1 # embedding model: (only option — EASY TO MISS; missing this shifts every later answer)
196
+ 6 # VLM: GLM (subscription API key)
197
+ 2 # GLM plan: coding
198
+ <key> # API key — pass via stdin from an env var; NEVER in the command line or logs
199
+ N # query planner via Ollama: no
200
+ 1 # bind 127.0.0.1 (dev, no auth)
201
+ 1933 # port
202
+ Y # save
203
+ ```
204
+
205
+ After saving the wizard echoes the resolved API key in its summary — **redact any captured log** before storing it.
206
+
207
+ Decision guardrails (from the KB §4): embedding `[3]` CPU is the default because it is keyless and tiny; offer `[1]` cloud embedding only when the user cares about retrieval quality and has a key; VLM is the piece worth a strong model — GLM via an existing subscription beats skipping it. `[9]` skip-VLM gives a server that stores but extracts poorly.
208
+
209
+ ### Step 4 — Wait out the build
210
+
211
+ Choosing CPU embedding triggers `pip install "openviking[local-embed]"`, which **builds `llama-cpp-python` from source** — several minutes of silent wheel-building. It is not hung. Do not kill it; verify with `ps aux | grep "openviking-server init"`.
212
+
213
+ ### Step 5 — Validate and start
214
+
215
+ ```sh
216
+ openviking-server doctor # expect: all checks PASS (config, auth, disk ≥ several GB)
217
+ openviking-server # foreground proof; then move to a supervisor
218
+ ```
219
+
220
+ Sandbox warning: server startup acquires a PID lock at
221
+ `~/.openviking/data/.openviking.pid` — under a workspace-only file sandbox it
222
+ dies with `PermissionError` / exit 3 at once. Start the server with full file
223
+ access (same as step 2's CLI housekeeping).
224
+
225
+ For persistence: launchd/systemd — never rely on an agent shell outliving the
226
+ session. In a harness session, never start the daemon with `nohup … &` inside a
227
+ **foreground** bash call: the harness reaps the call's process group when the
228
+ call returns and the daemon dies seconds later, health-ok inside the call but
229
+ connection-refused on the next one (RCA 2026-09-21 — The Server That Slept).
230
+ Run it as a harness background job instead. Verify:
231
+
232
+ ```sh
233
+ curl -s http://127.0.0.1:1933/health # {"status":"ok","healthy":true,...}
234
+ ov status # "Connected (Healthy)", queue 0 pending / 0 errors
235
+ ```
236
+
237
+ A stale `~/.openviking/pending/` queue (hundreds of files) from the downtime drains on its own — do not delete it.
238
+
239
+ ### Step 6 — Confirm the integration
240
+
241
+ Within a turn of the server going healthy, the plugin reconnects (MCP tools answer, `<openviking-context>` appears at the next session start). If MCP tools still error after a full turn: restart the harness session once. Only then suspect the bundle (`~/.dsh/profiles/web/package.json` must list `@openviking/dsh-memory-plugin` in `dsh.profile.bundles`).
242
+
243
+ If the plugin must be (re)installed, run from the **running host's checkout** (find it: `lsof -p <dsh-web-pid> | grep cwd`):
244
+
245
+ ```sh
246
+ pnpm dsh plugin --profile web add @openviking/dsh-memory-plugin
247
+ ```
248
+
249
+ This is the only install path that reconciles `dsh.profile.bundles` and survives profile rebuilds. With `patchReload: "live"` in the manifest the profile recomposes without a host restart and `<openviking-context>` returns mid-session.
250
+
251
+ ## 3. Verification checklist (all seven, in order)
252
+
253
+ 1. `openviking-server --version` → prints version
254
+ 2. `ls ~/.openviking/ov.conf ~/.openviking/ovcli.conf` → both exist
255
+ 3. `openviking-server doctor` → all PASS
256
+ 4. `curl -s http://127.0.0.1:1933/health` → `"healthy":true`
257
+ 5. `ov status` → Connected (Healthy), 0 errors
258
+ 6. `ov find "smoke test"` after any resource exists → ranked hits, not a connection error
259
+ 7. New session → `<openviking-context>` block present
260
+
261
+ ## 4. Known traps
262
+
263
+ - `ov status` saying "Unreachable" while the server is fine → the language gate; run `ov language en` first.
264
+ - Two config files, one directory (§1) — check the right one for the symptom.
265
+ - `auth_mode: "dev"` = no auth, localhost only. Never bind `0.0.0.0` without switching to API-key auth (docs: Authentication guide).
266
+ - Piped `init` answers are prompt-order-fragile (Step 3) — count prompts, don't trust defaults.
267
+ - When a D4 peer-dependency rejection forces `dsh plugin allow-version`, allow the **exact** plugin version and then install by **exact pin** (`add pkg@0.5.16`, not a `^` range) — a caret range can resolve to a sibling version the exemption does not cover, and the install rolls back (RCA 2026-09-24).
134
268
  - Re-examine actual prompt output on every version bump; this sequence was tested against OpenViking 0.4.20 / plugin 0.3.2 (2026-09-16) and the wizard's prompts are not a stable contract.
135
269
 
136
270
  ## 5. Report the run — always, before finishing
@@ -0,0 +1,35 @@
1
+ /** Type surface of the rack engine (rack.mjs) for test files. */
2
+ declare const CACHE_VERSION: 2;
3
+ export interface RackRow {
4
+ n: string;
5
+ id: string;
6
+ group?: 'owned' | 'external';
7
+ repo: string;
8
+ author?: string | null;
9
+ authorUrl?: string | null;
10
+ version?: string | null;
11
+ description?: string | null;
12
+ order?: number;
13
+ installSpec?: string;
14
+ installed: boolean;
15
+ pkg: string | null;
16
+ bundle: string | null;
17
+ }
18
+ export interface GardenRow {
19
+ id: string;
20
+ group: 'owned';
21
+ version: string | null;
22
+ description: string | null;
23
+ order: number;
24
+ installSpec: string;
25
+ repo: string;
26
+ }
27
+ export function scanGarden(gardenPath: string | undefined | null): GardenRow[];
28
+ export function parseReff(text: string): { plugins: { id: string; repo: string; author: string | null; authorUrl: string | null }[]; warnings: { code: string; detail: string }[] };
29
+ export function findDepKey(manifest: { deps: Record<string, string> }, id: string, repo?: string): string | null;
30
+ export function readManifest(path: string): { deps: Record<string, string>; bundles: string[]; missing: boolean };
31
+ export function isInstalled(manifest: { deps: Record<string, string> }, id: string, repo?: string): boolean;
32
+ export function buildSnapshot(args: { reffPath?: string; manifestPath?: string; gardenPath?: string; profile?: string }): { snapshot: { v: number; generatedAt: string; profile: string; plugins: RackRow[] }; warnings: { code: string; detail: string }[] };
33
+ export function toInstallSpec(repo: string): string;
34
+ export function installOwned(manifestPath: string, row: { id: string; installSpec: string }): string;
35
+ export function restoreClobberedBundles(manifestPath: string, preOwnedRows: { id: string; installSpec?: string; pkg?: string | null }[]): boolean;
@@ -14,12 +14,12 @@
14
14
  * dependencies[id] AND dsh.profile.bundles includes id. Gates surface, they
15
15
  * never auto-clear (D6).
16
16
  */
17
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
17
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
18
18
  import { homedir } from 'node:os';
19
19
  import { dirname, join, resolve } from 'node:path';
20
20
  import { spawnSync } from 'node:child_process';
21
21
 
22
- const CACHE_VERSION = 1;
22
+ export const CACHE_VERSION = 2;
23
23
 
24
24
  function parseArgs(argv) {
25
25
  const out = { command: argv[0], reload: false, reff: null, cache: null, manifest: null, profile: process.env.DSH_PROFILE ?? 'web', dshCmd: null, targets: [] };
@@ -30,6 +30,8 @@ function parseArgs(argv) {
30
30
  else if (a === '--cache') out.cache = argv[++i];
31
31
  else if (a === '--manifest') out.manifest = argv[++i];
32
32
  else if (a === '--profile') out.profile = argv[++i];
33
+ else if (a === '--garden') out.garden = argv[++i];
34
+ else if (a === '--garden-repo') out.gardenRepo = argv[++i];
33
35
  else if (a === '--dsh-cmd') out.dshCmd = argv[++i];
34
36
  else out.targets.push(a);
35
37
  }
@@ -54,6 +56,58 @@ export function parseReff(text) {
54
56
  return { plugins, warnings };
55
57
  }
56
58
 
59
+
60
+ /** Owned-species enumeration (ADR "The Plugin Garden" 2026-10-07, D1/D2):
61
+ * every direct child of the garden dir with a package.json wiring
62
+ * dsh.bundle.patch is one owned rack row, identified by its OWN package
63
+ * name. Rows carry version/description from the same file and an optional
64
+ * dsh.rack.order for display sort. Returns [] for a missing garden. */
65
+ export function scanGarden(gardenPath) {
66
+ if (!gardenPath || !existsSync(gardenPath)) return [];
67
+ const rows = [];
68
+ for (const child of readdirSync(gardenPath, { withFileTypes: true })) {
69
+ if (!child.isDirectory()) continue;
70
+ const pkgPath = join(gardenPath, child.name, 'package.json');
71
+ if (!existsSync(pkgPath)) continue;
72
+ let pkg;
73
+ try { pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); } catch { continue; }
74
+ const patch = pkg && pkg.dsh && pkg.dsh.bundle && pkg.dsh.bundle.patch;
75
+ if (!patch) continue;
76
+ const order = (pkg.dsh && pkg.dsh.rack && pkg.dsh.rack.order) ?? Number.POSITIVE_INFINITY;
77
+ rows.push({
78
+ id: pkg.name,
79
+ group: 'owned',
80
+ version: pkg.version ?? null,
81
+ description: pkg.description ?? null,
82
+ order,
83
+ installSpec: 'link:' + join(gardenPath, child.name),
84
+ repo: 'link:' + join(gardenPath, child.name)
85
+ });
86
+ }
87
+ return rows.sort((a, b) => (a.order - b.order) || a.id.localeCompare(b.id));
88
+ }
89
+
90
+ /** Repository identity (ADR-0016 D1): a catalog entry identifies a
91
+ * REPOSITORY, not a URL string. Strip protocol, .git suffix, trailing
92
+ * slashes and any /tree/<ref>(/...)? browse subtree, then keep the
93
+ * owner/repo tail. Two URLs with the same identity are one rack group. */
94
+ export function repoIdentity(url) {
95
+ const s = String(url || '')
96
+ .replace(/^[a-z+]+:(\/\/)?/i, '') // protocol (https://, git+, github:)
97
+ .split('/tree/')[0] // a github /tree/<ref>/... browse URL is a view INSIDE a repo
98
+ .replace(/\/+$/, '')
99
+ .replace(/\.git$/i, '')
100
+ .replace(/\/+$/, '');
101
+ return s.split('/').slice(-2).join('/');
102
+ }
103
+
104
+ /** Shared tail helper: last two path segments. findDepKey keeps a residual
105
+ * subtree (deps may point at subfolders); repoIdentity cuts it entirely. */
106
+ function repoTail(url) {
107
+ const s = String(url || '').replace(/\.git$/i, '').replace(/\/+$/, '');
108
+ return s.split('/').slice(-2).join('/');
109
+ }
110
+
57
111
  /** D4 (amended 2026-09-27): the manifest's DEPENDENCIES are the install
58
112
  * authority. pnpm records a git plugin under the package's REAL name from
59
113
  * its own package.json (e.g. '@temoa/dsh-rules-paths'), not the reff id -
@@ -64,8 +118,11 @@ export function parseReff(text) {
64
118
  * install gate. Returns the recorded dep key (the remove target) or null. */
65
119
  export function findDepKey(manifest, id, repo) {
66
120
  if (manifest.deps[id] !== undefined) return id;
67
- const tail = String(repo || '').replace(/\\.git$/, '').replace(/\/+$/, '').split('/').slice(-2).join('/');
68
- if (!tail) return null;
121
+ // A github /tree/<ref>/ browse URL is the same repo, not a distinct plugin
122
+ // (identity rule: repoIdentity, ADR-0016 D1); a residual subtree segment is
123
+ // kept here because deps may legitimately point at subfolders.
124
+ const tail = repoTail(String(repo || '').replace(/\/tree\/[^/]+(\/|$)/, '/'));
125
+ if (!tail || tail === '/') return null;
69
126
  for (const [key, value] of Object.entries(manifest.deps)) {
70
127
  if (typeof value === 'string' && value.includes(tail)) return key;
71
128
  }
@@ -89,39 +146,71 @@ export function isInstalled(manifest, id, repo) {
89
146
  return findDepKey(manifest, id, repo) !== null;
90
147
  }
91
148
 
92
- export function buildSnapshot({ reffPath, manifestPath, profile }) {
149
+ export function buildSnapshot({ reffPath, manifestPath, gardenPath, gardenRepo, profile }) {
93
150
  const warnings = [];
94
- if (!existsSync(reffPath)) warnings.push({ code: 'reff-missing', detail: reffPath });
95
- const parsed = existsSync(reffPath) ? parseReff(readFileSync(reffPath, 'utf8')) : { plugins: [], warnings: [] };
96
- warnings.push(...parsed.warnings);
97
151
  const manifest = readManifest(manifestPath);
98
152
  if (manifest.missing) warnings.push({ code: 'manifest-missing', detail: manifestPath });
153
+ const reconcile = (row) => {
154
+ const depKey = findDepKey(manifest, row.id, row.repo);
155
+ return { ...row, installed: depKey !== null, pkg: depKey, bundle: (depKey !== null && manifest.bundles.includes(depKey)) || null };
156
+ };
157
+ // Owned species first (garden scan), then external (reff) - flat rows,
158
+ // renumbered once; sources[] groups them by repo (shelf grammar).
159
+ const owned = scanGarden(gardenPath).map((row) => reconcile({ ...row, repo: gardenRepo ?? row.repo }));
160
+ const parsed = existsSync(reffPath) ? parseReff(readFileSync(reffPath, 'utf8')) : { plugins: [], warnings: [] };
161
+ warnings.push(...parsed.warnings);
162
+ // ADR-0016 D2/D3: an external reff row whose repository the garden already
163
+ // enumerates is SHADOWED — dropped here with a warning; the reff file itself
164
+ // is never rewritten and parseReff output is untouched.
165
+ const ownedIdents = new Set(owned.map((row) => repoIdentity(row.repo)));
166
+ const external = [];
167
+ for (const p of parsed.plugins) {
168
+ if (ownedIdents.has(repoIdentity(p.repo))) {
169
+ warnings.push({ code: 'reff-shadowed-by-garden', detail: p.id });
170
+ continue;
171
+ }
172
+ external.push(reconcile({
173
+ n: null,
174
+ id: p.id,
175
+ group: 'external',
176
+ repo: p.repo,
177
+ version: null,
178
+ description: null,
179
+ author: p.author,
180
+ ...(p.authorUrl ? { authorUrl: p.authorUrl } : {})
181
+ }));
182
+ }
183
+ const plugins = [...owned, ...external].map((row, i) => ({ ...row, n: String(i + 1) }));
184
+ // sources[]: one collapsible group per REPOSITORY (Shelf Chrome grammar),
185
+ // keyed by repoIdentity (ADR-0016 D2) so /tree browse URLs never split a repo.
186
+ const sources = [];
187
+ const byRepo = new Map();
188
+ for (const row of plugins) {
189
+ const key = repoIdentity(row.repo);
190
+ if (!byRepo.has(key)) {
191
+ sources.push({
192
+ id: key,
193
+ name: key.split('/').pop(),
194
+ author: row.author ?? null,
195
+ repo: row.repo,
196
+ plugins: []
197
+ });
198
+ byRepo.set(key, sources[sources.length - 1]);
199
+ }
200
+ byRepo.get(key).plugins.push(row);
201
+ }
99
202
  return {
100
203
  snapshot: {
101
204
  v: CACHE_VERSION,
102
205
  generatedAt: new Date().toISOString(),
103
206
  profile,
104
- plugins: parsed.plugins.map((p, i) => {
105
- const depKey = findDepKey(manifest, p.id, p.repo);
106
- return {
107
- n: String(i + 1),
108
- id: p.id,
109
- repo: p.repo,
110
- author: p.author,
111
- ...(p.authorUrl ? { authorUrl: p.authorUrl } : {}),
112
- installed: depKey !== null,
113
- // The recorded dependency name (pnpm's truth) - the remove target.
114
- pkg: depKey,
115
- bundle: (depKey !== null && manifest.bundles.includes(depKey)) || null
116
- };
117
- })
207
+ sources,
208
+ plugins
118
209
  },
119
210
  warnings
120
211
  };
121
212
  }
122
213
 
123
- /** D3: the reff URL becomes the dsh add spec: git+<url>.git (append .git
124
- * only when absent — the operator's line stays the single source). */
125
214
  export function toInstallSpec(repo) {
126
215
  return 'git+' + repo + (repo.endsWith('.git') ? '' : '.git');
127
216
  }
@@ -132,6 +221,47 @@ function resolveDshCommand(flags) {
132
221
  return 'npx --yes @deepseek-ai/dsh@latest';
133
222
  }
134
223
 
224
+
225
+ /** Owned-species install (Plugin Garden ADR, D3): write the link dependency,
226
+ * APPEND (never rewrite) the bundle entry, idempotent on re-run. The only
227
+ * manifest write the rack performs itself - scoped to @local link species;
228
+ * git/npm species keep the dsh add delegation (D3). Returns the dep key. */
229
+ export function installOwned(manifestPath, row) {
230
+ const doc = JSON.parse(readFileSync(manifestPath, 'utf8'));
231
+ doc.dependencies = doc.dependencies ?? {};
232
+ doc.dependencies[row.id] = row.installSpec;
233
+ const bundles = doc.dsh && doc.dsh.profile && Array.isArray(doc.dsh.profile.bundles) ? doc.dsh.profile.bundles : [];
234
+ if (!bundles.includes(row.id)) bundles.push(row.id);
235
+ if (doc.dsh && doc.dsh.profile) doc.dsh.profile.bundles = bundles;
236
+ writeFileSync(manifestPath, JSON.stringify(doc, null, 2));
237
+ return row.id;
238
+ }
239
+
240
+ /** Clobber guard (Plugin Garden ADR, 2026-10-07): a dsh plugin add rewrites
241
+ * the bundles array to just the new plugin and may drop earlier deps.
242
+ * Re-append every still-installed owned row's bundle entry and dep entry.
243
+ * Rows already recorded pass through untouched. */
244
+ export function restoreClobberedBundles(manifestPath, preOwnedRows) {
245
+ const doc = JSON.parse(readFileSync(manifestPath, 'utf8'));
246
+ let changed = false;
247
+ doc.dependencies = doc.dependencies ?? {};
248
+ const bundles = doc.dsh && doc.dsh.profile && Array.isArray(doc.dsh.profile.bundles) ? doc.dsh.profile.bundles : [];
249
+ for (const row of preOwnedRows) {
250
+ if (!row.pkg && !row.installSpec) continue;
251
+ if (doc.dependencies[row.id] === undefined) {
252
+ doc.dependencies[row.id] = row.installSpec;
253
+ changed = true;
254
+ }
255
+ if (!bundles.includes(row.id)) {
256
+ bundles.push(row.id);
257
+ changed = true;
258
+ }
259
+ }
260
+ if (doc.dsh && doc.dsh.profile) doc.dsh.profile.bundles = bundles;
261
+ if (changed) writeFileSync(manifestPath, JSON.stringify(doc, null, 2));
262
+ return changed;
263
+ }
264
+
135
265
  /** D3/D6: run dsh, relay its own diagnostics verbatim on failure. */
136
266
  export function runDsh(flags, action, spec) {
137
267
  const cmd = resolveDshCommand(flags) + ' plugin --profile ' + flags.profile + ' ' + action + ' ' + spec;
@@ -166,7 +296,7 @@ function main() {
166
296
  console.log(JSON.stringify({ v: CACHE_VERSION, ok: true, reused: true, snapshot: loadSnapshot(cachePath) }));
167
297
  return;
168
298
  }
169
- const { snapshot, warnings } = buildSnapshot({ reffPath, manifestPath, profile: args.profile });
299
+ const { snapshot, warnings } = buildSnapshot({ reffPath, manifestPath, gardenPath: args.garden, gardenRepo: args.gardenRepo, profile: args.profile });
170
300
  mkdirSync(dirname(cachePath), { recursive: true });
171
301
  writeFileSync(cachePath, JSON.stringify(snapshot, null, 2));
172
302
  console.log(JSON.stringify({ v: CACHE_VERSION, ok: true, snapshot, ...(warnings.length ? { errors: warnings.map((w) => w.code + ': ' + w.detail) } : {}) }));
@@ -186,6 +316,17 @@ function main() {
186
316
  if (!plugin) { results.push({ n: t, ok: false, error: 'not on the rack: ' + t }); continue; }
187
317
  if (action === 'install') {
188
318
  if (plugin.installed) { results.push({ n: plugin.n, id: plugin.id, ok: true, already: true }); continue; }
319
+ if (plugin.group === 'owned') {
320
+ // Owned species (Plugin Garden ADR, D3-scoped): link dep + bundles append + pnpm install.
321
+ const res = installOwned(manifestPath, plugin);
322
+ const inst = spawnSync('pnpm', ['install'], { cwd: dirname(manifestPath), encoding: 'utf8', timeout: 300000 });
323
+ if (inst.status !== 0) { results.push({ n: plugin.n, id: plugin.id, ok: false, error: 'pnpm install failed (exit ' + (inst.status ?? 1) + '): ' + String(inst.stderr ?? '').slice(-400) }); continue; }
324
+ plugin.installed = true;
325
+ plugin.pkg = res;
326
+ plugin.bundle = true;
327
+ results.push({ n: plugin.n, id: plugin.id, pkg: res, ok: true });
328
+ continue;
329
+ }
189
330
  const r = runDsh(args, 'add', toInstallSpec(plugin.repo));
190
331
  if (!r.ok) { results.push({ n: plugin.n, id: plugin.id, ok: false, error: r.error }); continue; }
191
332
  const manifest = readManifest(manifestPath);
@@ -234,4 +375,5 @@ function fail(errors) {
234
375
  process.exit(1);
235
376
  }
236
377
 
237
- main();
378
+ // Run only when executed directly (not when imported by tests/other tools).
379
+ if (process.argv[1] && import.meta.url === new URL('file://' + process.argv[1].replace(/\\/g, '/')).href) main();