@deepseek-insight/dsi 0.5.24 → 0.5.27

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 (51) hide show
  1. package/.agents/skills/dsi-adr/SKILL.md +3 -3
  2. package/.agents/skills/dsi-ov-setup/SKILL.md +6 -1
  3. package/.agents/skills/dsi-skill-shelf/SKILL.md +46 -0
  4. package/.agents/skills/dsi-skill-shelf/shelf.mjs +547 -0
  5. package/.agents/skills/dsi-spec/SKILL.md +4 -4
  6. package/.agents/skills/dsi-spec/references/tasks-json-template.md +1 -1
  7. package/build/client/_app/immutable/assets/0.DoqT6ojN.css +2 -0
  8. package/build/client/_app/immutable/assets/2.Bfl8hQcu.css +1 -0
  9. package/build/client/_app/immutable/chunks/DL2Y4nxy.js +1 -0
  10. package/build/client/_app/immutable/chunks/{xNyHdy4Z.js → DuItqZFP.js} +1 -1
  11. package/build/client/_app/immutable/chunks/Xs5m2yqM.js +1 -0
  12. package/build/client/_app/immutable/chunks/pNaMQbLr.js +2 -0
  13. package/build/client/_app/immutable/entry/{app.BfVt8pR9.js → app.DjIR2gNq.js} +2 -2
  14. package/build/client/_app/immutable/entry/start.Bho-erid.js +1 -0
  15. package/build/client/_app/immutable/nodes/1.CERO4WcC.js +1 -0
  16. package/build/client/_app/immutable/nodes/2.DwsATN7B.js +171 -0
  17. package/build/client/_app/version.json +1 -1
  18. package/build/handler.js +4 -4
  19. package/build/index.js +4 -4
  20. package/build/server/chunks/chunks/engine.js-CBxHjv0c.js +96 -0
  21. package/build/server/chunks/chunks/{internal.js-nJTGYhdX.js → internal.js-9ErK1M89.js} +3 -3
  22. package/build/server/chunks/chunks/{state.js-D0-EDcsi.js → state.js-CoQ1E4r6.js} +163 -3
  23. package/build/server/chunks/entries/endpoints/api/skills/install/_server.ts.js-CtHQQsxC.js +47 -0
  24. package/build/server/chunks/entries/endpoints/api/skills/reload/_server.ts.js-BOlSdtQw.js +33 -0
  25. package/build/server/chunks/entries/endpoints/api/skills/snapshot/_server.ts.js-Yz5rNblU.js +37 -0
  26. package/build/server/chunks/entries/endpoints/api/skills/uninstall/_server.ts.js-Bzy9Oq4k.js +47 -0
  27. package/build/server/chunks/entries/endpoints/api/skills/voice/_server.ts.js-yiGBLufB.js +72 -0
  28. package/build/server/chunks/entries/pages/_error.svelte.js-jdm5qmCK.js +43 -0
  29. package/build/server/chunks/entries/pages/{_page.svelte.js-D63btYnK.js → _page.svelte.js-qgIk1zgI.js} +9820 -3399
  30. package/build/server/chunks/{handler-DiaR2DmJ.js → handler-DBjyvmti.js} +3 -3
  31. package/build/server/chunks/{index.js-trwV04zl.js → index.js-DP_xAH0H.js} +2 -2
  32. package/build/server/chunks/{manifest.js-B2sb3fMn.js → manifest.js-B3WD8tJ6.js} +40 -5
  33. package/build/server/chunks/nodes/{0.js-CcvNYltc.js → 0.js-DO4F8XMq.js} +3 -3
  34. package/build/server/chunks/nodes/1.js-Vh-e4em9.js +9 -0
  35. package/build/server/chunks/nodes/{2.js-CEsSSgY6.js → 2.js-DtdHKW9v.js} +4 -4
  36. package/package.json +9 -1
  37. package/template/.dsi/prompts.sqlite +0 -0
  38. package/template/.dsi/resources/skills-reff.md +8 -0
  39. package/build/client/_app/immutable/assets/0.C4pk9RsQ.css +0 -2
  40. package/build/client/_app/immutable/assets/2.BpERqkk_.css +0 -1
  41. package/build/client/_app/immutable/chunks/BFAx3hqQ.js +0 -2
  42. package/build/client/_app/immutable/chunks/BjB44r24.js +0 -1
  43. package/build/client/_app/immutable/chunks/as89y6ti.js +0 -1
  44. package/build/client/_app/immutable/entry/start.BOYIplSn.js +0 -1
  45. package/build/client/_app/immutable/nodes/1.BZ_iRXtk.js +0 -1
  46. package/build/client/_app/immutable/nodes/2.BADJoHBB.js +0 -171
  47. package/build/server/chunks/entries/pages/_error.svelte.js-BTQyW5Df.js +0 -203
  48. package/build/server/chunks/nodes/1.js-2FULYexO.js +0 -9
  49. package/template/.dsi/prompts.sqlite-shm +0 -0
  50. package/template/.dsi/prompts.sqlite-wal +0 -0
  51. /package/build/client/_app/immutable/nodes/{0.BBoMIehN.js → 0.DlNmLZ8S.js} +0 -0
@@ -14,9 +14,9 @@ hard core: the self-audit in §5 is not optional.
14
14
 
15
15
  Exemplars, in authority order (paths relative to the DSI repo root):
16
16
 
17
- 1. `dev/architectural-decission/2026-08-27 - The Lineage Pin — Sub-Agents Below Their Spawner, Swap-Proof.md` — the gold standard: D1–D7, sequence + flowchart + state homes + control matrix, glossary, amendments log.
18
- 2. `dev/architectural-decission/2026-08-24 - The Panel Floor — Many Conversations, One Page.md` — decision set with honest give-ups.
19
- 3. `dev/architectural-decission/2026-08-29 - The Prompt Macro — Bang Runs the Shelf, Line by Line.md` — deep format on a composer-scale feature.
17
+ 1. `dev/architectural-decission/2026-08/2026-08-27 - The Lineage Pin — Sub-Agents Below Their Spawner, Swap-Proof.md` — the gold standard: D1–D7, sequence + flowchart + state homes + control matrix, glossary, amendments log.
18
+ 2. `dev/architectural-decission/2026-08/2026-08-24 - The Panel Floor — Many Conversations, One Page.md` — decision set with honest give-ups.
19
+ 3. `dev/architectural-decission/2026-08/2026-08-29 - The Prompt Macro — Bang Runs the Shelf, Line by Line.md` — deep format on a composer-scale feature.
20
20
 
21
21
  Read the exemplar closest to your subject before drafting a new ADR kind.
22
22
 
@@ -89,7 +89,12 @@ Sandbox warning: server startup acquires a PID lock at
89
89
  dies with `PermissionError` / exit 3 at once. Start the server with full file
90
90
  access (same as step 2's CLI housekeeping).
91
91
 
92
- For persistence: launchd/nohup/systemd — never rely on an agent shell outliving the session. Verify:
92
+ For persistence: launchd/systemd — never rely on an agent shell outliving the
93
+ session. In a harness session, never start the daemon with `nohup … &` inside a
94
+ **foreground** bash call: the harness reaps the call's process group when the
95
+ call returns and the daemon dies seconds later, health-ok inside the call but
96
+ connection-refused on the next one (RCA 2026-09-21 — The Server That Slept).
97
+ Run it as a harness background job instead. Verify:
93
98
 
94
99
  ```sh
95
100
  curl -s http://127.0.0.1:1933/health # {"status":"ok","healthy":true,...}
@@ -0,0 +1,46 @@
1
+ # dsi-skill-shelf
2
+
3
+ Install and uninstall agent skills from the SKR reference (`~/.dsi/resources/skills-reff.md`). Implements the ADR 'The Skill Shelf' (dev/architectural-decission/2026-09/, 2026-09-20).
4
+
5
+ ## Engine
6
+
7
+ `shelf.mjs` — plain Node, JSON on stdout, every payload carries `v: 1`.
8
+
9
+ ```bash
10
+ # build snapshot (reuses existing unless --reload)
11
+ node shelf.mjs refresh [--reload] [--skr PATH] [--cache PATH] [--skills-dir PATH] [--fixture-root PATH]
12
+
13
+ # signed install / uninstall
14
+ node shelf.mjs apply install 1.1 3.1-3.3 # numbers resolved by the caller; engine takes explicit targets
15
+ node shelf.mjs apply install 2.30 --allow-unstable # in-progress tier is opt-in (D6)
16
+ node shelf.mjs apply uninstall <skill-id> # refuses unsigned folders (D5)
17
+
18
+ node shelf.mjs snapshot-status # does the cache exist?
19
+ ```
20
+
21
+ ## Rules (do not violate)
22
+
23
+ - **No signature, no uninstall.** The engine hard-refuses to remove any folder without a valid `.dsi-provenance.json` (D5). Never work around this with rm.
24
+ - **No folder without signature.** A failed install cleans up its own folder (D4).
25
+ - **Snapshot-first (D3):** `refresh` reuses an existing cache; only `--reload` rebuilds. Never delete the cache to force a rebuild — pass `--reload`.
26
+ - **Unstable tiers (D6):** in-progress/deprecated skills install only with explicit `--allow-unstable`.
27
+ - **Collisions:** an existing folder aborts the install unless `--force`.
28
+ - Tests MUST use `--fixture-root` + `--cache` + `--skills-dir` overrides — never touch the real home directories from a test.
29
+
30
+
31
+ ## Translate at install (The Shelf Voice ADR, D4)
32
+
33
+ An install SHOULD carry a voice: pass `--voice '<json>'` with the skill's name,
34
+ description and whenToUse TRANSLATED BY YOU (the installing agent) into the
35
+ operator's locales (en + zh/id/es as appropriate):
36
+
37
+ ```bash
38
+ node shelf.mjs apply install 1.2 --voice '{"zh":{"name":"技能","description":"…","whenToUse":"…"},"id":{…}}'
39
+ ```
40
+
41
+ Rules: translate faithfully, never invent behavior; keep `name` short; an
42
+ invalid payload degrades the install to en-only (never a blocker); `en` is
43
+ always written as the scaffold. Uninstall removes the voice with the folder.
44
+ ## Shelf numbering
45
+
46
+ 1.x pstack (47), 2.x mattpocock (38: engineering 2.1-2.18, productivity 2.19-2.25, misc 2.26-2.29, in-progress 2.30-2.38), 3.x superpowers (15).
@@ -0,0 +1,547 @@
1
+ #!/usr/bin/env node
2
+ // dsi-skill-shelf engine — The Skill Shelf (ADR 2026-09-20, D1-D6)
3
+ // Plain Node ESM, no dependencies. All output is JSON on stdout: { v: 1, ok, ... }
4
+
5
+ import { createHash } from 'node:crypto'
6
+ import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
7
+ import { execFileSync } from 'node:child_process'
8
+ import { tmpdir } from 'node:os'
9
+ import { basename, dirname, join, resolve, sep } from 'node:path'
10
+ import { homedir } from 'node:os'
11
+
12
+ const CACHE_VERSION = 1
13
+ const SIGNATURE_NAME = '.dsi-provenance.json'
14
+ const UNSTABLE_TIERS = new Set(['in-progress', 'deprecated'])
15
+ // locales the shelf can voice (mirrors project.inlang settings.json)
16
+ const SHELF_LOCALES = ['en', 'zh', 'id', 'es']
17
+
18
+ // tier display order (ADR D6: in-progress is last, numbers 2.30-2.38)
19
+ const TIER_RANK = { engineering: 0, productivity: 1, misc: 2, 'in-progress': 3 }
20
+ function tierSortKey(sk) { return (sk.tier !== null && sk.tier in TIER_RANK ? TIER_RANK[sk.tier] : 0) + ':' + sk.path }
21
+
22
+ // source name -> UPSTREAM OVERRIDE (2026-09-21 redesign: the SKR is the
23
+ // registry — every github source enumerates from its own URL by default;
24
+ // a row here only OVERRIDES the derivation, e.g. pstack's nested layout).
25
+ // single: true (2026-09-22) = the repo hosts exactly ONE skill at its
26
+ // canonical root (prefix 'skill', entry SKILL.md or SKILL.src.md; the id
27
+ // comes from the frontmatter name). impeccable ships one skill mirrored
28
+ // into ~20 host dirs — prefixless enumeration would yield duplicate rows.
29
+ const SOURCE_SPECS = {
30
+ pstack: { repo: 'cursor/plugins', prefix: 'pstack/skills', tiered: false },
31
+ mattpocock: { repo: 'mattpocock/skills', prefix: 'skills', tiered: true },
32
+ superpowers: { repo: 'obra/superpowers', prefix: 'skills', tiered: false },
33
+ 'web-quality-skills': { repo: 'addyosmani/web-quality-skills', prefix: 'skills', tiered: false },
34
+ impeccable: { repo: 'pbakaus/impeccable', prefix: 'skill', tiered: false, single: true }
35
+ }
36
+
37
+ // Derive the enumeration spec from the SKR line itself: the github URL
38
+ // names the repo, an optional tail hints the prefix — either the classic
39
+ // /tree/<branch>/<path> or (2026-09-22, single-skill repos) a bare path
40
+ // like .../repo/skill. A prefix that IS 'skill' or ends in '/skill'
41
+ // marks single-skill mode. SOURCE_SPECS wins when a row exists.
42
+ const SINGLE_SKILL_RE = /(^|\/)skill$/
43
+ function specFor(src) {
44
+ if (SOURCE_SPECS[src.name]) return SOURCE_SPECS[src.name]
45
+ // src.url = the raw SKR line; src.repo = a snapshot source (skrRepo's
46
+ // output). Both carry a github URL the regex can read.
47
+ const url = src.url || src.repo || ''
48
+ const m = url.match(/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?(?:\/tree\/[^/]+(?:\/(.*))?|\/(.+))?$/)
49
+ if (!m) return null
50
+ const prefix = m[3] || m[4] || ''
51
+ return { repo: m[1] + '/' + m[2], prefix, tiered: false, single: SINGLE_SKILL_RE.test(prefix) }
52
+ }
53
+
54
+ // Skill entry files the shelf can enumerate (2026-09-22): the classic
55
+ // SKILL.md and the single-skill repos' authored SKILL.src.md.
56
+ const ENTRY_FILES = ['SKILL.md', 'SKILL.src.md']
57
+ function frontmatterName(text) {
58
+ const fm = text.match(/^---\r?\n([\s\S]*?)\r?\n---/)
59
+ const m = fm && fm[1].match(/^name:\s*(.+)$/m)
60
+ return m ? m[1].trim() : null
61
+ }
62
+
63
+ function parseArgs(argv) {
64
+ const args = { _: [] }
65
+ for (let i = 0; i < argv.length; i++) {
66
+ const a = argv[i]
67
+ if (a === '--reload') args.reload = true
68
+ else if (a === '--allow-unstable') args.allowUnstable = true
69
+ else if (a === '--force') args.force = true
70
+ else if (a.startsWith('--')) args[a.slice(2)] = argv[++i]
71
+ else args._.push(a)
72
+ }
73
+ return args
74
+ }
75
+
76
+ function fail(errors) {
77
+ console.log(JSON.stringify({ v: CACHE_VERSION, ok: false, errors }))
78
+ process.exit(1)
79
+ }
80
+
81
+ // --- SKR parsing (ADR D2: [name](repo-url) - [author](linkedin)) ---
82
+ export function parseSkr(text) {
83
+ const sources = []
84
+ for (const line of text.split('\n')) {
85
+ const m = line.match(/^\d+\.\s*\[(.+?)\]\((.+?)\)\s*-\s*\[(.+?)\]\((.+)\)\s*$/)
86
+ if (!m) continue
87
+ const name = m[1].trim().toLowerCase()
88
+ // authorUrl (2026-09-22, Shelf Credentials D2): the SKR line's second
89
+ // link target — the author's own profile URL — rides into the snapshot.
90
+ sources.push({ name, url: m[2].trim(), author: m[3].trim(), authorUrl: m[4].trim(), repo: SOURCE_SPECS[name] ? SOURCE_SPECS[name].repo : null })
91
+ }
92
+ return sources
93
+ }
94
+
95
+ // --- enumeration ---
96
+
97
+ function walkLocal(root) {
98
+ const out = []
99
+ const walk = (dir) => {
100
+ for (const entry of readdirSync(dir)) {
101
+ const p = join(dir, entry)
102
+ if (statSync(p).isDirectory()) walk(p)
103
+ else if (entry === 'SKILL.md') out.push(dirname(p))
104
+ }
105
+ }
106
+ walk(root)
107
+ return out
108
+ }
109
+
110
+ function enumLocal(src, fixtureRoot) {
111
+ // fixture tree mirrors the upstream layout under <fixtureRoot>/<sourceName>/
112
+ const spec = specFor(src)
113
+ const root = join(fixtureRoot, src.name)
114
+ if (!spec || !existsSync(root)) return []
115
+ if (spec.single) {
116
+ // Single-skill repo: one entry at <prefix>/, id from the frontmatter
117
+ // name (falls back to the folder name when the entry carries none).
118
+ const dir = join(root, spec.prefix)
119
+ const entry = ENTRY_FILES.find((f) => existsSync(join(dir, f)))
120
+ if (!entry) return []
121
+ let id = basename(spec.prefix)
122
+ try { id = frontmatterName(readFileSync(join(dir, entry), 'utf8')) || id } catch { /* keep folder name */ }
123
+ return [{ id, path: spec.prefix, tier: null, entry }]
124
+ }
125
+ const prefix = spec.prefix
126
+ const tiered = spec.tiered
127
+ const skills = walkLocal(root).map((dir) => {
128
+ const rel = dir.slice(root.length + sep.length)
129
+ const parts = rel.split(sep)
130
+ const id = parts[parts.length - 1]
131
+ let tier = null
132
+ if (tiered && parts.length > 2) tier = parts[1]
133
+ else if (tiered && parts.length === 2) tier = parts[0]
134
+ return { id, path: rel, tier: tier && UNSTABLE_TIERS.has(tier) ? tier : null }
135
+ })
136
+ skills.sort((a, b) => tierSortKey(a).localeCompare(tierSortKey(b)))
137
+ return skills
138
+ }
139
+
140
+ // GitHub API auth (2026-09-22): unauthenticated api.github.com allows
141
+ // only 60 req/hr per IP — one exhausted window aborts the whole refresh
142
+ // (observed: HTTP 403 on cursor/plugins). Prefer GITHUB_TOKEN / GH_TOKEN
143
+ // or the gh CLI credential when present (5000 req/hr).
144
+ let ghTokenMemo
145
+ function ghAuthHeaders() {
146
+ const headers = { 'user-agent': 'dsi-skill-shelf' }
147
+ let token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || ''
148
+ // Once per process: spawning the gh CLI on every fetch both slows batch
149
+ // enumeration and can hang a sandboxed run when gh is present but wedged.
150
+ // DSI_SHELF_NO_GH=1 opts out entirely (unit tests, sandboxes).
151
+ if (!token && !process.env.DSI_SHELF_NO_GH) {
152
+ if (ghTokenMemo === undefined) {
153
+ try { ghTokenMemo = execFileSync('gh', ['auth', 'token'], { stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000 }).toString().trim() } catch { ghTokenMemo = '' }
154
+ }
155
+ token = ghTokenMemo
156
+ }
157
+ if (token) headers.authorization = 'Bearer ' + token
158
+ return headers
159
+ }
160
+
161
+ // One trees call per source; failure is DATA ({ skills: [], error }),
162
+ // never an exception — one dead source must not abort the whole snapshot.
163
+ async function fetchTree(repo) {
164
+ try {
165
+ const res = await fetch('https://api.github.com/repos/' + repo + '/git/trees/HEAD?recursive=1', { headers: ghAuthHeaders() })
166
+ if (!res.ok) {
167
+ const hint = res.status === 403 ? ' (api.github.com rate limited? set GITHUB_TOKEN or run: gh auth login)' : ''
168
+ return { tree: null, error: 'github trees ' + repo + ' -> HTTP ' + res.status + hint }
169
+ }
170
+ return { tree: (await res.json()).tree || [], error: null }
171
+ } catch (e) {
172
+ return { tree: null, error: 'github trees ' + repo + ' -> ' + String(e.message || e) }
173
+ }
174
+ }
175
+
176
+ async function enumGitHub(src) {
177
+ const spec = specFor(src)
178
+ if (!spec) return { skills: [], error: null }
179
+ const repo = spec.repo
180
+ if (spec.single) {
181
+ // Single-skill repo (2026-09-22): exactly one skill at <prefix>/, so
182
+ // pick the entry file directly instead of scanning every SKILL.md
183
+ // blob (impeccable mirrors its one skill into ~20 host dirs — a tree
184
+ // scan would enumerate duplicates). The id is the frontmatter name.
185
+ const { tree, error } = await fetchTree(repo)
186
+ if (error) return { skills: [], error }
187
+ const entry = ENTRY_FILES.find((f) => tree.some((n) => n.type === 'blob' && n.path === spec.prefix + '/' + f))
188
+ if (!entry) return { skills: [], error: null }
189
+ let id = basename(spec.prefix)
190
+ try {
191
+ const raw = await fetch('https://raw.githubusercontent.com/' + repo + '/HEAD/' + spec.prefix + '/' + entry, { headers: { 'user-agent': 'dsi-skill-shelf' } })
192
+ if (raw.ok) id = frontmatterName(await raw.text()) || id
193
+ } catch { /* keep folder name */ }
194
+ return { skills: [{ id, path: spec.prefix, tier: null, entry }], error: null }
195
+ }
196
+ const { tree, error } = await fetchTree(repo)
197
+ if (error) return { skills: [], error }
198
+ const skills = []
199
+ for (const node of tree) {
200
+ if (node.type !== 'blob') continue
201
+ const parts = node.path.split('/')
202
+ if (!ENTRY_FILES.includes(parts[parts.length - 1])) continue
203
+ if (spec.prefix && !node.path.startsWith(spec.prefix + '/')) continue
204
+ const dirParts = parts.slice(0, -1)
205
+ const id = dirParts[dirParts.length - 1]
206
+ if (!id || id === 'deprecated') continue
207
+ let tier = null
208
+ if (spec.tiered && dirParts.length > 2) tier = dirParts[1]
209
+ else if (spec.tiered && dirParts.length === 2) tier = dirParts[0]
210
+ skills.push({ id, path: dirParts.join('/'), tier: tier && UNSTABLE_TIERS.has(tier) ? tier : null })
211
+ }
212
+ skills.sort((a, b) => tierSortKey(a).localeCompare(tierSortKey(b)))
213
+ return { skills, error: null }
214
+ }
215
+
216
+ // --- signatures / reconciliation (ADR D5: signature is the uninstall authority) ---
217
+
218
+ export function readSignatures(skillsDir) {
219
+ const signed = new Map()
220
+ if (!existsSync(skillsDir)) return signed
221
+ for (const entry of readdirSync(skillsDir)) {
222
+ const sigPath = join(skillsDir, entry, SIGNATURE_NAME)
223
+ if (!existsSync(sigPath)) continue
224
+ try {
225
+ const sig = JSON.parse(readFileSync(sigPath, 'utf8'))
226
+ if (sig && sig.skillId === entry && sig.v === CACHE_VERSION) signed.set(entry, sig)
227
+ } catch { /* unreadable signature is not authority */ }
228
+ }
229
+ return signed
230
+ }
231
+
232
+ // Skill overview (card UI, 2026-09-22): prefer the SKILL.md
233
+ // frontmatter `description:` line, else the first paragraph under an
234
+ // `## Overview` heading. Installed skills read from disk; uninstalled
235
+ // ones fetch the raw SKILL.md from the source repo (raw.githubusercontent
236
+ // is outside the API rate limit) so the card never lies about a missing
237
+ // overview. Parse failure still yields null — the honest fallback.
238
+ const OVERVIEW_MAX = 280
239
+ function parseOverview(text) {
240
+ let out = null
241
+ const fm = text.match(/^---\r?\n([\s\S]*?)\r?\n---/)
242
+ if (fm) {
243
+ const desc = fm[1].match(/^description:\s*(.+)$/m)
244
+ if (desc) out = desc[1].trim()
245
+ }
246
+ if (!out) {
247
+ const ov = text.match(/^##\s*Overview\s*\r?\n([\s\S]*?)(?=\n##\s|$)/m)
248
+ if (ov) out = ov[1].trim()
249
+ }
250
+ if (!out) return null
251
+ return out.length > OVERVIEW_MAX ? out.slice(0, OVERVIEW_MAX - 1).trimEnd() + '\u2026' : out
252
+ }
253
+ function readOverview(skillsDir, id, entry) {
254
+ const files = entry ? [entry] : ENTRY_FILES
255
+ for (const f of files) {
256
+ const mdPath = join(skillsDir, id, f)
257
+ if (!existsSync(mdPath)) continue
258
+ try {
259
+ return parseOverview(readFileSync(mdPath, 'utf8'))
260
+ } catch { return null }
261
+ }
262
+ return null
263
+ }
264
+ async function fetchOverview(repo, relPath, entry) {
265
+ const files = entry ? [entry] : ENTRY_FILES
266
+ for (const f of files) {
267
+ try {
268
+ const res = await fetch('https://raw.githubusercontent.com/' + repo + '/HEAD/' + relPath + '/' + f, { headers: { 'user-agent': 'dsi-skill-shelf' } })
269
+ if (!res.ok) continue
270
+ const out = parseOverview(await res.text())
271
+ if (out) return out
272
+ } catch {
273
+ // A thrown fetch (network down / connection refused) will not improve
274
+ // for the next entry file — stop instead of hammering the endpoint.
275
+ // HTTP non-ok still falls through to the next entry file.
276
+ return null
277
+ }
278
+ }
279
+ return null
280
+ }
281
+
282
+ // --- source version (Shelf Credentials D1, 2026-09-22) ---
283
+ // The collection's version lives in the repo's root package.json.
284
+ // Fixture mode reads it from the fixture tree (offline tests); live mode
285
+ // fetches the raw HEAD once per source per refresh (cache amortizes it).
286
+ async function harvestVersion(spec, src, fixtureRoot) {
287
+ try {
288
+ if (fixtureRoot) {
289
+ const pkg = JSON.parse(readFileSync(join(fixtureRoot, src.name, 'package.json'), 'utf8'))
290
+ return typeof pkg.version === 'string' ? pkg.version : null
291
+ }
292
+ if (!spec) return null
293
+ const res = await fetch('https://raw.githubusercontent.com/' + spec.repo + '/HEAD/package.json', { headers: { 'user-agent': 'dsi-skill-shelf' } })
294
+ if (!res.ok) return null
295
+ const pkg = JSON.parse(await res.text())
296
+ return typeof pkg.version === 'string' ? pkg.version : null
297
+ } catch { return null }
298
+ }
299
+
300
+ async function buildSnapshot(sourcesRaw, enums, skillsDir, fixtureRoot) {
301
+ const signed = readSignatures(skillsDir)
302
+ const generatedAt = new Date().toISOString()
303
+ const warnings = []
304
+ const sources = []
305
+ for (let si = 0; si < sourcesRaw.length; si++) { const src = sourcesRaw[si]
306
+ // Registry-gap warning (2026-09-21, revised): enumeration derives
307
+ // from the SKR URL itself, so an empty list is a REAL anomaly and
308
+ // the snapshot says why.
309
+ const spec = specFor(src)
310
+ const enumed = enums[si] || { skills: [], error: null }
311
+ if (!spec) {
312
+ warnings.push({ code: 'source-no-repo', source: src.name, detail: 'source "' + src.name + '" has no github repo URL in the SKR - cannot enumerate' })
313
+ } else if (enumed.error) {
314
+ // Enumeration failure (rate limit, network) degrades to a warning —
315
+ // the rest of the shelf still lands in the snapshot (2026-09-22).
316
+ warnings.push({ code: 'source-enum-failed', source: src.name, detail: enumed.error })
317
+ } else if (enumed.skills.length === 0) {
318
+ warnings.push({ code: 'source-empty', source: src.name, detail: 'source "' + src.name + '" enumerated zero skills' + (spec.prefix ? ' under prefix "' + spec.prefix + '"' : '') + ' - upstream layout may have moved' })
319
+ }
320
+ const skills = []
321
+ for (let ki = 0; ki < enumed.skills.length; ki++) {
322
+ const sk = enumed.skills[ki]
323
+ const installed = existsSync(join(skillsDir, sk.id))
324
+ // Overview: disk first, then the source repo's raw SKILL.md for
325
+ // uninstalled rows (sequential — gentle on raw.githubusercontent).
326
+ let overview = readOverview(skillsDir, sk.id, sk.entry)
327
+ // Fixture-root mode (unit tests) enumerates from disk only — a
328
+ // per-skill raw.githubusercontent fetch here fired ~100 requests per
329
+ // refresh (mock explosion). Overview stays null; live mode unaffected.
330
+ if (!overview && spec && !fixtureRoot) overview = await fetchOverview(spec.repo, sk.path, sk.entry)
331
+ skills.push({
332
+ n: (si + 1) + '.' + (ki + 1),
333
+ id: sk.id,
334
+ path: sk.path,
335
+ tier: sk.tier,
336
+ installed,
337
+ signed: signed.has(sk.id),
338
+ installedFrom: signed.has(sk.id) ? signed.get(sk.id).source : null,
339
+ overview
340
+ })
341
+ }
342
+ const version = await harvestVersion(spec, src, fixtureRoot)
343
+ sources.push({ id: src.name, name: src.name, author: src.author, authorUrl: src.authorUrl || null, version, repo: skrRepo(src), skills })
344
+ }
345
+ return { v: CACHE_VERSION, generatedAt, sources, warnings }
346
+ }
347
+
348
+ function skrRepo(src) {
349
+ const spec = specFor(src)
350
+ return spec ? 'https://github.com/' + spec.repo + (spec.prefix ? '/tree/main/' + spec.prefix : '') : src.url
351
+ }
352
+
353
+ // --- refresh ---
354
+
355
+ async function cmdRefresh(args) {
356
+ const skrPath = resolve(args.skr || join(homedir(), '.dsi/resources/skills-reff.md'))
357
+ const cachePath = resolve(args.cache || join(homedir(), '.dsi/resources/skr-cache.json'))
358
+ const skillsDir = resolve(args['skills-dir'] || join(homedir(), '.agents/skills'))
359
+ if (!args.reload && existsSync(cachePath)) {
360
+ return { snapshot: JSON.parse(readFileSync(cachePath, 'utf8')), reused: true }
361
+ }
362
+ const sourcesRaw = parseSkr(readFileSync(skrPath, 'utf8'))
363
+ if (sourcesRaw.length === 0) fail(['SKR parsed to zero sources: ' + skrPath])
364
+ const fixtureRoot = args['fixture-root'] ? resolve(args['fixture-root']) : null
365
+ const enums = []
366
+ for (const src of sourcesRaw) {
367
+ enums.push(fixtureRoot ? { skills: enumLocal(src, fixtureRoot), error: null } : await enumGitHub(src))
368
+ }
369
+ const snapshot = await buildSnapshot(sourcesRaw, enums, skillsDir, fixtureRoot)
370
+ mkdirSync(dirname(cachePath), { recursive: true })
371
+ writeFileSync(cachePath, JSON.stringify(snapshot, null, 2))
372
+ return { snapshot, reused: false }
373
+ }
374
+
375
+ // --- signed apply (ADR D4 + D5) ---
376
+
377
+ function findSkill(snapshot, n) {
378
+ for (const src of snapshot.sources) {
379
+ const sk = src.skills.find((s) => s.n === n || s.id === n)
380
+ if (sk) return { source: src, skill: sk }
381
+ }
382
+ return null
383
+ }
384
+
385
+ function copyDirLocal(fixtureRoot, sourceName, relPath, dest) {
386
+ const srcDir = join(fixtureRoot, sourceName, relPath)
387
+ if (!existsSync(srcDir)) throw new Error('fixture skill dir missing: ' + srcDir)
388
+ cpSync(srcDir, dest, { recursive: true })
389
+ }
390
+
391
+ function copyDirGitHub(source, relPath, dest) {
392
+ const spec = specFor(source)
393
+ if (!spec) throw new Error('source "' + source.id + '" has no github repo URL - cannot install')
394
+ const tmp = mkdtempSync(join(tmpdir(), 'shelf-clone-'))
395
+ try {
396
+ execFileSync('git', ['clone', '--depth', '1', '--filter=blob:none', '--sparse', 'https://github.com/' + spec.repo, tmp], { stdio: 'pipe' })
397
+ execFileSync('git', ['-C', tmp, 'sparse-checkout', 'set', relPath], { stdio: 'pipe' })
398
+ const srcDir = join(tmp, relPath)
399
+ if (!existsSync(srcDir)) throw new Error('upstream skill dir missing after clone: ' + relPath)
400
+ cpSync(srcDir, dest, { recursive: true })
401
+ } finally {
402
+ rmSync(tmp, { recursive: true, force: true })
403
+ }
404
+ }
405
+
406
+ function hashSkillFile(skillDir) {
407
+ // Single-skill repos author SKILL.src.md (2026-09-22): hash whichever
408
+ // entry file the installed skill actually carries.
409
+ const entry = ENTRY_FILES.find((f) => existsSync(join(skillDir, f)))
410
+ if (!entry) throw new Error('no SKILL.md / SKILL.src.md in installed skill dir: ' + skillDir)
411
+ return 'sha256:' + createHash('sha256').update(readFileSync(join(skillDir, entry))).digest('hex')
412
+ }
413
+
414
+ async function cmdApply(args) {
415
+ // args._ still carries the 'apply' command token from main()
416
+ const action = args._[1]
417
+ const targets = args._.slice(2)
418
+ const cachePath = resolve(args.cache || join(homedir(), '.dsi/resources/skr-cache.json'))
419
+ const skillsDir = resolve(args['skills-dir'] || join(homedir(), '.agents/skills'))
420
+ if (targets.length === 0) return fail(['apply ' + action + ' needs at least one target'])
421
+ if (!existsSync(cachePath)) return fail(['snapshot missing, run refresh first: ' + cachePath])
422
+ const snapshot = JSON.parse(readFileSync(cachePath, 'utf8'))
423
+ const results = []
424
+ if (action === 'install') {
425
+ const fixtureRoot = args['fixture-root'] ? resolve(args['fixture-root']) : null
426
+ for (const n of targets) {
427
+ const hit = findSkill(snapshot, n)
428
+ if (!hit) { results.push({ n, ok: false, error: 'not on shelf: ' + n }); continue }
429
+ const { source, skill } = hit
430
+ if (skill.tier && UNSTABLE_TIERS.has(skill.tier) && !args.allowUnstable) {
431
+ results.push({ n, id: skill.id, ok: false, error: 'unstable tier (' + skill.tier + ') requires --allow-unstable' })
432
+ continue
433
+ }
434
+ const dest = join(skillsDir, skill.id)
435
+ if (existsSync(dest) && !args.force) {
436
+ results.push({ n, id: skill.id, ok: false, error: 'collision at ' + dest + ' (use --force)' })
437
+ continue
438
+ }
439
+ mkdirSync(skillsDir, { recursive: true })
440
+ try {
441
+ if (existsSync(dest)) rmSync(dest, { recursive: true, force: true })
442
+ if (fixtureRoot) copyDirLocal(fixtureRoot, source.id, skill.path, dest)
443
+ else copyDirGitHub(source, skill.path, dest)
444
+ // Single-skill repos author SKILL.src.md (2026-09-22), but the
445
+ // DSH host skill provider only scans SKILL.md — without it the
446
+ // skill installs fine yet never appears in the host catalog and
447
+ // cannot be invoked. Materialize SKILL.md from the authored src.
448
+ const mdPath = join(dest, 'SKILL.md')
449
+ if (!existsSync(mdPath) && existsSync(join(dest, 'SKILL.src.md'))) {
450
+ copyFileSync(join(dest, 'SKILL.src.md'), mdPath)
451
+ }
452
+ const sig = {
453
+ v: CACHE_VERSION, source: source.id, n: skill.n, skillId: skill.id,
454
+ upstreamPath: source.id + '/' + skill.path,
455
+ installedAt: new Date().toISOString(),
456
+ contentHash: hashSkillFile(dest)
457
+ }
458
+ writeFileSync(join(dest, SIGNATURE_NAME), JSON.stringify(sig, null, 2))
459
+ // Voice sidecars (The Shelf Voice ADR D3/D4): .dsi-voice/<locale>.json
460
+ // travels with the skill. en is an ALWAYS-written scaffold; operator
461
+ // locales come from the --voice JSON payload authored by the
462
+ // installing agent. An invalid payload NEVER blocks the install -
463
+ // it degrades to en-only (D4) and says so on the result item.
464
+ let voiceError = null
465
+ const voice = { en: { name: skill.id, description: '', whenToUse: '' } }
466
+ if (args.voice) {
467
+ try {
468
+ const payload = JSON.parse(args.voice)
469
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new Error('voice payload must be an object')
470
+ for (const [loc, entry] of Object.entries(payload)) {
471
+ if (!SHELF_LOCALES.includes(loc)) throw new Error('unsupported locale: ' + loc)
472
+ if (!entry || typeof entry !== 'object' || typeof entry.name !== 'string' || !entry.name.trim() || typeof entry.description !== 'string') throw new Error('bad voice entry for ' + loc)
473
+ voice[loc] = { name: entry.name, description: entry.description, whenToUse: String(entry.whenToUse ?? '') }
474
+ }
475
+ } catch (e) {
476
+ voiceError = String(e.message || e)
477
+ }
478
+ }
479
+ const voiceDir = join(dest, '.dsi-voice')
480
+ mkdirSync(voiceDir, { recursive: true })
481
+ for (const [loc, entry] of Object.entries(voice)) {
482
+ writeFileSync(join(voiceDir, loc + '.json'), JSON.stringify({ v: CACHE_VERSION, ...entry }, null, 2))
483
+ }
484
+ results.push({ n, id: skill.id, ok: true, installed: true, voice: Object.keys(voice), ...(voiceError ? { voiceError } : {}) })
485
+ } catch (e) {
486
+ rmSync(dest, { recursive: true, force: true }) // D4: no folder without signature
487
+ results.push({ n, id: skill.id, ok: false, error: String(e.message || e) })
488
+ }
489
+ }
490
+ }
491
+ else if (action === 'uninstall') {
492
+ for (const id of targets) {
493
+ const dir = join(skillsDir, id)
494
+ const sigPath = join(dir, SIGNATURE_NAME)
495
+ if (!existsSync(dir)) { results.push({ id, ok: false, error: 'not installed: ' + id }); continue }
496
+ if (!existsSync(sigPath)) { results.push({ id, ok: false, error: 'unsigned - not uninstallable via shelf (D5)' }); continue }
497
+ try {
498
+ const sig = JSON.parse(readFileSync(sigPath, 'utf8'))
499
+ if (sig.skillId !== id) { results.push({ id, ok: false, error: 'signature id mismatch' }); continue }
500
+ rmSync(dir, { recursive: true, force: true })
501
+ results.push({ id, ok: true, uninstalled: true })
502
+ } catch (e) {
503
+ results.push({ id, ok: false, error: String(e.message || e) })
504
+ }
505
+ }
506
+ } else {
507
+ return fail(['unknown apply action: ' + action])
508
+ }
509
+ // Flip the snapshot flags for successful results (ADR sequence step:
510
+ // "flip installed / installedFrom") - without this the panel's next
511
+ // refetch shows stale state and the uninstall affordance never appears.
512
+ if (results.some((r) => r.ok)) {
513
+ for (const r of results) {
514
+ if (!r.ok) continue
515
+ for (const src of snapshot.sources) {
516
+ const sk = src.skills.find((s) => s.id === (r.id ?? ''))
517
+ if (!sk) continue
518
+ if (action === 'install') { sk.installed = true; sk.signed = true; sk.installedFrom = src.id }
519
+ else { sk.installed = false; sk.signed = false; sk.installedFrom = null }
520
+ }
521
+ }
522
+ writeFileSync(cachePath, JSON.stringify(snapshot, null, 2))
523
+ }
524
+ const ok = results.every((r) => r.ok)
525
+ return { ok, results }
526
+ }
527
+
528
+ // --- main ---
529
+
530
+ async function main() {
531
+ const args = parseArgs(process.argv.slice(2))
532
+ const command = args._[0]
533
+ if (command === 'refresh') {
534
+ const { snapshot, reused } = await cmdRefresh(args)
535
+ console.log(JSON.stringify({ v: CACHE_VERSION, ok: true, reused, snapshot }))
536
+ } else if (command === 'apply') {
537
+ const out = await cmdApply(args)
538
+ console.log(JSON.stringify({ v: CACHE_VERSION, ...out }))
539
+ } else if (command === 'snapshot-status') {
540
+ const cachePath = resolve(args.cache || join(homedir(), '.dsi/resources/skr-cache.json'))
541
+ console.log(JSON.stringify({ v: CACHE_VERSION, ok: true, present: existsSync(cachePath), path: cachePath }))
542
+ } else {
543
+ fail(['unknown command: ' + command + ' (use refresh | apply | snapshot-status)'])
544
+ }
545
+ }
546
+
547
+ main().catch((e) => fail([String(e && e.stack || e)]))
@@ -159,7 +159,7 @@ Answer these questions from codebase evidence (not guessing):
159
159
 
160
160
  ### Document 1: PRD
161
161
 
162
- Save to: `{project-path}/dev/specs/{TODAY} - {Feature Name}/PRD.md`
162
+ Save to: `{project-path}/dev/specs/{YYYY-MM}/{TODAY} - {Feature Name}/PRD.md`
163
163
 
164
164
  **Contents:**
165
165
  1. Current architecture — how it works today (with mermaid flowchart + sequence diagram)
@@ -183,7 +183,7 @@ Save to: `{project-path}/dev/specs/{TODAY} - {Feature Name}/PRD.md`
183
183
 
184
184
  ### Document 2: Tasks
185
185
 
186
- Save to: `{project-path}/dev/specs/{TODAY} - {Feature Name}/Tasks.md`
186
+ Save to: `{project-path}/dev/specs/{YYYY-MM}/{TODAY} - {Feature Name}/Tasks.md`
187
187
 
188
188
  **Contents:**
189
189
  1. Execution checklist — `⛔ Behavioral Commitments` as PUBLIC checklists (not private anti-patterns)
@@ -200,13 +200,13 @@ Save to: `{project-path}/dev/specs/{TODAY} - {Feature Name}/Tasks.md`
200
200
 
201
201
  ### Document 3: Karpathy Context
202
202
 
203
- Save to: `{project-path}/dev/specs/{TODAY} - {Feature Name}/karpathy-context.md`
203
+ Save to: `{project-path}/dev/specs/{YYYY-MM}/{TODAY} - {Feature Name}/karpathy-context.md`
204
204
 
205
205
  **Contents:** All 3 layers as structured markdown. This is the internal context file the task-executor loads when executing.
206
206
 
207
207
  ### Document 4: Tasks Machine Twin (Tasks.json)
208
208
 
209
- Save to: `{project-path}/dev/specs/{TODAY} - {Feature Name}/Tasks.json`
209
+ Save to: `{project-path}/dev/specs/{YYYY-MM}/{TODAY} - {Feature Name}/Tasks.json`
210
210
 
211
211
  The binding machine contract — the orchestration register flow maps these keys one-to-one into SQLite. **Rules:**
212
212
 
@@ -13,7 +13,7 @@ This file is the single source of truth for the JSON shape. When the orchestrati
13
13
  "$schema": "openclaw-insight/tasks-twin/v1",
14
14
  "featureName": "Orchestration Zoom Artifact Fix",
15
15
  "featureSlug": "orchestration-zoom-artifact-fix",
16
- "specPath": "dev/specs/2026-08-15 - Orchestration Zoom Artifact Fix",
16
+ "specPath": "dev/specs/2026-08/2026-08-15 - Orchestration Zoom Artifact Fix",
17
17
  "waves": [
18
18
  {
19
19
  "waveNumber": 1,